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:

  1. 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 on content_status.kind === "video".
  2. 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 requiring scr would 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) — currently soccer/fifa.world (the World Cup slug; the soccer/all aggregate does not carry World Cup matches) and basketball/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 on SPORTS_ENABLED and 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 with favorite: 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. Returns 404 { enabled: false, games: [] } when SPORTS_ENABLED is 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:

  1. Operator override wins over everything (see below).
  2. A confirmed movie screening names a film — handled by the screenings engine, not the audio gate. See movie-nights.
  3. The audio gate. If content_status.kind !== "video", the card stays hiddenthis is the idle-projector guard. A powered-but-silent projector reads as not-video and gets no card.
  4. 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 as candidates (so the operator can disambiguate).
    • no live game but video is on → a generic "On the big screen" card.

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:

ModeEffect
autoDefer to the audio-gate inference (clears the override).
offForce-hide the card.
genericForce an “On the big screen” label (optional custom text).
gameForce 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.
movieForce 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

FlagGatesDefault
SPORTS_ENABLEDThe ESPN cron refresh + /api/sports/live. Off → no ESPN calls, the api-health row stays idle, /api/sports/live returns 404.inert
NOW_SHOWING_ENABLEDThe 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_status heartbeat pipe — the Pi forwards its verdict; the column is populated.
  • computeNowShowing, /api/now-showing, and the /admin/now-showing operator 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 kind verdict isn’t yet trustworthy enough to auto-light the card. Per the project handoff, PR-2 (fusion) onward is blocked on the classifier flipping to active (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-health flags 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)


  • movie-nights — the movie / film side of “Now Showing” (musicals, scored films, the /screen archive)
  • 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