The bar-TV Now Showing card combines the Pi’s video-audio classification with ESPN’s live schedule. A powered but silent projector is insufficient. Operators can select a game, show a generic label, hide the card, or return to automatic mode.
Film detection and the /screen archive use a separate path described in Movie nights.
Sources: worker.js (Live sports schedule (ESPN, keyless) + "Now Showing" fusion sections), migrations/0042_apihealth_espn.sql, migrations/0043_heartbeat_content_status.sql
Current state
The ESPN schedule endpoint and heartbeat pipe are implemented behind flags. The card,
/api/now-showing, operator picker, and automatic game selection remain gated until content classification is active. See Shipped vs. gated.
The card concept
Identifying an arbitrary TV show or movie from audio requires paid automatic content recognition. A live sports broadcast can instead combine two available signals:
- The audio gate — “is a video broadcast audibly playing right now?” This comes from the Pi’s content classifier (see content-detect), forwarded in the heartbeat as
content_status. The gate keys oncontent_status.kind === "video". - The ESPN schedule — “which watched game is live right now?” This comes from ESPN’s keyless scoreboard, cached in KV and served by
/api/sports/live.
Audibly playing video plus exactly one watched live game produces a high-confidence card with the matchup, team crests, score, and clock.
ESPN supplies schedule state, not room state. The audio gate confirms that a broadcast is playing through the room audio before the card appears.
The gate deliberately uses the video/music verdict, not a crowd-noise (
scr) signal. Soccer broadcasts are commentary-dominant and the crowd-cheer detector rarely fires on them, so requiringscrwould miss most matches. The signal that matters is “this is a broadcast, not the house playlist.”
GET /api/sports/live — the ESPN schedule (shipped)
A small, keyless integration reads ESPN’s undocumented scoreboard endpoint (site.api.espn.com). It requires no API key.
- Feeds: a small allowlist (
SPORTS_FEEDS) — currentlysoccer/fifa.world(the World Cup slug; thesoccer/allaggregate does not carry World Cup matches) andbasketball/nba. Add league slugs (eng.1,uefa.champions, …) here as they come into season. - Date handling: ESPN’s default scoreboard returns games for its idea of “today,” which at late-night ET is still the prior day. The fetch pulls both the default and an explicit yesterday→today ET date range, then dedupes by game id — soccer needs the dated range, NBA needs the default, and the union covers both.
- KV cache + throttle: a
scheduled()cron step (refreshSportsCache) refreshes the cache at most ~1×/5min (SPORTS_REFETCH_MS), gated onSPORTS_ENABLEDand the cache timestamp. The serve path (handleSportsLive) only reads the cache (cold-fetches once if empty). A dead ESPN feed degrades to the last cache or empty — it never throws into a caller. - Per-game payload: id, matchup
display, league,state(pre/in/post),detail(clock/period), home/away names, team crest logos + live scores (both free in the ESPN payload), broadcast network, and start time. - Favorite team:
SPORTS_FAVORITE_TEAMS(currently["New York Knicks"]) flags matching games withfavorite: true. Live games sort first, then favorites — so a live Knicks game wins the auto-pick. (This also pairs with the future favorite-game auto-theme that swaps the bar-TV accent to Knicks orange.) - Response:
{ enabled, fetched_at, live: [...], games: [...] }, edge-cached 30s. Returns404 { enabled: false, games: [] }whenSPORTS_ENABLEDis off.
API health: ESPN is registered on /admin/api-health as a passive row (migrations/0042_apihealth_espn.sql). There’s no separate active probe — fetchSportsScoreboard records the result of the real cron fetch via recordApiHealth(env, "espn", …), so the row reflects actual usage. Low stakes: a missed game card never breaks the pub, and while SPORTS_ENABLED is off the row simply stays idle. (Because recordApiHealth does an UPDATE … WHERE api_name = ?, the seed row must exist first — that’s why the migration seeds it.)
The content_status heartbeat pipe (shipped pipe, no consumer yet)
migrations/0043_heartbeat_content_status.sql adds a nullable content_status TEXT column to the heartbeats table. Every cycle, the Pi’s classifier computes whether the room audio is music vs. a video broadcast plus the YAMNet signals, and the Pi forwards a compact snapshot of that verdict in its heartbeat:
content_status = { kind, conf, p_video, set, carve, mode, scr, spk, mus, low_match }
kind— the headline verdict ("music"|"video"| …). This is the field the audio gate reads.scr/spk/mus— the YAMNet sub-signals (crowd/cheering, dialogue, music presence).p_video,conf,low_match— the classifier’s confidence inputs.
Why the heartbeat and not the now-playing ingest? Because a broadcast with no identified track still needs to beat. Now-playing posts are song-change/silence-driven, so the “video is on, no song” case would never post. The heartbeat beats every ~30s regardless, so the gate always has a fresh verdict. (See now-playing-system for the heartbeat vs. now-playing split.)
The column is written by a separate, tolerant UPDATE in the heartbeat handler (the same pattern as tsc_status/qsys), so a DB that predates the migration never fails the core beat. This is PIPE ONLY — the column is populated, but the fusion that consumes it is gated (below).
Confidence tiers + the operator override picker
Confidence tiers (auto path)
computeNowShowing resolves the card in priority order:
- Operator override wins over everything (see below).
- A confirmed movie screening names a film — handled by the screenings engine, not the audio gate. See movie-nights.
- The audio gate. If
content_status.kind !== "video", the card stays hidden — this is the idle-projector guard. A powered-but-silent projector reads as not-video and gets no card. - Video is on → match to the schedule:
- one live watched game →
confidence: "high", that game. - multiple live games →
confidence: "low", favorite-team-first pick, with the others returned ascandidates(so the operator can disambiguate). - no live game but video is on → a generic
"On the big screen"card.
- one live watched game →
Operator override (/admin/now-showing)
The human “what’s on the big screen” backstop, stored in KV (nowshowing:override) and exposed at /admin/now-showing. The override wins over the audio gate — so a stuck or untuned classifier can’t suppress a card the operator knows is right, and the card can be demoed before the classifier is active. Modes:
| Mode | Effect |
|---|---|
auto | Defer to the audio-gate inference (clears the override). |
off | Force-hide the card. |
generic | Force an “On the big screen” label (optional custom text). |
game | Force a specific game. Snapshotted so the card persists even if the game drops out of the ESPN feed; re-resolved by id against the live schedule so the score/clock stays fresh. |
movie | Force a film title (also opens a confirmed screening so it lands in /screen). See movie-nights. |
GET /api/admin/now-showing returns the current override, resolved card, and pickable game list. POST sets the audited override. Both require a verified Pub operator with content.now-showing; all dynamic text is rendered through textContent.
Fusion pipeline
flowchart TD Pi[Pi content classifier<br/>music vs video] -->|heartbeat content_status| DB[(heartbeats.content_status)] ESPN[ESPN keyless scoreboard] -->|cron ~1x/5min| KV[(KV: sports:live)] DB -->|kind === video?<br/>AUDIO GATE| Fuse{computeNowShowing} KV -->|live watched game| Fuse Override[Operator override<br/>/admin/now-showing] -->|wins over gate| Fuse Fuse -->|hidden if not video| Card[Bar-TV Now Showing card] Fuse -->|high / low confidence| Card
The gate (content_status.kind === "video") and the schedule (sports:live KV) meet in computeNowShowing; the operator override short-circuits ahead of both.
Flags
| Flag | Gates | Default |
|---|---|---|
SPORTS_ENABLED | The ESPN cron refresh + /api/sports/live. Off → no ESPN calls, the api-health row stays idle, /api/sports/live returns 404. | inert |
NOW_SHOWING_ENABLED | The fusion: /api/now-showing, the bar-TV card, the operator picker’s live effect. Off → /api/now-showing returns 404 and the card never shows. | inert |
The movie/screenings detection, re-tag, cron, and archive ride a separate flag, SCREENINGS_ENABLED (off/shadow/on); the movie card additionally rides NOW_SHOWING_ENABLED. See movie-nights.
Shipped vs. gated
Live in code (behind flags):
GET /api/sports/live— ESPN keyless scoreboard → KV cache, throttled cron, favorite-team flag (Knicks). Verified against a live game.- ESPN registered on
/admin/api-health(passive). - The
content_statusheartbeat pipe — the Pi forwards its verdict; the column is populated. computeNowShowing,/api/now-showing, and the/admin/now-showingoperator picker are built and read the override + schedule + gate.
Gated on the content classifier going active:
- Auto-lighting the card from the audio gate. The classifier is still in shadow and untuned — its
kindverdict isn’t yet trustworthy enough to auto-light the card. Per the project handoff, PR-2 (fusion) onward is blocked on the classifier flipping toactive(CONTENT_DETECT_MODE=active), which itself waits on a supervised re-fit of the YAMNet weights from a labeled music + TV + game soak. See content-detect. - Until then, the operator override is the only reliable way to light the card (it wins over the gate, by design — for exactly this demo-before-active reason).
- Phase 2 follow-ons (an “On the Screens” setlist surface, favorite-game auto-theme to Knicks orange) sit behind the same classifier-active gate.
Known gaps
- Arbitrary movie/TV titles are out of scope. Naming a non-sports broadcast from audio needs paid ACR. The sports card works only because ESPN’s free schedule names the game; the movie path works only for soundtrack-identifiable films (see movie-nights). A random show on the TV gets, at most, a generic “On the big screen” card.
- ESPN is undocumented / gray-area. If the endpoint starts 5xx-ing or rate-limiting, the feature silently goes dark (degrades to the last cache, then empty).
/admin/api-healthflags it. - Multi-game ambiguity is low-confidence. With several live games at once, the auto-pick is favorite-team-first but flagged
confidence: "low"; the operator picker is the disambiguator.
Sources: worker.js (computeNowShowing, fetchSportsScoreboard, handleAdminNowShowing), HANDOFF.md (Audio-confirmed sports “Now Showing” section)
Related
- movie-nights — the movie / film side of “Now Showing” (musicals, scored films, the
/screenarchive) - content-detect — the Pi’s music-vs-video classifier that produces the audio gate verdict
- now-playing-system — the full Pi → Worker pipeline, including the heartbeat that carries
content_status