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/<id>/
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 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-31 15:57:41 -04:00
parent 24d10fda5d
commit d3ad2397fb
2 changed files with 45 additions and 2 deletions

View file

@ -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/<id>/ 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') {

View file

@ -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);