prismcast 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +9 -8
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.js +1 -1
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +10 -34
- package/dist/browser/channelSelection.js +19 -96
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +19 -0
- package/dist/browser/display.js +16 -11
- package/dist/browser/display.js.map +1 -1
- package/dist/browser/index.d.ts +9 -46
- package/dist/browser/index.js +136 -191
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +65 -0
- package/dist/browser/login.js +196 -0
- package/dist/browser/login.js.map +1 -0
- package/dist/browser/precaching.js +12 -0
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
- package/dist/browser/tuning/comcastPolymer.js +655 -0
- package/dist/browser/tuning/comcastPolymer.js.map +1 -0
- package/dist/browser/tuning/cox.d.ts +2 -0
- package/dist/browser/tuning/cox.js +14 -0
- package/dist/browser/tuning/cox.js.map +1 -0
- package/dist/browser/tuning/directv.js +1 -1
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +2 -2
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +1 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +1 -1
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/shared.d.ts +46 -0
- package/dist/browser/tuning/shared.js +100 -0
- package/dist/browser/tuning/shared.js.map +1 -0
- package/dist/browser/tuning/sling.js +1 -1
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.js +1 -1
- package/dist/browser/tuning/spectrum.js.map +1 -1
- package/dist/browser/tuning/thumbnailRow.js +1 -1
- package/dist/browser/tuning/thumbnailRow.js.map +1 -1
- package/dist/browser/tuning/tileClick.js +1 -1
- package/dist/browser/tuning/tileClick.js.map +1 -1
- package/dist/browser/tuning/xfinity.js +10 -603
- package/dist/browser/tuning/xfinity.js.map +1 -1
- package/dist/browser/tuning/youtubeTv.js +1 -1
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +18 -0
- package/dist/browser/video.js +69 -107
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +4 -2
- package/dist/channels/index.js +2261 -800
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.js +30 -17
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +37 -63
- package/dist/config/index.js.map +1 -1
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providerPacks.js +4 -1
- package/dist/config/providerPacks.js.map +1 -1
- package/dist/config/providers.d.ts +12 -5
- package/dist/config/providers.js +133 -104
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.js +15 -9
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +108 -7
- package/dist/config/userChannels.js +374 -44
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +3 -1
- package/dist/config/userConfig.js +31 -19
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.js +27 -39
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -4
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/hdhr/discover.js +7 -15
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/index.js +9 -15
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +3 -0
- package/dist/native/index.js +16 -25
- package/dist/native/index.js.map +1 -1
- package/dist/native/probe.d.ts +4 -3
- package/dist/native/probe.js +36 -12
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -0
- package/dist/native/proxy.js +369 -326
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +26 -48
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/channels.js +5 -11
- package/dist/routes/channels.js.map +1 -1
- package/dist/routes/components.d.ts +43 -8
- package/dist/routes/components.js +122 -19
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +2 -2
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +749 -71
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +93 -2
- package/dist/routes/config/channels/table.js +594 -104
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +1 -1
- package/dist/routes/config/index.js +1 -1
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/providers.d.ts +6 -6
- package/dist/routes/config/providers.js +76 -102
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/config/settings.js +57 -36
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/icons.d.ts +16 -0
- package/dist/routes/icons.js +44 -0
- package/dist/routes/icons.js.map +1 -0
- package/dist/routes/index.d.ts +0 -16
- package/dist/routes/index.js +0 -17
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +68 -27
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/providers.js +106 -3
- package/dist/routes/providers.js.map +1 -1
- package/dist/routes/root/content.js +96 -40
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.js +2 -1
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +955 -310
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +695 -569
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/index.d.ts +1 -0
- package/dist/routes/root/scripts/index.js +1 -0
- package/dist/routes/root/scripts/index.js.map +1 -1
- package/dist/routes/root/scripts/shared.d.ts +6 -0
- package/dist/routes/root/scripts/shared.js +437 -0
- package/dist/routes/root/scripts/shared.js.map +1 -0
- package/dist/routes/root/scripts/status.js +33 -24
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +157 -11
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +1 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +4 -35
- package/dist/service/commands.js.map +1 -1
- package/dist/streaming/codec.d.ts +20 -0
- package/dist/streaming/codec.js +68 -0
- package/dist/streaming/codec.js.map +1 -0
- package/dist/streaming/fmp4Segmenter.d.ts +2 -0
- package/dist/streaming/fmp4Segmenter.js +7 -4
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.js +274 -188
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +9 -3
- package/dist/streaming/hlsResume.js +15 -8
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +1 -2
- package/dist/streaming/hlsSegments.js +48 -46
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +8 -12
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.js +625 -585
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mpegts.js +1 -1
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/preroll.d.ts +41 -20
- package/dist/streaming/preroll.js +157 -76
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/recovery.d.ts +0 -6
- package/dist/streaming/recovery.js +0 -8
- package/dist/streaming/recovery.js.map +1 -1
- package/dist/streaming/registry.d.ts +36 -9
- package/dist/streaming/registry.js +6 -9
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +3 -3
- package/dist/streaming/setup.js +38 -22
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +7 -6
- package/dist/streaming/showInfo.js +156 -36
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +14 -2
- package/dist/streaming/statusEmitter.js +22 -4
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +34 -0
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +3 -2
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +8 -3
- package/dist/types/selection.d.ts +1 -0
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +7 -15
- package/dist/types/streaming.js +5 -1
- package/dist/types/streaming.js.map +1 -1
- package/dist/upgrade/commands.js +1 -16
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/utils/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +0 -5
- package/dist/utils/debugFilter.js +5 -11
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +12 -0
- package/dist/utils/delay.js +20 -0
- package/dist/utils/delay.js.map +1 -1
- package/dist/utils/evaluate.js +6 -11
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +13 -5
- package/dist/utils/ffmpeg.js +32 -11
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/format.d.ts +20 -3
- package/dist/utils/format.js +32 -6
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logEmitter.js +1 -1
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/retry.d.ts +15 -9
- package/dist/utils/retry.js +8 -26
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/timing.d.ts +5 -0
- package/dist/utils/timing.js +13 -0
- package/dist/utils/timing.js.map +1 -0
- package/dist/utils/version.js +2 -2
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -5
package/dist/streaming/hls.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
/* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* hls.ts: HLS streaming request handlers for PrismCast.
|
|
4
|
+
*/
|
|
5
|
+
import { getEffectiveCaptureCodec, isCaptureHardwareAccelerated } from "./codec.js";
|
|
1
6
|
import { LOG, formatError, runWithStreamContext, startTimer } from "../utils/index.js";
|
|
2
7
|
import { StreamSetupError, createPageWithCapture, generateStreamId, setupStream } from "./setup.js";
|
|
3
|
-
import { consumeResumeData, getResumeSegmentIndex } from "./hlsResume.js";
|
|
4
8
|
import { createHLSState, getAllStreams, getNextStreamId, getStream, getStreamCount, registerStream, updateLastAccess } from "./registry.js";
|
|
5
9
|
import { createInitialStreamStatus, emitStreamAdded } from "./statusEmitter.js";
|
|
10
|
+
import { deleteResumeData, getResumeSegmentIndex, peekResumeData } from "./hlsResume.js";
|
|
6
11
|
import { emitCurrentSystemStatus, isLoginModeActive, unregisterManagedPage } from "../browser/index.js";
|
|
7
|
-
import { generatePrerollPlaylist, getPrerollSegmentCount, isPrerollReady } from "./preroll.js";
|
|
12
|
+
import { generatePrerollPlaylist, getPrerollCodec, getPrerollSegmentCount, isPrerollReady } from "./preroll.js";
|
|
8
13
|
import { getAllChannels, isPredefinedChannelDisabled } from "../config/userChannels.js";
|
|
9
14
|
import { getAudioPlaylist, getAudioSegment, getInitSegment, getPlaylist, getSegment, getVideoPlaylist, waitForPlaylist } from "./hlsSegments.js";
|
|
10
15
|
import { getAuthDomainForChannel, getProviderTagForChannel, getResolvedChannel, resolveProviderKey } from "../config/providers.js";
|
|
@@ -17,25 +22,31 @@ import { createFMP4Segmenter } from "./fmp4Segmenter.js";
|
|
|
17
22
|
import { createHash } from "node:crypto";
|
|
18
23
|
import { getProviderBySlug } from "../browser/channelSelection.js";
|
|
19
24
|
import { registerClient } from "./clients.js";
|
|
25
|
+
import { suppressPageAudio } from "../browser/video.js";
|
|
20
26
|
import { triggerShowNameUpdate } from "./showInfo.js";
|
|
21
27
|
/* This module handles HLS (HTTP Live Streaming) output using fMP4 (fragmented MP4) segments. HLS mode uses MP4/AAC capture from puppeteer-stream, which is then
|
|
22
|
-
* segmented natively without any external dependencies. The
|
|
28
|
+
* segmented natively without any external dependencies. The stream initialization flow has three phases:
|
|
29
|
+
*
|
|
30
|
+
* Phase 1 — Registration (synchronous, in the request handler):
|
|
31
|
+
* The client requests a playlist. If no stream exists and preroll is available, a pending registry entry is registered immediately with a deferred preroll timer.
|
|
32
|
+
* The client receives a valid, playable playlist on the first request — no blocking wait for the real stream. If preroll is unavailable, the request blocks until
|
|
33
|
+
* stream setup completes (fallback path).
|
|
23
34
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* 3. Asynchronously, setupStream() creates the browser page, navigates, captures, and creates the fMP4 segmenter. The segmenter starts at segment index 10
|
|
28
|
-
* (after the preroll range) with a pending discontinuity tag.
|
|
29
|
-
* 4. When the segmenter produces its first real playlist, it overwrites the preroll playlist. The next client poll receives real content.
|
|
30
|
-
* 5. Client fetches init.mp4 once, then media segments at /hls/:name/segmentN.m4s
|
|
31
|
-
* 6. Idle timeout terminates streams with no recent segment requests
|
|
35
|
+
* Phase 2 — Browser setup (async, fire-and-forget from the request handler):
|
|
36
|
+
* setupStream() creates the browser page, navigates to the URL, initializes playback, and starts capture. This produces the StreamSetupResult with the capture
|
|
37
|
+
* stream, page, profile, and monitor. The pending registry entry is filled in with these references.
|
|
32
38
|
*
|
|
33
|
-
*
|
|
39
|
+
* Phase 3 — Streaming pipeline (async, after browser setup):
|
|
40
|
+
* If the provider's manifest is interceptable, native HLS streaming is attempted via startNativeProxy(). If native is viable, the capture pipeline is stopped and
|
|
41
|
+
* the proxy takes over. Otherwise, createCaptureSegmenter() creates the fMP4 segmenter and pipes the capture stream. When the first real playlist arrives
|
|
42
|
+
* (from either the segmenter or native proxy), the preroll timer is cancelled and the client receives live content on the next poll.
|
|
43
|
+
*
|
|
44
|
+
* Shared streams: If multiple clients request the same channel (or the same ad-hoc URL with the same profile), they share one stream. The first client triggers
|
|
34
45
|
* stream creation, and subsequent clients get the existing playlist and segments. Ad-hoc streams are identified by a synthetic key ("play-<hash>") derived from the
|
|
35
46
|
* URL and profile, allowing them to use the same channelToStreamId deduplication mechanism as predefined channels.
|
|
36
47
|
*/
|
|
37
48
|
// Delay before seeding the preroll playlist in milliseconds. If stream setup completes before this timer fires, the preroll is skipped and the client receives real
|
|
38
|
-
// content directly. This ensures fast-tuning providers (native HLS at 2-3s, most capture providers at 4-7s) never see preroll, while slow providers (Xfinity at
|
|
49
|
+
// content directly. This ensures fast-tuning providers (native HLS at 2-3s, most capture providers at 4-7s) never see preroll, while slow providers (Xfinity/Cox at
|
|
39
50
|
// 13-15s) get preroll content after the delay to prevent HTTP timeouts.
|
|
40
51
|
const PREROLL_DELAY_MS = 9_000;
|
|
41
52
|
// Login mode error body used by both validateChannel() and handlePlayStream() to ensure consistent response format.
|
|
@@ -122,7 +133,8 @@ export async function ensureChannelStream(channelName, req, res) {
|
|
|
122
133
|
}
|
|
123
134
|
// When the preroll is available, register a pending stream immediately with a preroll playlist so the client gets a valid playlist on the first request. The full
|
|
124
135
|
// stream setup runs asynchronously. When the preroll is not available (FFmpeg missing or failed), fall back to blocking initialization.
|
|
125
|
-
|
|
136
|
+
const prerollCodec = getPrerollCodec();
|
|
137
|
+
if (isPrerollReady(prerollCodec)) {
|
|
126
138
|
// Check capacity before registering the pending entry. The pending entry occupies a registry slot, so registering it at max capacity would cause setupStream's
|
|
127
139
|
// capacity check to reject — after we've already returned a preroll playlist to the client. By gating here, we can still send a proper 503 error response.
|
|
128
140
|
if (getStreamCount() >= CONFIG.streaming.maxConcurrentStreams) {
|
|
@@ -134,7 +146,7 @@ export async function ensureChannelStream(channelName, req, res) {
|
|
|
134
146
|
}
|
|
135
147
|
}
|
|
136
148
|
const clientAddress = req.ip ?? req.socket.remoteAddress ?? null;
|
|
137
|
-
const pending = registerPendingStream(channelName, validation.channel, clientAddress, req);
|
|
149
|
+
const pending = registerPendingStream(channelName, validation.channel, clientAddress, req, prerollCodec);
|
|
138
150
|
// Launch async setup. Errors are caught here to clean up the pending entry and prevent unhandled rejections.
|
|
139
151
|
void completeStreamSetup({
|
|
140
152
|
channel: validation.channel,
|
|
@@ -346,8 +358,8 @@ async function sendPlaylistResponse(streamId, clientAddress, res) {
|
|
|
346
358
|
// the segmenter's playlist takes over.
|
|
347
359
|
const stream = getStream(streamId);
|
|
348
360
|
if (stream && !stream.hls.hasRealPlaylist) {
|
|
349
|
-
if (stream.hls.prerollBaseUrl && stream.hls.prerollStartTime) {
|
|
350
|
-
playlist = generatePrerollPlaylist(stream.hls.prerollBaseUrl, stream.hls.resumeSegmentIndex, stream.hls.prerollStartTime);
|
|
361
|
+
if (stream.hls.prerollBaseUrl && stream.hls.prerollCodec && stream.hls.prerollStartTime) {
|
|
362
|
+
playlist = generatePrerollPlaylist(stream.hls.prerollBaseUrl, stream.hls.prerollCodec, stream.hls.resumeSegmentIndex, stream.hls.prerollStartTime);
|
|
351
363
|
}
|
|
352
364
|
LOG.debug("streaming:preroll", "Serving preroll playlist for stream %d.", streamId);
|
|
353
365
|
}
|
|
@@ -392,6 +404,34 @@ function cleanupOrphanedSetup(segmenter) {
|
|
|
392
404
|
LOG.debug("streaming:setup", "Stream was terminated during setup. Stopping orphaned segmenter.");
|
|
393
405
|
segmenter.stop();
|
|
394
406
|
}
|
|
407
|
+
// Maps vertical resolution heights to standard display labels. Used by formatNativeQuality() to convert "1920x1080" → "1080p".
|
|
408
|
+
const RESOLUTION_LABELS = { "1080": "1080p", "2160": "4K", "360": "360p", "480": "480p", "720": "720p" };
|
|
409
|
+
/**
|
|
410
|
+
* Formats the native HLS quality string for the "Streaming..." log line. Combines codec, bandwidth (as Mbps), and resolution (as standard label like "1080p")
|
|
411
|
+
* into a compact suffix. Returns an empty string when no values are available, or a comma-prefixed string like ", H264 12.1Mbps 1080p" for inclusion in the log.
|
|
412
|
+
* @param bandwidth - Declared bandwidth in bits per second from the manifest. Zero when absent.
|
|
413
|
+
* @param codec - Video codec label (e.g., "H264", "HEVC"), or null when absent.
|
|
414
|
+
* @param resolution - Resolution string from the manifest (e.g., "1920x1080"), or null when absent.
|
|
415
|
+
* @returns Formatted quality string for log output.
|
|
416
|
+
*/
|
|
417
|
+
function formatNativeQuality(bandwidth, codec, resolution) {
|
|
418
|
+
const parts = [];
|
|
419
|
+
if (codec) {
|
|
420
|
+
parts.push(codec);
|
|
421
|
+
}
|
|
422
|
+
if (bandwidth > 0) {
|
|
423
|
+
parts.push((bandwidth / 1000000).toFixed(1) + "Mbps");
|
|
424
|
+
}
|
|
425
|
+
if (resolution) {
|
|
426
|
+
// Map the vertical resolution to a standard label. Parse the height from "WIDTHxHEIGHT" format.
|
|
427
|
+
const height = resolution.split("x")[1];
|
|
428
|
+
parts.push(RESOLUTION_LABELS[height] ?? resolution);
|
|
429
|
+
}
|
|
430
|
+
if (parts.length === 0) {
|
|
431
|
+
return "";
|
|
432
|
+
}
|
|
433
|
+
return ", " + parts.join(" ");
|
|
434
|
+
}
|
|
395
435
|
/**
|
|
396
436
|
* Creates a tab replacement handler for recovery from unresponsive browser tabs. When the monitor detects 3+ consecutive evaluate timeouts, it calls this handler to:
|
|
397
437
|
* 1. Stop the current segmenter and FFmpeg process
|
|
@@ -553,14 +593,15 @@ export async function initializeStream(options) {
|
|
|
553
593
|
* Registers a pending stream entry in the registry with deferred preroll. This is the synchronous Phase 1 of the two-phase stream initialization used by the HLS
|
|
554
594
|
* playlist handler. The pending entry has a real stream ID but no playlist yet — the response is held until either the preroll timer fires (after PREROLL_DELAY_MS)
|
|
555
595
|
* or real content arrives from the segmenter/native proxy. This ensures that fast-tuning streams (native, most capture providers) skip preroll entirely, while slow
|
|
556
|
-
* streams (Xfinity at 13-15s) get preroll content after the delay.
|
|
596
|
+
* streams (Xfinity/Cox at 13-15s) get preroll content after the delay.
|
|
557
597
|
* @param channelName - The channel key for registration and deduplication.
|
|
558
598
|
* @param channel - The resolved channel definition.
|
|
559
599
|
* @param clientAddress - Client IP address for Channels DVR API integration.
|
|
560
600
|
* @param req - Express request object for deriving the base URL.
|
|
601
|
+
* @param codec - The preroll codec variant to use for this stream.
|
|
561
602
|
* @returns The allocated stream IDs.
|
|
562
603
|
*/
|
|
563
|
-
function registerPendingStream(channelName, channel, clientAddress, req) {
|
|
604
|
+
function registerPendingStream(channelName, channel, clientAddress, req, codec) {
|
|
564
605
|
const numericStreamId = getNextStreamId();
|
|
565
606
|
const streamIdStr = generateStreamId(channelName, channel.url);
|
|
566
607
|
// Derive the base URL from the request for absolute preroll segment URLs.
|
|
@@ -578,18 +619,19 @@ function registerPendingStream(channelName, channel, clientAddress, req) {
|
|
|
578
619
|
hls.resumeSegmentIndex = getResumeSegmentIndex(channelName) ?? 0;
|
|
579
620
|
// Capture the stream start time at registration. This timestamp is used for the registry's startTime field (stream age display, etc.).
|
|
580
621
|
const streamStartTime = new Date();
|
|
581
|
-
if (isPrerollReady()) {
|
|
622
|
+
if (isPrerollReady(codec)) {
|
|
582
623
|
// Set preroll metadata immediately at registration so the segmenter and native proxy can read it regardless of whether the deferred timer has fired. The segment
|
|
583
|
-
// count and base URL are structural properties of the preroll system — they determine segment index offsets and composite playlist
|
|
584
|
-
// only the client-facing timing (when the standalone preroll playlist begins serving). Decoupling these ensures correct behavior for
|
|
585
|
-
// scenarios (e.g., Fox native at 5s tune but 15s proxy first poll) where setup completes before the timer fires.
|
|
624
|
+
// count, codec, and base URL are structural properties of the preroll system — they determine segment index offsets, codec-aware URL paths, and composite playlist
|
|
625
|
+
// behavior. The timer controls only the client-facing timing (when the standalone preroll playlist begins serving). Decoupling these ensures correct behavior for
|
|
626
|
+
// fast-tune/slow-proxy scenarios (e.g., Fox native at 5s tune but 15s proxy first poll) where setup completes before the timer fires.
|
|
586
627
|
hls.prerollBaseUrl = baseUrl;
|
|
587
|
-
hls.
|
|
628
|
+
hls.prerollCodec = codec;
|
|
629
|
+
hls.prerollSegmentCount = getPrerollSegmentCount(codec);
|
|
588
630
|
hls.prerollTimer = setTimeout(() => {
|
|
589
631
|
// Record the preroll start time and seed the initial progressive playlist. On subsequent polls, sendPlaylistResponse() regenerates the playlist with an
|
|
590
632
|
// advancing window based on elapsed time from this start time, simulating a live stream.
|
|
591
633
|
hls.prerollStartTime = new Date();
|
|
592
|
-
hls.playlist = generatePrerollPlaylist(baseUrl, hls.resumeSegmentIndex, hls.prerollStartTime);
|
|
634
|
+
hls.playlist = generatePrerollPlaylist(baseUrl, codec, hls.resumeSegmentIndex, hls.prerollStartTime);
|
|
593
635
|
hls.signalPlaylistReady();
|
|
594
636
|
}, PREROLL_DELAY_MS);
|
|
595
637
|
}
|
|
@@ -606,9 +648,11 @@ function registerPendingStream(channelName, channel, clientAddress, req) {
|
|
|
606
648
|
function createPendingEntry(options) {
|
|
607
649
|
const { channel, channelName, hls, numericStreamId, streamIdStr, url } = options;
|
|
608
650
|
registerStream({
|
|
651
|
+
captureCodec: null,
|
|
609
652
|
channelName: channel?.name ?? null,
|
|
610
653
|
clientAddress: options.clientAddress ?? null,
|
|
611
654
|
ffmpegProcess: null,
|
|
655
|
+
hardwareAccelerated: false,
|
|
612
656
|
hls,
|
|
613
657
|
id: numericStreamId,
|
|
614
658
|
info: {
|
|
@@ -616,7 +660,9 @@ function createPendingEntry(options) {
|
|
|
616
660
|
storeKey: channelName
|
|
617
661
|
},
|
|
618
662
|
mpegTsClientCount: 0,
|
|
663
|
+
nativeBandwidth: 0,
|
|
619
664
|
nativeProxy: null,
|
|
665
|
+
nativeResolution: null,
|
|
620
666
|
page: null,
|
|
621
667
|
preTuned: options.preTuned ?? false,
|
|
622
668
|
profile: null,
|
|
@@ -652,13 +698,168 @@ function handleSetupFailure(numericStreamId, channelName, channel, error, logErr
|
|
|
652
698
|
// Only log when the error won't be caught and logged by an outer handler (non-blocking fire-and-forget path).
|
|
653
699
|
if (logError) {
|
|
654
700
|
if (error instanceof StreamSetupError) {
|
|
655
|
-
LOG.warn("Stream setup failed for %s: %s
|
|
701
|
+
LOG.warn("Stream setup failed for %s: %s", channelName, error.userMessage);
|
|
656
702
|
}
|
|
657
703
|
else {
|
|
658
704
|
LOG.error("Unexpected error during stream setup for %s: %s.", channelName, formatError(error));
|
|
659
705
|
}
|
|
660
706
|
}
|
|
661
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Attempts to upgrade the stream from capture to native HLS. Finalizes the manifest interception, probes the manifest, creates a native proxy, stops the capture
|
|
710
|
+
* pipeline, suppresses page audio, and updates the registry entry. Returns the codec and formatted quality string on success, or null if native is not viable.
|
|
711
|
+
* @param setup - The stream setup result from setupStream().
|
|
712
|
+
* @param numericStreamId - The stream's numeric ID.
|
|
713
|
+
* @param channelName - The channel key for logging and cache operations.
|
|
714
|
+
* @param url - The stream URL for the native proxy.
|
|
715
|
+
* @param mpegTsClient - Whether the client is an MPEG-TS consumer.
|
|
716
|
+
* @returns The native codec and quality info, or null if native streaming was not viable.
|
|
717
|
+
*/
|
|
718
|
+
async function startNativeProxy(setup, numericStreamId, channelName, url, mpegTsClient) {
|
|
719
|
+
if (!setup.manifestInterception) {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
setup.manifestInterception.finalize(setup.directTune);
|
|
723
|
+
// Read the preroll segment count from the pending entry to pass to the native coordinator. This value is set at registration time (not in the timer callback),
|
|
724
|
+
// so it's available regardless of whether the deferred preroll timer has fired. The proxy uses it for segment index offset. The base URL for composite playlists
|
|
725
|
+
// is read dynamically from the stream's HLS state at playlist generation time.
|
|
726
|
+
const pendingForNative = getStream(numericStreamId);
|
|
727
|
+
const nativePrerollSegmentCount = pendingForNative?.hls.prerollSegmentCount ?? 0;
|
|
728
|
+
const nativeResult = await attemptNativeStreaming({
|
|
729
|
+
channelName,
|
|
730
|
+
interceptionPromise: setup.manifestInterception.promise,
|
|
731
|
+
mpegTsClient,
|
|
732
|
+
onError: (error) => {
|
|
733
|
+
if (isTerminationInitiated(numericStreamId)) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// Log the error and clear the probe cache. The proxy has already stopped itself (set errorThresholdReached + stopped). The monitor detects
|
|
737
|
+
// hasErrored() on the next 2-second tick and triggers L3 fallback to capture mode, preserving the stream for the DVR client.
|
|
738
|
+
LOG.warn("Native proxy error for %s: %s. Falling back to capture.", channelName, error);
|
|
739
|
+
clearProbeCache(channelName);
|
|
740
|
+
},
|
|
741
|
+
page: setup.page,
|
|
742
|
+
prerollCodec: pendingForNative?.hls.prerollCodec ?? "h264",
|
|
743
|
+
prerollSegmentCount: nativePrerollSegmentCount,
|
|
744
|
+
streamId: numericStreamId,
|
|
745
|
+
streamIdStr: setup.streamId,
|
|
746
|
+
url
|
|
747
|
+
});
|
|
748
|
+
if (!nativeResult) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
const currentStream = getStream(numericStreamId);
|
|
752
|
+
if (!currentStream) {
|
|
753
|
+
nativeResult.proxy.stop();
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
// Stop the capture pipeline — native streaming replaces it entirely.
|
|
757
|
+
if (!setup.rawCaptureStream.destroyed) {
|
|
758
|
+
setup.rawCaptureStream.destroy();
|
|
759
|
+
}
|
|
760
|
+
if (setup.ffmpegProcess) {
|
|
761
|
+
setup.ffmpegProcess.kill();
|
|
762
|
+
}
|
|
763
|
+
// Update the registry entry to reflect native mode. For streams with separate audio, clear preroll state — preroll is muxed video+audio and can't be
|
|
764
|
+
// split into separate renditions. For muxed-audio streams, preserve preroll state so the proxy can build composite playlists with preroll entries.
|
|
765
|
+
currentStream.ffmpegProcess = null;
|
|
766
|
+
currentStream.hls.hasAudio = nativeResult.hasAudio;
|
|
767
|
+
if (nativeResult.hasAudio) {
|
|
768
|
+
currentStream.hls.prerollBaseUrl = null;
|
|
769
|
+
currentStream.hls.prerollCodec = null;
|
|
770
|
+
currentStream.hls.prerollSegmentCount = 0;
|
|
771
|
+
}
|
|
772
|
+
currentStream.captureCodec = nativeResult.codec;
|
|
773
|
+
currentStream.hardwareAccelerated = false;
|
|
774
|
+
currentStream.nativeBandwidth = nativeResult.bandwidth;
|
|
775
|
+
currentStream.nativeProxy = nativeResult.proxy;
|
|
776
|
+
currentStream.nativeResolution = nativeResult.resolution;
|
|
777
|
+
currentStream.rawCaptureStream = null;
|
|
778
|
+
currentStream.streamingMode = "native";
|
|
779
|
+
// Start the native proxy. Signal init segment readiness immediately — native MPEG-TS segments carry their own PAT/PMT codec configuration in every
|
|
780
|
+
// segment, so there is no separate init segment to wait for. Without this, MPEG-TS clients block on waitForInitSegment() and time out before the proxy's
|
|
781
|
+
// first poll cycle completes.
|
|
782
|
+
nativeResult.proxy.start();
|
|
783
|
+
currentStream.hls.signalInitSegmentReady();
|
|
784
|
+
// Suppress audio on the browser page. The page stays alive for token refresh but the video element's audio is not part of the native stream — without
|
|
785
|
+
// suppression, it plays audibly on the local machine.
|
|
786
|
+
await suppressPageAudio(setup.page);
|
|
787
|
+
LOG.debug("native:coordinator", "Capture pipeline stopped for %s. Native proxy active.", channelName);
|
|
788
|
+
return { codec: nativeResult.codec, quality: formatNativeQuality(nativeResult.bandwidth, nativeResult.codec, nativeResult.resolution) };
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Creates the fMP4 segmenter for capture mode streams. Reads resume data, creates the segmenter with preroll and resume configuration, pipes the capture stream,
|
|
792
|
+
* and stores the segmenter in the registry. Resume data is consumed only after the segmenter is successfully stored, ensuring it survives if creation fails.
|
|
793
|
+
* @param setup - The stream setup result from setupStream().
|
|
794
|
+
* @param numericStreamId - The stream's numeric ID.
|
|
795
|
+
* @param channelName - The channel key for resume data and logging.
|
|
796
|
+
* @returns True if the segmenter was created and stored, false if the stream was terminated during setup.
|
|
797
|
+
*/
|
|
798
|
+
function createCaptureSegmenter(setup, numericStreamId, channelName) {
|
|
799
|
+
// Peek at resume data from a previous shutdown without consuming it. The data is consumed (deleted) only after the segmenter is successfully created and
|
|
800
|
+
// stored in the registry. This ensures resume data survives if segmenter creation fails — the next stream start retries with the same resume state instead
|
|
801
|
+
// of losing it and causing an HLS sequence reset.
|
|
802
|
+
const resumeData = peekResumeData(channelName);
|
|
803
|
+
const currentStream = getStream(numericStreamId);
|
|
804
|
+
const prerollSegmentCount = currentStream?.hls.prerollSegmentCount ?? 0;
|
|
805
|
+
// When preroll is active, use the snapshotted resume index (stored on HLS state at registration) so the segmenter's starting index is guaranteed to match
|
|
806
|
+
// the preroll playlist's MEDIA-SEQUENCE offset. When preroll is inactive, use the resume data directly — no preroll playlist to be consistent with.
|
|
807
|
+
const baseSegmentIndex = (prerollSegmentCount > 0) ? (currentStream?.hls.resumeSegmentIndex ?? 0) : (resumeData?.segmentIndex ?? 0);
|
|
808
|
+
// Create the fMP4 segmenter. The starting segment index accounts for both the resume offset and the preroll segment range. When preroll is active, the
|
|
809
|
+
// segmenter includes preroll entries in its sliding window via the compositor. The pending discontinuity at the preroll-to-real boundary is always needed —
|
|
810
|
+
// previousInitSegment is only passed without preroll, because the preroll init segment differs from the real init and the discontinuity must not be
|
|
811
|
+
// suppressed by an init-match comparison against the prior session.
|
|
812
|
+
const segmenter = createFMP4Segmenter({
|
|
813
|
+
...(resumeData ? {
|
|
814
|
+
initialTrackTimestamps: resumeData.trackTimestamps,
|
|
815
|
+
...((prerollSegmentCount === 0) ? { previousInitSegment: resumeData.initSegment } : {}),
|
|
816
|
+
startingInitVersion: resumeData.initVersion
|
|
817
|
+
} : {}),
|
|
818
|
+
...((prerollSegmentCount > 0) ? {
|
|
819
|
+
prerollBaseUrl: currentStream?.hls.prerollBaseUrl ?? null,
|
|
820
|
+
prerollCodec: currentStream?.hls.prerollCodec ?? "h264",
|
|
821
|
+
prerollSegmentCount
|
|
822
|
+
} : {}),
|
|
823
|
+
...((resumeData || (prerollSegmentCount > 0)) ? {
|
|
824
|
+
pendingDiscontinuity: true,
|
|
825
|
+
startingSegmentIndex: baseSegmentIndex + prerollSegmentCount
|
|
826
|
+
} : {}),
|
|
827
|
+
onError: (error) => {
|
|
828
|
+
// Skip error handling if termination was already initiated.
|
|
829
|
+
if (isTerminationInitiated(numericStreamId)) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
LOG.error("Segmenter error for %s: %s.", channelName, formatError(error));
|
|
833
|
+
terminateStream(numericStreamId, channelName, "stream processing error");
|
|
834
|
+
void emitCurrentSystemStatus();
|
|
835
|
+
},
|
|
836
|
+
onStop: () => {
|
|
837
|
+
// Skip handling if termination was already initiated.
|
|
838
|
+
if (isTerminationInitiated(numericStreamId)) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
LOG.error("Segmenter stopped unexpectedly for %s.", channelName);
|
|
842
|
+
terminateStream(numericStreamId, channelName, "stream ended unexpectedly");
|
|
843
|
+
void emitCurrentSystemStatus();
|
|
844
|
+
},
|
|
845
|
+
streamId: numericStreamId
|
|
846
|
+
});
|
|
847
|
+
// Pipe the capture stream to the segmenter.
|
|
848
|
+
segmenter.pipe(setup.captureStream);
|
|
849
|
+
// Store the segmenter reference in the registry.
|
|
850
|
+
const captureStream = getStream(numericStreamId);
|
|
851
|
+
if (captureStream) {
|
|
852
|
+
captureStream.segmenter = segmenter;
|
|
853
|
+
// Now that the segmenter is created, piped, and stored, consume the resume data so it's not used again on a subsequent stream start for the same channel.
|
|
854
|
+
if (resumeData) {
|
|
855
|
+
deleteResumeData(channelName);
|
|
856
|
+
}
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
// Stream was terminated during setup (rare race condition). Clean up the orphaned segmenter.
|
|
860
|
+
cleanupOrphanedSetup(segmenter);
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
662
863
|
/**
|
|
663
864
|
* Completes the async portion of stream initialization. Creates the browser page, navigates to the URL, sets up capture, creates the segmenter, and fills in the
|
|
664
865
|
* pending registry entry. On failure, cleans up the pending entry via terminateStream().
|
|
@@ -726,135 +927,30 @@ async function completeStreamSetup(options) {
|
|
|
726
927
|
// seconds. If we cancelled the timer at setup completion, the client would have no playlist during the proxy's first poll — the preroll timer fires after
|
|
727
928
|
// PREROLL_DELAY_MS and provides content during that gap. For the capture path, the segmenter produces its first playlist within ~2 seconds of creation, so
|
|
728
929
|
// the timer is cancelled almost immediately after setup anyway.
|
|
729
|
-
// Attempt native streaming if a manifest interception handle is available.
|
|
730
|
-
|
|
731
|
-
|
|
930
|
+
// Attempt native streaming if a manifest interception handle is available. If native is viable, the capture pipeline is stopped and the proxy takes over.
|
|
931
|
+
let nativeCodec = null;
|
|
932
|
+
let nativeQuality = "";
|
|
732
933
|
let streamingMode = "capture";
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
const pendingForNative = getStream(numericStreamId);
|
|
739
|
-
const nativePrerollSegmentCount = pendingForNative?.hls.prerollSegmentCount ?? 0;
|
|
740
|
-
const nativeResult = await attemptNativeStreaming({
|
|
741
|
-
channelName,
|
|
742
|
-
interceptionPromise: setup.manifestInterception.promise,
|
|
743
|
-
mpegTsClient,
|
|
744
|
-
onError: (error) => {
|
|
745
|
-
if (isTerminationInitiated(numericStreamId)) {
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
// Log the error and clear the probe cache. The proxy has already stopped itself (set errorThresholdReached + stopped). The monitor detects
|
|
749
|
-
// hasErrored() on the next 2-second tick and triggers L3 fallback to capture mode, preserving the stream for the DVR client.
|
|
750
|
-
LOG.warn("Native proxy error for %s: %s. Falling back to capture.", channelName, error);
|
|
751
|
-
clearProbeCache(channelName);
|
|
752
|
-
},
|
|
753
|
-
page: setup.page,
|
|
754
|
-
prerollSegmentCount: nativePrerollSegmentCount,
|
|
755
|
-
streamId: numericStreamId,
|
|
756
|
-
streamIdStr: setup.streamId,
|
|
757
|
-
url
|
|
758
|
-
});
|
|
759
|
-
if (nativeResult) {
|
|
760
|
-
const currentStream = getStream(numericStreamId);
|
|
761
|
-
if (!currentStream) {
|
|
762
|
-
nativeResult.proxy.stop();
|
|
763
|
-
return null;
|
|
764
|
-
}
|
|
765
|
-
// Stop the capture pipeline — native streaming replaces it entirely.
|
|
766
|
-
if (!setup.rawCaptureStream.destroyed) {
|
|
767
|
-
setup.rawCaptureStream.destroy();
|
|
768
|
-
}
|
|
769
|
-
if (setup.ffmpegProcess) {
|
|
770
|
-
setup.ffmpegProcess.kill();
|
|
771
|
-
}
|
|
772
|
-
// Update the registry entry to reflect native mode. For streams with separate audio, clear preroll state — preroll is muxed video+audio and can't be
|
|
773
|
-
// split into separate renditions. For muxed-audio streams, preserve preroll state so the proxy can build composite playlists with preroll entries.
|
|
774
|
-
currentStream.ffmpegProcess = null;
|
|
775
|
-
currentStream.hls.hasAudio = nativeResult.hasAudio;
|
|
776
|
-
if (nativeResult.hasAudio) {
|
|
777
|
-
currentStream.hls.prerollBaseUrl = null;
|
|
778
|
-
currentStream.hls.prerollSegmentCount = 0;
|
|
779
|
-
}
|
|
780
|
-
currentStream.nativeProxy = nativeResult.proxy;
|
|
781
|
-
currentStream.rawCaptureStream = null;
|
|
782
|
-
currentStream.streamingMode = "native";
|
|
783
|
-
streamingMode = "native";
|
|
784
|
-
// Start the native proxy. Signal init segment readiness immediately — native MPEG-TS segments carry their own PAT/PMT codec configuration in every
|
|
785
|
-
// segment, so there is no separate init segment to wait for. Without this, MPEG-TS clients block on waitForInitSegment() and time out before the proxy's
|
|
786
|
-
// first poll cycle completes.
|
|
787
|
-
nativeResult.proxy.start();
|
|
788
|
-
currentStream.hls.signalInitSegmentReady();
|
|
789
|
-
LOG.debug("native:coordinator", "Capture pipeline stopped for %s. Native proxy active.", channelName);
|
|
790
|
-
}
|
|
934
|
+
const nativeStreamResult = await startNativeProxy(setup, numericStreamId, channelName, url, mpegTsClient);
|
|
935
|
+
if (nativeStreamResult) {
|
|
936
|
+
streamingMode = "native";
|
|
937
|
+
nativeCodec = nativeStreamResult.codec;
|
|
938
|
+
nativeQuality = nativeStreamResult.quality;
|
|
791
939
|
}
|
|
792
940
|
// If native streaming was not viable or not attempted, create the fMP4 segmenter for capture mode.
|
|
793
941
|
if (streamingMode === "capture") {
|
|
794
|
-
|
|
795
|
-
// preventing HLS sequence resets that cause Channels DVR to produce broken recording timelines. Preroll and resume work together — the preroll playlist
|
|
796
|
-
// is already offset by the resume segment index (set in registerPendingStream), and the segmenter starts after the preroll range.
|
|
797
|
-
const resumeData = consumeResumeData(channelName);
|
|
798
|
-
const currentStream = getStream(numericStreamId);
|
|
799
|
-
const prerollSegmentCount = currentStream?.hls.prerollSegmentCount ?? 0;
|
|
800
|
-
// When preroll is active, use the snapshotted resume index (stored on HLS state at registration) so the segmenter's starting index is guaranteed to match
|
|
801
|
-
// the preroll playlist's MEDIA-SEQUENCE offset. When preroll is inactive, use the resume data directly — no preroll playlist to be consistent with.
|
|
802
|
-
const baseSegmentIndex = (prerollSegmentCount > 0) ? (currentStream?.hls.resumeSegmentIndex ?? 0) : (resumeData?.segmentIndex ?? 0);
|
|
803
|
-
// Create the fMP4 segmenter. The starting segment index accounts for both the resume offset and the preroll segment range. When preroll is active, the
|
|
804
|
-
// segmenter includes preroll entries in its sliding window via the compositor. The pending discontinuity at the preroll→real boundary is always needed —
|
|
805
|
-
// previousInitSegment is only passed without preroll, because the preroll init segment differs from the real init and the discontinuity must not be
|
|
806
|
-
// suppressed by an init-match comparison against the prior session.
|
|
807
|
-
const segmenter = createFMP4Segmenter({
|
|
808
|
-
...(resumeData ? {
|
|
809
|
-
initialTrackTimestamps: resumeData.trackTimestamps,
|
|
810
|
-
...((prerollSegmentCount === 0) ? { previousInitSegment: resumeData.initSegment } : {}),
|
|
811
|
-
startingInitVersion: resumeData.initVersion
|
|
812
|
-
} : {}),
|
|
813
|
-
...((prerollSegmentCount > 0) ? {
|
|
814
|
-
prerollBaseUrl: currentStream?.hls.prerollBaseUrl ?? null,
|
|
815
|
-
prerollSegmentCount
|
|
816
|
-
} : {}),
|
|
817
|
-
...((resumeData || (prerollSegmentCount > 0)) ? {
|
|
818
|
-
pendingDiscontinuity: true,
|
|
819
|
-
startingSegmentIndex: baseSegmentIndex + prerollSegmentCount
|
|
820
|
-
} : {}),
|
|
821
|
-
onError: (error) => {
|
|
822
|
-
// Skip error handling if termination was already initiated.
|
|
823
|
-
if (isTerminationInitiated(numericStreamId)) {
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
LOG.error("Segmenter error for %s: %s.", channelName, formatError(error));
|
|
827
|
-
terminateStream(numericStreamId, channelName, "stream processing error");
|
|
828
|
-
void emitCurrentSystemStatus();
|
|
829
|
-
},
|
|
830
|
-
onStop: () => {
|
|
831
|
-
// Skip handling if termination was already initiated.
|
|
832
|
-
if (isTerminationInitiated(numericStreamId)) {
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
LOG.error("Segmenter stopped unexpectedly for %s.", channelName);
|
|
836
|
-
terminateStream(numericStreamId, channelName, "stream ended unexpectedly");
|
|
837
|
-
void emitCurrentSystemStatus();
|
|
838
|
-
},
|
|
839
|
-
streamId: numericStreamId
|
|
840
|
-
});
|
|
841
|
-
// Pipe the capture stream to the segmenter.
|
|
842
|
-
segmenter.pipe(setup.captureStream);
|
|
843
|
-
// Store the segmenter reference in the registry.
|
|
844
|
-
const captureStream = getStream(numericStreamId);
|
|
845
|
-
if (captureStream) {
|
|
846
|
-
captureStream.segmenter = segmenter;
|
|
847
|
-
}
|
|
848
|
-
else {
|
|
849
|
-
// Stream was terminated during setup (rare race condition). Clean up the orphaned segmenter.
|
|
850
|
-
cleanupOrphanedSetup(segmenter);
|
|
942
|
+
if (!createCaptureSegmenter(setup, numericStreamId, channelName)) {
|
|
851
943
|
return null;
|
|
852
944
|
}
|
|
853
945
|
}
|
|
854
|
-
const
|
|
946
|
+
const effectiveCodec = getEffectiveCaptureCodec();
|
|
947
|
+
const captureHwAccel = isCaptureHardwareAccelerated();
|
|
948
|
+
const ffmpegCodec = captureHwAccel ? ("\u26A1 " + effectiveCodec.toUpperCase()) : effectiveCodec.toUpperCase();
|
|
949
|
+
const captureMode = (streamingMode === "native") ? ("native HLS" + nativeQuality) :
|
|
950
|
+
(CONFIG.streaming.captureMode === "ffmpeg" ? "FFmpeg [" + ffmpegCodec + "]" : "Native fMP4");
|
|
855
951
|
const displayName = channel?.name ?? url;
|
|
856
952
|
const tuneTime = ((Date.now() - setup.startTime.getTime()) / 1000).toFixed(1);
|
|
857
|
-
LOG.info("Streaming %s
|
|
953
|
+
LOG.info("Streaming %s: %s, %s, %s. Tuned in %ss%s.", displayName, setup.providerName, setup.profileName, captureMode, tuneTime, setup.directTune ? " (direct)" : "");
|
|
858
954
|
// Mark channel health as successful. Only for predefined channels (channel is defined). Ad-hoc URL streams have no persistent channel identity. Domain auth
|
|
859
955
|
// is conditionally skipped when the provider defines validateTune and the tuned channel does not prove paid access (e.g., Sling Freestream channels).
|
|
860
956
|
if (channel) {
|
|
@@ -865,9 +961,19 @@ async function completeStreamSetup(options) {
|
|
|
865
961
|
const markAuth = !provider?.validateTune || provider.validateTune(channel.channelSelector ?? channelName);
|
|
866
962
|
markChannelSuccess(channelName, successAuthDomain, markAuth);
|
|
867
963
|
}
|
|
868
|
-
//
|
|
964
|
+
// Update the registry entry with codec and hardware acceleration state, then emit the stream added event for the dashboard. Native streams set their codec
|
|
965
|
+
// and quality fields earlier (when the native proxy is created), so only capture mode needs updating here.
|
|
966
|
+
const streamCodec = (streamingMode === "native") ? nativeCodec : effectiveCodec.toUpperCase();
|
|
967
|
+
const hwAccelerated = (streamingMode !== "native") && captureHwAccel;
|
|
968
|
+
const currentEntry = getStream(numericStreamId);
|
|
969
|
+
if (currentEntry) {
|
|
970
|
+
currentEntry.captureCodec = streamCodec;
|
|
971
|
+
currentEntry.hardwareAccelerated = hwAccelerated;
|
|
972
|
+
}
|
|
869
973
|
emitStreamAdded(createInitialStreamStatus({
|
|
974
|
+
captureCodec: streamCodec,
|
|
870
975
|
channelName: channel?.name ?? null,
|
|
976
|
+
hardwareAccelerated: hwAccelerated,
|
|
871
977
|
numericStreamId,
|
|
872
978
|
providerName: setup.providerName,
|
|
873
979
|
startTime: setup.startTime,
|
|
@@ -913,62 +1019,42 @@ async function startHLSStream(channelName, url, req, res, channel) {
|
|
|
913
1019
|
}
|
|
914
1020
|
}
|
|
915
1021
|
// Idle Detection.
|
|
1022
|
+
/**
|
|
1023
|
+
* Returns all streams that have exceeded the idle timeout and have no active MPEG-TS clients. Pretuned streams are excluded — they have no clients by design and
|
|
1024
|
+
* the pretune module manages their lifecycle via a safety timeout. The result is sorted by last access time (oldest first) so callers can efficiently pick the
|
|
1025
|
+
* longest-idle stream for reclamation.
|
|
1026
|
+
* @returns Idle streams sorted by last access time ascending (oldest first).
|
|
1027
|
+
*/
|
|
1028
|
+
function getIdleStreams() {
|
|
1029
|
+
const now = Date.now();
|
|
1030
|
+
return getAllStreams()
|
|
1031
|
+
.filter((stream) => !stream.preTuned && (stream.mpegTsClientCount === 0) && ((now - stream.info.lastPlaylistRequest) >= CONFIG.hls.idleTimeout))
|
|
1032
|
+
.sort((a, b) => a.info.lastPlaylistRequest - b.info.lastPlaylistRequest);
|
|
1033
|
+
}
|
|
916
1034
|
/**
|
|
917
1035
|
* Checks for idle streams and terminates them. Called periodically by the idle detection interval.
|
|
918
1036
|
*/
|
|
919
1037
|
export function cleanupIdleStreams() {
|
|
920
|
-
const
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
for (const stream of streams) {
|
|
924
|
-
// Skip pretuned streams. These have no clients by design and must not be terminated by the idle timer. The pretune module manages their lifecycle via a
|
|
925
|
-
// safety timeout.
|
|
926
|
-
if (stream.preTuned) {
|
|
927
|
-
continue;
|
|
928
|
-
}
|
|
929
|
-
// Skip streams with active MPEG-TS clients. These streams are still being consumed even if no HLS playlist requests have been made recently.
|
|
930
|
-
if (stream.mpegTsClientCount > 0) {
|
|
931
|
-
continue;
|
|
932
|
-
}
|
|
933
|
-
const idleTime = now - stream.info.lastPlaylistRequest;
|
|
934
|
-
if (idleTime >= CONFIG.hls.idleTimeout) {
|
|
935
|
-
terminateStream(stream.id, stream.info.storeKey, "no active clients");
|
|
936
|
-
terminatedCount++;
|
|
937
|
-
}
|
|
1038
|
+
const idle = getIdleStreams();
|
|
1039
|
+
for (const stream of idle) {
|
|
1040
|
+
terminateStream(stream.id, stream.info.storeKey, "no active clients");
|
|
938
1041
|
}
|
|
939
1042
|
// Emit system status once after all idle streams are terminated.
|
|
940
|
-
if (
|
|
1043
|
+
if (idle.length > 0) {
|
|
941
1044
|
void emitCurrentSystemStatus();
|
|
942
1045
|
}
|
|
943
1046
|
}
|
|
944
1047
|
/**
|
|
945
|
-
* Attempts to reclaim a single idle stream to free capacity for a new request.
|
|
946
|
-
*
|
|
947
|
-
* channel-surfing users to get new streams without being rejected while abandoned streams linger.
|
|
1048
|
+
* Attempts to reclaim a single idle stream to free capacity for a new request. Terminates the stream that has been idle the longest. This is called when the
|
|
1049
|
+
* concurrent stream limit is reached, allowing channel-surfing users to get new streams without being rejected while abandoned streams linger.
|
|
948
1050
|
* @returns True if a stream was reclaimed, false if no idle streams exist.
|
|
949
1051
|
*/
|
|
950
1052
|
function reclaimIdleStream() {
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
let oldest = null;
|
|
954
|
-
for (const stream of streams) {
|
|
955
|
-
// Skip pretuned streams — they should not be reclaimed for new stream capacity.
|
|
956
|
-
if (stream.preTuned) {
|
|
957
|
-
continue;
|
|
958
|
-
}
|
|
959
|
-
// Skip streams with active MPEG-TS clients.
|
|
960
|
-
if (stream.mpegTsClientCount > 0) {
|
|
961
|
-
continue;
|
|
962
|
-
}
|
|
963
|
-
const idleTime = now - stream.info.lastPlaylistRequest;
|
|
964
|
-
// Only consider streams that have exceeded the idle timeout, and pick the one that has been idle the longest.
|
|
965
|
-
if ((idleTime >= CONFIG.hls.idleTimeout) && (!oldest || (stream.info.lastPlaylistRequest < oldest.info.lastPlaylistRequest))) {
|
|
966
|
-
oldest = stream;
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
if (!oldest) {
|
|
1053
|
+
const idle = getIdleStreams();
|
|
1054
|
+
if (idle.length === 0) {
|
|
970
1055
|
return false;
|
|
971
1056
|
}
|
|
1057
|
+
const oldest = idle[0];
|
|
972
1058
|
LOG.info("Reclaiming idle stream %s (%s) to free capacity.", oldest.id, oldest.info.storeKey);
|
|
973
1059
|
terminateStream(oldest.id, oldest.info.storeKey, "reclaimed for new stream");
|
|
974
1060
|
void emitCurrentSystemStatus();
|