From 4df004c18aa7ab44ba7dae2c41c52f7946e7b957 Mon Sep 17 00:00:00 2001 From: bramval Date: Sat, 24 Jan 2026 19:59:00 +0100 Subject: [PATCH] Scale display player by switching from SSE to polling --- app/routes/api.py | 14 +++++--- app/templates/display/player.html | 58 ++++++++----------------------- 2 files changed, 24 insertions(+), 48 deletions(-) diff --git a/app/routes/api.py b/app/routes/api.py index 47b61cb..97ff44c 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -29,11 +29,15 @@ def _enforce_and_touch_display_session(display: Display, sid: str | None): return True, None cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS) - DisplaySession.query.filter( - DisplaySession.display_id == display.id, - DisplaySession.last_seen_at < cutoff, - ).delete(synchronize_session=False) - db.session.commit() + # Cleanup old sessions. Avoid committing if nothing was deleted (saves write locks on SQLite). + deleted = ( + DisplaySession.query.filter( + DisplaySession.display_id == display.id, + DisplaySession.last_seen_at < cutoff, + ).delete(synchronize_session=False) + ) + if deleted: + db.session.commit() existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first() if existing: diff --git a/app/templates/display/player.html b/app/templates/display/player.html index ade62dc..5335d48 100644 --- a/app/templates/display/player.html +++ b/app/templates/display/player.html @@ -38,9 +38,6 @@ let idx = 0; let timer = null; - let es = null; - let esRetryMs = 1000; - async function fetchPlaylist() { const qs = sid ? `?sid=${encodeURIComponent(sid)}` : ''; const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' }); @@ -115,37 +112,17 @@ // keep retrying; if a slot frees up the display will start automatically. } - // Open live event stream: when server signals a change, reload playlist immediately. - connectEvents(); - - // Fallback refresh (in case SSE is blocked by a proxy/network): every 5 minutes. + // Poll for updates instead of SSE. + // This scales better for 100s of displays because it avoids long-lived HTTP + // connections (which otherwise tie up gunicorn sync workers). + // Default: check every 20s (can be overridden via ?poll=seconds). + const pollParam = parseInt(new URLSearchParams(window.location.search).get('poll') || '', 10); + const pollSeconds = Number.isFinite(pollParam) && pollParam > 0 ? pollParam : 20; setInterval(async () => { - try { - playlist = await fetchPlaylist(); - if (!stage.firstChild) { - idx = 0; - next(); - } - } catch(e) { - clearStage(); - setNotice(e && e.message ? e.message : 'Unable to load playlist.'); - } - }, 300000); - } - - function connectEvents() { - if (isPreview) return; // preview shouldn't consume a slot / keep a long-lived connection - - try { if (es) es.close(); } catch(e) { /* ignore */ } - - const qs = sid ? `?sid=${encodeURIComponent(sid)}` : ''; - es = new EventSource(`/api/display/${token}/events${qs}`); - - es.addEventListener('changed', async () => { try { const newPlaylist = await fetchPlaylist(); - // If content changed, restart from the beginning. + // Restart if something changed. const oldStr = JSON.stringify(playlist); const newStr = JSON.stringify(newPlaylist); playlist = newPlaylist; @@ -154,21 +131,16 @@ next(); } - esRetryMs = 1000; // reset backoff on success + // If player is blank (e.g. after a temporary error), kick it. + if (!stage.firstChild) { + idx = 0; + next(); + } } catch(e) { - // leave current playback; we'll retry via reconnect handler + clearStage(); + setNotice(e && e.message ? e.message : 'Unable to load playlist.'); } - }); - - es.onerror = () => { - try { es.close(); } catch(e) { /* ignore */ } - es = null; - - // Exponential backoff up to 30s - const wait = esRetryMs; - esRetryMs = Math.min(30000, Math.floor(esRetryMs * 1.7)); - setTimeout(connectEvents, wait); - }; + }, pollSeconds * 1000); } start();