prismcast 1.2.0 → 1.3.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/README.md +26 -1
- package/dist/app.js +29 -2
- 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 +14 -4
- package/dist/browser/channelSelection.js +48 -704
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/index.d.ts +18 -4
- package/dist/browser/index.js +205 -39
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/tuning/fox.d.ts +19 -0
- package/dist/browser/tuning/fox.js +61 -0
- package/dist/browser/tuning/fox.js.map +1 -0
- package/dist/browser/tuning/hbo.d.ts +22 -0
- package/dist/browser/tuning/hbo.js +182 -0
- package/dist/browser/tuning/hbo.js.map +1 -0
- package/dist/browser/tuning/hulu.d.ts +30 -0
- package/dist/browser/tuning/hulu.js +514 -0
- package/dist/browser/tuning/hulu.js.map +1 -0
- package/dist/browser/tuning/sling.d.ts +22 -0
- package/dist/browser/tuning/sling.js +287 -0
- package/dist/browser/tuning/sling.js.map +1 -0
- package/dist/browser/tuning/thumbnailRow.d.ts +18 -0
- package/dist/browser/tuning/thumbnailRow.js +107 -0
- package/dist/browser/tuning/thumbnailRow.js.map +1 -0
- package/dist/browser/tuning/tileClick.d.ts +18 -0
- package/dist/browser/tuning/tileClick.js +102 -0
- package/dist/browser/tuning/tileClick.js.map +1 -0
- package/dist/browser/tuning/youtubeTv.d.ts +17 -0
- package/dist/browser/tuning/youtubeTv.js +99 -0
- package/dist/browser/tuning/youtubeTv.js.map +1 -0
- package/dist/browser/video.d.ts +2 -2
- package/dist/browser/video.js +18 -8
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.js +131 -12
- package/dist/channels/index.js.map +1 -1
- package/dist/config/profiles.d.ts +5 -5
- package/dist/config/profiles.js +35 -11
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providers.d.ts +48 -4
- package/dist/config/providers.js +182 -22
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +10 -0
- package/dist/config/sites.js +96 -17
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.js +21 -3
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +2 -1
- package/dist/config/userConfig.js +12 -4
- package/dist/config/userConfig.js.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/routes/auth.js +6 -0
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/config.d.ts +5 -0
- package/dist/routes/config.js +273 -35
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/debug.d.ts +6 -0
- package/dist/routes/debug.js +274 -0
- package/dist/routes/debug.js.map +1 -0
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +3 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +17 -5
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/root.js +507 -128
- package/dist/routes/root.js.map +1 -1
- package/dist/routes/theme.js +7 -0
- package/dist/routes/theme.js.map +1 -1
- package/dist/service/commands.js +8 -8
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.js +22 -16
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +7 -0
- package/dist/streaming/fmp4Segmenter.js +124 -15
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +74 -6
- package/dist/streaming/hls.js +212 -130
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsSegments.js +11 -4
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +1 -1
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.d.ts +1 -1
- package/dist/streaming/monitor.js +202 -53
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +29 -1
- package/dist/streaming/mp4Parser.js +243 -18
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +5 -2
- package/dist/streaming/mpegts.js +122 -29
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/registry.d.ts +1 -1
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +13 -1
- package/dist/streaming/setup.js +136 -36
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +9 -9
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.js +5 -6
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/index.d.ts +16 -3
- package/dist/types/index.js +5 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/debugFilter.d.ts +38 -0
- package/dist/utils/debugFilter.js +157 -0
- package/dist/utils/debugFilter.js.map +1 -0
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +5 -0
- package/dist/utils/ffmpeg.js +13 -2
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/fileLogger.d.ts +2 -1
- package/dist/utils/fileLogger.js +4 -2
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logEmitter.d.ts +1 -0
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/logger.d.ts +9 -8
- package/dist/utils/logger.js +52 -28
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/retry.d.ts +1 -1
- package/dist/utils/retry.js +3 -3
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/version.js +4 -9
- package/dist/utils/version.js.map +1 -1
- package/package.json +6 -6
package/dist/streaming/hls.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LOG, delay, formatError, runWithStreamContext } from "../utils/index.js";
|
|
1
|
+
import { LOG, delay, formatError, runWithStreamContext, startTimer } from "../utils/index.js";
|
|
2
2
|
import { StreamSetupError, createPageWithCapture, setupStream } from "./setup.js";
|
|
3
3
|
import { createHLSState, getAllStreams, getStream, getStreamCount, registerStream, updateLastAccess } from "./registry.js";
|
|
4
4
|
import { createInitialStreamStatus, emitStreamAdded } from "./statusEmitter.js";
|
|
@@ -12,27 +12,64 @@ import { createFMP4Segmenter } from "./fmp4Segmenter.js";
|
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import { registerClient } from "./clients.js";
|
|
14
14
|
import { triggerShowNameUpdate } from "./showInfo.js";
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Login mode error body used by both validateChannel() and handlePlayStream() to ensure consistent response format.
|
|
16
|
+
const LOGIN_MODE_BODY = { error: "Login in progress", message: "Please complete authentication before starting new streams." };
|
|
17
|
+
/**
|
|
18
|
+
* Validates a channel name for streaming. Performs all fast, synchronous checks: disabled status, provider resolution, channel lookup, and login mode. Returns a
|
|
19
|
+
* discriminated union so callers can handle success and failure without coupling to Express response objects.
|
|
17
20
|
*
|
|
18
|
-
*
|
|
19
|
-
* 2. If no stream exists, we call initializeStream() which runs setupStream() and creates a native fMP4 segmenter
|
|
20
|
-
* 3. The segmenter parses the MP4 stream and produces init.mp4 (codec config) + segment0.m4s, segment1.m4s, ...
|
|
21
|
-
* 4. We store init segment and media segments in memory, return playlist to client
|
|
22
|
-
* 5. Client fetches init.mp4 once, then media segments at /hls/:name/segmentN.m4s
|
|
23
|
-
* 6. Idle timeout terminates streams with no recent segment requests
|
|
21
|
+
* This is extracted from ensureChannelStream() so it can be called by both HLS and MPEG-TS code paths without duplicating the validation logic.
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
23
|
+
* @param channelName - The channel key to validate.
|
|
24
|
+
* @returns Validation result with channel data on success, or error details on failure.
|
|
25
|
+
*/
|
|
26
|
+
export function validateChannel(channelName) {
|
|
27
|
+
if (isPredefinedChannelDisabled(channelName)) {
|
|
28
|
+
return { body: "Channel is disabled.", statusCode: 404, valid: false };
|
|
29
|
+
}
|
|
30
|
+
// Resolve provider selection. For multi-provider channels, this returns the user's selected provider key (e.g., "espn-disneyplus"). For single-provider channels
|
|
31
|
+
// or if no selection exists, it returns the canonical key unchanged.
|
|
32
|
+
const resolvedKey = resolveProviderKey(channelName);
|
|
33
|
+
// Get the resolved channel with inheritance applied. For provider variants, this merges the variant's properties with inherited properties from the canonical
|
|
34
|
+
// entry (name, stationId).
|
|
35
|
+
const channel = getResolvedChannel(resolvedKey);
|
|
36
|
+
// Fall back to getAllChannels if the resolved channel doesn't exist (e.g., for ad-hoc streams or non-grouped channels).
|
|
37
|
+
const effectiveChannel = channel ?? getAllChannels()[channelName];
|
|
38
|
+
// Log a warning if a provider selection resolved to a missing variant (e.g., variant was removed from channels after selection was saved).
|
|
39
|
+
if (!channel && (resolvedKey !== channelName)) {
|
|
40
|
+
LOG.warn("Provider '%s' not found for channel '%s'. Using default provider.", resolvedKey, channelName);
|
|
41
|
+
}
|
|
42
|
+
// Runtime check needed even though TypeScript thinks channel is always defined (Record indexing quirk).
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
44
|
+
if (!effectiveChannel) {
|
|
45
|
+
return { body: "Channel not found.", statusCode: 404, valid: false };
|
|
46
|
+
}
|
|
47
|
+
// Block new stream requests while login mode is active. This prevents the browser from being disrupted during authentication.
|
|
48
|
+
if (isLoginModeActive()) {
|
|
49
|
+
return { body: LOGIN_MODE_BODY, statusCode: 503, valid: false };
|
|
50
|
+
}
|
|
51
|
+
return { channel: effectiveChannel, resolvedKey, valid: true };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Sends a validation error response to the client. Handles both plain text bodies (via res.send) and object bodies (via res.json).
|
|
55
|
+
* @param validation - The failed validation result.
|
|
56
|
+
* @param res - Express response object.
|
|
28
57
|
*/
|
|
58
|
+
export function sendValidationError(validation, res) {
|
|
59
|
+
if (typeof validation.body === "object") {
|
|
60
|
+
res.status(validation.statusCode).json(validation.body);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
res.status(validation.statusCode).send(validation.body);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
29
66
|
// Public Endpoint Handlers.
|
|
30
67
|
/**
|
|
31
|
-
* Ensures a stream is running for a channel. If no stream exists, starts one. If a stream startup is in progress (
|
|
32
|
-
* stream ID if successful, or null if an error occurred (with the error response already sent to the client).
|
|
68
|
+
* Ensures a stream is running for a channel. If no stream exists, starts one. If a stream startup is in progress (-1 sentinel), waits for it to complete. Returns
|
|
69
|
+
* the stream ID if successful, or null if an error occurred (with the error response already sent to the client).
|
|
33
70
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
71
|
+
* The existing-stream check runs first so that ad-hoc streams (registered under synthetic keys like "play-a1b2c3d4") can be served without failing the
|
|
72
|
+
* "Channel not found" check.
|
|
36
73
|
*
|
|
37
74
|
* For channels with multiple providers (e.g., ESPN via ESPN.com or Disney+), the user's provider selection is resolved before looking up the channel definition.
|
|
38
75
|
* The stream is registered under the canonical key (channelName) for deduplication, but uses the resolved provider's URL and settings.
|
|
@@ -47,49 +84,24 @@ export async function ensureChannelStream(channelName, req, res) {
|
|
|
47
84
|
// be served by the standard HLS playlist handler without failing the "Channel not found" check. A stream in channelToStreamId was already validated when it was
|
|
48
85
|
// started, so no re-validation is needed.
|
|
49
86
|
const streamId = getChannelStreamId(channelName);
|
|
50
|
-
// If a stream is already running (not a
|
|
87
|
+
// If a stream is already running (not a startup-in-progress sentinel), return it directly.
|
|
51
88
|
if ((streamId !== undefined) && (streamId !== -1)) {
|
|
52
89
|
return streamId;
|
|
53
90
|
}
|
|
54
|
-
// If a
|
|
91
|
+
// If a startup is in progress (-1 sentinel), another request is already starting this stream. Poll until the real stream ID appears or we timeout.
|
|
55
92
|
if (streamId === -1) {
|
|
56
93
|
return awaitStreamReady(channelName, res);
|
|
57
94
|
}
|
|
58
95
|
// No existing stream — validate the channel and start a new one. Channel validation is only needed for new streams because existing streams were already validated
|
|
59
96
|
// at startup time.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
// Resolve provider selection. For multi-provider channels, this returns the user's selected provider key (e.g., "espn-disneyplus"). For single-provider channels
|
|
65
|
-
// or if no selection exists, it returns the canonical key unchanged.
|
|
66
|
-
const resolvedKey = resolveProviderKey(channelName);
|
|
67
|
-
// Get the resolved channel with inheritance applied. For provider variants, this merges the variant's properties with inherited properties from the canonical
|
|
68
|
-
// entry (name, stationId).
|
|
69
|
-
const channel = getResolvedChannel(resolvedKey);
|
|
70
|
-
// Fall back to getAllChannels if the resolved channel doesn't exist (e.g., for ad-hoc streams or non-grouped channels).
|
|
71
|
-
const effectiveChannel = channel ?? getAllChannels()[channelName];
|
|
72
|
-
// Log a warning if a provider selection resolved to a missing variant (e.g., variant was removed from channels after selection was saved).
|
|
73
|
-
if (!channel && (resolvedKey !== channelName)) {
|
|
74
|
-
LOG.warn("Provider '%s' not found for channel '%s'. Using default provider.", resolvedKey, channelName);
|
|
75
|
-
}
|
|
76
|
-
// Runtime check needed even though TypeScript thinks channel is always defined (Record indexing quirk).
|
|
77
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
78
|
-
if (!effectiveChannel) {
|
|
79
|
-
res.status(404).send("Channel not found.");
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
// Block new stream requests while login mode is active. This prevents the browser from being disrupted during authentication.
|
|
83
|
-
if (isLoginModeActive()) {
|
|
84
|
-
res.status(503).json({
|
|
85
|
-
error: "Login in progress",
|
|
86
|
-
message: "Please complete authentication before starting new streams."
|
|
87
|
-
});
|
|
97
|
+
const validation = validateChannel(channelName);
|
|
98
|
+
if (!validation.valid) {
|
|
99
|
+
sendValidationError(validation, res);
|
|
88
100
|
return null;
|
|
89
101
|
}
|
|
90
102
|
// Start the stream using the resolved channel's URL. The stream is registered under channelName (canonical key) for deduplication, but uses the resolved
|
|
91
103
|
// provider's definition.
|
|
92
|
-
const newStreamId = await startHLSStream(channelName,
|
|
104
|
+
const newStreamId = await startHLSStream(channelName, validation.channel.url, req, res, validation.channel);
|
|
93
105
|
if (newStreamId === null) {
|
|
94
106
|
// Error response already sent by startHLSStream.
|
|
95
107
|
return null;
|
|
@@ -97,7 +109,7 @@ export async function ensureChannelStream(channelName, req, res) {
|
|
|
97
109
|
return newStreamId;
|
|
98
110
|
}
|
|
99
111
|
/**
|
|
100
|
-
* Handles HLS playlist requests. Ensures a stream is running for the
|
|
112
|
+
* Handles HLS playlist requests. Ensures a stream is running for the channel (blocking until ready if a new stream must start), then returns the playlist.
|
|
101
113
|
*
|
|
102
114
|
* Route: GET /hls/:name/stream.m3u8
|
|
103
115
|
*
|
|
@@ -110,40 +122,12 @@ export async function handleHLSPlaylist(req, res) {
|
|
|
110
122
|
res.status(400).send("Channel name is required.");
|
|
111
123
|
return;
|
|
112
124
|
}
|
|
125
|
+
const clientAddress = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
113
126
|
const streamId = await ensureChannelStream(channelName, req, res);
|
|
114
127
|
if (streamId === null) {
|
|
115
|
-
// Error response already sent by ensureChannelStream.
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
// Capture client address for client tracking.
|
|
119
|
-
const clientAddress = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
120
|
-
// If a playlist is already available, return it immediately.
|
|
121
|
-
const existingPlaylist = getPlaylist(streamId);
|
|
122
|
-
if (existingPlaylist) {
|
|
123
|
-
updateLastAccess(streamId);
|
|
124
|
-
registerClient(streamId, clientAddress, "hls");
|
|
125
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
126
|
-
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
|
127
|
-
res.send(existingPlaylist);
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
// Wait for the first playlist to be ready.
|
|
131
|
-
const playlistReady = await waitForPlaylist(streamId, CONFIG.streaming.navigationTimeout);
|
|
132
|
-
if (!playlistReady) {
|
|
133
|
-
res.setHeader("Retry-After", "5");
|
|
134
|
-
res.status(503).send("Stream is starting. Please retry.");
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const playlist = getPlaylist(streamId);
|
|
138
|
-
if (!playlist) {
|
|
139
|
-
res.status(500).send("Playlist not available.");
|
|
140
128
|
return;
|
|
141
129
|
}
|
|
142
|
-
|
|
143
|
-
registerClient(streamId, clientAddress, "hls");
|
|
144
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
145
|
-
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
|
146
|
-
res.send(playlist);
|
|
130
|
+
await sendPlaylistResponse(streamId, clientAddress, res);
|
|
147
131
|
}
|
|
148
132
|
/**
|
|
149
133
|
* Handles HLS segment requests. Returns the requested segment from memory. Supports both the fMP4 initialization segment (init.mp4) and media segments (.m4s).
|
|
@@ -161,7 +145,7 @@ export function handleHLSSegment(req, res) {
|
|
|
161
145
|
return;
|
|
162
146
|
}
|
|
163
147
|
const streamId = getChannelStreamId(channelName);
|
|
164
|
-
if (streamId === undefined) {
|
|
148
|
+
if ((streamId === undefined) || (streamId === -1)) {
|
|
165
149
|
res.status(404).send("Stream not found.");
|
|
166
150
|
return;
|
|
167
151
|
}
|
|
@@ -173,9 +157,7 @@ export function handleHLSSegment(req, res) {
|
|
|
173
157
|
return;
|
|
174
158
|
}
|
|
175
159
|
updateLastAccess(streamId);
|
|
176
|
-
|
|
177
|
-
res.setHeader("Content-Type", "video/mp4");
|
|
178
|
-
res.send(initSegment);
|
|
160
|
+
sendSegment(initSegment, res);
|
|
179
161
|
return;
|
|
180
162
|
}
|
|
181
163
|
// Handle media segments (.m4s).
|
|
@@ -185,9 +167,7 @@ export function handleHLSSegment(req, res) {
|
|
|
185
167
|
return;
|
|
186
168
|
}
|
|
187
169
|
updateLastAccess(streamId);
|
|
188
|
-
|
|
189
|
-
res.setHeader("Content-Type", "video/mp4");
|
|
190
|
-
res.send(segment);
|
|
170
|
+
sendSegment(segment, res);
|
|
191
171
|
}
|
|
192
172
|
// Ad-Hoc Streaming.
|
|
193
173
|
/**
|
|
@@ -222,7 +202,7 @@ export async function handlePlayStream(req, res) {
|
|
|
222
202
|
res.redirect(302, "/hls/" + channelName + "/stream.m3u8");
|
|
223
203
|
return;
|
|
224
204
|
}
|
|
225
|
-
// If a
|
|
205
|
+
// If a startup is in progress (-1 sentinel), another request is already starting this stream. Poll until the real stream ID appears or we timeout, then redirect.
|
|
226
206
|
if (streamId === -1) {
|
|
227
207
|
const resolvedId = await awaitStreamReady(channelName, res);
|
|
228
208
|
if (resolvedId === null) {
|
|
@@ -234,15 +214,12 @@ export async function handlePlayStream(req, res) {
|
|
|
234
214
|
}
|
|
235
215
|
// Block new stream requests while login mode is active.
|
|
236
216
|
if (isLoginModeActive()) {
|
|
237
|
-
res.status(503).json(
|
|
238
|
-
error: "Login in progress",
|
|
239
|
-
message: "Please complete authentication before starting new streams."
|
|
240
|
-
});
|
|
217
|
+
res.status(503).json(LOGIN_MODE_BODY);
|
|
241
218
|
return;
|
|
242
219
|
}
|
|
243
220
|
// Capture client IP for Channels DVR API integration.
|
|
244
221
|
const clientAddress = req.ip ?? req.socket.remoteAddress ?? null;
|
|
245
|
-
// Start a new ad-hoc stream. initializeStream handles
|
|
222
|
+
// Start a new ad-hoc stream. initializeStream handles capture setup, segmenter creation, and event emission.
|
|
246
223
|
try {
|
|
247
224
|
const newStreamId = await initializeStream({ channelName, channelSelector: selector, clickSelector, clickToPlay, clientAddress, profileOverride, url });
|
|
248
225
|
if (newStreamId === null) {
|
|
@@ -264,28 +241,25 @@ export async function handlePlayStream(req, res) {
|
|
|
264
241
|
}
|
|
265
242
|
res.redirect(302, "/hls/" + channelName + "/stream.m3u8");
|
|
266
243
|
}
|
|
267
|
-
//
|
|
244
|
+
// Startup Polling.
|
|
268
245
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* failed), or the timeout expires.
|
|
246
|
+
* Polls for a stream startup to complete. The -1 sentinel in channelToStreamId signals that startup is in progress. Returns the resolved stream ID on success, null
|
|
247
|
+
* if startup failed (sentinel removed), or undefined if the timeout expired while startup is still active.
|
|
272
248
|
*
|
|
273
|
-
*
|
|
249
|
+
* This is the shared inner polling loop used by both awaitStreamReady() (which sends error responses) and awaitStreamReadySilent() (which does not).
|
|
274
250
|
*
|
|
275
251
|
* @param channelName - The channel name (or synthetic ad-hoc key) to poll.
|
|
276
|
-
* @
|
|
277
|
-
* @returns The resolved stream ID on success, or null if startup failed or timed out (error response already sent).
|
|
252
|
+
* @returns The resolved stream ID, null if startup failed, or undefined if timed out.
|
|
278
253
|
*/
|
|
279
|
-
async function
|
|
254
|
+
async function pollStreamReady(channelName) {
|
|
280
255
|
const pollInterval = 200;
|
|
281
256
|
const deadline = Date.now() + CONFIG.streaming.navigationTimeout;
|
|
282
257
|
while (Date.now() < deadline) {
|
|
283
258
|
// eslint-disable-next-line no-await-in-loop
|
|
284
259
|
await delay(pollInterval);
|
|
285
260
|
const streamId = getChannelStreamId(channelName);
|
|
286
|
-
// The startup failed and the
|
|
261
|
+
// The startup failed and the sentinel was removed.
|
|
287
262
|
if (streamId === undefined) {
|
|
288
|
-
res.status(500).send("Stream startup failed.");
|
|
289
263
|
return null;
|
|
290
264
|
}
|
|
291
265
|
// Real stream ID is now available.
|
|
@@ -293,10 +267,104 @@ async function awaitStreamReady(channelName, res) {
|
|
|
293
267
|
return streamId;
|
|
294
268
|
}
|
|
295
269
|
}
|
|
296
|
-
// Timed out waiting for the
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
270
|
+
// Timed out waiting for the startup to complete.
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Waits for a stream startup to complete. This is used when a second request arrives while the first is still starting the stream. The -1 sentinel in
|
|
275
|
+
* channelToStreamId signals that startup is in progress. We poll until the sentinel is replaced with a real stream ID, removed (startup failed), or the
|
|
276
|
+
* timeout expires.
|
|
277
|
+
*
|
|
278
|
+
* On failure, the appropriate error response is sent to the client and null is returned.
|
|
279
|
+
*
|
|
280
|
+
* @param channelName - The channel name (or synthetic ad-hoc key) to poll.
|
|
281
|
+
* @param res - Express response object for sending error responses on failure.
|
|
282
|
+
* @returns The resolved stream ID on success, or null if startup failed or timed out (error response already sent).
|
|
283
|
+
*/
|
|
284
|
+
async function awaitStreamReady(channelName, res) {
|
|
285
|
+
const result = await pollStreamReady(channelName);
|
|
286
|
+
// Startup failed (sentinel removed).
|
|
287
|
+
if (result === null) {
|
|
288
|
+
res.status(500).send("Stream startup failed.");
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
// Timed out.
|
|
292
|
+
if (result === undefined) {
|
|
293
|
+
res.setHeader("Retry-After", "5");
|
|
294
|
+
res.status(503).send("Stream is starting. Please retry.");
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Waits for a stream startup to complete without sending any HTTP responses. Used by MPEG-TS when headers have already been flushed and error responses cannot be
|
|
301
|
+
* sent.
|
|
302
|
+
*
|
|
303
|
+
* @param channelName - The channel name (or synthetic ad-hoc key) to poll.
|
|
304
|
+
* @returns The resolved stream ID on success, or null if startup failed or timed out.
|
|
305
|
+
*/
|
|
306
|
+
export async function awaitStreamReadySilent(channelName) {
|
|
307
|
+
const result = await pollStreamReady(channelName);
|
|
308
|
+
// Both null (failed) and undefined (timed out) map to null for the silent variant.
|
|
309
|
+
if ((result === null) || (result === undefined)) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
// Response Helpers.
|
|
315
|
+
/**
|
|
316
|
+
* Sends the playlist for a stream, waiting for the first playlist if needed. Handles client registration and access tracking. This is the shared pattern used by
|
|
317
|
+
* multiple code paths in handleHLSPlaylist() to avoid duplicating the get-wait-check-register-send sequence.
|
|
318
|
+
* @param streamId - The numeric stream ID.
|
|
319
|
+
* @param clientAddress - Client address for tracking.
|
|
320
|
+
* @param res - Express response object.
|
|
321
|
+
*/
|
|
322
|
+
async function sendPlaylistResponse(streamId, clientAddress, res) {
|
|
323
|
+
// Try to get an existing playlist first.
|
|
324
|
+
let playlist = getPlaylist(streamId);
|
|
325
|
+
// If no playlist yet, wait for the first one.
|
|
326
|
+
if (!playlist) {
|
|
327
|
+
const playlistReady = await waitForPlaylist(streamId, CONFIG.streaming.navigationTimeout);
|
|
328
|
+
if (!playlistReady) {
|
|
329
|
+
res.setHeader("Retry-After", "5");
|
|
330
|
+
res.status(503).send("Stream is starting. Please retry.");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
playlist = getPlaylist(streamId);
|
|
334
|
+
if (!playlist) {
|
|
335
|
+
res.status(500).send("Playlist not available.");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// Log the time from stream start to first playlist delivery. This only fires for the initial playlist wait, not for subsequent playlist polls.
|
|
339
|
+
const stream = getStream(streamId);
|
|
340
|
+
if (stream) {
|
|
341
|
+
const elapsed = ((Date.now() - stream.startTime.getTime()) / 1000).toFixed(3);
|
|
342
|
+
LOG.debug("timing:hls", "Playlist delivered to client in %ss.", elapsed);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
updateLastAccess(streamId);
|
|
346
|
+
registerClient(streamId, clientAddress, "hls");
|
|
347
|
+
sendPlaylist(playlist, res);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Sends a playlist string as an HLS response with appropriate headers.
|
|
351
|
+
* @param playlist - The M3U8 playlist content.
|
|
352
|
+
* @param res - Express response object.
|
|
353
|
+
*/
|
|
354
|
+
function sendPlaylist(playlist, res) {
|
|
355
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
356
|
+
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
|
357
|
+
res.send(playlist);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Sends a segment buffer as a video/mp4 response with appropriate headers.
|
|
361
|
+
* @param data - The segment data.
|
|
362
|
+
* @param res - Express response object.
|
|
363
|
+
*/
|
|
364
|
+
function sendSegment(data, res) {
|
|
365
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
366
|
+
res.setHeader("Content-Type", "video/mp4");
|
|
367
|
+
res.send(data);
|
|
300
368
|
}
|
|
301
369
|
// Stream Lifecycle.
|
|
302
370
|
/**
|
|
@@ -308,7 +376,7 @@ async function awaitStreamReady(channelName, res) {
|
|
|
308
376
|
* @param segmenter - The orphaned fMP4 segmenter instance to stop.
|
|
309
377
|
*/
|
|
310
378
|
function cleanupOrphanedSetup(segmenter) {
|
|
311
|
-
LOG.
|
|
379
|
+
LOG.debug("streaming:setup", "Stream was terminated during setup. Stopping orphaned segmenter.");
|
|
312
380
|
segmenter.stop();
|
|
313
381
|
}
|
|
314
382
|
/**
|
|
@@ -333,39 +401,46 @@ function cleanupOrphanedSetup(segmenter) {
|
|
|
333
401
|
*/
|
|
334
402
|
function createTabReplacementHandler(numericStreamId, streamId, channelName, url, profile, metadataComment, onCircuitBreak) {
|
|
335
403
|
return async () => {
|
|
404
|
+
const tabElapsed = startTimer();
|
|
336
405
|
// Get the current stream entry.
|
|
337
406
|
const stream = getStream(numericStreamId);
|
|
338
407
|
if (!stream) {
|
|
339
|
-
LOG.
|
|
408
|
+
LOG.debug("recovery:tab", "Tab replacement requested but stream %s no longer exists.", streamId);
|
|
340
409
|
return null;
|
|
341
410
|
}
|
|
342
|
-
// Get the current segment index from the old segmenter before stopping it.
|
|
411
|
+
// Get the current init segment, segment index, and per-track timestamps from the old segmenter before stopping it. The init segment enables discontinuity
|
|
412
|
+
// suppression when codec parameters are unchanged, the segment index allows the new segmenter to continue numbering, and the track timestamps ensure monotonic
|
|
413
|
+
// baseMediaDecodeTime across capture restarts.
|
|
414
|
+
const currentInitSegment = stream.segmenter?.getInitSegment();
|
|
415
|
+
const currentInitVersion = stream.segmenter?.getInitVersion() ?? 0;
|
|
343
416
|
const currentSegmentIndex = stream.segmenter?.getSegmentIndex() ?? 0;
|
|
417
|
+
const currentTrackTimestamps = stream.segmenter?.getTrackTimestamps();
|
|
344
418
|
// Destroy the OLD capture stream first. This MUST happen before closing the page to ensure chrome.tabCapture releases the capture. Without this, the new
|
|
345
419
|
// getStream() call would hang with "Cannot capture a tab with an active stream" error.
|
|
346
420
|
if (stream.rawCaptureStream && !stream.rawCaptureStream.destroyed) {
|
|
347
|
-
LOG.debug("Destroying old capture stream for tab replacement.");
|
|
421
|
+
LOG.debug("recovery:tab", "Destroying old capture stream for tab replacement.");
|
|
348
422
|
stream.rawCaptureStream.destroy();
|
|
349
423
|
}
|
|
350
424
|
// Stop the current segmenter if it exists.
|
|
351
425
|
if (stream.segmenter) {
|
|
352
|
-
LOG.debug("Stopping current segmenter for tab replacement.");
|
|
426
|
+
LOG.debug("recovery:tab", "Stopping current segmenter for tab replacement.");
|
|
353
427
|
stream.segmenter.stop();
|
|
354
428
|
}
|
|
355
429
|
// Stop the FFmpeg process if it exists.
|
|
356
430
|
if (stream.ffmpegProcess) {
|
|
357
|
-
LOG.debug("Stopping FFmpeg process for tab replacement.");
|
|
431
|
+
LOG.debug("recovery:tab", "Stopping FFmpeg process for tab replacement.");
|
|
358
432
|
stream.ffmpegProcess.kill();
|
|
359
433
|
}
|
|
360
434
|
// Close the current page.
|
|
361
435
|
const oldPage = stream.page;
|
|
362
436
|
unregisterManagedPage(oldPage);
|
|
363
437
|
if (!oldPage.isClosed()) {
|
|
364
|
-
LOG.debug("Closing unresponsive page for tab replacement.");
|
|
438
|
+
LOG.debug("recovery:tab", "Closing unresponsive page for tab replacement.");
|
|
365
439
|
oldPage.close().catch((error) => {
|
|
366
|
-
LOG.
|
|
440
|
+
LOG.debug("recovery:tab", "Page close error during tab replacement: %s.", formatError(error));
|
|
367
441
|
});
|
|
368
442
|
}
|
|
443
|
+
LOG.debug("timing:tab", "Old tab cleanup complete. (+%sms)", tabElapsed());
|
|
369
444
|
// Create a new page with capture.
|
|
370
445
|
let captureResult;
|
|
371
446
|
try {
|
|
@@ -381,12 +456,14 @@ function createTabReplacementHandler(numericStreamId, streamId, channelName, url
|
|
|
381
456
|
});
|
|
382
457
|
}
|
|
383
458
|
catch (error) {
|
|
384
|
-
LOG.
|
|
459
|
+
LOG.warn("Failed to create new page during tab replacement: %s.", formatError(error));
|
|
385
460
|
return null;
|
|
386
461
|
}
|
|
387
|
-
|
|
388
|
-
//
|
|
462
|
+
LOG.debug("timing:tab", "New page with capture created. (+%sms)", tabElapsed());
|
|
463
|
+
// Create a new segmenter for the new capture stream. Continue from the current segment index for playlist continuity, pass the per-track timestamp counters
|
|
464
|
+
// for monotonic baseMediaDecodeTime, and mark the first segment with a discontinuity tag so clients know the stream parameters may have changed.
|
|
389
465
|
const newSegmenter = createFMP4Segmenter({
|
|
466
|
+
initialTrackTimestamps: currentTrackTimestamps,
|
|
390
467
|
onError: (error) => {
|
|
391
468
|
if (isTerminationInitiated(numericStreamId)) {
|
|
392
469
|
return;
|
|
@@ -399,11 +476,13 @@ function createTabReplacementHandler(numericStreamId, streamId, channelName, url
|
|
|
399
476
|
if (isTerminationInitiated(numericStreamId)) {
|
|
400
477
|
return;
|
|
401
478
|
}
|
|
402
|
-
LOG.
|
|
479
|
+
LOG.error("Segmenter stopped unexpectedly after tab replacement for %s.", channelName);
|
|
403
480
|
terminateStream(numericStreamId, channelName, "stream ended unexpectedly after recovery");
|
|
404
481
|
void emitCurrentSystemStatus();
|
|
405
482
|
},
|
|
406
483
|
pendingDiscontinuity: true,
|
|
484
|
+
previousInitSegment: currentInitSegment,
|
|
485
|
+
startingInitVersion: currentInitVersion,
|
|
407
486
|
startingSegmentIndex: currentSegmentIndex,
|
|
408
487
|
streamId: numericStreamId
|
|
409
488
|
});
|
|
@@ -415,6 +494,7 @@ function createTabReplacementHandler(numericStreamId, streamId, channelName, url
|
|
|
415
494
|
stream.rawCaptureStream = captureResult.rawCaptureStream;
|
|
416
495
|
stream.segmenter = newSegmenter;
|
|
417
496
|
LOG.info("Tab replacement complete. New capture started with segment continuity.");
|
|
497
|
+
LOG.debug("timing:tab", "Tab replacement complete. Total: %sms.", tabElapsed());
|
|
418
498
|
return {
|
|
419
499
|
context: captureResult.context,
|
|
420
500
|
page: captureResult.page
|
|
@@ -422,26 +502,27 @@ function createTabReplacementHandler(numericStreamId, streamId, channelName, url
|
|
|
422
502
|
};
|
|
423
503
|
}
|
|
424
504
|
/**
|
|
425
|
-
* Initializes a new HLS stream. This is the shared stream startup logic used by both channel-based and ad-hoc streams. It handles
|
|
426
|
-
*
|
|
505
|
+
* Initializes a new HLS stream. This is the shared stream startup logic used by both channel-based and ad-hoc streams. It handles browser capture setup, segmenter
|
|
506
|
+
* creation, stream registration, and event emission.
|
|
427
507
|
*
|
|
428
|
-
*
|
|
429
|
-
* HTTP error responses appropriately (channel-based streams need HDHomeRun
|
|
508
|
+
* A -1 sentinel is set in channelToStreamId during setup to prevent duplicate stream starts. On success, the sentinel is replaced with the real stream ID. On
|
|
509
|
+
* failure, the sentinel is removed and the error is re-thrown for the caller to handle HTTP error responses appropriately (channel-based streams need HDHomeRun
|
|
510
|
+
* headers, ad-hoc streams do not).
|
|
430
511
|
*
|
|
431
512
|
* @param options - Stream initialization options.
|
|
432
513
|
* @returns The stream ID on success, or null if the stream was terminated during the narrow setup window (orphaned setup race condition).
|
|
433
514
|
* @throws StreamSetupError if setup fails, or Error for unexpected failures.
|
|
434
515
|
*/
|
|
435
|
-
async function initializeStream(options) {
|
|
516
|
+
export async function initializeStream(options) {
|
|
436
517
|
const { channel, channelName, channelSelector, clickSelector, clickToPlay, clientAddress, profileOverride, url } = options;
|
|
437
|
-
//
|
|
438
|
-
const
|
|
439
|
-
setChannelStreamId(channelName,
|
|
518
|
+
// Set a -1 sentinel to prevent duplicate stream starts while we're setting up.
|
|
519
|
+
const startupSentinel = -1;
|
|
520
|
+
setChannelStreamId(channelName, startupSentinel);
|
|
440
521
|
let setup;
|
|
441
522
|
// Circuit breaker callback — terminate the stream on unrecoverable errors.
|
|
442
523
|
const onCircuitBreak = () => {
|
|
443
524
|
const streamId = getChannelStreamId(channelName);
|
|
444
|
-
if ((streamId !== undefined) && (streamId !==
|
|
525
|
+
if ((streamId !== undefined) && (streamId !== startupSentinel)) {
|
|
445
526
|
terminateStream(streamId, channelName, "too many errors");
|
|
446
527
|
void emitCurrentSystemStatus();
|
|
447
528
|
}
|
|
@@ -469,13 +550,13 @@ async function initializeStream(options) {
|
|
|
469
550
|
}, onCircuitBreak);
|
|
470
551
|
}
|
|
471
552
|
catch (error) {
|
|
472
|
-
// Remove
|
|
553
|
+
// Remove startup sentinel on failure and re-throw for the caller to handle error responses.
|
|
473
554
|
deleteChannelStreamId(channelName);
|
|
474
555
|
throw error;
|
|
475
556
|
}
|
|
476
557
|
// Update the channel mapping with the real stream ID.
|
|
477
558
|
setChannelStreamId(channelName, setup.numericStreamId);
|
|
478
|
-
// Continue within stream context for consistent logging.
|
|
559
|
+
// Continue within stream context for consistent logging.
|
|
479
560
|
return runWithStreamContext({ channelName: channel?.name, streamId: setup.streamId, url: setup.url },
|
|
480
561
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
481
562
|
async () => {
|
|
@@ -517,7 +598,7 @@ async function initializeStream(options) {
|
|
|
517
598
|
if (isTerminationInitiated(setup.numericStreamId)) {
|
|
518
599
|
return;
|
|
519
600
|
}
|
|
520
|
-
LOG.
|
|
601
|
+
LOG.error("Segmenter stopped unexpectedly for %s.", channelName);
|
|
521
602
|
terminateStream(setup.numericStreamId, channelName, "stream ended unexpectedly");
|
|
522
603
|
void emitCurrentSystemStatus();
|
|
523
604
|
},
|
|
@@ -537,7 +618,8 @@ async function initializeStream(options) {
|
|
|
537
618
|
}
|
|
538
619
|
const captureMode = CONFIG.streaming.captureMode === "ffmpeg" ? "FFmpeg" : "Native";
|
|
539
620
|
const displayName = channel?.name ?? url;
|
|
540
|
-
|
|
621
|
+
const tuneTime = ((Date.now() - setup.startTime.getTime()) / 1000).toFixed(1);
|
|
622
|
+
LOG.info("Streaming %s (%s, %s). Tuned in %ss.", displayName, setup.profileName, captureMode, tuneTime);
|
|
541
623
|
// Emit stream added event.
|
|
542
624
|
emitStreamAdded(createInitialStreamStatus({
|
|
543
625
|
channelName: channel?.name ?? null,
|