From d3ad2397fbf0f7d4b4d3d4f10fa459973a04ff3e Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 15:57:41 -0400 Subject: [PATCH] fix(playout): serve preview m3u8 via /api to bypass the proxy's static cache Root cause of the persistent black preview, fully isolated: ZAMPP1's nginx serves the live .m3u8 fresh on every request (no-store works there), but the PUBLIC reverse proxy (159.112.211.103 -> ZAMPP1) caches the static .m3u8 by path with a multi-second TTL, ignoring both the origin's no-store and query params. hls.js reloads the playlist ~every second, always landing inside that TTL, so it sees the live playlist as never advancing ("live playlist MISSED" forever), never establishes the timeline, and never loads a fragment -> readyState 0 (black). Proven: rapid reads via ZAMPP1 localhost advance (404->405); the same rapid reads via the public URL are stuck; query-busting doesn't help (proxy caches by path). Fix: serve the playlist through GET /api/v1/playout/channels/:id/hls/index.m3u8 instead of the static /media/live path. /api/ is not proxy-cached (the live status poll already updates fine through it), so hls.js always gets the fresh live edge. Segment (.ts) lines are rewritten to absolute /media/live// URLs so they still load from the static path (immutable; caching them is correct). ProgramMonitor points hls.js at the /api playlist and sends the session cookie (xhrSetup withCredentials) since /api is auth-gated. Co-Authored-By: Claude Opus 4.8 --- services/mam-api/src/routes/playout.js | 33 ++++++++++++++++++++++ services/web-ui/public/screens-playout.jsx | 14 +++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/services/mam-api/src/routes/playout.js b/services/mam-api/src/routes/playout.js index 491dd87..168075e 100644 --- a/services/mam-api/src/routes/playout.js +++ b/services/mam-api/src/routes/playout.js @@ -11,6 +11,7 @@ import express from 'express'; import http from 'http'; +import { readFile } from 'fs/promises'; import { Queue } from 'bullmq'; import pool from '../db/pool.js'; import { validateUuid } from '../middleware/errors.js'; @@ -406,6 +407,38 @@ router.get('/channels/:id/status', async (req, res, next) => { } }); +// GET /playout/channels/:id/hls/index.m3u8 — the live preview playlist, served +// through the API (not the static /media/live path) so it bypasses the public +// reverse proxy's static cache. That proxy caches the .m3u8 by path with a +// multi-second TTL and ignores the origin's no-store, so hls.js's ~1s reloads +// always got a STALE playlist ("MISSED" forever → monitor stayed black). The +// /api/ path is not proxy-cached (the status poll updates fine), so this always +// returns the fresh live edge. Segment (.ts) lines are rewritten to absolute +// /media/live// URLs so they still load from the static path (immutable, +// caching them is fine). mam-api shares the same /media volume the sidecars +// write to. +const HLS_MEDIA_DIR = process.env.PLAYOUT_MEDIA_DIR || '/media'; +router.get('/channels/:id/hls/index.m3u8', async (req, res, next) => { + try { + const cid = req.channel.id; + let body; + try { + body = await readFile(`${HLS_MEDIA_DIR}/live/${cid}/index.m3u8`, 'utf8'); + } catch (e) { + return res.status(404).json({ error: 'No live preview for this channel yet' }); + } + // Rewrite bare segment names to absolute static URLs. + const rewritten = body + .split('\n') + .map((line) => (/^[^#].*\.ts\s*$/.test(line) ? `/media/live/${cid}/${line.trim()}` : line)) + .join('\n'); + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.send(rewritten); + } catch (err) { next(err); } +}); + // ── Transport ──────────────────────────────────────────────────────────────── async function transport(req, res, action, body = null) { if (req.channel.status !== 'running') { diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx index 0e906fc..3d1d2b8 100644 --- a/services/web-ui/public/screens-playout.jsx +++ b/services/web-ui/public/screens-playout.jsx @@ -372,7 +372,11 @@ function ProgramMonitor({ channel, engine }) { const videoRef = React.useRef(null); const hlsRef = React.useRef(null); const onAir = channel.status === 'running'; - const previewUrl = `/media/live/${channel.id}/index.m3u8`; + // Load the playlist through the API (not the static /media/live path): the + // public reverse proxy caches the static .m3u8 with a multi-second TTL and + // ignores no-store, which starved hls.js's reloads of the live edge and kept + // the monitor black. /api/ isn't proxy-cached, so this always returns fresh. + const previewUrl = `/api/v1/playout/channels/${channel.id}/hls/index.m3u8`; const elapsed = useElapsed(engine && engine.currentItemStartedAt); React.useEffect(() => { @@ -385,7 +389,13 @@ function ProgramMonitor({ channel, engine }) { if (!onAir) { vid.src = ''; return; } if (window.Hls && window.Hls.isSupported()) { - const hls = new window.Hls({ liveSyncDurationCount: 3, liveMaxLatencyDurationCount: 6 }); + const hls = new window.Hls({ + liveSyncDurationCount: 3, + liveMaxLatencyDurationCount: 6, + // The playlist is served from /api/ (auth-gated); send the session + // cookie so the request authenticates. Segments are static + public. + xhrSetup: (xhr) => { xhr.withCredentials = true; }, + }); hlsRef.current = hls; hls.loadSource(previewUrl); hls.attachMedia(vid);