From 4eaf9b56f721e15eaf446f9f48ab68e0a79583a1 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 9 Mar 2022 05:15:21 +0000 Subject: [PATCH 01/23] Try to ensure websocket is closed when forgetting about it Might not be necessary, but if it is then it prevents a sitation where a websocket is still open but we've forgotten about it, so we will never broadcast any new messages to it and the client will be practically frozen in time until they disconnect and open a new websocket. Also update the user's last_seen when the websocket is closed. This prevents a user with js enabled who's actually idle being considered absent and being rotated when their websocket accidentally closes for a few seconds. --- anonstream/routes/websocket.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index 1734f21..158561a 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -5,6 +5,7 @@ import asyncio from quart import current_app, websocket +from anonstream.user import see from anonstream.websocket import websocket_outbound, websocket_inbound from anonstream.routes.wrappers import with_user_from @@ -19,4 +20,9 @@ async def live(user): try: await asyncio.gather(producer, consumer) finally: + see(user) user['websockets'].remove(queue) + try: + await websocket.close(1000) + except RuntimeError: + pass From dab389abccc4df2c0c08deca8ebb7f0a708c404e Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 8 Jun 2022 23:57:02 +0000 Subject: [PATCH 02/23] More precise debug messages when segment generator exits --- anonstream/segments.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/anonstream/segments.py b/anonstream/segments.py index ba9089f..bcf0582 100644 --- a/anonstream/segments.py +++ b/anonstream/segments.py @@ -76,10 +76,14 @@ def get_next_segment(uri): segment = None return segment -async def get_segment_uris(): +async def get_segment_uris(token): try: segment = get_starting_segment() except Offline: + print( + f'[debug @ {time.time():.3f}: {token=}] ' + f'stream went offline before we could find any segments' + ) return else: yield segment.init_section.uri @@ -92,12 +96,22 @@ async def get_segment_uris(): try: next_segment = get_next_segment(segment.uri) except Offline: + print( + f'[debug @ {time.time():.3f}: {token=}] ' + f'stream went offline while looking for the segment ' + f'following {uri=}' + ) return else: if next_segment is not None: segment = next_segment break elif time.monotonic() - t0 >= CONFIG['SEGMENT_SEARCH_TIMEOUT']: + print( + f'[debug @ {time.time():.3f}: {token=}] ' + f'timed out looking for the segment following {uri=}' + f'(timeout={CONFIG["SEGMENT_SEARCH_TIMEOUT"]}s)' + ) return else: await asyncio.sleep(CONFIG['SEGMENT_SEARCH_COOLDOWN']) @@ -112,8 +126,7 @@ def path_for(uri): async def segments(segment_read_hook=lambda uri: None, token=None): print(f'[debug @ {time.time():.3f}: {token=}] entering segment generator') - uri = None - async for uri in get_segment_uris(): + async for uri in get_segment_uris(token): #print(f'[debug @ {time.time():.3f}: {token=}] {uri=}') try: path = path_for(uri) @@ -136,10 +149,4 @@ async def segments(segment_read_hook=lambda uri: None, token=None): f'segment {uri=} at {path=} unexpectedly does not exist' ) break - else: - print( - f'[debug @ {time.time():.3f}: {token=}] ' - f'could not find segment following {uri=} after at least ' - f'{CONFIG["SEGMENT_SEARCH_TIMEOUT"]} seconds' - ) print(f'[debug @ {time.time():.3f}: {token=}] exiting segment generator') From 73824f70d7c19114caf168685d5bdfffdc5f2042 Mon Sep 17 00:00:00 2001 From: n9k Date: Thu, 10 Mar 2022 08:21:21 +0000 Subject: [PATCH 03/23] Lock js chat scroll when not at bottom --- anonstream/static/anonstream.js | 31 ++++++++++++++++++++++++------- anonstream/static/style.css | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 2e45164..f84f946 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -18,7 +18,9 @@ const jsmarkup_info_float_button = '`; const jsmarkup_chat_users = `\
@@ -692,11 +694,13 @@ const on_websocket_message = (event) => { case "message": console.log("ws message", receipt); create_and_add_chat_message(receipt.message); - chat_messages.scrollTo({ - left: 0, - top: chat_messages.scrollTopMax, - behavior: "smooth", - }); + if (chat_messages.dataset.scrollLock === undefined) { + chat_messages.scrollTo({ + left: 0, + top: chat_messages.scrollTopMax, + behavior: "smooth", + }); + } break; case "set-users": @@ -866,7 +870,20 @@ const peg_bottom = (entries) => { } const resize = new ResizeObserver(peg_bottom); resize.observe(chat_messages); +track_scroll(chat_messages); + +/* chat scroll lock */ chat_messages.addEventListener("scroll", (event) => { track_scroll(chat_messages); + const scroll = chat_messages.scrollTopMax - chat_messages.scrollTop; + const locked = chat_messages.dataset.scrollLock !== undefined + if (scroll >= 160 && !locked) { + chat_messages.dataset.scrollLock = ""; + } else if (scroll == 0 && locked) { + chat_messages.removeAttribute("data-scroll-lock"); + } +}); +const chat_messages_unlock = document.getElementById("chat-messages-unlock"); +chat_messages_unlock.addEventListener("click", (event) => { + chat_messages.scrollTop = chat_messages.scrollTopMax; }); -track_scroll(chat_messages); diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 46fd76d..4420dcb 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -180,6 +180,28 @@ noscript { bottom: 0; font-size: 11pt; } +#chat-messages_js:not([data-scroll-lock]) + #chat-messages-unlock { + display: none; +} +#chat-messages-unlock { + position: absolute; + bottom: 1rem; + color: inherit; + font-size: 10pt; + text-align: center; + width: calc(100% - 4rem); + margin: 0 2rem; + padding: 0.5rem 0; + box-sizing: border-box; + background-color: #316aaf; + border-radius: 4px; + border: 1px outset #4c91e6; + box-shadow: 0 0 3px #4c91e6; + cursor: pointer; +} +#chat-messages-unlock:hover { + background-color: #3674bf; +} #chat-messages_nojs { height: 100%; } From 035235861190cf7f3d1288fdc11f1a3f70cedd8a Mon Sep 17 00:00:00 2001 From: n9k Date: Thu, 10 Mar 2022 13:58:17 +0000 Subject: [PATCH 04/23] Compress some responses Adds dependency `quart-compress` --- anonstream/__init__.py | 10 ++++++++++ requirements.txt | 2 ++ 2 files changed, 12 insertions(+) diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 8c8a358..e8c0b14 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -7,12 +7,15 @@ import toml from collections import OrderedDict from quart import Quart +from quart_compress import Compress from werkzeug.security import generate_password_hash from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer from anonstream.utils.colour import color_to_colour from anonstream.utils.user import generate_token +compress = Compress() + def create_app(config_file): with open(config_file) as fp: config = toml.load(fp) @@ -112,4 +115,11 @@ def create_app(config_file): import anonstream.routes import anonstream.tasks + # Compress some responses + compress.init_app(app) + app.config.update({ + "COMPRESS_MIN_SIZE": 2048, + "COMPRESS_LEVEL": 9, + }) + return app diff --git a/requirements.txt b/requirements.txt index b42fa16..4bc316a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ aiofiles==0.8.0 asgiref==3.5.0 blinker==1.4 +Brotli==1.0.9 git+https://github.com/lepture/captcha@27920681b86c27c990da484984f673dba1dd47e5#egg=captcha click==8.0.4 h11==0.13.0 @@ -16,6 +17,7 @@ MarkupSafe==2.1.0 Pillow==9.0.1 priority==2.0.0 quart==0.16.3 +quart-compress==0.2.1 toml==0.10.2 uvicorn==0.17.5 Werkzeug==2.0.3 From 0aad5554089f9fbebf3263ed3e23b75822047a57 Mon Sep 17 00:00:00 2001 From: n9k Date: Mon, 14 Mar 2022 08:21:38 +0000 Subject: [PATCH 05/23] Info iframe: properly show uptime text-only fallback --- anonstream/templates/nojs_info.html | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/anonstream/templates/nojs_info.html b/anonstream/templates/nojs_info.html index 37c0620..14c4fb2 100644 --- a/anonstream/templates/nojs_info.html +++ b/anonstream/templates/nojs_info.html @@ -83,11 +83,14 @@ #h1 { animation-duration: {{ 36000 - uptime }}s; } + #uptime-dynamic { + animation: disappear step-end {{ 360000 - uptime }}s forwards; + } #uptime-dynamic-overflow { animation: appear step-end {{ 360000 - uptime }}s backwards; } - #uptime-dynamic { - animation: disappear step-end {{ 360000 - uptime }}s forwards; + #uptime-dynamic-overflow::after { + content: "100+ hours"; } @keyframes appear { from { @@ -146,11 +149,11 @@ Uptime: {%- if uptime >= 3600 -%} - {{- (uptime // 3600) | int -}} + {{- uptime | int // 3600 -}} {{- ':' -}} {{- '%02.0f' | format(uptime % 3600 // 60) -}} {%- else -%} - {{- uptime % 3600 // 60 | int -}} + {{- uptime | int % 3600 // 60 -}} {%- endif -%} {{- ':' -}} {{- '%02.0f' | format(uptime % 60) -}} @@ -170,7 +173,7 @@ {{- '' -}} -
100+ hours
+
{% endif %} From 6a4e16eaf42b4c3c4d8749297d15e023e52bdd7f Mon Sep 17 00:00:00 2001 From: n9k Date: Mon, 14 Mar 2022 09:47:59 +0000 Subject: [PATCH 06/23] Use × instead of ✗ Tor Browser on Linux prefers × --- anonstream/templates/nojs_chat_form.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anonstream/templates/nojs_chat_form.html b/anonstream/templates/nojs_chat_form.html index 2d47b54..67f8bf2 100644 --- a/anonstream/templates/nojs_chat_form.html +++ b/anonstream/templates/nojs_chat_form.html @@ -242,13 +242,13 @@ {% else %} - +
(cleared)
{% endif %} -
+
From a7bfab4f260b6b617d3351220e01dc45216274ad Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 30 Mar 2022 08:41:42 +0000 Subject: [PATCH 07/23] Offline screen --- anonstream/routes/nojs.py | 3 ++- anonstream/static/anonstream.js | 33 +++++++++++++++++++++------ anonstream/static/style.css | 21 ++++++++++++++++- anonstream/templates/nojs_stream.html | 31 +++++++++++++++++++++---- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index c1b7d59..967f894 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -5,7 +5,7 @@ from quart import current_app, request, render_template, redirect, url_for, esca from anonstream.captcha import get_random_captcha_digest_for from anonstream.chat import add_chat_message, Rejected -from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership +from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha from anonstream.routes.wrappers import with_user_from, render_template_with_etag from anonstream.helpers.chat import get_scrollback @@ -24,6 +24,7 @@ async def nojs_stream(user): 'nojs_stream.html', csp=generate_csp(), user=user, + online=is_online(), ) @current_app.route('/info.html') diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index f84f946..08eed06 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -11,7 +11,8 @@ const TOKEN_HASH = document.body.dataset.tokenHash; const CSP = document.body.dataset.csp; /* insert js-only markup */ -const jsmarkup_stream = `` +const jsmarkup_stream_video = '' +const jsmarkup_stream_offline = '

[offline]

' const jsmarkup_info = '
'; const jsmarkup_info_float = ''; const jsmarkup_info_float_button = ''; @@ -83,9 +84,13 @@ const insert_jsmarkup = () => { style_tripcode_colors.nonce = CSP; document.head.insertAdjacentElement("beforeend", style_tripcode_colors); } - if (document.getElementById("stream_js") === null) { + if (document.getElementById("stream__video") === null) { const parent = document.getElementById("stream"); - parent.insertAdjacentHTML("beforeend", jsmarkup_stream); + parent.insertAdjacentHTML("beforeend", jsmarkup_stream_video); + } + if (document.getElementById("stream__offline") === null) { + const parent = document.getElementById("stream"); + parent.insertAdjacentHTML("beforeend", jsmarkup_stream_offline); } if (document.getElementById("info_js") === null) { const parent = document.getElementById("info"); @@ -568,6 +573,12 @@ const update_users_list = () => { chat_users_notwatching_header.innerText = `Not watching (${notwatching})`; } +const show_offline_screen = () => { + video.removeAttribute("src"); + video.load(); + stream.dataset.offline = ""; +} + const on_websocket_message = (event) => { //console.log("websocket message", event); const receipt = JSON.parse(event.data); @@ -664,7 +675,7 @@ const on_websocket_message = (event) => { } // stream reload button - if (stats === null || stream.networkState === stream.NETWORK_LOADING) { + if (stats === null || video.networkState === video.NETWORK_LOADING) { info_button.removeAttribute("data-visible"); } else { info_button.dataset.visible = ""; @@ -819,18 +830,26 @@ const connect_websocket = () => { connect_websocket(); /* stream reload button */ -const stream = document.getElementById("stream_js"); +const video = document.getElementById("stream__video"); const info_button = document.getElementById("info_js__float__button"); info_button.addEventListener("click", (event) => { - stream.load(); + stream.removeAttribute("data-offline"); + video.src = `/stream.mp4?token=${encodeURIComponent(TOKEN)}`; + video.load(); info_button.removeAttribute("data-visible"); }); -stream.addEventListener("error", (event) => { +video.addEventListener("error", (event) => { + if (video.error !== null && video.error.message === "404: Not Found") { + show_offline_screen(); + } if (stats !== null) { info_button.dataset.visible = ""; } }); +/* load stream */ +video.src = `/stream.mp4?token=${encodeURIComponent(TOKEN)}`; + /* override js-only chat form */ const chat_form_nonce = document.getElementById("chat-form_js__nonce"); const chat_form_comment = document.getElementById("chat-form_js__comment"); diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 4420dcb..f0482dd 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -56,11 +56,30 @@ noscript { #stream { background: black; grid-area: stream; + position: relative; } -#stream_js { +#stream__video { width: 100%; height: 100%; } +#stream__offline { + position: absolute; + top: 0; + width: 100%; + height: 100%; + text-align: center; + display: grid; + align-content: center; + font-size: 20pt; + background-color: black; + user-select: none; +} +#stream__offline > h1 { + margin: 0; +} +#stream:not([data-offline]) > #stream__offline { + display: none; +} #stream_nojs { height: 100%; } diff --git a/anonstream/templates/nojs_stream.html b/anonstream/templates/nojs_stream.html index 025e409..b657618 100644 --- a/anonstream/templates/nojs_stream.html +++ b/anonstream/templates/nojs_stream.html @@ -16,14 +16,35 @@ height: 100%; margin: 0; overflow: hidden; + color: #ddd; + font-family: sans-serif; } - #stream { - width: 100%; - height: 100%; - } + {% if online %} + #video { + width: 100%; + height: 100%; + } + {% else %} + #offline { + width: 100%; + height: 100%; + text-align: center; + display: grid; + align-content: center; + font-size: 20pt; + user-select: none; + } + #offline > h1 { + margin: 0; + } + {% endif %} - + {% if online %} + + {% else %} +

[offline]

+ {% endif %} From 4b68023cf215dbf5a321d9ac3e68ced6707d1700 Mon Sep 17 00:00:00 2001 From: n9k Date: Sat, 2 Apr 2022 04:46:24 +0000 Subject: [PATCH 08/23] Add websocket ping/pong Client and server both close the connection if they don't hear from the other party after a timeout period. This is a failsafe and should improve reliability. --- anonstream/__init__.py | 2 ++ anonstream/helpers/user.py | 2 +- anonstream/routes/websocket.py | 8 +++++--- anonstream/static/anonstream.js | 23 ++++++++++++++++++++++- anonstream/tasks.py | 25 ++++++++++++++++++++++++- anonstream/utils/websocket.py | 5 ++++- anonstream/websocket.py | 26 ++++++++++++++++++++------ config.toml | 2 ++ 8 files changed, 80 insertions(+), 13 deletions(-) diff --git a/anonstream/__init__.py b/anonstream/__init__.py index e8c0b14..ce9dd30 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -53,6 +53,8 @@ def create_app(config_file): 'MAX_CHAT_SCROLLBACK': config['memory']['chat_scrollback'], 'TASK_PERIOD_ROTATE_USERS': config['tasks']['rotate_users'], 'TASK_PERIOD_ROTATE_CAPTCHAS': config['tasks']['rotate_captchas'], + 'TASK_PERIOD_ROTATE_WEBSOCKETS': config['tasks']['rotate_websockets'], + 'TASK_PERIOD_BROADCAST_PING': config['tasks']['broadcast_ping'], 'TASK_PERIOD_BROADCAST_USERS_UPDATE': config['tasks']['broadcast_users_update'], 'TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE': config['tasks']['broadcast_stream_info_update'], 'THRESHOLD_USER_NOTWATCHING': config['thresholds']['user_notwatching'], diff --git a/anonstream/helpers/user.py b/anonstream/helpers/user.py index 08f897b..dbe48d4 100644 --- a/anonstream/helpers/user.py +++ b/anonstream/helpers/user.py @@ -35,7 +35,7 @@ def generate_user(timestamp, token, broadcaster, presence): 'tag': tag, 'broadcaster': broadcaster, 'verified': broadcaster, - 'websockets': set(), + 'websockets': {}, 'name': None, 'color': colour_to_color(colour), 'tripcode': None, diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index 158561a..665085a 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -3,6 +3,8 @@ import asyncio +from math import inf + from quart import current_app, websocket from anonstream.user import see @@ -13,7 +15,7 @@ from anonstream.routes.wrappers import with_user_from @with_user_from(websocket) async def live(user): queue = asyncio.Queue(maxsize=0) - user['websockets'].add(queue) + user['websockets'][queue] = -inf producer = websocket_outbound(queue, user) consumer = websocket_inbound(queue, user) @@ -21,8 +23,8 @@ async def live(user): await asyncio.gather(producer, consumer) finally: see(user) - user['websockets'].remove(queue) + user['websockets'].pop(queue) try: - await websocket.close(1000) + await websocket.close(1001) except RuntimeError: pass diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 08eed06..e756354 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -269,6 +269,9 @@ let stats = null; let stats_received = null; let default_name = {true: "Broadcaster", false: "Anonymous"}; let max_chat_scrollback = 256; +let pingpong_period = 8.0; +let ping = null; +const pingpong_timeout = () => pingpong_period * 1.5 + 4.0; const tidy_stylesheet = ({stylesheet, selector_regex, ignore_condition}) => { const to_delete = []; const to_ignore = new Set(); @@ -592,7 +595,7 @@ const on_websocket_message = (event) => { case "init": console.log("ws init", receipt); - // set title + pingpong_period = receipt.pingpong; set_title(receipt.title); // update stats (uptime/viewership) @@ -775,6 +778,13 @@ const on_websocket_message = (event) => { break; + case "ping": + console.log("ws ping"); + ping = new Date(); + const payload = {type: "pong"}; + ws.send(JSON.stringify(payload)); + break; + default: console.log("incomprehensible websocket message", receipt); } @@ -906,3 +916,14 @@ const chat_messages_unlock = document.getElementById("chat-messages-unlock"); chat_messages_unlock.addEventListener("click", (event) => { chat_messages.scrollTop = chat_messages.scrollTopMax; }); + +/* close websocket after prolonged absence of pings */ +const rotate_websocket = () => { + const this_pingpong_timeout = pingpong_timeout(); + if (ping === null || (new Date() - ping) / 1000 > this_pingpong_timeout) { + console.log(`no pings heard in ${this_pingpong_timeout} seconds, closing websocket...`); + ws.close(); + } + setTimeout(rotate_websocket, this_pingpong_timeout * 1000); +}; +setTimeout(rotate_websocket, pingpong_timeout() * 1000); diff --git a/anonstream/tasks.py b/anonstream/tasks.py index 1ee79d7..694b23c 100644 --- a/anonstream/tasks.py +++ b/anonstream/tasks.py @@ -5,7 +5,7 @@ import asyncio import itertools from functools import wraps -from quart import current_app +from quart import current_app, websocket from anonstream.broadcast import broadcast, broadcast_users_update from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership @@ -86,6 +86,27 @@ async def t_expire_captchas(iteration): for digest in to_delete: CAPTCHAS.pop(digest) +@with_period(CONFIG['TASK_PERIOD_ROTATE_WEBSOCKETS']) +@with_timestamp +async def t_close_websockets(timestamp, iteration): + THRESHOLD = CONFIG['TASK_PERIOD_BROADCAST_PING'] * 1.5 + 4.0 + if iteration == 0: + return + else: + for user in USERS: + for queue in user['websockets']: + last_pong = user['websockets'][queue] + last_pong_ago = timestamp - last_pong + if last_pong_ago > THRESHOLD: + queue.put_nowait({'type': 'close'}) + +@with_period(CONFIG['TASK_PERIOD_BROADCAST_PING']) +async def t_broadcast_ping(iteration): + if iteration == 0: + return + else: + broadcast(USERS, payload={'type': 'ping'}) + @with_period(CONFIG['TASK_PERIOD_BROADCAST_USERS_UPDATE']) async def t_broadcast_users_update(iteration): if iteration == 0: @@ -147,5 +168,7 @@ async def t_broadcast_stream_info_update(iteration): current_app.add_background_task(t_sunset_users) current_app.add_background_task(t_expire_captchas) +current_app.add_background_task(t_close_websockets) +current_app.add_background_task(t_broadcast_ping) current_app.add_background_task(t_broadcast_users_update) current_app.add_background_task(t_broadcast_stream_info_update) diff --git a/anonstream/utils/websocket.py b/anonstream/utils/websocket.py index dbd6142..294b75f 100644 --- a/anonstream/utils/websocket.py +++ b/anonstream/utils/websocket.py @@ -3,7 +3,7 @@ from enum import Enum -WS = Enum('WS', names=('MESSAGE, CAPTCHA, APPEARANCE')) +WS = Enum('WS', names=('PONG', 'MESSAGE', 'CAPTCHA', 'APPEARANCE')) class Malformed(Exception): pass @@ -48,5 +48,8 @@ def parse_websocket_data(receipt): case 'captcha': return WS.CAPTCHA, () + case 'pong': + return WS.PONG, () + case _: raise Malformed('malformed type') diff --git a/anonstream/websocket.py b/anonstream/websocket.py index cc4026b..739af37 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -10,6 +10,7 @@ from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership from anonstream.captcha import get_random_captcha_digest_for from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha, try_change_appearance +from anonstream.wrappers import with_timestamp from anonstream.utils.chat import generate_nonce from anonstream.utils.websocket import parse_websocket_data, Malformed, WS @@ -29,11 +30,16 @@ async def websocket_outbound(queue, user): }, 'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'], 'digest': get_random_captcha_digest_for(user), + 'pingpong': CONFIG['TASK_PERIOD_BROADCAST_PING'], } await websocket.send_json(payload) while True: payload = await queue.get() - await websocket.send_json(payload) + if payload['type'] == 'close': + await websocket.close(1011) + break + else: + await websocket.send_json(payload) async def websocket_inbound(queue, user): while True: @@ -59,17 +65,25 @@ async def websocket_inbound(queue, user): handle = handle_inbound_appearance case WS.CAPTCHA: handle = handle_inbound_captcha - payload = handle(user, *parsed) + case WS.PONG: + handle = handle_inbound_pong + payload = handle(queue, user, *parsed) - queue.put_nowait(payload) + if payload is not None: + queue.put_nowait(payload) -def handle_inbound_captcha(user): +@with_timestamp +def handle_inbound_pong(timestamp, queue, user): + user['websockets'][queue] = timestamp + return None + +def handle_inbound_captcha(queue, user): return { 'type': 'captcha', 'digest': get_random_captcha_digest_for(user), } -def handle_inbound_appearance(user, name, color, password, want_tripcode): +def handle_inbound_appearance(queue, user, name, color, password, want_tripcode): errors = try_change_appearance(user, name, color, password, want_tripcode) if errors: return { @@ -85,7 +99,7 @@ def handle_inbound_appearance(user, name, color, password, want_tripcode): #'tripcode': user['tripcode'], } -def handle_inbound_message(user, nonce, comment, digest, answer): +def handle_inbound_message(queue, user, nonce, comment, digest, answer): try: verification_happened = verify(user, digest, answer) except BadCaptcha as e: diff --git a/config.toml b/config.toml index f34542a..b5cf4b9 100644 --- a/config.toml +++ b/config.toml @@ -33,6 +33,8 @@ chat_scrollback = 256 [tasks] rotate_users = 60.0 rotate_captchas = 60.0 +rotate_websockets = 2.0 +broadcast_ping = 8.0 broadcast_users_update = 4.0 broadcast_stream_info_update = 3.0 From c0de94bc5d7c4bb859aefff9acd3d6db5c90dac4 Mon Sep 17 00:00:00 2001 From: n9k Date: Fri, 8 Apr 2022 03:13:18 +0000 Subject: [PATCH 09/23] Remove redundant failsafe `websocket.close()` Ping timeouts should do the same thing. --- anonstream/routes/websocket.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index 665085a..933c9c8 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -24,7 +24,3 @@ async def live(user): finally: see(user) user['websockets'].pop(queue) - try: - await websocket.close(1001) - except RuntimeError: - pass From 2c899cc18d99c6e8e57e2c380b13c67344e9501f Mon Sep 17 00:00:00 2001 From: n9k Date: Tue, 19 Apr 2022 12:10:57 +0000 Subject: [PATCH 10/23] Add timeout for each ASGI http.response.body message Ensures that if a client becomes idle the segment generator is exited within a constant amount of time (probably more than this timeout because of the ASGI server's write buffer and the OS's socket write buffer, but still constant). --- anonstream/__init__.py | 2 +- anonstream/quart.py | 51 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 anonstream/quart.py diff --git a/anonstream/__init__.py b/anonstream/__init__.py index ce9dd30..67d5aa3 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -6,10 +6,10 @@ import secrets import toml from collections import OrderedDict -from quart import Quart from quart_compress import Compress from werkzeug.security import generate_password_hash +from anonstream.quart import Quart from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer from anonstream.utils.colour import color_to_colour from anonstream.utils.user import generate_token diff --git a/anonstream/quart.py b/anonstream/quart.py new file mode 100644 index 0000000..5761e86 --- /dev/null +++ b/anonstream/quart.py @@ -0,0 +1,51 @@ +import asyncio + +from werkzeug.wrappers import Response as WerkzeugResponse +from quart.app import Quart as Quart_ +from quart.asgi import ASGIHTTPConnection as ASGIHTTPConnection_ +from quart.utils import encode_headers + + +RESPONSE_ITERATOR_TIMEOUT = 10 + + +class ASGIHTTPConnection(ASGIHTTPConnection_): + async def _send_response(self, send, response): + await send({ + "type": "http.response.start", + "status": response.status_code, + "headers": encode_headers(response.headers), + }) + + if isinstance(response, WerkzeugResponse): + for data in response.response: + body = data.encode(response.charset) if isinstance(data, str) else data + await asyncio.wait_for( + send({ + "type": "http.response.body", + "body": body, + "more_body": True, + }), + timeout=RESPONSE_ITERATOR_TIMEOUT, + ) + else: + async with response.response as response_body: + async for data in response_body: + body = data.encode(response.charset) if isinstance(data, str) else data + await asyncio.wait_for( + send({ + "type": "http.response.body", + "body": body, + "more_body": True, + }), + timeout=RESPONSE_ITERATOR_TIMEOUT, + ) + await send({ + "type": "http.response.body", + "body": b"", + "more_body": False, + }) + + +class Quart(Quart_): + asgi_http_class = ASGIHTTPConnection From 95f12fa6323dc40fac4eea53377a9f8d65ed1f57 Mon Sep 17 00:00:00 2001 From: n9k Date: Tue, 15 Mar 2022 10:24:59 +0000 Subject: [PATCH 11/23] Send in responses when auth fails --- anonstream/routes/wrappers.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index ad8da2b..8a4484d 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -33,14 +33,21 @@ def auth_required(f): async def wrapper(*args, **kwargs): if check_auth(request): return await f(*args, **kwargs) - hint = 'The broadcaster should log in with the credentials printed ' \ - 'in their terminal.' - body = ( - f'

{hint}

' - if request.authorization is None else - '

Wrong username or password. Refresh the page to try again.

' - f'

{hint}

' + hint = ( + 'The broadcaster should log in with the credentials printed in ' + 'their terminal.' ) + if request.authorization is None: + body = ( + f'\n' + f'

{hint}

\n' + ) + else: + body = ( + f'\n' + f'

Wrong username or password. Refresh the page to try again.

\n' + f'

{hint}

\n' + ) return body, 401, {'WWW-Authenticate': 'Basic'} return wrapper From 77d68629b66a87f5eb642c1ced945854d05caef5 Mon Sep 17 00:00:00 2001 From: n9k Date: Sat, 2 Apr 2022 14:38:07 +0000 Subject: [PATCH 12/23] CSS: fullheight mobile chat, remove iframe margins --- anonstream/static/style.css | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/anonstream/static/style.css b/anonstream/static/style.css index f0482dd..76936a5 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -44,6 +44,7 @@ a { color: #42a5d7; } iframe { + display: grid; width: 100%; border: none; box-sizing: border-box; @@ -119,13 +120,11 @@ noscript { #chat { display: grid; - grid-auto-rows: auto 1fr auto; + grid-auto-rows: auto minmax(150px, 1fr) auto; background-color: var(--chat-bg-color); border-top: var(--chat-border); border-bottom: var(--chat-border); grid-area: chat; - height: 50vh; - min-height: 24ch; position: relative; } #chat__toggle { @@ -511,7 +510,7 @@ footer { border-style: inset; } #both:target #info_nojs { - height: 9ch; + height: 11ch; } #info:target { grid-row-end: chat-end; From 57053b5eca194056f03ff56ec13271517a8a679a Mon Sep 17 00:00:00 2001 From: n9k Date: Tue, 19 Apr 2022 02:46:03 +0000 Subject: [PATCH 13/23] Chat insignia: solid orangered background --- anonstream/static/style.css | 7 ++++++- anonstream/templates/nojs_chat_messages.html | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 76936a5..b6fe740 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -239,7 +239,12 @@ noscript { cursor: default; } .chat-insignia { - text-shadow: 0 0 2px orangered; + color: var(--chat-bg); + border: 1px outset #0000007f; + text-shadow: 0 0 1px var(--chat-bg-color); + background-color: orangered; + padding: 0 2px; + border-radius: 3px; cursor: help; } .chat-name { diff --git a/anonstream/templates/nojs_chat_messages.html b/anonstream/templates/nojs_chat_messages.html index 1487b5f..a22cf54 100644 --- a/anonstream/templates/nojs_chat_messages.html +++ b/anonstream/templates/nojs_chat_messages.html @@ -109,7 +109,12 @@ cursor: default; } .chat-message__insignia { - text-shadow: 0 0 2px orangered; + color: var(--chat-bg); + border: 1px outset #0000007f; + text-shadow: 0 0 1px var(--chat-bg-color); + background-color: orangered; + padding: 0 2px; + border-radius: 3px; cursor: help; } .chat-message__name { From 31b82a9983c8d6da805acef6c9689d29d56cee06 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 20 Apr 2022 10:07:20 +0000 Subject: [PATCH 14/23] Websocket: ping immediately --- anonstream/websocket.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/anonstream/websocket.py b/anonstream/websocket.py index 739af37..6393ee0 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -33,6 +33,7 @@ async def websocket_outbound(queue, user): 'pingpong': CONFIG['TASK_PERIOD_BROADCAST_PING'], } await websocket.send_json(payload) + await websocket.send_json({'type': 'ping'}) while True: payload = await queue.get() if payload['type'] == 'close': @@ -74,6 +75,7 @@ async def websocket_inbound(queue, user): @with_timestamp def handle_inbound_pong(timestamp, queue, user): + print(f'[pong] {user["token"]}') user['websockets'][queue] = timestamp return None From 9e91349ca921962681d81c0827e5b1a07000aaa9 Mon Sep 17 00:00:00 2001 From: n9k Date: Fri, 13 May 2022 23:38:54 +0000 Subject: [PATCH 15/23] m3u8 init_section may not exist --- anonstream/segments.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/anonstream/segments.py b/anonstream/segments.py index bcf0582..e69bd88 100644 --- a/anonstream/segments.py +++ b/anonstream/segments.py @@ -85,7 +85,8 @@ async def get_segment_uris(token): f'stream went offline before we could find any segments' ) return - else: + + if segment.init_section is not None: yield segment.init_section.uri while True: From 542d6c9ae58633479286b84186ea042c3ff53860 Mon Sep 17 00:00:00 2001 From: n9k Date: Sat, 28 May 2022 05:37:41 +0000 Subject: [PATCH 16/23] Detect chat flooding by counting lines Reject comments by line count. Ratelimit users by number of lines sent in chat. --- anonstream/__init__.py | 6 ++++-- anonstream/chat.py | 34 +++++++++++++++++++++++++++++++--- anonstream/helpers/user.py | 3 ++- anonstream/user.py | 4 ++-- anonstream/utils/chat.py | 8 ++++++++ config.toml | 6 +++++- 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 67d5aa3..563ad18 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -66,8 +66,10 @@ def create_app(config_file): 'CHAT_NAME_MIN_CONTRAST': config['chat']['min_name_contrast'], 'CHAT_BACKGROUND_COLOUR': color_to_colour(config['chat']['background_color']), 'CHAT_LEGACY_TRIPCODE_ALGORITHM': config['chat']['legacy_tripcode_algorithm'], - 'FLOOD_DURATION': config['flood']['duration'], - 'FLOOD_THRESHOLD': config['flood']['threshold'], + 'FLOOD_MESSAGE_DURATION': config['flood']['messages']['duration'], + 'FLOOD_MESSAGE_THRESHOLD': config['flood']['messages']['threshold'], + 'FLOOD_LINE_DURATION': config['flood']['lines']['duration'], + 'FLOOD_LINE_THRESHOLD': config['flood']['lines']['threshold'], 'CAPTCHA_LIFETIME': config['captcha']['lifetime'], 'CAPTCHA_FONTS': config['captcha']['fonts'], 'CAPTCHA_ALPHABET': config['captcha']['alphabet'], diff --git a/anonstream/chat.py b/anonstream/chat.py index 9c789e8..ea875f6 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -8,7 +8,7 @@ from quart import current_app, escape from anonstream.broadcast import broadcast, broadcast_users_update from anonstream.helpers.chat import generate_nonce_hash, get_scrollback -from anonstream.utils.chat import get_message_for_websocket +from anonstream.utils.chat import get_message_for_websocket, get_approx_linespan CONFIG = current_app.config MESSAGES_BY_ID = current_app.messages_by_id @@ -33,6 +33,26 @@ def add_chat_message(user, nonce, comment, ignore_empty=False): if ignore_empty and len(comment) == 0: return False + timestamp_ms = time.time_ns() // 1_000_000 + timestamp = timestamp_ms // 1000 + + # Check user + while user['linespan']: + linespan_timestamp, _ = user['linespan'][0] + if timestamp - linespan_timestamp >= CONFIG['FLOOD_LINE_DURATION']: + user['linespan'].popleft() + else: + break + total_recent_linespan = sum(map( + lambda linespan_tuple: linespan_tuple[1], + user['linespan'], + )) + if total_recent_linespan > CONFIG['FLOOD_LINE_THRESHOLD']: + raise Rejected( + f'Chat overuse in the last ' + f'{CONFIG["FLOOD_LINE_DURATION"]:.0f} seconds' + ) + # Check message message_id = generate_nonce_hash(nonce) if message_id in MESSAGES_BY_ID: @@ -41,10 +61,18 @@ def add_chat_message(user, nonce, comment, ignore_empty=False): raise Rejected('Message was empty') if len(comment) > 512: raise Rejected('Message exceeded 512 chars') + if comment.count('\n') + 1 > 12: + raise Rejected('Message exceeded 12 lines') + + linespan = get_approx_linespan(comment) + if linespan > 12: + raise Rejected('Message would span too many lines') + + # Record linespan + linespan_tuple = (timestamp, linespan) + user['linespan'].append(linespan_tuple) # Create and add message - timestamp_ms = time.time_ns() // 1_000_000 - timestamp = timestamp_ms // 1000 try: last_message = next(reversed(MESSAGES)) except StopIteration: diff --git a/anonstream/helpers/user.py b/anonstream/helpers/user.py index dbe48d4..82442c5 100644 --- a/anonstream/helpers/user.py +++ b/anonstream/helpers/user.py @@ -3,7 +3,7 @@ import hashlib import base64 -from collections import OrderedDict +from collections import deque, OrderedDict from math import inf from quart import current_app @@ -45,6 +45,7 @@ def generate_user(timestamp, token, broadcaster, presence): 'watching': -inf, }, 'presence': presence, + 'linespan': deque(), } def get_default_name(user): diff --git a/anonstream/user.py b/anonstream/user.py index 51df031..84a7a2d 100644 --- a/anonstream/user.py +++ b/anonstream/user.py @@ -152,12 +152,12 @@ def deverify(timestamp, user): n_user_messages = 0 for message in reversed(MESSAGES): message_sent_ago = timestamp - message['timestamp'] - if message_sent_ago >= CONFIG['FLOOD_DURATION']: + if message_sent_ago >= CONFIG['FLOOD_MESSAGE_DURATION']: break elif message['token'] == user['token']: n_user_messages += 1 - if n_user_messages >= CONFIG['FLOOD_THRESHOLD']: + if n_user_messages >= CONFIG['FLOOD_MESSAGE_THRESHOLD']: user['verified'] = False def _update_presence(timestamp, user): diff --git a/anonstream/utils/chat.py b/anonstream/utils/chat.py index b35b945..0b4d5f7 100644 --- a/anonstream/utils/chat.py +++ b/anonstream/utils/chat.py @@ -3,6 +3,7 @@ import base64 import hashlib +import math import secrets class NonceReuse(Exception): @@ -18,3 +19,10 @@ def get_message_for_websocket(user, message): **{key: message[key] for key in message_keys}, **{key: user[key] for key in user_keys}, } + +def get_approx_linespan(text): + def height(line): + return math.ceil(len(line) / 48) + linespan = sum(map(height, text.splitlines())) + linespan = linespan if linespan > 0 else 1 + return linespan diff --git a/config.toml b/config.toml index b5cf4b9..672a57e 100644 --- a/config.toml +++ b/config.toml @@ -49,10 +49,14 @@ min_name_contrast = 3.0 background_color = "#232327" legacy_tripcode_algorithm = false -[flood] +[flood.messages] duration = 20.0 threshold = 4 +[flood.lines] +duration = 20.0 +threshold = 20 + [thresholds] user_notwatching = 8.0 user_tentative = 20.0 From ae6c1ba5a9ced4111127b8a459f8dc8705dc8e4b Mon Sep 17 00:00:00 2001 From: n9k Date: Sat, 28 May 2022 05:40:53 +0000 Subject: [PATCH 17/23] Reject whitespace-only comments --- anonstream/chat.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/anonstream/chat.py b/anonstream/chat.py index ea875f6..473d9b5 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -59,6 +59,8 @@ def add_chat_message(user, nonce, comment, ignore_empty=False): raise Rejected('Discarded suspected duplicate message') if len(comment) == 0: raise Rejected('Message was empty') + if len(comment.strip()) == 0: + raise Rejected('Message was practically empty') if len(comment) > 512: raise Rejected('Message exceeded 512 chars') if comment.count('\n') + 1 > 12: From 45a4d88720bbb3c4cdb227f08c0ad570ed1ba81b Mon Sep 17 00:00:00 2001 From: n9k Date: Thu, 9 Jun 2022 01:43:13 +0000 Subject: [PATCH 18/23] STREAMING.md: complete instructions --- STREAMING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/STREAMING.md b/STREAMING.md index 4f82e34..f511406 100644 --- a/STREAMING.md +++ b/STREAMING.md @@ -24,8 +24,10 @@ Click `Settings` and set these: * Advanced * Recording * Filename Formatting: `stream` + * Overwrite if file exists: yes * Video * Output (Scaled) Resolution: `960x540` or lower + * Common FPS Values: any integer framerate (e.g. 30 or 60) * Output * Output Mode: `Advanced` * Recording: @@ -37,7 +39,7 @@ Click `Settings` and set these: | Container Format | `hls` | | Muxer Settings (if any) | `hls_init_time=0 hls_time=2 hls_list_size=120 hls_flags=delete_segments hls_segment_type=fmp4` | | Video bitrate | `420 Kbps` or lower | - | Keyframe interval (frames) | `30` (same as the framerate, or exactly half) | + | Keyframe interval (frames) | `framerate*hls_time`, e.g. for 60fps and an `hls_time` of 2 seconds, use 120 | | Video Encoder | libx264, or an H.264 hardware encoder (e.g. `h264_nvenc` for Nvidia, [see here][ffmpeg]) | | Audio Bitrate | `96 Kbps` | | Audio Encoder | `aac` | From 667e35bf1643fad35ab5216d09457644b72d44e8 Mon Sep 17 00:00:00 2001 From: n9k Date: Sat, 11 Jun 2022 22:24:58 +0000 Subject: [PATCH 19/23] Handle OSErrors reading playlist, give reasons for offline --- anonstream/segments.py | 49 +++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/anonstream/segments.py b/anonstream/segments.py index e69bd88..cf7c429 100644 --- a/anonstream/segments.py +++ b/anonstream/segments.py @@ -25,11 +25,12 @@ class UnsafePath(Exception): def get_mtime(): try: mtime = os.path.getmtime(CONFIG['SEGMENT_PLAYLIST']) - except FileNotFoundError as e: - raise Stale from e + except OSError as e: + raise Stale(f"couldn't stat playlist: {e}") from e else: - if time.time() - mtime >= CONFIG['SEGMENT_PLAYLIST_STALE_THRESHOLD']: - raise Stale + mtime_ago = time.time() - mtime + if mtime_ago >= CONFIG['SEGMENT_PLAYLIST_STALE_THRESHOLD']: + raise Stale(f'last modified {mtime_ago:.1f}s ago') return mtime @ttl_cache(CONFIG['SEGMENT_PLAYLIST_CACHE_LIFETIME']) @@ -38,13 +39,18 @@ def get_playlist(): try: mtime = get_mtime() except Stale as e: - raise Offline from e + reason, *_ = e.args + raise Offline(f'stale playlist: {reason}') from e else: - playlist = m3u8._load_from_file(CONFIG['SEGMENT_PLAYLIST']) - if playlist.is_endlist: - raise Offline - if len(playlist.segments) == 0: - raise Offline + try: + playlist = m3u8._load_from_file(CONFIG['SEGMENT_PLAYLIST']) + except OSError: + raise Offline(f"couldn't read playlist: {e}") from e + else: + if playlist.is_endlist: + raise Offline('playlist ended') + if len(playlist.segments) == 0: + raise Offline('empty playlist') return playlist, mtime @@ -79,10 +85,11 @@ def get_next_segment(uri): async def get_segment_uris(token): try: segment = get_starting_segment() - except Offline: + except Offline as e: + reason, *_ = e.args print( f'[debug @ {time.time():.3f}: {token=}] ' - f'stream went offline before we could find any segments' + f'stream went offline before we could find any segments ({reason})' ) return @@ -96,11 +103,12 @@ async def get_segment_uris(token): while True: try: next_segment = get_next_segment(segment.uri) - except Offline: + except Offline as e: + reason, *_ = e.args print( f'[debug @ {time.time():.3f}: {token=}] ' - f'stream went offline while looking for the segment ' - f'following {uri=}' + f'stream went offline while looking for the ' + f'segment following {segment.uri!r} ({reason})' ) return else: @@ -110,7 +118,8 @@ async def get_segment_uris(token): elif time.monotonic() - t0 >= CONFIG['SEGMENT_SEARCH_TIMEOUT']: print( f'[debug @ {time.time():.3f}: {token=}] ' - f'timed out looking for the segment following {uri=}' + f'timed out looking for the segment following ' + f'{segment.uri!r} ' f'(timeout={CONFIG["SEGMENT_SEARCH_TIMEOUT"]}s)' ) return @@ -135,7 +144,7 @@ async def segments(segment_read_hook=lambda uri: None, token=None): unsafe_path, *_ = e.args print( f'[debug @ {time.time():.3f}: {token=}] ' - f'segment {uri=} has unsafe {path=}' + f'segment {uri=} has {unsafe_path=}' ) break @@ -150,4 +159,10 @@ async def segments(segment_read_hook=lambda uri: None, token=None): f'segment {uri=} at {path=} unexpectedly does not exist' ) break + except OSError as e: + print( + f'[debug @ {time.time():.3f}: {token=}] ' + f'segment {uri=} at {path=} cannot be read: {e}' + ) + break print(f'[debug @ {time.time():.3f}: {token=}] exiting segment generator') From 971ab4769a356c561a22e348362f821bf09e183a Mon Sep 17 00:00:00 2001 From: n9k Date: Sat, 11 Jun 2022 22:57:23 +0000 Subject: [PATCH 20/23] Use accesskey 'r' for reload stream button --- anonstream/static/anonstream.js | 2 +- anonstream/templates/nojs_info.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index e756354..f68c5d5 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -15,7 +15,7 @@ const jsmarkup_stream_video = '
+ -