diff --git a/anonstream/__init__.py b/anonstream/__init__.py index d2c609e..b853268 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -47,6 +47,7 @@ def create_app(config_file): app.stream_title = None app.stream_uptime = None app.stream_viewership = None + app.last_info_task = None # Background tasks' asyncio.sleep tasks, cancelled on shutdown app.background_sleep = set() diff --git a/anonstream/control/spec/common.py b/anonstream/control/spec/common.py index 34ec014..0ecd92f 100644 --- a/anonstream/control/spec/common.py +++ b/anonstream/control/spec/common.py @@ -62,11 +62,16 @@ class ArgsInt(Args): return self.spec, 1, [n] class ArgsString(Args): + def transform_string(self, string): + return string + def consume(self, words, index): try: string = words[index] except IndexError: raise NoParse(f'incomplete: expected string') + else: + string = self.transform_string(string) return self.spec, 1, [string] class ArgsJson(Args): diff --git a/anonstream/control/spec/methods/user.py b/anonstream/control/spec/methods/user.py index edafb2c..147f066 100644 --- a/anonstream/control/spec/methods/user.py +++ b/anonstream/control/spec/methods/user.py @@ -20,10 +20,19 @@ class ArgsJsonTokenUser(ArgsJsonString): raise NoParse(f'no user with token {token!r}') return user +class ArgsJsonHashUser(ArgsString): + def transform_string(self, token_hash): + for user in USERS: + if user['token_hash'] == token_hash: + break + else: + raise NoParse(f'no user with token_hash {token_hash!r}') + return user + def ArgsUser(spec): return Str({ 'token': ArgsJsonTokenUser(spec), - #'hash': ArgsJsonHashUser(spec), + 'hash': ArgsJsonHashUser(spec), }) async def cmd_user_help(): @@ -31,19 +40,18 @@ async def cmd_user_help(): response = ( 'Usage: user [show | attr USER | get USER ATTR | set USER ATTR VALUE]\n' 'Commands:\n' - ' user [show].......................show all users\' tokens\n' - ' user attr USER....................show names of a user\'s attributes\n' - ' user get USER ATTR................show an attribute of a user\n' - ' user set USER ATTR................set an attribute of a user\n' - ' user eyes USER [show].............show a user\'s active video responses\n' - ' user eyes USER delete EYES_ID.....end a video response to a user\n' + ' user [show]...................show all users\' tokens\n' + ' user attr USER................show names of a user\'s attributes\n' + ' user get USER ATTR............show an attribute of a user\n' + ' user set USER ATTR............set an attribute of a user\n' + ' user eyes USER [show].........show a user\'s active video responses\n' + ' user eyes USER delete EYES_ID.end a video response to a user\n' 'Definitions:\n' - #' USER..............................={token TOKEN | hash HASH}\n' - ' USER..............................=token TOKEN\n' - ' TOKEN..............................a token, json string\n' - #' HASH..............................a token hash\n' - ' ATTR...............................a user attribute, re:[a-z0-9_]+\n' - ' EYES_ID............................a user\'s eyes_id, base 10 integer\n' + ' USER..........................={token TOKEN | hash HASH}\n' + ' TOKEN.........................a token, json string\n' + ' HASH..........................a token hash\n' + ' ATTR..........................a user attribute, re:[a-z0-9_]+\n' + ' EYES_ID.......................a user\'s eyes_id, base 10 integer\n' ) return normal, response diff --git a/anonstream/segments.py b/anonstream/segments.py index 2267818..cc4edca 100644 --- a/anonstream/segments.py +++ b/anonstream/segments.py @@ -9,7 +9,7 @@ import aiofiles import m3u8 from quart import current_app -from anonstream.wrappers import ttl_cache, with_timestamp +from anonstream.wrappers import ttl_cache CONFIG = current_app.config diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 7aa082d..2392443 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -215,7 +215,7 @@ const create_chat_message = (object) => { const create_chat_user_name = (user) => { const chat_user_name = document.createElement("span"); chat_user_name.classList.add("chat-name"); - chat_user_name.innerText = get_user_name({user}); + chat_user_name.innerText = get_user_name({user}).replaceAll(/\r?\n/g, " "); //chat_user_name.dataset.color = user.color; // not working in any browser if (!user.broadcaster && user.name === null) { const b = document.createElement("b"); @@ -648,6 +648,10 @@ const on_websocket_message = (event) => { default_name = receipt.default; max_chat_scrollback = receipt.scrollback; + // if the chat is scrolled all the way to the bottom, make sure this is + // still the case after updating user names and tripcodes + at_bottom = chat_messages.scrollTop === chat_messages.scrollTopMax; + // update users users = receipt.users; update_user_names(); @@ -655,6 +659,15 @@ const on_websocket_message = (event) => { update_user_tripcodes(); update_users_list() + // ensure chat scroll (see above) + if (at_bottom) { + chat_messages.scrollTo({ + left: 0, + top: chat_messages.scrollTopMax, + behavior: "instant", + }); + } + // appearance form default values const user = users[TOKEN_HASH]; if (user.name !== null) { @@ -739,10 +752,26 @@ const on_websocket_message = (event) => { for (const token_hash of Object.keys(receipt.users)) { users[token_hash] = receipt.users[token_hash]; } + + // if the chat is scrolled all the way to the bottom, make sure this is + // still the case after updating user names and tripcodes + at_bottom = chat_messages.scrollTop === chat_messages.scrollTopMax; + + // update users update_user_names(); update_user_colors(); update_user_tripcodes(); update_users_list() + + // ensure chat scroll (see above) + if (at_bottom) { + chat_messages.scrollTo({ + left: 0, + top: chat_messages.scrollTopMax, + behavior: "instant", + }); + } + break; case "rem-users": diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 74dee06..a2a379b 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -552,6 +552,9 @@ footer { #chat-form_js[data-captcha] #chat-live__status [data-verbose="false"] { display: inline; } + #info_nojs { + height: 100% !important; + } #nochat:target { --chat-width: 0px; } diff --git a/anonstream/stream.py b/anonstream/stream.py index 6f130c0..0ee9e52 100644 --- a/anonstream/stream.py +++ b/anonstream/stream.py @@ -44,7 +44,7 @@ def get_stream_uptime(rounded=True): uptime = round(uptime, 2) if rounded else uptime return uptime -@with_timestamp +@with_timestamp() def get_raw_viewership(timestamp): users = get_watching_users(timestamp) return max( @@ -52,8 +52,8 @@ def get_raw_viewership(timestamp): default=0, ) -def get_stream_uptime_and_viewership(for_websocket=False): - uptime = get_stream_uptime() +def get_stream_uptime_and_viewership(rounded=True, for_websocket=False): + uptime = get_stream_uptime(rounded=rounded) if not for_websocket: viewership = None if uptime is None else get_raw_viewership() result = (uptime, viewership) diff --git a/anonstream/tasks.py b/anonstream/tasks.py index f25a2fc..802bf40 100644 --- a/anonstream/tasks.py +++ b/anonstream/tasks.py @@ -44,7 +44,7 @@ def with_period(period): return periodically @with_period(CONFIG['TASK_ROTATE_EYES']) -@with_timestamp +@with_timestamp() async def t_delete_eyes(timestamp, iteration): if iteration == 0: return @@ -59,7 +59,7 @@ async def t_delete_eyes(timestamp, iteration): user['eyes']['current'].pop(eyes_id) @with_period(CONFIG['TASK_ROTATE_USERS']) -@with_timestamp +@with_timestamp() async def t_sunset_users(timestamp, iteration): if iteration == 0: return @@ -102,7 +102,7 @@ async def t_expire_captchas(iteration): CAPTCHAS.pop(digest) @with_period(CONFIG['TASK_ROTATE_WEBSOCKETS']) -@with_timestamp +@with_timestamp() async def t_close_websockets(timestamp, iteration): THRESHOLD = CONFIG['TASK_BROADCAST_PING'] * 1.5 + 4.0 if iteration == 0: @@ -130,56 +130,58 @@ async def t_broadcast_users_update(iteration): broadcast_users_update() @with_period(CONFIG['TASK_BROADCAST_STREAM_INFO_UPDATE']) -async def t_broadcast_stream_info_update(iteration): +@with_timestamp(precise=True) +async def t_broadcast_stream_info_update(timestamp, iteration): if iteration == 0: title = await get_stream_title() - uptime, viewership = get_stream_uptime_and_viewership() + uptime, viewership = get_stream_uptime_and_viewership(rounded=False) current_app.stream_title = title current_app.stream_uptime = uptime current_app.stream_viewership = viewership else: - payload = {} - + info = {} title = await get_stream_title() - uptime, viewership = get_stream_uptime_and_viewership() + uptime, viewership = get_stream_uptime_and_viewership(rounded=False) # Check if the stream title has changed if current_app.stream_title != title: current_app.stream_title = title - payload['title'] = title + info['title'] = title # Check if the stream uptime has changed unexpectedly - if current_app.stream_uptime is None: - expected_uptime = None + last_uptime, current_app.stream_uptime = ( + current_app.stream_uptime, uptime + ) + if last_uptime is None: + projected_uptime = None else: - expected_uptime = ( - current_app.stream_uptime - + CONFIG['TASK_BROADCAST_STREAM_INFO_UPDATE'] - ) - current_app.stream_uptime = uptime - if uptime is None and expected_uptime is None: + last_info_task_ago = timestamp - current_app.last_info_task + projected_uptime = last_uptime + last_info_task_ago + if uptime is None and projected_uptime is None: stats_changed = False - elif uptime is None or expected_uptime is None: + elif uptime is None or projected_uptime is None: stats_changed = True else: - stats_changed = abs(uptime - expected_uptime) >= 0.5 + stats_changed = abs(uptime - projected_uptime) >= 0.5 # Check if viewership has changed if current_app.stream_viewership != viewership: current_app.stream_viewership = viewership stats_changed = True + # Broadcast iff anything has changed if stats_changed: if uptime is None: - payload['stats'] = None + info['stats'] = None else: - payload['stats'] = { - 'uptime': uptime, + info['stats'] = { + 'uptime': round(uptime, 2), 'viewership': viewership, } + if info: + broadcast(USERS, payload={'type': 'info', **info}) - if payload: - broadcast(USERS, payload={'type': 'info', **payload}) + current_app.last_info_task = timestamp current_app.add_background_task(t_delete_eyes) current_app.add_background_task(t_sunset_users) diff --git a/anonstream/templates/nojs_info.html b/anonstream/templates/nojs_info.html index 5ce88fe..c770f50 100644 --- a/anonstream/templates/nojs_info.html +++ b/anonstream/templates/nojs_info.html @@ -75,13 +75,13 @@ animation: appear step-end both; } #m1 { - animation-duration: {{ 600 - uptime }}s; + animation-delay: {{ 600 - uptime }}s; } #h0 { - animation-duration: {{ 3600 - uptime }}s; + animation-delay: {{ 3600 - uptime }}s; } #h1 { - animation-duration: {{ 36000 - uptime }}s; + animation-delay: {{ 36000 - uptime }}s; } #uptime-dynamic { animation: disappear step-end {{ 360000 - uptime }}s forwards; @@ -95,12 +95,15 @@ @keyframes appear { from { width: 0; + height: 0; visibility: hidden; } } @keyframes disappear { to { + width: 0; height: 0; + visibility: hidden; opacity: 0; } } diff --git a/anonstream/user.py b/anonstream/user.py index 75c50eb..32ea0a7 100644 --- a/anonstream/user.py +++ b/anonstream/user.py @@ -127,16 +127,16 @@ def change_tripcode(user, password, dry_run=False): def delete_tripcode(user): user['tripcode'] = None -@with_timestamp +@with_timestamp() def see(timestamp, user): user['last']['seen'] = timestamp -@with_timestamp +@with_timestamp() def watched(timestamp, user): user['last']['seen'] = timestamp user['last']['watching'] = timestamp -@with_timestamp +@with_timestamp() def get_all_users_for_websocket(timestamp): return { user['token_hash']: get_user_for_websocket(user) @@ -160,7 +160,7 @@ def verify(user, digest, answer): return verification_happened -@with_timestamp +@with_timestamp() def deverify(timestamp, user): if not user['verified']: return @@ -182,7 +182,7 @@ def _update_presence(timestamp, user): USERS_UPDATE_BUFFER.add(user['token']) return user['presence'] -@with_timestamp +@with_timestamp() def update_presence(timestamp, user): return _update_presence(timestamp, user) @@ -224,7 +224,7 @@ def get_unsunsettable_users(timestamp): get_users_and_update_presence(timestamp), ) -@with_timestamp +@with_timestamp() def get_users_by_presence(timestamp): users_by_presence = { Presence.WATCHING: [], @@ -236,7 +236,7 @@ def get_users_by_presence(timestamp): users_by_presence[user['presence']].append(user) return users_by_presence -@with_timestamp +@with_timestamp(precise=True) def create_eyes(timestamp, user, headers): # Enforce cooldown last_created_ago = timestamp - user['last']['eyes'] @@ -271,7 +271,7 @@ def create_eyes(timestamp, user, headers): } return eyes_id -@with_timestamp +@with_timestamp(precise=True) def renew_eyes(timestamp, user, eyes_id, just_read_new_segment=False): try: eyes = user['eyes']['current'][eyes_id] diff --git a/anonstream/websocket.py b/anonstream/websocket.py index d9bb0cc..4878e1e 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -72,7 +72,7 @@ async def websocket_inbound(queue, user): if payload is not None: queue.put_nowait(payload) -@with_timestamp +@with_timestamp() def handle_inbound_pong(timestamp, queue, user): print(f'[pong] {user["token"]}') user['websockets'][queue] = timestamp diff --git a/anonstream/wrappers.py b/anonstream/wrappers.py index 6e1e857..3a8ead1 100644 --- a/anonstream/wrappers.py +++ b/anonstream/wrappers.py @@ -4,23 +4,25 @@ import time from functools import wraps -def with_timestamp(f): - @wraps(f) - def wrapper(*args, **kwargs): - timestamp = int(time.time()) - return f(timestamp, *args, **kwargs) - - return wrapper - -def with_first_argument(x): - def with_x(f): +def with_function_call(fn, *fn_args, **fn_kwargs): + def with_result(f): @wraps(f) def wrapper(*args, **kwargs): - return f(x, *args, **kwargs) - + result = fn(*fn_args, **fn_kwargs) + return f(result, *args, **kwargs) return wrapper + return with_result - return with_x +def with_constant(x): + return with_function_call(lambda: x) + +def with_timestamp(monotonic=False, precise=False): + n = 1_000_000_000 + if monotonic: + fn = precise and time.monotonic or (lambda: time.monotonic_ns() // n) + else: + fn = precise and time.time or (lambda: time.time_ns() // n) + return with_function_call(fn) def try_except_log(errors, exception_class): def try_except_log_specific(f): @@ -30,9 +32,7 @@ def try_except_log(errors, exception_class): return f(*args, **kwargs) except exception_class as e: errors.append(e) - return wrapper - return try_except_log_specific def ttl_cache(ttl): @@ -42,19 +42,14 @@ def ttl_cache(ttl): ''' def ttl_cache_specific(f): value, expires = None, None - @wraps(f) def wrapper(): nonlocal value, expires - if expires is None or time.monotonic() >= expires: value = f() expires = time.monotonic() + ttl - return value - return wrapper - return ttl_cache_specific def ttl_cache_async(ttl): @@ -63,17 +58,12 @@ def ttl_cache_async(ttl): ''' def ttl_cache_specific(f): value, expires = None, None - @wraps(f) async def wrapper(): nonlocal value, expires - if expires is None or time.monotonic() >= expires: value = await f() expires = time.monotonic() + ttl - return value - return wrapper - return ttl_cache_specific