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.
Files changed (129) hide show
  1. package/README.md +26 -1
  2. package/dist/app.js +29 -2
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.js +1 -1
  5. package/dist/browser/cdp.js.map +1 -1
  6. package/dist/browser/channelSelection.d.ts +14 -4
  7. package/dist/browser/channelSelection.js +48 -704
  8. package/dist/browser/channelSelection.js.map +1 -1
  9. package/dist/browser/index.d.ts +18 -4
  10. package/dist/browser/index.js +205 -39
  11. package/dist/browser/index.js.map +1 -1
  12. package/dist/browser/tuning/fox.d.ts +19 -0
  13. package/dist/browser/tuning/fox.js +61 -0
  14. package/dist/browser/tuning/fox.js.map +1 -0
  15. package/dist/browser/tuning/hbo.d.ts +22 -0
  16. package/dist/browser/tuning/hbo.js +182 -0
  17. package/dist/browser/tuning/hbo.js.map +1 -0
  18. package/dist/browser/tuning/hulu.d.ts +30 -0
  19. package/dist/browser/tuning/hulu.js +514 -0
  20. package/dist/browser/tuning/hulu.js.map +1 -0
  21. package/dist/browser/tuning/sling.d.ts +22 -0
  22. package/dist/browser/tuning/sling.js +287 -0
  23. package/dist/browser/tuning/sling.js.map +1 -0
  24. package/dist/browser/tuning/thumbnailRow.d.ts +18 -0
  25. package/dist/browser/tuning/thumbnailRow.js +107 -0
  26. package/dist/browser/tuning/thumbnailRow.js.map +1 -0
  27. package/dist/browser/tuning/tileClick.d.ts +18 -0
  28. package/dist/browser/tuning/tileClick.js +102 -0
  29. package/dist/browser/tuning/tileClick.js.map +1 -0
  30. package/dist/browser/tuning/youtubeTv.d.ts +17 -0
  31. package/dist/browser/tuning/youtubeTv.js +99 -0
  32. package/dist/browser/tuning/youtubeTv.js.map +1 -0
  33. package/dist/browser/video.d.ts +2 -2
  34. package/dist/browser/video.js +18 -8
  35. package/dist/browser/video.js.map +1 -1
  36. package/dist/channels/index.js +131 -12
  37. package/dist/channels/index.js.map +1 -1
  38. package/dist/config/profiles.d.ts +5 -5
  39. package/dist/config/profiles.js +35 -11
  40. package/dist/config/profiles.js.map +1 -1
  41. package/dist/config/providers.d.ts +48 -4
  42. package/dist/config/providers.js +182 -22
  43. package/dist/config/providers.js.map +1 -1
  44. package/dist/config/sites.d.ts +10 -0
  45. package/dist/config/sites.js +96 -17
  46. package/dist/config/sites.js.map +1 -1
  47. package/dist/config/userChannels.js +21 -3
  48. package/dist/config/userChannels.js.map +1 -1
  49. package/dist/config/userConfig.d.ts +2 -1
  50. package/dist/config/userConfig.js +12 -4
  51. package/dist/config/userConfig.js.map +1 -1
  52. package/dist/index.js +10 -4
  53. package/dist/index.js.map +1 -1
  54. package/dist/routes/auth.js +6 -0
  55. package/dist/routes/auth.js.map +1 -1
  56. package/dist/routes/config.d.ts +5 -0
  57. package/dist/routes/config.js +273 -35
  58. package/dist/routes/config.js.map +1 -1
  59. package/dist/routes/debug.d.ts +6 -0
  60. package/dist/routes/debug.js +274 -0
  61. package/dist/routes/debug.js.map +1 -0
  62. package/dist/routes/index.d.ts +1 -0
  63. package/dist/routes/index.js +3 -0
  64. package/dist/routes/index.js.map +1 -1
  65. package/dist/routes/logs.js +17 -5
  66. package/dist/routes/logs.js.map +1 -1
  67. package/dist/routes/root.js +507 -128
  68. package/dist/routes/root.js.map +1 -1
  69. package/dist/routes/theme.js +7 -0
  70. package/dist/routes/theme.js.map +1 -1
  71. package/dist/service/commands.js +8 -8
  72. package/dist/service/commands.js.map +1 -1
  73. package/dist/service/generators.js +22 -16
  74. package/dist/service/generators.js.map +1 -1
  75. package/dist/streaming/fmp4Segmenter.d.ts +7 -0
  76. package/dist/streaming/fmp4Segmenter.js +124 -15
  77. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  78. package/dist/streaming/hls.d.ts +74 -6
  79. package/dist/streaming/hls.js +212 -130
  80. package/dist/streaming/hls.js.map +1 -1
  81. package/dist/streaming/hlsSegments.js +11 -4
  82. package/dist/streaming/hlsSegments.js.map +1 -1
  83. package/dist/streaming/lifecycle.js +1 -1
  84. package/dist/streaming/lifecycle.js.map +1 -1
  85. package/dist/streaming/monitor.d.ts +1 -1
  86. package/dist/streaming/monitor.js +202 -53
  87. package/dist/streaming/monitor.js.map +1 -1
  88. package/dist/streaming/mp4Parser.d.ts +29 -1
  89. package/dist/streaming/mp4Parser.js +243 -18
  90. package/dist/streaming/mp4Parser.js.map +1 -1
  91. package/dist/streaming/mpegts.d.ts +5 -2
  92. package/dist/streaming/mpegts.js +122 -29
  93. package/dist/streaming/mpegts.js.map +1 -1
  94. package/dist/streaming/registry.d.ts +1 -1
  95. package/dist/streaming/registry.js.map +1 -1
  96. package/dist/streaming/setup.d.ts +13 -1
  97. package/dist/streaming/setup.js +136 -36
  98. package/dist/streaming/setup.js.map +1 -1
  99. package/dist/streaming/showInfo.js +9 -9
  100. package/dist/streaming/showInfo.js.map +1 -1
  101. package/dist/streaming/statusEmitter.js +5 -6
  102. package/dist/streaming/statusEmitter.js.map +1 -1
  103. package/dist/types/index.d.ts +16 -3
  104. package/dist/types/index.js +5 -1
  105. package/dist/types/index.js.map +1 -1
  106. package/dist/utils/debugFilter.d.ts +38 -0
  107. package/dist/utils/debugFilter.js +157 -0
  108. package/dist/utils/debugFilter.js.map +1 -0
  109. package/dist/utils/evaluate.js.map +1 -1
  110. package/dist/utils/ffmpeg.d.ts +5 -0
  111. package/dist/utils/ffmpeg.js +13 -2
  112. package/dist/utils/ffmpeg.js.map +1 -1
  113. package/dist/utils/fileLogger.d.ts +2 -1
  114. package/dist/utils/fileLogger.js +4 -2
  115. package/dist/utils/fileLogger.js.map +1 -1
  116. package/dist/utils/index.d.ts +1 -0
  117. package/dist/utils/index.js +1 -0
  118. package/dist/utils/index.js.map +1 -1
  119. package/dist/utils/logEmitter.d.ts +1 -0
  120. package/dist/utils/logEmitter.js.map +1 -1
  121. package/dist/utils/logger.d.ts +9 -8
  122. package/dist/utils/logger.js +52 -28
  123. package/dist/utils/logger.js.map +1 -1
  124. package/dist/utils/retry.d.ts +1 -1
  125. package/dist/utils/retry.js +3 -3
  126. package/dist/utils/retry.js.map +1 -1
  127. package/dist/utils/version.js +4 -9
  128. package/dist/utils/version.js.map +1 -1
  129. package/package.json +6 -6
@@ -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
- /* 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
16
- * segmented natively without any external dependencies. The overall flow is:
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
- * 1. Client requests playlist at /hls/:name/stream.m3u8 (predefined channel) or /play?url=...&profile=... (ad-hoc URL)
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
- * Shared streams: If multiple clients request the same channel (or the same ad-hoc URL with the same profile), they share one segmenter. The first client triggers
26
- * 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
27
- * URL and profile, allowing them to use the same channelToStreamId deduplication mechanism as predefined channels.
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 (placeholder), waits for it to complete. Returns the
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
- * This is the shared entry point for both HLS and MPEG-TS handlers. It handles channel validation, login mode blocking, and concurrent startup deduplication. The
35
- * existing-stream check runs first so that ad-hoc streams (started via /play with synthetic keys) can be served without requiring a predefined channel definition.
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 placeholder), return it directly.
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 placeholder (-1) exists, another request is already starting this stream. Poll until the real stream ID appears or we timeout.
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
- if (isPredefinedChannelDisabled(channelName)) {
61
- res.status(404).send("Channel is disabled.");
62
- return null;
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, effectiveChannel.url, req, res, effectiveChannel);
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 requested channel, waits for the first playlist to be produced, then returns it.
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
- updateLastAccess(streamId);
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
- res.setHeader("Cache-Control", "no-cache");
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
- res.setHeader("Cache-Control", "no-cache");
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 placeholder (-1) exists, another request is already starting this stream. Poll until the real stream ID appears or we timeout, then redirect.
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 placeholder management, capture setup, segmenter creation, and event emission.
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
- // Placeholder Polling.
244
+ // Startup Polling.
268
245
  /**
269
- * Waits for a stream placeholder to resolve to a real stream ID. This is used when a second request arrives while the first is still starting the stream. The
270
- * placeholder (-1) in channelToStreamId signals that startup is in progress. We poll until the placeholder is replaced with a real stream ID, removed (startup
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
- * On failure, the appropriate error response is sent to the client and null is returned.
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
- * @param res - Express response object for sending error responses on failure.
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 awaitStreamReady(channelName, res) {
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 placeholder was removed.
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 placeholder to resolve.
297
- res.setHeader("Retry-After", "5");
298
- res.status(503).send("Stream is starting. Please retry.");
299
- return null;
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.warn("Stream was terminated during setup. Stopping orphaned segmenter.");
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.warn("Tab replacement requested but stream %s no longer exists.", streamId);
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. This allows the new segmenter to continue numbering from where we left off.
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.warn("Page close error during tab replacement: %s.", formatError(error));
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.error("Failed to create new page during tab replacement: %s.", formatError(error));
459
+ LOG.warn("Failed to create new page during tab replacement: %s.", formatError(error));
385
460
  return null;
386
461
  }
387
- // Create a new segmenter for the new capture stream. Continue from the current segment index for playlist continuity, and mark the first segment with a
388
- // discontinuity tag so clients know the stream parameters may have changed.
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.warn("Segmenter stopped unexpectedly after tab replacement for %s.", channelName);
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 placeholder management, browser
426
- * capture setup, segmenter creation, stream registration, and event emission.
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
- * On success, the stream is fully registered and producing segments. On failure, the placeholder is cleaned up and the error is re-thrown for the caller to handle
429
- * HTTP error responses appropriately (channel-based streams need HDHomeRun headers, ad-hoc streams do not).
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
- // Create a placeholder to prevent duplicate stream starts while we're setting up.
438
- const placeholderStreamId = -1;
439
- setChannelStreamId(channelName, placeholderStreamId);
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 !== placeholderStreamId)) {
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 placeholder on failure and re-throw for the caller to handle error responses.
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. The async is required by runWithStreamContext's signature.
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.warn("Segmenter stopped unexpectedly for %s.", channelName);
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
- LOG.info("Streaming %s (%s, %s).", displayName, setup.profileName, captureMode);
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,