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:
parent
24d10fda5d
commit
d3ad2397fb
2 changed files with 45 additions and 2 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue