-
-
Notifications
You must be signed in to change notification settings - Fork 722
feat(schedule): show which asset is on screen right now #3308
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
6a751c4
70743db
85a7c1f
aa93e20
b277b63
90946a7
8256c96
cb93e6d
bb27c9f
d0ee0d3
82a2746
1a8aa42
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The dedup handles the single-asset playlist, but nothing caps the rate when the asset genuinely changes.
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 |
||
| _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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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': | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two things follow from this being a factory rather than a cached client, and they are only visible at the call site:
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. |
||
| """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') | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the
/wsdisclosure 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=['*'],AllowedHostsOriginValidatorgates 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 openws://<device>/wsin 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.tsfiresrefresh-assetson 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.