diff --git a/.claude/skills/anthias-viewer/SKILL.md b/.claude/skills/anthias-viewer/SKILL.md index bd4286993..4d4cc8abd 100644 --- a/.claude/skills/anthias-viewer/SKILL.md +++ b/.claude/skills/anthias-viewer/SKILL.md @@ -28,6 +28,8 @@ known; OPEN items are flagged. ## Viewer ↔ Redis pub/sub messaging - **Command channel is `anthias.viewer`** (Redis pub/sub); server↔viewer request-reply uses `anthias.viewer` publish + BLPOP on `anthias.reply.`. +- **Now-playing is a published fact, not a round trip (#3177).** The viewer writes `viewer:now_playing_asset_id` on every rotation; `anthias_common.now_playing` announces changes on `anthias.now_playing` (deduped, plus a 1 s floor: `duration` may be 0, so rotation is not self-limiting). `page_context.assets()` reads the key to flag `Asset.is_now_playing`, a transient attribute rather than a column. **The TTL is liveness, not content**: 180 s, refreshed by the viewer's `now-playing-refresher` thread every 60 s. Deriving it from the asset's duration is the obvious-looking mistake, because durations run to a year. `blank` calls `clear()`; `stop` deliberately does not, because it leaves a frame on screen. Absent key = no highlight, never a guess. +- **One process-wide WS subscriber, never one per socket.** `app/consumers.py` bridges `anthias.now_playing` onto `WS_GROUP` with `group_send`, refcounted by open socket. `/ws` is unauthenticated and `vendor.ts` opens it on every page, so per-socket would let any reachable origin pin a Redis connection per socket. The bridge **drops the payload** and sends the generic `'*'` sentinel: the browser ignores the body anyway, and asset ids on an unauthenticated socket leak what is on screen. It reads with `get_message(timeout=...)` rather than `listen()`, because `PubSub.check_health` only runs between reads. - **Viewer subscriber can die silently and keep playing (OPEN).** Seen on a 1 GB arm64 board (~16 h uptime): `redis-cli pubsub channels` listed only `hostcmd`, **no `anthias.viewer`**, so `publish anthias.viewer "viewer blank"` returned `subscribers=0` and went nowhere — board deaf to next/previous/stop/blank, yet still cycling assets. `docker restart` restores it. **Diagnose with `redis-cli pubsub channels`, not the ready flag.** `viewer-subscriber-ready` lies: set once on subscribe, only cleared on `redis.ConnectionError`, so it stays `1` when the thread dies any other way. Suspected: `_consume()` raising anything that isn't `redis.ConnectionError` escapes `run()`'s except and kills `ViewerSubscriber.run()` for the process lifetime. No issue filed. - **Subscriber topic-prefix trap:** the viewer subscriber splits commands via `data.partition(' ')` and drops anything without the `viewer ` topic prefix. `processing.py` celery publishes raw `'reload'` (no prefix) which would be dropped — unverified suspect. - **stop/blank pause path — NOT a bug (issue #3136).** QA reported `stop`/`blank` not halting rotation on headless pi5, but it **did not reproduce** on the real pi5 RC (2026-07-08). The `global loop_is_stopped` fix (#3065) is an ancestor of the RC. The QA symptom was a measurement artifact (4 s durations → the natural `Showing asset...` line fires within ~20 ms of the publish). Recommend closing #3136 as not-reproducible. diff --git a/CLAUDE.md b/CLAUDE.md index 48c1e4328..1a0d6c17b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Anthias runs as a set of Docker containers: - **redis** (port 6379) — Celery broker + result backend, Channels channel layer, and the viewer signalling bus (pub/sub channel + per-correlation-ID reply lists). - **webview** — Qt-based browser for rendering content on the display; fetches `/anthias_assets/` from anthias-server. -Inter-service messaging is all Redis: WebSocket fan-out from Celery to browsers goes via Channels/Redis, and server↔viewer commands/replies use Redis pub/sub on `anthias.viewer` with BLPOP on `anthias.reply.` for the few request-reply paths. The primary database is SQLite stored at `~/.anthias/anthias.db`, with configuration in `~/.anthias/anthias.conf`. (Pre-rebrand installations have these at `~/.screenly/screenly.db` and `~/.screenly/screenly.conf`; `bin/migrate_legacy_paths.sh` migrates them on upgrade and leaves back-compat symlinks.) +Inter-service messaging is all Redis: WebSocket fan-out from Celery to browsers goes via Channels/Redis, and server↔viewer commands/replies use Redis pub/sub on `anthias.viewer` with BLPOP on `anthias.reply.` for the few request-reply paths. The viewer also publishes plain facts the server reads directly (CEC availability, SMART, and the now-playing asset at `viewer:now_playing_asset_id`, whose changes are also announced on `anthias.now_playing`) rather than answering a round trip per render. The primary database is SQLite stored at `~/.anthias/anthias.db`, with configuration in `~/.anthias/anthias.conf`. (Pre-rebrand installations have these at `~/.screenly/screenly.db` and `~/.screenly/screenly.conf`; `bin/migrate_legacy_paths.sh` migrates them on upgrade and leaves back-compat symlinks.) ### Key Directories diff --git a/src/anthias_common/now_playing.py b/src/anthias_common/now_playing.py new file mode 100644 index 000000000..52b66f97a --- /dev/null +++ b/src/anthias_common/now_playing.py @@ -0,0 +1,174 @@ +"""The asset the viewer currently has on screen. + +anthias-viewer writes this on every rotation; anthias-server reads it +when it renders the schedule table, so the operator can see which row +is live. Play order can't answer that once shuffle is on (#3177). + +A published fact rather than a request-reply round trip: the table +re-renders every 5s per open browser, so asking the viewer per render +would put a blocking BLPOP on the render path and wake the display +loop for it. Same shape as ``cec:available`` and the SMART fact. + +The TTL is a liveness signal, not a content one: a short window kept +alive by :func:`refresh` on a timer, like the display-resolution fact. +Deriving it from the asset's duration instead was tempting and wrong. +Durations run to a year, so a viewer that died mid-rotation could have +gone on claiming a row for months, and a paused viewer would have +dropped the highlight off a picture that was still on screen. + +What :func:`refresh` re-asserts is this process's own memory of what it +last put on screen, never whatever happens to be in Redis. Blind- +extending the key with EXPIRE looks equivalent and is not: a restarted +viewer would inherit its dead predecessor's claim and renew it while +its own screen is still on the splash, and one that crash-loops faster +than the TTL would renew it forever, which is the exact failure the +TTL exists to end. Re-asserting also restores the fact by itself if +Redis is flushed under us, which EXPIRE cannot do. + +Every call is best-effort: the write sits in the display loop, where +an exception would take the screen down, and the read gates a page +render that must still work with the viewer down. +""" + +import logging +from time import monotonic +from typing import Any + +from anthias_common.warn_once import WarnOnce + +logger = logging.getLogger(__name__) + +#: Redis key holding the asset_id the viewer is displaying. +NOW_PLAYING_KEY = 'viewer:now_playing_asset_id' + +#: Pub/sub channel announcing that the value moved, for anyone who +#: wants it pushed rather than polled. Its own channel, not the viewer +#: command bus: the audience is browsers. The id rides along for +#: ``redis-cli subscribe``, but nothing is entitled to it -- the +#: consumer drops the payload, because /ws is unauthenticated (see +#: :mod:`anthias_server.app.consumers`). +NOW_PLAYING_CHANNEL = 'anthias.now_playing' + +#: How long the fact outlives the last thing the viewer said. Same +#: 3-minute window as the display-resolution fact, and for the same +#: reason: it must survive an ordinary slow tick but not a dead viewer. +TTL_S = 180 + +#: Refresh cadence. Comfortably inside TTL_S so two missed ticks in a +#: row still don't expire a fact that is merely late. +REFRESH_INTERVAL_S = 60 + +#: Floor on how often a change is announced; the key write is never +#: skipped. ``duration`` may be 0 (v2 serializer: ``min_value=0``), so +#: rotation is not self-limiting, and every announcement costs each +#: open tab a full table render. The 5s poll used to be the ceiling on +#: that; this keeps one. A dropped nudge costs one poll of staleness. +MIN_ANNOUNCE_INTERVAL_S = 1.0 + +#: What THIS process last put on screen, or None if it hasn't put +#: anything there yet. Only :func:`refresh` reads it; the module +#: docstring says why it exists rather than trusting the key. +_believed: str | None = None + +#: When the last announcement went out, for MIN_ANNOUNCE_INTERVAL_S. +_last_announced_at: float | None = None + +#: Warn-once latch for this module's Redis calls. +_latch = WarnOnce(logger) + + +def _announce(client: Any, payload: str) -> None: + """Nudge the browsers, at most once per MIN_ANNOUNCE_INTERVAL_S. + + The second gate after the callers' dedup, for a value that moves + faster than a browser can usefully redraw. No catch-up: the 5s + poll already carries whatever a dropped nudge would have. + """ + global _last_announced_at + now = monotonic() + if ( + _last_announced_at is not None + and now - _last_announced_at < MIN_ANNOUNCE_INTERVAL_S + ): + return + client.publish(NOW_PLAYING_CHANNEL, payload) + # After the publish, not before: a failed one is caught upstream, + # and advancing the floor anyway would drop the next genuine + # change too. + _last_announced_at = now + + +def publish(client: Any, asset_id: str | None) -> None: + """Record — and announce — the asset now on screen.""" + global _believed + if not asset_id: + clear(client) + return + try: + # SET always runs: it is what refreshes the TTL. Only the + # announcement is deduped, because a single-asset playlist + # would otherwise re-render every open table on every loop for + # no news. ``get=True`` returns the old value (Redis >= 6.2). + previous = client.set(NOW_PLAYING_KEY, asset_id, ex=TTL_S, get=True) + _believed = asset_id + if previous != asset_id: + _announce(client, asset_id) + _latch.worked('publish') + except Exception as exc: + _latch.warn('publish', 'Could not publish the now-playing asset', exc) + + +def refresh(client: Any) -> None: + """Re-assert what this process last put on screen. + + On a timer rather than per rotation, because a rotation can be an + hour long. A no-op until this process has displayed something, so + a restart cannot keep its predecessor's claim alive and + :func:`clear` retires the fact for good rather than for one tick. + Silent: it re-states a value the browsers already have. + """ + if _believed is None: + return + try: + client.set(NOW_PLAYING_KEY, _believed, ex=TTL_S) + _latch.worked('refresh') + except Exception as exc: + _latch.warn('refresh', 'Could not refresh the now-playing asset', exc) + + +def clear(client: Any) -> None: + """Record that nothing is on screen. + + Announced only when something actually stopped: the viewer clears + on every tick of an empty playlist, and announcing each one would + re-render every open table several times a minute on an idle + device. + """ + global _believed + _believed = None + try: + if client.delete(NOW_PLAYING_KEY): + _announce(client, '') + _latch.worked('clear') + except Exception as exc: + _latch.warn('clear', 'Could not clear the now-playing asset', exc) + + +def read(client: Any) -> str | None: + """The asset_id the viewer last reported, or ``None``. + + ``None`` covers every unknown: the viewer hasn't reported yet, the + fact expired because the viewer stopped saying it, or Redis is + unreachable. + """ + try: + raw = client.get(NOW_PLAYING_KEY) + _latch.worked('read') + except Exception as exc: + _latch.warn('read', 'Could not read the now-playing asset', exc) + return None + if not raw: + return None + if isinstance(raw, bytes): + raw = raw.decode('utf-8', errors='replace') + return str(raw) diff --git a/src/anthias_common/storage_health.py b/src/anthias_common/storage_health.py index c755543c4..9e1592e60 100644 --- a/src/anthias_common/storage_health.py +++ b/src/anthias_common/storage_health.py @@ -84,6 +84,7 @@ from typing import Any from anthias_common import smart +from anthias_common.warn_once import WarnOnce logger = logging.getLogger(__name__) @@ -219,29 +220,9 @@ } -_warned: set[str] = set() - - -def _warn_once(key: str, message: str) -> None: - """Log ``message`` at WARNING the first time, DEBUG thereafter. - - Deliberately a small local copy of the helper in - :mod:`anthias_common.undervoltage` rather than a shared import: - the two modules are siblings with no dependency between them, and - the alternative was reaching into another module's private - helper. - - The conditions this guards (an unreadable boot id) are properties - of the device, not of an individual reading, so they are worth - stating once. The watcher and every page render call in here, so - without the throttle one persistent fault would bury the device - log. - """ - if key in _warned: - logger.debug(message) - return - _warned.add(key) - logger.warning(message) +#: As in :mod:`anthias_common.undervoltage` — its own instance so the +#: shared ``no_boot_id`` key can't silence that module's warning. +_warn = WarnOnce(logger) def _read_text(path: str) -> str | None: @@ -866,7 +847,7 @@ def _save_latch( check whether the card is wearing out would be self-defeating. """ if boot_id is None: - _warn_once( + _warn.warn( 'no_boot_id', 'No kernel boot id available; reporting storage health from ' 'live readings only and not persisting history.', diff --git a/src/anthias_common/undervoltage.py b/src/anthias_common/undervoltage.py index 8886435a4..9217e3f78 100644 --- a/src/anthias_common/undervoltage.py +++ b/src/anthias_common/undervoltage.py @@ -54,6 +54,8 @@ from datetime import UTC, datetime from typing import Any +from anthias_common.warn_once import WarnOnce + # The hwmon class dir is kernel-global and readable from inside an # unprivileged container, which is the whole point of using it. HWMON_ROOT = '/sys/class/hwmon' @@ -188,29 +190,10 @@ def _empty_state() -> dict[str, Any]: } -_warned: set[str] = set() - - -def _warn_once(key: str, message: str) -> None: - """Log ``message`` at WARNING the first time, DEBUG thereafter. - - These conditions (an unreadable boot id, a Redis that will not - answer) are properties of the device, not of the individual - reading, so they are worth stating once and not repeating. The - watcher and every page render call into this module, so without - the throttle a single persistent fault would bury everything else - in the device log. - - Scoped to the process, which for the celery worker means once per - boot in practice. It deliberately is not keyed on the kernel boot - id: the main caller is the branch that fires precisely *because* - the boot id could not be read. - """ - if key in _warned: - logger.debug(message) - return - _warned.add(key) - logger.warning(message) +#: Device-level faults here (an unreadable boot id, a Redis that will +#: not answer) are worth stating once, not on every reading: the +#: watcher and every page render call in here. +_warn = WarnOnce(logger) def _coerce_count(value: Any) -> int: @@ -261,7 +244,7 @@ def _load_latch( try: raw = redis_client.get(REDIS_KEY) except Exception: - _warn_once( + _warn.warn( 'latch_unreadable', 'Could not read the under-voltage latch from Redis; ' 'treating history as unknown rather than empty.', @@ -344,7 +327,7 @@ def record_observation( # state; the live reading is still accurate, and the paired # discard in ``_load_latch`` keeps the two halves consistent. if boot_id is None: - _warn_once( + _warn.warn( 'no_boot_id', 'No kernel boot id available; reporting under-voltage from ' 'the live sensor only and not persisting history.', diff --git a/src/anthias_common/utils.py b/src/anthias_common/utils.py index 3d8945a54..2fa0dd3ef 100644 --- a/src/anthias_common/utils.py +++ b/src/anthias_common/utils.py @@ -16,6 +16,7 @@ import certifi import pytz import redis +import redis.asyncio import requests import sh import urllib3 @@ -729,6 +730,38 @@ def connect_to_redis() -> 'redis.Redis': return redis.Redis(host='redis', decode_responses=True, port=6379, db=0) +def connect_to_redis_async() -> 'redis.asyncio.Redis': + """Async twin of :func:`connect_to_redis`, for the ASGI paths. + + A separate client rather than a shared one: redis-py's sync and + async clients can't share a connection pool. + + Both timeouts are safe on a pub/sub client under redis-py 8.x: + ``PubSub.parse_response`` hands ``read_response`` ``math.inf`` for + a blocking read, which is its documented opt-out from + ``socket_timeout``. They therefore bound the dial, the SUBSCRIBE + and the retry layer's reconnect/AUTH/HELLO, but never a read that + is legitimately waiting for the next rotation. + + The same opt-out means ``socket_timeout`` cannot notice a half-open + socket under a blocking ``listen()``, and neither can + ``health_check_interval``: it only fires from + ``PubSub.check_health``, which runs when ``parse_response`` is + re-entered. Detecting that is the caller's job -- see + ``_watch_now_playing`` in :mod:`anthias_server.app.consumers`, + which reads with an explicit timeout in a loop. + """ + return redis.asyncio.Redis( + host='redis', + decode_responses=True, + port=6379, + db=0, + socket_connect_timeout=5, + socket_timeout=5, + health_check_interval=30, + ) + + def is_docker() -> bool: return os.path.isfile('/.dockerenv') diff --git a/src/anthias_common/warn_once.py b/src/anthias_common/warn_once.py new file mode 100644 index 000000000..fe89d48cd --- /dev/null +++ b/src/anthias_common/warn_once.py @@ -0,0 +1,51 @@ +"""Warn once per fault, then stay quiet until it clears. + +A device-level fault — no kernel boot id, a Redis that will not answer +— is a property of the device, not of the reading that noticed it. The +under-voltage watcher, the storage-health watcher, the now-playing +reporter and every page render funnel through these paths, so an +unthrottled warning buries the rest of the journal: GH #3268 measured +that class of repetition evicting crash diagnostics inside a day. + +An instance per module rather than one shared set, for two reasons +that both bite. The line keeps its own module's logger name, so the +journal still says which subsystem noticed; and the keys stay +namespaced, which matters because ``undervoltage`` and +``storage_health`` both use ``no_boot_id`` and neither may silence the +other. +""" + +import logging + + +class WarnOnce: + """WARNING the first time a key fails, DEBUG until it succeeds.""" + + def __init__(self, logger: logging.Logger) -> None: + self._logger = logger + self._seen: set[str] = set() + + def warn( + self, key: str, message: str, exc: Exception | None = None + ) -> None: + log = self._logger.debug if key in self._seen else self._logger.warning + self._seen.add(key) + if exc is None: + log(message) + else: + log('%s: %s', message, exc) + + def worked(self, key: str) -> None: + """Re-arm ``key`` after a call succeeds. + + So a two-second blip at container start doesn't silence a + genuinely different fault — a WRONGTYPE, a decode failure — + for the life of the process. + """ + self._seen.discard(key) + + def reset(self) -> None: + """Forget every latched key. For tests, which would otherwise + let the first failure in a run behave differently from the + rest.""" + self._seen.clear() diff --git a/src/anthias_server/api/tests/test_assets.py b/src/anthias_server/api/tests/test_assets.py index 5a5e36f10..2d17f3513 100644 --- a/src/anthias_server/api/tests/test_assets.py +++ b/src/anthias_server/api/tests/test_assets.py @@ -237,6 +237,22 @@ def test_get_assets_after_create_should_return_1_asset( assert len(assets) == 1 +@pytest.mark.django_db +@pytest.mark.parametrize('version', ['v1', 'v1_1', 'v1_2', 'v2']) +def test_get_assets_does_not_leak_view_only_attributes( + api_client: APIClient, version: str +) -> None: + """``Asset.is_now_playing`` is device state annotated per render + for the schedule table (#3177), not a column and not part of any + API version's wire shape. Every serializer lists its fields + explicitly today, so this is insurance against a later switch to + ``fields = '__all__'``.""" + _create_asset(api_client, ASSET_CREATION_DATA, version) + + assets = _get_assets(api_client, version) + assert 'is_now_playing' not in assets[0] + + @pytest.mark.django_db @pytest.mark.parametrize('version', ['v1', 'v1_1', 'v1_2', 'v2']) def test_get_asset_by_id_should_return_asset( diff --git a/src/anthias_server/app/consumers.py b/src/anthias_server/app/consumers.py index 0446bacfc..1bbe65203 100644 --- a/src/anthias_server/app/consumers.py +++ b/src/anthias_server/app/consumers.py @@ -1,3 +1,5 @@ +import asyncio +import contextlib import logging from typing import Any @@ -5,18 +7,159 @@ from channels.generic.websocket import AsyncWebsocketConsumer from channels.layers import get_channel_layer +from anthias_common import now_playing +from anthias_common.utils import connect_to_redis_async +from anthias_common.warn_once import WarnOnce + logger = logging.getLogger(__name__) WS_GROUP = 'ws_server' +#: This module's own latch, not now_playing's: a push failure here is +#: the server's, and reaching into that module's instance would file it +#: in the journal under the viewer-side module's logger name. +_warn = WarnOnce(logger) + +#: Ceiling on one read, not a delay: get_message returns the moment a +#: message lands, so this costs nothing in latency. Its only job is to +#: re-enter parse_response, because that is where PubSub.check_health +#: runs and a blocking listen() would never get there. Matched to the +#: client's health_check_interval, so an idle connection is probed +#: about twice a minute rather than woken sixty times. +_SUBSCRIPTION_POLL_S = 30.0 + +#: The process's single now-playing subscriber, and the sockets +#: relying on it. A set of channel names rather than a counter: a +#: double release, or a connect whose disconnect never ran, is then +#: idempotent instead of leaving the arithmetic permanently off. Process-wide rather than per socket: /ws has no auth +#: and vendor.ts opens it on every page, so per-socket would let +#: anything that can reach the device claim a Redis connection and a +#: Per *process*, so this is one subscriber +#: per device only while bin/start_server.sh runs uvicorn without +#: --workers; N workers would mean N group_sends per rotation, each +#: fanning out to the whole shared group. +_now_playing_watcher: 'asyncio.Task[None] | None' = None +_watchers_wanted: set[str] = set() + + +def _acquire_now_playing_watcher(channel_name: str) -> None: + """Start the subscriber if this is the first socket to need it. + + Restarts a finished task too: the body ends on any Redis failure, + so a server that outlives an outage retries on the next connect + instead of staying poll-only until the container restarts. + """ + global _now_playing_watcher + _watchers_wanted.add(channel_name) + task = _now_playing_watcher + # cancelling() as well as done(): a task that has been asked to + # stop but has not unwound yet is on its way out, and handing it + # back to a browser that just arrived would leave that browser on + # poll-only for good. + if task is not None and not task.done() and not task.cancelling(): + return + _now_playing_watcher = asyncio.create_task(_watch_now_playing()) + + +def _release_now_playing_watcher(channel_name: str) -> None: + """Stop the subscriber once the last socket has gone. + + Cancelled, not awaited: a cancelled task is not an unretrieved + exception, so this costs no asyncio ERROR log (and so no Sentry + event), and the task's ``finally`` closes the client on the next + pass. Stopping at zero also leaves nothing pending at shutdown. + + The reference is kept rather than dropped, because the event loop + holds only a weak one and the task still has an ``await`` to run + in its ``finally``. + """ + _watchers_wanted.discard(channel_name) + if _watchers_wanted or _now_playing_watcher is None: + return + _now_playing_watcher.cancel() + + +async def _watch_now_playing() -> None: + """Bridge the viewer's now-playing announcements onto WS_GROUP. + + The table's 5s poll already keeps the highlight correct; this only + decides whether it lands with the picture or up to 5s later + (#3177), so any failure just ends the task. + + Fan-out goes through ``group_send`` rather than straight to a + socket, which is what lets this be a background task at all: + Channels dispatches a consumer's handlers one at a time, so a send + from outside that loop could interleave with an ``asset_update``. + """ + layer = get_channel_layer() + if layer is None: + return + client = None + try: + client = connect_to_redis_async() + pubsub = client.pubsub(ignore_subscribe_messages=True) + await pubsub.subscribe(now_playing.NOW_PLAYING_CHANNEL) + _warn.worked('subscription') + while True: + message = await pubsub.get_message( + ignore_subscribe_messages=True, + timeout=_SUBSCRIPTION_POLL_S, + ) + if message is None: + continue + # Payload dropped, not forwarded: vendor.ts fires htmx + # refresh-assets on any message and never reads the body, + # so the id buys it nothing on an endpoint that has no auth + # and, under ALLOWED_HOSTS=['*'], no working origin check. + # This narrows the exposure rather than closing it: + # notify_asset_update still carries real ids on every write, + # and the frame's timing still marks each rotation. Closing + # it means auth on /ws. + await layer.group_send( + WS_GROUP, {'type': 'asset_update', 'asset_id': '*'} + ) + except Exception as exc: + # Latched rather than DEBUG: "no Redis" is expected and stays + # one line, but a redis-py API change would otherwise disable + # the push with nothing in the journal, and the tests mock the + # client end to end. CancelledError is a BaseException, so an + # ordinary teardown does not land here. + _warn.warn( + 'subscription', + 'Now-playing push unavailable; browsers fall back to the 5s ' + 'schedule-table poll', + exc, + ) + finally: + # Enough on its own: the client owns the subscription's pool, + # and aclose() disconnects in-use connections too. Suppressed + # because this also runs on the cancellation path, where a + # raise would become the task's unretrieved result. + if client is not None: + with contextlib.suppress(Exception): + await client.aclose() + class AssetConsumer(AsyncWebsocketConsumer): async def connect(self) -> None: await self.channel_layer.group_add(WS_GROUP, self.channel_name) await self.accept() + _acquire_now_playing_watcher(self.channel_name) async def disconnect(self, code: int) -> None: - await self.channel_layer.group_discard(WS_GROUP, self.channel_name) + try: + # First: leaving a dead channel name in the group means + # every later notify_asset_update fans out to it. + await self.channel_layer.group_discard(WS_GROUP, self.channel_name) + finally: + # In a finally because group_discard raises when Redis is + # unreachable, and Channels lets that escape rather than + # reaching StopConsumer. Skipping the release would leave + # the name in the set for good, and the subscription alive + # with no sockets behind it. Safe for a socket that never finished + # connect(), because discarding a name that was never + # added is a no-op. + _release_now_playing_watcher(self.channel_name) async def asset_update(self, event: dict[str, Any]) -> None: # Plain text frame: the client only needs to know "something diff --git a/src/anthias_server/app/design_system.py b/src/anthias_server/app/design_system.py index 9d9550496..102b4e40a 100644 --- a/src/anthias_server/app/design_system.py +++ b/src/anthias_server/app/design_system.py @@ -153,10 +153,18 @@ ), ( 'Success', - 'Live schedule windows, healthy states, success toasts.', + ( + 'Live schedule windows, healthy states, success toasts. Split ' + 'the same way as Danger: --color-success-fill is a background ' + 'with --color-on-success on top, because --color-success-on-wash ' + 'lightens for dark mode and reusing it on a solid fill measures ' + '1.85:1 there.' + ), [ 'success', 'success-bright', + 'success-fill', + 'on-success', 'success-wash', 'success-wash-strong', 'success-edge', diff --git a/src/anthias_server/app/models.py b/src/anthias_server/app/models.py index abbb5c22d..a527515df 100644 --- a/src/anthias_server/app/models.py +++ b/src/anthias_server/app/models.py @@ -241,6 +241,10 @@ class Asset(models.Model): # ``or {}`` guard. metadata = models.JSONField(default=dict, blank=True) + # Not a column. Device state, not asset state, so page_context + # annotates it per render (#3177) and it is False everywhere else. + is_now_playing: bool = False + class Meta: db_table = 'assets' diff --git a/src/anthias_server/app/page_context.py b/src/anthias_server/app/page_context.py index b3546759a..174c53bb5 100644 --- a/src/anthias_server/app/page_context.py +++ b/src/anthias_server/app/page_context.py @@ -17,7 +17,12 @@ import psutil from django.template.defaultfilters import filesizeformat -from anthias_common import device_helper, storage_health, undervoltage +from anthias_common import ( + device_helper, + now_playing, + storage_health, + undervoltage, +) from anthias_common.board import LOW_RAM_THRESHOLD_KB from anthias_common.utils import ( clamp_screen_rotation, @@ -647,10 +652,15 @@ def assets() -> dict[str, Any]: """ from anthias_server.app.models import Asset + # Absent when the viewer hasn't reported yet or Redis is down, in + # which case no row is marked rather than a guess (#3177). + current_asset_id = now_playing.read(_redis) + qs = Asset.objects.all() active: list[Asset] = [] inactive: list[Asset] = [] for asset in qs: + asset.is_now_playing = asset.asset_id == current_asset_id if asset.is_enabled and not asset.is_processing: active.append(asset) else: diff --git a/src/anthias_server/app/static/sass/_styles.scss b/src/anthias_server/app/static/sass/_styles.scss index c3f420c22..0d6080f81 100644 --- a/src/anthias_server/app/static/sass/_styles.scss +++ b/src/anthias_server/app/static/sass/_styles.scss @@ -450,6 +450,13 @@ label { text-align: left; } min-width: 0; } +// Now-playing row (#3177). Declared before .is-selected so the +// operator's own selection still wins the background; the chip carries +// the meaning either way. Translucent, so it works on both surfaces. +.asset-table tbody tr.is-now-playing td { + background: var(--color-success-wash); +} + .asset-table tbody tr.is-selected td { background: var(--color-accent-wash); } @@ -560,6 +567,14 @@ label { text-align: left; } color: var(--chip-all-text); border-color: var(--chip-all-edge); } +// "Playing now" — device state, not schedule config, so it borrows the +// live palette of the schedule dot. Solid rather than washed like every +// other chip: beside the green --all pill, two washes read as one. +.schedule-chip--playing { + background: var(--color-success-fill); + color: var(--color-on-success); + border-color: var(--color-success-fill); +} // Schedule-window cell — replaces the prior pair of raw start/end // datetime columns with a single human-readable block: status dot, diff --git a/src/anthias_server/app/static/src/tailwind.css b/src/anthias_server/app/static/src/tailwind.css index 92343f0a3..ac70c6026 100644 --- a/src/anthias_server/app/static/src/tailwind.css +++ b/src/anthias_server/app/static/src/tailwind.css @@ -233,6 +233,12 @@ --color-success-ring: rgb(52 211 153 / 0.18); --color-success-ring-pulse: rgb(52 211 153 / 0.32); --color-success-on-wash: var(--color-green-900); + /* Ink-on-fill pair for the "Playing now" chip, split the way + --color-danger is above: --color-success-on-wash flips to green-50 + in dark and measures 1.85:1 on a solid fill. Held by INK_ON_FILL + in tests/test_design_tokens.py. */ + --color-success-fill: var(--color-green-500); + --color-on-success: var(--color-green-900); --color-success-on-wash-strong: var(--color-green-50); --color-focus-ring: rgb(124 48 205 / 0.35); diff --git a/src/anthias_server/app/templates/_asset_row.html b/src/anthias_server/app/templates/_asset_row.html index c2db7cb24..ab56035c5 100644 --- a/src/anthias_server/app/templates/_asset_row.html +++ b/src/anthias_server/app/templates/_asset_row.html @@ -1,6 +1,8 @@ {% load asset_filters %} {% with pills=asset|schedule_pills %} - +{% comment %} Alpine merges :class onto the static class attribute + rather than replacing it, so selecting a row keeps is-now-playing. {% endcomment %} +