Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions server/LIVE_PACKET.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

Docker Compose sets `STREAMVAULT_LIVE_PACKET_AWARE=1` globally for current and future video channels. A direct server process still requires explicit `1`; any other value keeps the previous FFmpeg-per-session HLS implementation. `STREAMVAULT_LIVE_PACKET_AWARE=0` is the global emergency rollback, without touching raw archives or saved recordings. This does not alter archive/show capture or the legacy TS route.

The `/api/live/:id/authorize`, `/index.m3u8`, and `/segment/:segmentId.ts` handlers retain their existing app authentication, signed native-playback tickets, reader admission, and private cache. Per-channel admission happens before async setup. In packet mode, the worker accepts only a loopback `/api/stream/:id` URL and supplies an Authorization header when configured, without putting it on argv. The existing `/api/stream/:id` route itself remains publicly accessible as before; the new signed HLS routes do not change that route's access policy. One PyAV demux/mux plus one FFmpeg HLS mux spans clean upstream Content-Length and chunked EOFs. The final packet of BOTH H.264 and AAC remains private at each source boundary: a socket cut can make PyAV flush a truncated ADTS audio frame even with `is_corrupt=False`. A replay must exactly match the committed A/V history and replace each withheld packet with a same-timestamp, same-flags payload extending its prefix before any successor output. This prevents publishing a partial AAC frame or permanently rejecting its later complete counterpart. Source reads, seam probes, staged files, published cache, readers, and channel slots have bounds. FFmpeg retains a 64-entry staging manifest to survive accelerated replay; Node removes each staged TS after its private served copy is published, so staging media do not accumulate with the manifest. A failed seam or worker exit after accepting source media invalidates the cache and latches that channel unavailable until process restart; it never reconnects raw media into the same presentation. An initial HTTP/transport failure before accepting any media leaves no playable segment and permits a fresh worker on a later authorization (not a hot retry loop). Once media is established, an unavailable HTTP reopen receives at most three attempts inside the same worker, with cancellable 250 ms gaps. It does not refresh byte/publication watchdogs or replace the mux/timeline; any eventual media still undergoes full A/V seam proof. Exhaustion and unsafe bodies invalidate the presentation as before. Native players receive an unavailable response; the client retries 503/429 within a 25-second authorization budget before considering legacy fallback, honoring `Retry-After` without retrying earlier than requested and cancelling obsolete player/backend generations. Authentication rejections never permit fallback. Chromium uses Hls.js/MSE when supported even if it advertises native HLS; iPhone/Safari retain native HLS, and native-only browsers keep their fallback. This avoids Chromium's short native buffer and source-replay underruns seen in the ESPN browser soak. Unsupported tracks (including subtitles) and incompatible codecs fail closed in this mode.
The `/api/live/:id/authorize`, `/index.m3u8`, and `/segment/:segmentId.ts` handlers retain their existing app authentication, signed native-playback tickets, reader admission, and private cache. Per-channel admission happens before async setup. In packet mode, the worker accepts only a loopback `/api/stream/:id` URL and supplies an Authorization header when configured, without putting it on argv. The existing `/api/stream/:id` route itself remains publicly accessible as before; the new signed HLS routes do not change that route's access policy. One PyAV demux/mux plus one FFmpeg HLS mux spans clean upstream Content-Length and chunked EOFs. The final packet of BOTH H.264 and AAC remains private at each source boundary: a socket cut can make PyAV flush a truncated ADTS audio frame even with `is_corrupt=False`. A replay must exactly match the committed A/V history and replace each withheld packet with a same-timestamp, same-flags payload extending its prefix before any successor output. This prevents publishing a partial AAC frame or permanently rejecting its later complete counterpart. Source reads, seam probes, staged files, published cache, readers, and channel slots have bounds. FFmpeg retains a 64-entry staging manifest to survive accelerated replay; Node removes each staged TS after its private served copy is published, so staging media do not accumulate with the manifest. A failed seam or worker exit after accepting source media invalidates the cache and latches that channel unavailable until process restart; it never reconnects raw media into the same presentation. An initial HTTP/transport failure before accepting any media leaves no playable segment and permits a fresh worker on a later authorization (not a hot retry loop). Once media is established, an unavailable HTTP reopen receives at most three attempts inside the same worker, with cancellable one- and two-second cooldown gaps. It does not refresh byte/publication watchdogs or replace the mux/timeline; any eventual media still undergoes full A/V seam proof. Exhaustion and unsafe bodies invalidate the presentation as before. Native players receive an unavailable response; the client retries 503/429 within a 50-second authorization budget before considering legacy fallback, honoring `Retry-After` without retrying earlier than requested and cancelling obsolete player/backend generations. Authentication rejections never permit fallback. Chromium uses Hls.js/MSE when supported even if it advertises native HLS; iPhone/Safari retain native HLS, and native-only browsers keep their fallback. This avoids Chromium's short native buffer and source-replay underruns seen in the ESPN browser soak. Unsupported tracks (including subtitles) and incompatible codecs fail closed in this mode.

Stream-copy cuts HLS at source keyframes, so a two-second requested cadence does not impose a four-second GOP limit. Published segments must have a finite duration greater than zero and at most 12 seconds, with the existing per-segment byte bound unchanged. Every playlist advertises a fixed `TARGETDURATION:12`; it does not change as short and long GOPs arrive. Latched unsafe channels return immediate 501 without `Retry-After`, while real warmup/capacity still returns retryable 503. This status distinction never clears a latch, resumes an unproved seam, or restores stale ticket media. Production diagnostics record only channel ID and fixed retirement category.

The runtime image installs Debian `python3` and `python3-av`. Node invokes `/usr/bin/python3` so PyAV is visible; Python's `/usr/local/bin/python3` may not see apt-installed modules. FFmpeg and Python diagnostics are suppressed because URLs can contain credentials. Upstream responses are streamed with a 16 KiB read cap, a bounded socket stall timeout, finite seam history/probe, and a staged/published disk ceiling; cumulative response bytes are **not** capped, since a legitimate live HTTP body may stay open for hours. Source-read progress is marked while consuming a repeated prefix, so the HLS publication watchdog does not kill an actively catching-up stitch; an actually silent source still times out. A separate 45-second cap without any NEW published segment kills and latches the channel even if bytes (such as TS null packets) keep arriving; bytes alone are not playable progress. The Node channel owner, not an arbitrary Python wall-clock timer, retires idle viewers.
The runtime image installs Debian `python3` and `python3-av`. Node invokes `/usr/bin/python3` so PyAV is visible; Python's `/usr/local/bin/python3` may not see apt-installed modules. FFmpeg and Python diagnostics are suppressed because URLs can contain credentials. Upstream responses are streamed with a 16 KiB read cap, a bounded 10-second socket timeout below the owner's unchanged 15-second source-silence deadline, finite seam history/probe, and a staged/published disk ceiling; cumulative response bytes are **not** capped, since a legitimate live HTTP body may stay open for hours. Source-read progress is marked while consuming a repeated prefix, so the HLS publication watchdog does not kill an actively catching-up stitch; an actually silent source still times out. A separate 45-second cap without any NEW published segment kills and latches the channel even if bytes (such as TS null packets) keep arriving; bytes alone are not playable progress. The Node channel owner, not an arbitrary Python wall-clock timer, retires idle viewers.

Local integration tests exercise an authenticated loopback proxy, signed playlist **and** segment fetches, both clean EOF framings, an exact non-IDR continuation checked against a baseline packet inventory, and strict FFmpeg video/audio HLS decode. Negative cases include shifted AAC, a missing replay picture, incompatible video, false Content-Length, and an open silent source; previously cached segments are invalidated and the channel does not thrash-restart. Admission, reader cap, and shutdown cleanup are separately exercised. The original standalone matcher tests include ambiguous replay and bounded probing. A production-sized, natural-EOF fixture also exercises a fast two-session HLS manifest rollover on the Pi with the default poll interval and quotas.

**Acceptance scope:** CI builds and runs the actual x64 and ARM64 runtime images with Debian PyAV; x64 also runs the packaged server suite. Two natural live-proxy EOF responses (~84 MB each) were locally replayed across one non-IDR seam with strict HLS decode and once-only H.264/AAC boundary hashes. This does not prove sustained Pi throughput under concurrent viewers, replay catch-up against a physical player's buffered lead, or physical Samsung/iPhone playback. Audio-only retains its existing path; subtitle/codec layouts this worker cannot verify fail closed and can use the legacy route on a new authorization. A channel whose seam cannot be verified is never spliced with unproved media.
**Acceptance scope:** CI builds and runs the actual x64 and ARM64 runtime images with Debian PyAV; x64 also runs the packaged server suite. Two natural live-proxy EOF responses (~84 MB each) were locally replayed across one non-IDR seam with strict HLS decode and once-only H.264/AAC boundary hashes. This does not prove sustained Pi throughput under concurrent viewers, replay catch-up against a physical player's buffered lead, or physical Samsung/iPhone playback. Native Apple/WebKit clients request `/api/live-compatible` if the strict primary presentation is unavailable or fails. This independently signed, four-channel-capped FFmpeg stream-copy HLS buffer uses a sibling cache and new segment epochs, publishing explicit discontinuities rather than making a seamless-stitch claim. Primary unsafe latches and stale media remain invalid, and authorization rejection never downgrades to compatibility. Unavailable compatibility ends with an actionable error, not native raw MPEG-TS or an endless spinner. Audio-only retains its existing path. A channel whose seam cannot be verified is never spliced with unproved media.
20 changes: 15 additions & 5 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,19 +217,29 @@ app.use(createArchiveRouter({ store: archiveStore, root: archiveRoot, secret: ar
getChannel: getChannelById, getRecording, getPrograms: getArchivePrograms,
start: id => archiveCapture.start(id), stop: id => archiveCapture.stopArchive(id),
prioritize: (id, start, end) => archiveCapture.prioritizeWindow(id, start, end) }));
const liveBuffer = createLiveBuffer({ root: process.env.STREAMVAULT_LIVE_BUFFER_DIR ||
path.join(process.env.TMPDIR || '/var/tmp', 'streamvault-live-buffer'),
const liveBufferRoot = path.resolve(process.env.STREAMVAULT_LIVE_BUFFER_DIR ||
path.join(process.env.TMPDIR || '/var/tmp', 'streamvault-live-buffer'));
const liveBuffer = createLiveBuffer({ root: liveBufferRoot,
packetAware: packetAwareLiveEnabled(process.env.STREAMVAULT_LIVE_PACKET_AWARE),
// IDs are validated by packet ingestion; reasons are fixed server categories.
// Never log source URLs, tickets, or worker stderr.
onUnsafe: (reason, id) => console.warn('Live HLS unavailable', { channelId: id, reason }) });
app.use('/api/live', createLiveRouter(liveBuffer, id => {
// Separate disposable cache and router secret: never reopen a latched primary
// presentation or make its old tickets/segment epochs valid in compatibility mode.
const compatibleLiveBuffer = createLiveBuffer({
root: `${liveBufferRoot}-compatible`,
packetAware: false, maxChannels: 4,
});
const liveSource = (id: string) => {
if (!/^[-A-Za-z0-9_]+$/.test(id)) return null;
const channel = getChannelById(id);
// The existing proxy validates upstream responses and hides credential-bearing
// provider URLs from the FFmpeg process arguments.
return channel?.content_type === 'livetv' && channel.url
? `http://127.0.0.1:${PORT}/api/stream/${encodeURIComponent(id)}?subs=1` : null;
}));
};
app.use('/api/live', createLiveRouter(liveBuffer, liveSource));
app.use('/api/live-compatible', createLiveRouter(compatibleLiveBuffer, liveSource, { basePath: '/api/live-compatible' }));

// Request logging; include completion timing only when a request is slow.
app.use((req, res, next) => {
Expand Down Expand Up @@ -2414,7 +2424,7 @@ function shutdown(signal: string): void {
});

const recorderAndAnalysisShutdown = (async () => {
await Promise.all([schedulerShutdown, stopAllRecordings(), archiveCapture.stopAll(), liveBuffer.stop()]);
await Promise.all([schedulerShutdown, stopAllRecordings(), archiveCapture.stopAll(), liveBuffer.stop(), compatibleLiveBuffer.stop()]);
await stopCommercialAnalysisWorker();
})().catch(error => {
logger.warn(`Recorder/analysis shutdown failed: ${error instanceof Error ? error.message : error}`);
Expand Down
6 changes: 4 additions & 2 deletions server/src/live-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,9 @@ export function createLiveBuffer(options: LiveBufferOptions = {}) {
};
}

export function createLiveRouter(buffer: ReturnType<typeof createLiveBuffer>, source: (id: string) => string | null | undefined) {
export function createLiveRouter(buffer: ReturnType<typeof createLiveBuffer>, source: (id: string) => string | null | undefined,
options: { basePath?: '/api/live' | '/api/live-compatible' } = {}) {
const basePath = options.basePath ?? '/api/live';
const router = Router();
const secret = randomBytes(32);
const ttl = 24 * 60 * 60_000;
Expand Down Expand Up @@ -410,7 +412,7 @@ export function createLiveRouter(buffer: ReturnType<typeof createLiveBuffer>, so
if (buffer.isUnsafe(id)) { res.status(501).end(); return; }
if (!ready) { res.set('Retry-After', '2').status(503).end(); return; }
const { ticket, expiresAt } = ticketFor(id);
res.set('Cache-Control', 'private, no-store').json({ playlistUrl: `/api/live/${encodeURIComponent(id)}/index.m3u8?ticket=${ticket}`, expiresAt });
res.set('Cache-Control', 'private, no-store').json({ playlistUrl: `${basePath}/${encodeURIComponent(id)}/index.m3u8?ticket=${ticket}`, expiresAt });
});
router.get('/:id/index.m3u8', async (req, res) => {
const id = String(req.params.id);
Expand Down
Loading
Loading