feat(schedule): show which asset is on screen right now - #3308
feat(schedule): show which asset is on screen right now#3308mickzijdel wants to merge 5 commits into
Conversation
The Schedule Overview could not answer the one question an operator asks while standing in front of the screen: which of these is playing? With shuffle on, play order says nothing about it, so the only way to tell today is to go and look at the TV (Screenly#3177). The viewer already knows — scheduler.current_asset_id — but only answered on request, over the blocking BLPOP round trip behind /api/v1/viewer_current_asset. The table re-renders every 5s for every open browser, so asking per render would wake the display loop on each poll. It publishes the id to Redis instead, the same shape as cec:available and the SMART fact, and the render just reads a key. The TTL is liveness, not content: a 3-minute window kept alive by a refresher on a 1-minute tick, matching the display-resolution fact. Deriving it from the asset's own duration was the first instinct and was wrong in both directions — durations run to a year, so a viewer that died mid-rotation would have gone on claiming a row for months, while a viewer paused with `stop` would have dropped the highlight off a picture still on the screen. Tying the fact to "the viewer said something recently" gets both right without either knowing about the other. `blank` retires the fact outright, because unlike `stop` it leaves nothing on screen to point at. The named row gets a tinted background and a solid "Playing now" chip. Nothing is highlighted when the viewer hasn't reported, so a stopped viewer or an unreachable Redis shows no highlight rather than a stale guess. The chip needed a token the design system didn't have. --color-success is safe as a fill, but its ink partner --color-success-on-wash belongs to the translucent wash and lightens for dark mode, which strands dark text at 1.85:1 there. Adds --color-success-fill / --color-on-success as a stable pair, exactly the split --color-danger already draws, and puts them in the contrast harness so the next person can't repeat it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WiKQ8i8nJRJudQxSVtdkCi
…e poll The highlight was correct but late: the table asks the server for a fresh render every 5s, so an operator stepping through assets with Next watched the screen change and the page catch up a beat later. The viewer now announces each change on a pub/sub channel of its own, and the WebSocket consumer subscribes for the life of a socket and nudges its browser — the same "something changed, re-fetch the table" frame the consumer already sends on writes, which vendor.ts turns into an htmx refresh. Measured at 1-12ms from the viewer's publish to the frame leaving the consumer, against a real Redis. Only actual changes are announced. Every announcement costs every open browser a full table render, and a single-asset playlist rotates forever with no news to report, so the SET carries `get=True` and the publish only fires when the value moved. The SET itself stays unconditional because it is what refreshes the liveness TTL. Nothing here is load-bearing: no Redis, a dropped subscription or a closed socket ends the task quietly and the 5s poll goes on keeping the table correct. The subscription is per-connection, so it dies with its socket — note that vendor.ts opens /ws on every page, not just the schedule page, so it is one Redis subscription per open tab. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WiKQ8i8nJRJudQxSVtdkCi
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3308 +/- ##
=========================================
Coverage ? 90.43%
=========================================
Files ? 86
Lines ? 10036
Branches ? 1109
=========================================
Hits ? 9076
Misses ? 706
Partials ? 254 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…the key Review found the liveness TTL didn't deliver the one property it was added for. refresh() was a bare EXPIRE, so it extended whatever sat in Redis regardless of who put it there or whether this process had ever displayed anything. A viewer that restarts starts ticking before wait_for_server and the splash, so it inherited its dead predecessor's claim and renewed it for the whole ~60-120s boot while its own screen showed the splash page. A viewer crash-looping faster than the TTL — the Sentry ANTHIAS-3 class this file already documents — renewed it forever, which is exactly the stale claim the TTL was supposed to end. The comparison to the display-resolution reporter was what hid it: that one re-derives its value every tick, so it can only assert something currently true. This one extended a value it never re-derived. Now it re-asserts the module's own memory of what it last put on screen, and does nothing at all until this process has put something there. Two things fall out of using SET rather than EXPIRE. The fact now survives Redis losing it — an unclean restart inside the fsync window, a flushed volume, an eviction — where before a pinned hour-long dashboard would have gone unhighlighted until it finally rotated, the very case the refresher exists to serve. And clear() retires it for good instead of for one tick. Also closes a race between the two threads. blank_display() runs on the subscriber thread and retires the fact, but a rotation already past its own check on the main thread could re-create it microseconds later, and with the loop then parked on loop_is_stopped nothing would ever retire it again — a highlight pinned to a black screen. The refresher tick now reconciles that, and boot clears whatever a previous process left behind rather than waiting for it to expire. Verified against a real Redis: an inherited key's TTL is left to decay (100s stayed 100s), this process's own is renewed to 180, a flushed Redis is repopulated on the next tick, and a cleared fact stays gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WiKQ8i8nJRJudQxSVtdkCi
Review found disconnect() gated group_discard behind the subscription task's teardown. That teardown closes a Redis connection, and connect_to_redis_async sets no socket timeout, so redis-py's close wraps its wait in async_timeout(None) — a half-open socket to a wedged Redis stalls it with no ceiling. The channel name would then stay in ws_server and every later notify_asset_update would fan out to a dead channel. group_discard now runs first and unconditionally, and the wait for the task is capped. Narrows the suppress to CancelledError and TimeoutError. The Exception arm was dead code — the task body catches Exception on both its paths, so nothing but a BaseException can escape it — and it also swallowed a cancellation aimed at disconnect() itself, making the ASGI server's own teardown timeout unable to interrupt it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WiKQ8i8nJRJudQxSVtdkCi
…ptions The warn-once latch never reset, so the first Redis blip after a container start silenced that call site at DEBUG for the life of the process — days to weeks for anthias-server. Since each key latches a whole `except Exception`, a genuinely different fault afterwards (a WRONGTYPE from a key something else wrote, a decode failure) would never be seen. It now re-arms on the next success, which is warn-once-per-outage rather than warn-once-ever. The sibling helpers in undervoltage and storage_health latch one narrow branch each, so they don't have this problem to solve. Two descriptions also drifted from the code during the TTL rework: a comment in test_viewer.py still said the TTL came from the clamped duration, which stopped being true when it became a liveness window, and CLAUDE.md named the pub/sub channel where it meant the key the server reads. Both now say what the code does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WiKQ8i8nJRJudQxSVtdkCi
|
vpetersson-bot
left a comment
There was a problem hiding this comment.
Reviewed this as untrusted input, including a pass for anything hostile hiding in the diff. Nothing malicious found. I checked out the branch, ran the targeted suites (41 new/adjacent tests, 516 in test_viewer.py + test_template_views.py + api/tests/test_assets.py) and ruff check / ruff format --check — all green here too.
One note on process rather than code: this PR edits CLAUDE.md and .claude/skills/anthias-viewer/SKILL.md, which steer how agents behave in this repo. I read both hunks in full — they are factual and match the code, no injected directives — but instruction files arriving from a fork deserve a deliberate human read rather than being skimmed as docs.
What holds up
The reasoning in the module docstring is right where it counts, and I verified the parts that were checkable rather than taking them on faith:
- The TTL-as-liveness argument. Deriving it from
Asset.durationreally would be wrong in both directions, andrefresh()re-asserting_believedrather than blind-EXPIREing is the correct call — a crash-looping viewer renewing its dead predecessor's claim forever is exactly the failure the TTL exists to end. - No surface left unflagged. All three render paths (
views.py:209,:221,:1494) go throughpage_context.assets(), so there is no view that renders_asset_row.htmlwith the highlight silently missing. SET ... GETneeds Redis >= 6.2.Dockerfile.redis.j2installsredis-serverfrom Debian trixie, so this is fine — worth having confirmed, because the failure mode would have been a silently dead feature (warn once, then DEBUG forever).- The contrast claim.
--color-on-successon--color-success-fillis#0e4a30on#34d399= 5.35:1, and neither--color-green-500nor--color-green-900is redeclared intheme-dark.css, so the pair really is stable across both themes.
What I'd want changed
The substantive findings are all in the push half (commit 2), and the first two share one fix. Details inline; summarised here:
- One Redis connection and one pub/sub subscription per open browser tab, on an endpoint that is unauthenticated and (with the default
ALLOWED_HOSTS=['*']) not origin-gated either. self.send()from a barecreate_taskbreaks Channels' send serialisation — a now-playing frame can interleave withasset_update.- The nudge has no rate limit. The 5s poll used to be the ceiling on table renders; it no longer is.
- The
/wsdisclosure you flagged reaches further than the note says — and costs nothing to fix, since the client ignores the payload.
All four are in the layer the PR body itself describes as "purely an optimisation". Routing the fan-out through the existing channel layer instead of a per-socket subscription addresses 1, 2 and 4 at once and reuses the notify_asset_update path that is already there. Commit 1 (the fact and the highlight) I have no reservations about.
| # exactly this socket's. Note that vendor.ts opens /ws on every | ||
| # page, not just the schedule page, so this is one Redis | ||
| # subscription per open tab. | ||
| self._now_playing_task = asyncio.create_task(self._watch_now_playing()) |
There was a problem hiding this comment.
One Redis connection per open tab, on an unauthenticated endpoint.
connect_to_redis_async() builds a fresh client — and therefore a fresh connection pool — on every call, so this is one Redis connection plus one asyncio task per WebSocket. Your own comment notes vendor.ts opens /ws on every page, so that is already one per tab rather than one per schedule page.
The part that makes it more than a housekeeping concern: /ws has no auth, and AllowedHostsOriginValidator is a no-op under the default ALLOWED_HOSTS=['*'] (asgi.py says so explicitly). WebSocket handshakes are not subject to CORS, so any page the operator happens to have open — not just something already on the LAN — can open sockets in a loop and pin a Redis connection and an event-loop task each, on a board that may have 512 MB.
Suggested shape: one process-wide subscriber task that re-broadcasts onto the existing WS_GROUP via group_send with a new handler type, exactly as notify_asset_update already does. That makes it one Redis subscription per server process regardless of tab count, reuses the fan-out path that is already tested, and fixes the concurrency issue below as a side effect.
| data = message.get('data') | ||
| if not isinstance(data, str): | ||
| continue | ||
| await self.send(text_data=data) |
There was a problem hiding this comment.
Sending from an independent task breaks Channels' serialisation guarantee.
AsyncWebsocketConsumer runs its handlers one at a time in the consumer's own dispatch loop, which is why asset_update never has to think about a concurrent send. This task sits outside that loop, so a now-playing frame can land while asset_update is mid-send — an upload finishing during a rotation is enough. Concurrent websocket.send on a single ASGI connection is not something uvicorn's ws implementations promise to serialise for you.
It will be rare, and the symptom would be an ugly one to debug from a field report. Routing through the channel layer (see the comment on connect) removes the concurrency rather than making it safe.
| if not isinstance(data, str): | ||
| continue | ||
| await self.send(text_data=data) | ||
| except Exception: |
There was a problem hiding this comment.
Minor, but worth weighing against the module's own logging philosophy: except Exception at DEBUG means a genuine defect here — a renamed redis-py API, a pubsub() signature change, aclose() going away — silently disables the feature with nothing in the journal at the default level. The tests mock the client end to end, so they would not catch it either.
now_playing already carries a _warn_once latch built for exactly this trade-off (warn on the first failure per process, DEBUG after). Reusing it here would keep the quiet-by-default behaviour for the expected "no Redis" case while still surfacing the unexpected one.
| # CancelledError only: the task body already swallows Exception | ||
| # on both its paths, so a broader suppress would be dead code | ||
| # that also swallowed a cancellation aimed at disconnect itself. | ||
| with contextlib.suppress(asyncio.CancelledError, TimeoutError): |
There was a problem hiding this comment.
Nit: the comment says a broader suppress "would also swallow a cancellation aimed at disconnect itself" — but suppressing asyncio.CancelledError here does exactly that. If the server is shutting down and disconnect() is itself cancelled, wait_for raises CancelledError, this swallows it, and disconnect returns as though nothing happened.
task.cancel() followed by await asyncio.wait({task}, timeout=self.NOW_PLAYING_TEARDOWN_S) gives you the same bounded wait without absorbing a cancellation meant for the caller.
| # turns one into a full table re-render, and a single-asset | ||
| # playlist would otherwise pay that on every loop for no news. | ||
| # ``get=True`` returns the previous value (Redis >= 6.2). | ||
| previous = client.set(NOW_PLAYING_KEY, asset_id, ex=TTL_S, get=True) |
There was a problem hiding this comment.
The dedup handles the single-asset playlist, but nothing caps the rate when the asset genuinely changes.
clamp_duration floors at 0, so a playlist of short- or zero-duration images rotates as fast as the display loop turns. Every one of those rotations is a distinct value, so it clears the previous != asset_id check, publishes, and each open tab turns that into a full _asset_table.html fetch — Asset.objects.all(), a Redis read, and a template render, per tab, per rotation.
Before this change the 5s poll was a hard ceiling on how often that could happen. It no longer is, and the direction of the regression is toward the weakest hardware. The PR body's framing — the push "only decides whether the operator sees it land with the picture or up to 5s later" — is right about the benefit but understates the cost at the fast end.
A floor on announcement frequency here (say, no more than one publish per second, keeping the unconditional SET for the TTL) would preserve the felt-instant case that motivates the feature while restoring a bound. Debouncing the htmx trigger in vendor.ts would work too, but server-side is cheaper and covers every client.
| #: nudges open browsers so the highlight arrives with the picture | ||
| #: rather than up to 5s later. Its own channel, not the viewer command | ||
| #: bus: the audience here is browsers, not the viewer. | ||
| NOW_PLAYING_CHANNEL = 'anthias.now_playing' |
There was a problem hiding this comment.
On the /ws disclosure you flagged in the PR body — worth restating with the reach it actually has, because the note reads as narrower than the exposure.
With the default ALLOWED_HOSTS=['*'], AllowedHostsOriginValidator gates nothing, and WebSocket handshakes are not CORS-gated. So this is not only readable by something already sitting on the LAN: any website the operator visits while their device is reachable can open ws://<device>/ws in the background and record a continuous feed of asset UUIDs and rotation timing. That is a fair bit more than "an unauthenticated listener".
The cheap part: the browser does not use the payload at all — vendor.ts fires refresh-assets on any message and ignores the body. Publishing an empty frame (or a fixed sentinel) on this channel would remove the disclosure outright and cost nothing, and the poll already carries the actual id over the authenticated HTTP path. Worth doing here rather than deferring, since "flagging rather than fixing" is what turns into a permanent property.
| return redis.Redis(host='redis', decode_responses=True, port=6379, db=0) | ||
|
|
||
|
|
||
| def connect_to_redis_async() -> 'redis.asyncio.Redis': |
There was a problem hiding this comment.
Two things follow from this being a factory rather than a cached client, and they are only visible at the call site:
- Every call is a new connection pool. The one caller today invokes it per WebSocket (see
consumers.py), so pool-per-tab is the real behaviour. - No socket timeout is set, which the
NOW_PLAYING_TEARDOWN_Scomment inconsumers.pyalready identifies as the reason teardown can stall against a wedged Redis. Settingsocket_timeout/socket_connect_timeouthere would make that bound unnecessary rather than worked around.
If the fan-out moves to a single process-wide subscriber, a module-level lazily-created client would be the natural shape and both points go away.



Issues Fixed
Closes #3177.
Description
The Schedule Overview couldn't answer the question an operator asks while standing in front of the screen: which of these is playing? With shuffle on, play order says nothing about it, so the only way to tell was to go and look at the TV.
Start with
src/anthias_common/now_playing.py— it is the whole protocol in ~140 lines, and its module docstring carries the reasoning the rest of the diff follows from. Thensrc/anthias_viewer/__init__.pyfor the four call sites, andapp/consumers.pyfor the push half.The viewer already knows the answer (
scheduler.current_asset_id) but only answered on request, over the blocking BLPOP behind/api/v1/viewer_current_asset. The table re-renders every 5s per open browser, so asking per render would wake the display loop on every poll. It publishes the id to Redis instead — the same shape ascec:availableand the SMART fact — and the render just reads a key.Two commits, each green on its own:
The TTL is liveness, not content. Deriving it from the asset's own duration was the first instinct and was wrong in both directions: durations run to a year, so a viewer that died mid-rotation would keep claiming a row for months, while a viewer paused with
stopwould drop the highlight off a picture still on screen. It is now a fixed 180s window refreshed on a 60s tick, matching the display-resolution fact.blankretires the fact outright, because unlikestopit leaves nothing on screen to point at.Announcements are deduped. Each one costs every open browser a full table render, and a single-asset playlist rotates forever with no news, so the
SETcarriesget=Trueand the publish only fires when the value moved. TheSETitself stays unconditional, since it is what refreshes the TTL.New design tokens.
--color-successis fine as a fill, but its ink partner--color-success-on-washbelongs to the translucent wash and lightens for dark mode, stranding dark text at 1.85:1 there. Adds--color-success-fill/--color-on-successas a stable pair — the split--color-dangeralready draws — and registers them in the contrast harness and the design-system page so this can't recur silently.Asset.is_now_playingis a transient attribute, not a column: no migration, and a new test asserts it stays out of all four API versions' responses.Two notes for reviewers:
/wsis unauthenticated (AllowedHostsOriginValidatoronly). It already emitted asset ids on writes, but it now carries a continuous feed of asset UUIDs and rotation timing to any unauthenticated listener. Not a media path —/anthias_assets/is gated to the Docker bridge CIDR — so this is disclosure of what is on screen and when it changes. Flagging rather than fixing here.api/tests/test_v1_endpoints.py, that is the pre-existing xdist flake filed as Test suite flakes under pytest -n auto: cleanup_asset_dir wipes an asset directory shared by all xdist workers #3307, not this change.Checklist