prismcast 1.5.2 → 1.7.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 (239) hide show
  1. package/README.md +44 -28
  2. package/dist/app.d.ts +5 -0
  3. package/dist/app.js +63 -5
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/cdp.js +1 -1
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/channelSelection.d.ts +10 -34
  8. package/dist/browser/channelSelection.js +31 -96
  9. package/dist/browser/channelSelection.js.map +1 -1
  10. package/dist/browser/display.d.ts +19 -0
  11. package/dist/browser/display.js +16 -11
  12. package/dist/browser/display.js.map +1 -1
  13. package/dist/browser/index.d.ts +31 -54
  14. package/dist/browser/index.js +334 -272
  15. package/dist/browser/index.js.map +1 -1
  16. package/dist/browser/login.d.ts +65 -0
  17. package/dist/browser/login.js +193 -0
  18. package/dist/browser/login.js.map +1 -0
  19. package/dist/browser/precaching.js +15 -3
  20. package/dist/browser/precaching.js.map +1 -1
  21. package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
  22. package/dist/browser/tuning/comcastPolymer.js +655 -0
  23. package/dist/browser/tuning/comcastPolymer.js.map +1 -0
  24. package/dist/browser/tuning/cox.d.ts +2 -0
  25. package/dist/browser/tuning/cox.js +14 -0
  26. package/dist/browser/tuning/cox.js.map +1 -0
  27. package/dist/browser/tuning/directv.js +14 -1
  28. package/dist/browser/tuning/directv.js.map +1 -1
  29. package/dist/browser/tuning/fox.js +14 -2
  30. package/dist/browser/tuning/fox.js.map +1 -1
  31. package/dist/browser/tuning/hbo.js +12 -1
  32. package/dist/browser/tuning/hbo.js.map +1 -1
  33. package/dist/browser/tuning/hulu.js +24 -1
  34. package/dist/browser/tuning/hulu.js.map +1 -1
  35. package/dist/browser/tuning/shared.d.ts +46 -0
  36. package/dist/browser/tuning/shared.js +100 -0
  37. package/dist/browser/tuning/shared.js.map +1 -0
  38. package/dist/browser/tuning/sling.js +35 -5
  39. package/dist/browser/tuning/sling.js.map +1 -1
  40. package/dist/browser/tuning/spectrum.js +13 -1
  41. package/dist/browser/tuning/spectrum.js.map +1 -1
  42. package/dist/browser/tuning/thumbnailRow.js +1 -1
  43. package/dist/browser/tuning/thumbnailRow.js.map +1 -1
  44. package/dist/browser/tuning/tileClick.js +1 -1
  45. package/dist/browser/tuning/tileClick.js.map +1 -1
  46. package/dist/browser/tuning/xfinity.d.ts +2 -0
  47. package/dist/browser/tuning/xfinity.js +14 -0
  48. package/dist/browser/tuning/xfinity.js.map +1 -0
  49. package/dist/browser/tuning/youtubeTv.js +15 -1
  50. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  51. package/dist/browser/video.d.ts +20 -0
  52. package/dist/browser/video.js +168 -124
  53. package/dist/browser/video.js.map +1 -1
  54. package/dist/channels/index.d.ts +2 -2
  55. package/dist/channels/index.js +2075 -683
  56. package/dist/channels/index.js.map +1 -1
  57. package/dist/config/health.d.ts +28 -28
  58. package/dist/config/health.js +72 -54
  59. package/dist/config/health.js.map +1 -1
  60. package/dist/config/index.js +29 -64
  61. package/dist/config/index.js.map +1 -1
  62. package/dist/config/paths.d.ts +12 -0
  63. package/dist/config/paths.js +16 -0
  64. package/dist/config/paths.js.map +1 -1
  65. package/dist/config/profiles.d.ts +3 -2
  66. package/dist/config/profiles.js +45 -34
  67. package/dist/config/profiles.js.map +1 -1
  68. package/dist/config/providerPacks.js +4 -1
  69. package/dist/config/providerPacks.js.map +1 -1
  70. package/dist/config/providers.d.ts +19 -5
  71. package/dist/config/providers.js +123 -93
  72. package/dist/config/providers.js.map +1 -1
  73. package/dist/config/sites.d.ts +28 -0
  74. package/dist/config/sites.js +104 -127
  75. package/dist/config/sites.js.map +1 -1
  76. package/dist/config/userChannels.d.ts +53 -2
  77. package/dist/config/userChannels.js +222 -13
  78. package/dist/config/userChannels.js.map +1 -1
  79. package/dist/config/userConfig.d.ts +2 -1
  80. package/dist/config/userConfig.js +6 -15
  81. package/dist/config/userConfig.js.map +1 -1
  82. package/dist/config/userProfiles.d.ts +0 -1
  83. package/dist/config/userProfiles.js +18 -10
  84. package/dist/config/userProfiles.js.map +1 -1
  85. package/dist/hdhr/channelMap.d.ts +1 -1
  86. package/dist/hdhr/channelMap.js +4 -4
  87. package/dist/hdhr/channelMap.js.map +1 -1
  88. package/dist/index.js +25 -22
  89. package/dist/index.js.map +1 -1
  90. package/dist/native/decrypt.d.ts +32 -0
  91. package/dist/native/decrypt.js +85 -0
  92. package/dist/native/decrypt.js.map +1 -0
  93. package/dist/native/index.d.ts +61 -0
  94. package/dist/native/index.js +342 -0
  95. package/dist/native/index.js.map +1 -0
  96. package/dist/native/intercept.d.ts +35 -0
  97. package/dist/native/intercept.js +184 -0
  98. package/dist/native/intercept.js.map +1 -0
  99. package/dist/native/probe.d.ts +49 -0
  100. package/dist/native/probe.js +294 -0
  101. package/dist/native/probe.js.map +1 -0
  102. package/dist/native/proxy.d.ts +54 -0
  103. package/dist/native/proxy.js +947 -0
  104. package/dist/native/proxy.js.map +1 -0
  105. package/dist/routes/components.d.ts +43 -0
  106. package/dist/routes/components.js +122 -0
  107. package/dist/routes/components.js.map +1 -1
  108. package/dist/routes/config/channels/index.d.ts +2 -2
  109. package/dist/routes/config/channels/index.js +1 -1
  110. package/dist/routes/config/channels/index.js.map +1 -1
  111. package/dist/routes/config/channels/routes.js +443 -65
  112. package/dist/routes/config/channels/routes.js.map +1 -1
  113. package/dist/routes/config/channels/table.d.ts +76 -0
  114. package/dist/routes/config/channels/table.js +369 -89
  115. package/dist/routes/config/channels/table.js.map +1 -1
  116. package/dist/routes/config/index.d.ts +1 -1
  117. package/dist/routes/config/index.js +1 -1
  118. package/dist/routes/config/index.js.map +1 -1
  119. package/dist/routes/config/providers.d.ts +6 -6
  120. package/dist/routes/config/providers.js +73 -99
  121. package/dist/routes/config/providers.js.map +1 -1
  122. package/dist/routes/debug.js +5 -5
  123. package/dist/routes/debug.js.map +1 -1
  124. package/dist/routes/hls.js +12 -2
  125. package/dist/routes/hls.js.map +1 -1
  126. package/dist/routes/index.d.ts +0 -15
  127. package/dist/routes/index.js +2 -16
  128. package/dist/routes/index.js.map +1 -1
  129. package/dist/routes/providers.js +106 -3
  130. package/dist/routes/providers.js.map +1 -1
  131. package/dist/routes/root/content.js +124 -47
  132. package/dist/routes/root/content.js.map +1 -1
  133. package/dist/routes/root/index.js +2 -1
  134. package/dist/routes/root/index.js.map +1 -1
  135. package/dist/routes/root/scripts/channels.js +772 -312
  136. package/dist/routes/root/scripts/channels.js.map +1 -1
  137. package/dist/routes/root/scripts/config.js +596 -544
  138. package/dist/routes/root/scripts/config.js.map +1 -1
  139. package/dist/routes/root/scripts/index.d.ts +1 -0
  140. package/dist/routes/root/scripts/index.js +1 -0
  141. package/dist/routes/root/scripts/index.js.map +1 -1
  142. package/dist/routes/root/scripts/shared.d.ts +6 -0
  143. package/dist/routes/root/scripts/shared.js +391 -0
  144. package/dist/routes/root/scripts/shared.js.map +1 -0
  145. package/dist/routes/root/scripts/status.js +49 -33
  146. package/dist/routes/root/scripts/status.js.map +1 -1
  147. package/dist/routes/root/styles.js +117 -10
  148. package/dist/routes/root/styles.js.map +1 -1
  149. package/dist/service/commands.js +2 -1
  150. package/dist/service/commands.js.map +1 -1
  151. package/dist/streaming/fmp4Segmenter.d.ts +5 -0
  152. package/dist/streaming/fmp4Segmenter.js +146 -42
  153. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  154. package/dist/streaming/hls.d.ts +24 -19
  155. package/dist/streaming/hls.js +603 -297
  156. package/dist/streaming/hls.js.map +1 -1
  157. package/dist/streaming/hlsResume.d.ts +16 -3
  158. package/dist/streaming/hlsResume.js +35 -8
  159. package/dist/streaming/hlsResume.js.map +1 -1
  160. package/dist/streaming/hlsSegments.d.ts +41 -2
  161. package/dist/streaming/hlsSegments.js +131 -28
  162. package/dist/streaming/hlsSegments.js.map +1 -1
  163. package/dist/streaming/lifecycle.js +27 -13
  164. package/dist/streaming/lifecycle.js.map +1 -1
  165. package/dist/streaming/monitor.d.ts +1 -0
  166. package/dist/streaming/monitor.js +862 -474
  167. package/dist/streaming/monitor.js.map +1 -1
  168. package/dist/streaming/mp4Parser.d.ts +52 -6
  169. package/dist/streaming/mp4Parser.js +156 -30
  170. package/dist/streaming/mp4Parser.js.map +1 -1
  171. package/dist/streaming/mpegts.d.ts +2 -2
  172. package/dist/streaming/mpegts.js +156 -109
  173. package/dist/streaming/mpegts.js.map +1 -1
  174. package/dist/streaming/playlistBuilder.d.ts +38 -0
  175. package/dist/streaming/playlistBuilder.js +66 -0
  176. package/dist/streaming/playlistBuilder.js.map +1 -0
  177. package/dist/streaming/preroll.d.ts +126 -0
  178. package/dist/streaming/preroll.js +434 -0
  179. package/dist/streaming/preroll.js.map +1 -0
  180. package/dist/streaming/pretune.d.ts +8 -0
  181. package/dist/streaming/pretune.js +226 -0
  182. package/dist/streaming/pretune.js.map +1 -0
  183. package/dist/streaming/recovery.d.ts +0 -6
  184. package/dist/streaming/recovery.js +0 -8
  185. package/dist/streaming/recovery.js.map +1 -1
  186. package/dist/streaming/registry.d.ts +57 -9
  187. package/dist/streaming/registry.js +26 -7
  188. package/dist/streaming/registry.js.map +1 -1
  189. package/dist/streaming/setup.d.ts +8 -1
  190. package/dist/streaming/setup.js +71 -27
  191. package/dist/streaming/setup.js.map +1 -1
  192. package/dist/streaming/showInfo.d.ts +29 -4
  193. package/dist/streaming/showInfo.js +210 -38
  194. package/dist/streaming/showInfo.js.map +1 -1
  195. package/dist/streaming/statusEmitter.d.ts +20 -6
  196. package/dist/streaming/statusEmitter.js +31 -7
  197. package/dist/streaming/statusEmitter.js.map +1 -1
  198. package/dist/types/channels.d.ts +32 -0
  199. package/dist/types/config.d.ts +2 -2
  200. package/dist/types/index.d.ts +2 -2
  201. package/dist/types/index.js.map +1 -1
  202. package/dist/types/profiles.d.ts +13 -1
  203. package/dist/types/selection.d.ts +5 -1
  204. package/dist/types/shared.d.ts +1 -1
  205. package/dist/types/streaming.d.ts +7 -0
  206. package/dist/utils/chromeFetch.d.ts +20 -0
  207. package/dist/utils/chromeFetch.js +43 -0
  208. package/dist/utils/chromeFetch.js.map +1 -0
  209. package/dist/utils/debugFilter.d.ts +0 -5
  210. package/dist/utils/debugFilter.js +20 -11
  211. package/dist/utils/debugFilter.js.map +1 -1
  212. package/dist/utils/delay.d.ts +28 -0
  213. package/dist/utils/delay.js +35 -1
  214. package/dist/utils/delay.js.map +1 -1
  215. package/dist/utils/evaluate.js +6 -11
  216. package/dist/utils/evaluate.js.map +1 -1
  217. package/dist/utils/ffmpeg.d.ts +14 -12
  218. package/dist/utils/ffmpeg.js +114 -134
  219. package/dist/utils/ffmpeg.js.map +1 -1
  220. package/dist/utils/format.d.ts +6 -0
  221. package/dist/utils/format.js +8 -0
  222. package/dist/utils/format.js.map +1 -1
  223. package/dist/utils/index.d.ts +3 -0
  224. package/dist/utils/index.js +3 -0
  225. package/dist/utils/index.js.map +1 -1
  226. package/dist/utils/logEmitter.js +1 -1
  227. package/dist/utils/logEmitter.js.map +1 -1
  228. package/dist/utils/pid.d.ts +40 -0
  229. package/dist/utils/pid.js +83 -0
  230. package/dist/utils/pid.js.map +1 -0
  231. package/dist/utils/retry.d.ts +15 -9
  232. package/dist/utils/retry.js +7 -23
  233. package/dist/utils/retry.js.map +1 -1
  234. package/dist/utils/timing.d.ts +5 -0
  235. package/dist/utils/timing.js +13 -0
  236. package/dist/utils/timing.js.map +1 -0
  237. package/dist/utils/version.js +2 -2
  238. package/dist/utils/version.js.map +1 -1
  239. package/package.json +7 -7
@@ -1,20 +1,50 @@
1
- import { LOG, delay, formatError, runWithStreamContext, startTimer } from "../utils/index.js";
2
- import { StreamSetupError, createPageWithCapture, setupStream } from "./setup.js";
3
- import { createHLSState, getAllStreams, getStream, getStreamCount, registerStream, updateLastAccess } from "./registry.js";
1
+ import { LOG, formatError, runWithStreamContext, startTimer } from "../utils/index.js";
2
+ import { generatePrerollPlaylist, getPrerollCodec, getPrerollSegmentCount, isPrerollReady } from "./preroll.js";
3
+ import { StreamSetupError, createPageWithCapture, generateStreamId, setupStream } from "./setup.js";
4
+ import { createHLSState, getAllStreams, getNextStreamId, getStream, getStreamCount, registerStream, updateLastAccess } from "./registry.js";
4
5
  import { createInitialStreamStatus, emitStreamAdded } from "./statusEmitter.js";
5
- import { deleteChannelStreamId, getChannelStreamId, isTerminationInitiated, setChannelStreamId, terminateStream } from "./lifecycle.js";
6
+ import { deleteResumeData, getResumeSegmentIndex, peekResumeData } from "./hlsResume.js";
6
7
  import { emitCurrentSystemStatus, isLoginModeActive, unregisterManagedPage } from "../browser/index.js";
7
8
  import { getAllChannels, isPredefinedChannelDisabled } from "../config/userChannels.js";
8
- import { getInitSegment, getPlaylist, getSegment, waitForPlaylist } from "./hlsSegments.js";
9
- import { getProviderTagForChannel, getResolvedChannel, resolveProviderKey } from "../config/providers.js";
9
+ import { getAudioPlaylist, getAudioSegment, getInitSegment, getPlaylist, getSegment, getVideoPlaylist, waitForPlaylist } from "./hlsSegments.js";
10
+ import { getAuthDomainForChannel, getProviderTagForChannel, getResolvedChannel, resolveProviderKey } from "../config/providers.js";
11
+ import { getChannelStreamId, isTerminationInitiated, setChannelStreamId, terminateStream } from "./lifecycle.js";
10
12
  import { markChannelFailure, markChannelSuccess } from "../config/health.js";
11
13
  import { CONFIG } from "../config/index.js";
12
- import { consumeResumeData } from "./hlsResume.js";
14
+ import { attemptNativeStreaming } from "../native/index.js";
15
+ import { clearProbeCache } from "../native/probe.js";
13
16
  import { createFMP4Segmenter } from "./fmp4Segmenter.js";
14
17
  import { createHash } from "node:crypto";
18
+ import { getGpuCapabilities } from "../browser/display.js";
15
19
  import { getProviderBySlug } from "../browser/channelSelection.js";
16
20
  import { registerClient } from "./clients.js";
21
+ import { suppressPageAudio } from "../browser/video.js";
17
22
  import { triggerShowNameUpdate } from "./showInfo.js";
23
+ /* 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
24
+ * segmented natively without any external dependencies. The stream initialization flow has three phases:
25
+ *
26
+ * Phase 1 — Registration (synchronous, in the request handler):
27
+ * 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.
28
+ * 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
29
+ * stream setup completes (fallback path).
30
+ *
31
+ * Phase 2 — Browser setup (async, fire-and-forget from the request handler):
32
+ * setupStream() creates the browser page, navigates to the URL, initializes playback, and starts capture. This produces the StreamSetupResult with the capture
33
+ * stream, page, profile, and monitor. The pending registry entry is filled in with these references.
34
+ *
35
+ * Phase 3 — Streaming pipeline (async, after browser setup):
36
+ * If the provider's manifest is interceptable, native HLS streaming is attempted via startNativeProxy(). If native is viable, the capture pipeline is stopped and
37
+ * the proxy takes over. Otherwise, createCaptureSegmenter() creates the fMP4 segmenter and pipes the capture stream. When the first real playlist arrives
38
+ * (from either the segmenter or native proxy), the preroll timer is cancelled and the client receives live content on the next poll.
39
+ *
40
+ * 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
41
+ * 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
42
+ * URL and profile, allowing them to use the same channelToStreamId deduplication mechanism as predefined channels.
43
+ */
44
+ // 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
45
+ // 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
46
+ // 13-15s) get preroll content after the delay to prevent HTTP timeouts.
47
+ const PREROLL_DELAY_MS = 9_000;
18
48
  // Login mode error body used by both validateChannel() and handlePlayStream() to ensure consistent response format.
19
49
  const LOGIN_MODE_BODY = { error: "Login in progress", message: "Please complete authentication before starting new streams." };
20
50
  /**
@@ -68,8 +98,8 @@ export function sendValidationError(validation, res) {
68
98
  }
69
99
  // Public Endpoint Handlers.
70
100
  /**
71
- * 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
72
- * the stream ID if successful, or null if an error occurred (with the error response already sent to the client).
101
+ * Ensures a stream is running for a channel. If no stream exists, registers a pending stream with a preroll playlist (when available) and launches async setup. If a
102
+ * stream already exists (either fully set up or pending), returns its ID immediately.
73
103
  *
74
104
  * The existing-stream check runs first so that ad-hoc streams (registered under synthetic keys like "play-a1b2c3d4") can be served without failing the
75
105
  * "Channel not found" check.
@@ -80,21 +110,16 @@ export function sendValidationError(validation, res) {
80
110
  * @param channelName - The channel key (or synthetic ad-hoc key) to stream.
81
111
  * @param req - Express request object (for profile override and client IP).
82
112
  * @param res - Express response object (for error responses).
83
- * @returns The stream ID if a stream is running, or null if an error occurred.
113
+ * @returns The stream ID if a stream is running or pending, or null if an error occurred.
84
114
  */
85
115
  export async function ensureChannelStream(channelName, req, res) {
86
116
  // Check for an existing stream first. This must happen before channel validation so that ad-hoc streams (registered under synthetic keys like "play-a1b2c3d4") can
87
117
  // 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
88
- // started, so no re-validation is needed.
118
+ // started, so no re-validation is needed. With pending stream registration, this always returns a real stream ID (never a sentinel).
89
119
  const streamId = getChannelStreamId(channelName);
90
- // If a stream is already running (not a startup-in-progress sentinel), return it directly.
91
- if ((streamId !== undefined) && (streamId !== -1)) {
120
+ if (streamId !== undefined) {
92
121
  return streamId;
93
122
  }
94
- // 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.
95
- if (streamId === -1) {
96
- return awaitStreamReady(channelName, res);
97
- }
98
123
  // 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
99
124
  // at startup time.
100
125
  const validation = validateChannel(channelName);
@@ -102,17 +127,43 @@ export async function ensureChannelStream(channelName, req, res) {
102
127
  sendValidationError(validation, res);
103
128
  return null;
104
129
  }
105
- // Start the stream using the resolved channel's URL. The stream is registered under channelName (canonical key) for deduplication, but uses the resolved
106
- // provider's definition.
107
- const newStreamId = await startHLSStream(channelName, validation.channel.url, req, res, validation.channel);
108
- if (newStreamId === null) {
109
- // Error response already sent by startHLSStream.
110
- return null;
130
+ // 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
131
+ // stream setup runs asynchronously. When the preroll is not available (FFmpeg missing or failed), fall back to blocking initialization.
132
+ const prerollCodec = getPrerollCodec();
133
+ if (isPrerollReady(prerollCodec)) {
134
+ // Check capacity before registering the pending entry. The pending entry occupies a registry slot, so registering it at max capacity would cause setupStream's
135
+ // 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.
136
+ if (getStreamCount() >= CONFIG.streaming.maxConcurrentStreams) {
137
+ if (!reclaimIdleStream()) {
138
+ res.setHeader("Retry-After", "10");
139
+ res.setHeader("X-HDHomeRun-Error", "All Tuners In Use");
140
+ res.status(503).send("Maximum concurrent streams (" + String(CONFIG.streaming.maxConcurrentStreams) + ") reached. Try again later.");
141
+ return null;
142
+ }
143
+ }
144
+ const clientAddress = req.ip ?? req.socket.remoteAddress ?? null;
145
+ const pending = registerPendingStream(channelName, validation.channel, clientAddress, req, prerollCodec);
146
+ // Launch async setup. Errors are caught here to clean up the pending entry and prevent unhandled rejections.
147
+ void completeStreamSetup({
148
+ channel: validation.channel,
149
+ channelName,
150
+ clientAddress,
151
+ numericStreamId: pending.numericStreamId,
152
+ profileOverride: req.query.profile,
153
+ streamIdStr: pending.streamIdStr,
154
+ url: validation.channel.url
155
+ }).catch((error) => {
156
+ handleSetupFailure(pending.numericStreamId, channelName, validation.channel, error);
157
+ });
158
+ return pending.numericStreamId;
111
159
  }
112
- return newStreamId;
160
+ // Fallback: no preroll available. Block until the stream is fully set up.
161
+ return startHLSStream(channelName, validation.channel.url, req, res, validation.channel);
113
162
  }
114
163
  /**
115
- * 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.
164
+ * Handles HLS playlist requests. If a stream exists (fully set up or pending with preroll), returns the playlist immediately. If no stream exists and the preroll is
165
+ * available, registers a pending stream with a preroll playlist and returns it immediately while setup runs asynchronously. Falls back to blocking initialization when
166
+ * the preroll is not available.
116
167
  *
117
168
  * Route: GET /hls/:name/stream.m3u8
118
169
  *
@@ -133,7 +184,8 @@ export async function handleHLSPlaylist(req, res) {
133
184
  await sendPlaylistResponse(streamId, clientAddress, res);
134
185
  }
135
186
  /**
136
- * Handles HLS segment requests. Returns the requested segment from memory. Supports both the fMP4 initialization segment (init.mp4) and media segments (.m4s).
187
+ * Handles HLS segment requests. Returns the requested segment from memory. Supports the fMP4 initialization segment (init.mp4), capture-mode media segments
188
+ * (.m4s), native-mode video segments (.ts), and audio segments for streams with separate audio renditions.
137
189
  *
138
190
  * Route: GET /hls/:name/:segment
139
191
  *
@@ -148,11 +200,11 @@ export function handleHLSSegment(req, res) {
148
200
  return;
149
201
  }
150
202
  const streamId = getChannelStreamId(channelName);
151
- if ((streamId === undefined) || (streamId === -1)) {
203
+ if (streamId === undefined) {
152
204
  res.status(404).send("Stream not found.");
153
205
  return;
154
206
  }
155
- // Handle init segment (init.mp4) separately from media segments (.m4s).
207
+ // Handle init segment (init.mp4) separately from media segments (.m4s, .ts).
156
208
  if (segmentName === "init.mp4") {
157
209
  const initSegment = getInitSegment(streamId);
158
210
  if (!initSegment) {
@@ -160,17 +212,55 @@ export function handleHLSSegment(req, res) {
160
212
  return;
161
213
  }
162
214
  updateLastAccess(streamId);
163
- sendSegment(initSegment, res);
215
+ sendSegment(initSegment, "init.mp4", res);
164
216
  return;
165
217
  }
166
- // Handle media segments (.m4s).
167
- const segment = getSegment(streamId, segmentName);
218
+ // Handle media segments (.m4s and .ts). Check both video and audio segment stores.
219
+ const segment = getSegment(streamId, segmentName) ?? getAudioSegment(streamId, segmentName);
168
220
  if (!segment) {
169
221
  res.status(404).send("Segment not found.");
170
222
  return;
171
223
  }
172
224
  updateLastAccess(streamId);
173
- sendSegment(segment, res);
225
+ sendSegment(segment, segmentName, res);
226
+ }
227
+ /**
228
+ * Handles HLS variant playlist requests for streams with separate audio renditions. Serves video.m3u8 or audio.m3u8 depending on the requested playlist name.
229
+ * Returns 404 for streams that don't have separate audio.
230
+ *
231
+ * Route: GET /hls/:name/video.m3u8 and GET /hls/:name/audio.m3u8
232
+ *
233
+ * @param req - Express request object.
234
+ * @param res - Express response object.
235
+ */
236
+ export function handleHLSVariantPlaylist(req, res) {
237
+ const channelName = req.params.name;
238
+ // Extract the playlist filename from the URL path. The route is registered as two explicit paths (/video.m3u8 and /audio.m3u8) rather than a parameterized route,
239
+ // so there is no :playlist param to read.
240
+ const lastSlash = req.path.lastIndexOf("/");
241
+ const playlistName = lastSlash >= 0 ? req.path.slice(lastSlash + 1) : undefined;
242
+ if (!channelName || !playlistName) {
243
+ res.status(400).send("Channel name and playlist name are required.");
244
+ return;
245
+ }
246
+ const streamId = getChannelStreamId(channelName);
247
+ if (streamId === undefined) {
248
+ res.status(404).send("Stream not found.");
249
+ return;
250
+ }
251
+ let playlist;
252
+ if (playlistName === "video.m3u8") {
253
+ playlist = getVideoPlaylist(streamId);
254
+ }
255
+ else if (playlistName === "audio.m3u8") {
256
+ playlist = getAudioPlaylist(streamId);
257
+ }
258
+ if (!playlist) {
259
+ res.status(404).send("Playlist not found.");
260
+ return;
261
+ }
262
+ updateLastAccess(streamId);
263
+ sendPlaylist(playlist, res);
174
264
  }
175
265
  // Ad-Hoc Streaming.
176
266
  /**
@@ -198,20 +288,9 @@ export async function handlePlayStream(req, res) {
198
288
  // Generate a deterministic synthetic key from the trimmed URL, profile, selector, clickToPlay, and clickSelector. Including these ensures that the same URL with
199
289
  // different options produces separate streams. The newline delimiter is safe since URLs cannot contain literal newlines.
200
290
  const channelName = "play-" + createHash("sha256").update(url + "\n" + (profileOverride ?? "") + "\n" + (selector ?? "") + "\n" + (clickToPlay ? "1" : "") + "\n" + (clickSelector ?? "")).digest("hex").slice(0, 8);
201
- // Check for an existing stream.
291
+ // Check for an existing stream (either fully set up or pending). With pending stream registration, the stream ID is always a real ID.
202
292
  const streamId = getChannelStreamId(channelName);
203
- // If a stream is already running, redirect immediately.
204
- if ((streamId !== undefined) && (streamId !== -1)) {
205
- res.redirect(302, "/hls/" + channelName + "/stream.m3u8");
206
- return;
207
- }
208
- // 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.
209
- if (streamId === -1) {
210
- const resolvedId = await awaitStreamReady(channelName, res);
211
- if (resolvedId === null) {
212
- // Error response already sent by awaitStreamReady.
213
- return;
214
- }
293
+ if (streamId !== undefined) {
215
294
  res.redirect(302, "/hls/" + channelName + "/stream.m3u8");
216
295
  return;
217
296
  }
@@ -222,7 +301,7 @@ export async function handlePlayStream(req, res) {
222
301
  }
223
302
  // Capture client IP for Channels DVR API integration.
224
303
  const clientAddress = req.ip ?? req.socket.remoteAddress ?? null;
225
- // Start a new ad-hoc stream. initializeStream handles capture setup, segmenter creation, and event emission.
304
+ // Start a new ad-hoc stream. initializeStream handles pending registration, capture setup, segmenter creation, and event emission.
226
305
  try {
227
306
  const newStreamId = await initializeStream({ channelName, channelSelector: selector, clickSelector, clickToPlay, clientAddress, profileOverride, url });
228
307
  if (newStreamId === null) {
@@ -244,109 +323,46 @@ export async function handlePlayStream(req, res) {
244
323
  }
245
324
  res.redirect(302, "/hls/" + channelName + "/stream.m3u8");
246
325
  }
247
- // Startup Polling.
248
- /**
249
- * 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
250
- * if startup failed (sentinel removed), or undefined if the timeout expired while startup is still active.
251
- *
252
- * This is the shared inner polling loop used by both awaitStreamReady() (which sends error responses) and awaitStreamReadySilent() (which does not).
253
- *
254
- * @param channelName - The channel name (or synthetic ad-hoc key) to poll.
255
- * @returns The resolved stream ID, null if startup failed, or undefined if timed out.
256
- */
257
- async function pollStreamReady(channelName) {
258
- const pollInterval = 200;
259
- const deadline = Date.now() + CONFIG.streaming.navigationTimeout;
260
- while (Date.now() < deadline) {
261
- // eslint-disable-next-line no-await-in-loop
262
- await delay(pollInterval);
263
- const streamId = getChannelStreamId(channelName);
264
- // The startup failed and the sentinel was removed.
265
- if (streamId === undefined) {
266
- return null;
267
- }
268
- // Real stream ID is now available.
269
- if (streamId !== -1) {
270
- return streamId;
271
- }
272
- }
273
- // Timed out waiting for the startup to complete.
274
- return undefined;
275
- }
276
- /**
277
- * 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
278
- * channelToStreamId signals that startup is in progress. We poll until the sentinel is replaced with a real stream ID, removed (startup failed), or the
279
- * timeout expires.
280
- *
281
- * On failure, the appropriate error response is sent to the client and null is returned.
282
- *
283
- * @param channelName - The channel name (or synthetic ad-hoc key) to poll.
284
- * @param res - Express response object for sending error responses on failure.
285
- * @returns The resolved stream ID on success, or null if startup failed or timed out (error response already sent).
286
- */
287
- async function awaitStreamReady(channelName, res) {
288
- const result = await pollStreamReady(channelName);
289
- // Startup failed (sentinel removed).
290
- if (result === null) {
291
- res.status(500).send("Stream startup failed.");
292
- return null;
293
- }
294
- // Timed out.
295
- if (result === undefined) {
296
- res.setHeader("Retry-After", "5");
297
- res.status(503).send("Stream is starting. Please retry.");
298
- return null;
299
- }
300
- return result;
301
- }
302
- /**
303
- * 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
304
- * sent.
305
- *
306
- * @param channelName - The channel name (or synthetic ad-hoc key) to poll.
307
- * @returns The resolved stream ID on success, or null if startup failed or timed out.
308
- */
309
- export async function awaitStreamReadySilent(channelName) {
310
- const result = await pollStreamReady(channelName);
311
- // Both null (failed) and undefined (timed out) map to null for the silent variant.
312
- if ((result === null) || (result === undefined)) {
313
- return null;
314
- }
315
- return result;
316
- }
317
326
  // Response Helpers.
318
327
  /**
319
- * 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
320
- * multiple code paths in handleHLSPlaylist() to avoid duplicating the get-wait-check-register-send sequence.
328
+ * Sends the playlist for a stream. With the deferred preroll timer, the playlist may not be available immediately after stream registration it arrives when either
329
+ * the timer fires (seeding preroll) or the segmenter produces real content, whichever comes first. This function awaits the playlistReady promise to handle that
330
+ * window. For the blocking fallback path (no FFmpeg / no preroll), the playlist is guaranteed to exist because initializeStream blocks until the segmenter produces
331
+ * it. Returns 404 only if the stream was terminated or the playlist wait timed out.
321
332
  * @param streamId - The numeric stream ID.
322
333
  * @param clientAddress - Client address for tracking.
323
334
  * @param res - Express response object.
324
335
  */
325
336
  async function sendPlaylistResponse(streamId, clientAddress, res) {
326
- // Try to get an existing playlist first.
327
337
  let playlist = getPlaylist(streamId);
328
- // If no playlist yet, wait for the first one.
338
+ // The playlist may not be populated yet if the deferred preroll timer hasn't fired and the segmenter hasn't produced content. Wait for the playlistReady promise
339
+ // which resolves when either source provides a playlist. The 30-second timeout covers pathological cases like setup hanging.
329
340
  if (!playlist) {
330
- const playlistReady = await waitForPlaylist(streamId, CONFIG.streaming.navigationTimeout);
331
- if (!playlistReady) {
332
- res.setHeader("Retry-After", "5");
333
- res.status(503).send("Stream is starting. Please retry.");
334
- return;
335
- }
336
- playlist = getPlaylist(streamId);
337
- if (!playlist) {
338
- res.status(500).send("Playlist not available.");
339
- return;
340
- }
341
- // Log the time from stream start to first playlist delivery. This only fires for the initial playlist wait, not for subsequent playlist polls.
342
- const stream = getStream(streamId);
343
- if (stream) {
344
- const elapsed = ((Date.now() - stream.startTime.getTime()) / 1000).toFixed(3);
345
- LOG.debug("timing:hls", "Playlist delivered to client in %ss.", elapsed);
341
+ const ready = await waitForPlaylist(streamId, 30_000);
342
+ if (ready) {
343
+ playlist = getPlaylist(streamId);
346
344
  }
347
345
  }
346
+ if (!playlist) {
347
+ res.status(404).send("Stream not found.");
348
+ return;
349
+ }
348
350
  updateLastAccess(streamId);
349
351
  registerClient(streamId, clientAddress, "hls");
352
+ // When still serving preroll, regenerate the progressive playlist on each poll so the sliding window advances based on elapsed wall-clock time. This simulates a
353
+ // live stream — the client sees new segments appear on each poll and keeps playing without stalling. Once real content arrives, hasRealPlaylist becomes true and
354
+ // the segmenter's playlist takes over.
355
+ const stream = getStream(streamId);
356
+ if (stream && !stream.hls.hasRealPlaylist) {
357
+ if (stream.hls.prerollBaseUrl && stream.hls.prerollCodec && stream.hls.prerollStartTime) {
358
+ playlist = generatePrerollPlaylist(stream.hls.prerollBaseUrl, stream.hls.prerollCodec, stream.hls.resumeSegmentIndex, stream.hls.prerollStartTime);
359
+ }
360
+ LOG.debug("streaming:preroll", "Serving preroll playlist for stream %d.", streamId);
361
+ }
362
+ if (stream?.preTuned) {
363
+ stream.preTuned = false;
364
+ LOG.debug("streaming:pretune", "Cleared pretune flag for stream %d on first client connection from %s.", streamId, clientAddress);
365
+ }
350
366
  sendPlaylist(playlist, res);
351
367
  }
352
368
  /**
@@ -360,13 +376,15 @@ function sendPlaylist(playlist, res) {
360
376
  res.send(playlist);
361
377
  }
362
378
  /**
363
- * Sends a segment buffer as a video/mp4 response with appropriate headers.
379
+ * Sends a segment buffer with appropriate Content-Type headers. MPEG-TS segments (.ts) get video/MP2T, fMP4 segments (.m4s and init.mp4) get video/mp4.
364
380
  * @param data - The segment data.
381
+ * @param segmentName - The segment filename (used to determine Content-Type).
365
382
  * @param res - Express response object.
366
383
  */
367
- function sendSegment(data, res) {
384
+ function sendSegment(data, segmentName, res) {
385
+ const contentType = segmentName.endsWith(".ts") ? "video/MP2T" : "video/mp4";
368
386
  res.setHeader("Cache-Control", "no-cache");
369
- res.setHeader("Content-Type", "video/mp4");
387
+ res.setHeader("Content-Type", contentType);
370
388
  res.send(data);
371
389
  }
372
390
  // Stream Lifecycle.
@@ -382,6 +400,34 @@ function cleanupOrphanedSetup(segmenter) {
382
400
  LOG.debug("streaming:setup", "Stream was terminated during setup. Stopping orphaned segmenter.");
383
401
  segmenter.stop();
384
402
  }
403
+ // Maps vertical resolution heights to standard display labels. Used by formatNativeQuality() to convert "1920x1080" → "1080p".
404
+ const RESOLUTION_LABELS = { "1080": "1080p", "2160": "4K", "360": "360p", "480": "480p", "720": "720p" };
405
+ /**
406
+ * Formats the native HLS quality string for the "Streaming..." log line. Combines codec, bandwidth (as Mbps), and resolution (as standard label like "1080p")
407
+ * 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.
408
+ * @param bandwidth - Declared bandwidth in bits per second from the manifest. Zero when absent.
409
+ * @param codec - Video codec label (e.g., "H264", "HEVC"), or null when absent.
410
+ * @param resolution - Resolution string from the manifest (e.g., "1920x1080"), or null when absent.
411
+ * @returns Formatted quality string for log output.
412
+ */
413
+ function formatNativeQuality(bandwidth, codec, resolution) {
414
+ const parts = [];
415
+ if (codec) {
416
+ parts.push(codec);
417
+ }
418
+ if (bandwidth > 0) {
419
+ parts.push((bandwidth / 1000000).toFixed(1) + "Mbps");
420
+ }
421
+ if (resolution) {
422
+ // Map the vertical resolution to a standard label. Parse the height from "WIDTHxHEIGHT" format.
423
+ const height = resolution.split("x")[1];
424
+ parts.push(RESOLUTION_LABELS[height] ?? resolution);
425
+ }
426
+ if (parts.length === 0) {
427
+ return "";
428
+ }
429
+ return ", " + parts.join(" ");
430
+ }
385
431
  /**
386
432
  * Creates a tab replacement handler for recovery from unresponsive browser tabs. When the monitor detects 3+ consecutive evaluate timeouts, it calls this handler to:
387
433
  * 1. Stop the current segmenter and FFmpeg process
@@ -393,6 +439,9 @@ function cleanupOrphanedSetup(segmenter) {
393
439
  *
394
440
  * The handler preserves existing HLS segments and marks a discontinuity so clients know the stream parameters may have changed.
395
441
  *
442
+ * Note: This handler is only invoked for capture-mode streams. Native-mode streams bypass video element monitoring entirely (the monitor early-returns for native
443
+ * streams), so evaluate timeouts that trigger tab replacement cannot occur.
444
+ *
396
445
  * @param numericStreamId - The stream's numeric ID for registry lookups.
397
446
  * @param streamId - The stream's string ID for logging.
398
447
  * @param channelName - The channel name (or synthetic ad-hoc key like "play-a1b2c3d4") used as the store key for error callbacks and termination.
@@ -435,14 +484,16 @@ function createTabReplacementHandler(numericStreamId, streamId, channelName, url
435
484
  LOG.debug("recovery:tab", "Stopping FFmpeg process for tab replacement.");
436
485
  stream.ffmpegProcess.kill();
437
486
  }
438
- // Close the current page.
487
+ // Close the current page. The page may be null for pending entries whose async setup has not yet completed.
439
488
  const oldPage = stream.page;
440
- unregisterManagedPage(oldPage);
441
- if (!oldPage.isClosed()) {
442
- LOG.debug("recovery:tab", "Closing unresponsive page for tab replacement.");
443
- oldPage.close().catch((error) => {
444
- LOG.debug("recovery:tab", "Page close error during tab replacement: %s.", formatError(error));
445
- });
489
+ if (oldPage) {
490
+ unregisterManagedPage(oldPage);
491
+ if (!oldPage.isClosed()) {
492
+ LOG.debug("recovery:tab", "Closing unresponsive page for tab replacement.");
493
+ oldPage.close().catch((error) => {
494
+ LOG.debug("recovery:tab", "Page close error during tab replacement: %s.", formatError(error));
495
+ });
496
+ }
446
497
  }
447
498
  LOG.debug("timing:tab", "Old tab cleanup complete. (+%sms)", tabElapsed());
448
499
  // Create a new page with capture.
@@ -456,6 +507,7 @@ function createTabReplacementHandler(numericStreamId, streamId, channelName, url
456
507
  },
457
508
  profile,
458
509
  streamId,
510
+ tabReplacement: true,
459
511
  url
460
512
  });
461
513
  }
@@ -507,167 +559,432 @@ function createTabReplacementHandler(numericStreamId, streamId, channelName, url
507
559
  };
508
560
  }
509
561
  /**
510
- * 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
511
- * creation, stream registration, and event emission.
562
+ * Initializes a new HLS stream. This is the blocking wrapper used by callers that need to wait for the full setup to complete (pretune, MPEG-TS, ad-hoc play).
563
+ * Registers a pending stream entry with the channel-to-stream mapping, then awaits the full async setup.
512
564
  *
513
- * 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
514
- * 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
515
- * headers, ad-hoc streams do not).
565
+ * For the non-blocking path used by HLS playlist requests, see registerPendingStream() + completeStreamSetup() called from ensureChannelStream().
516
566
  *
517
567
  * @param options - Stream initialization options.
518
- * @returns The stream ID on success, or null if the stream was terminated during the narrow setup window (orphaned setup race condition).
568
+ * @returns The stream ID on success, or null if the stream was terminated during setup.
519
569
  * @throws StreamSetupError if setup fails, or Error for unexpected failures.
520
570
  */
521
571
  export async function initializeStream(options) {
522
- const { channel, channelName, channelSelector, clickSelector, clickToPlay, clientAddress, profileOverride, url } = options;
523
- // Set a -1 sentinel to prevent duplicate stream starts while we're setting up.
524
- const startupSentinel = -1;
525
- setChannelStreamId(channelName, startupSentinel);
526
- let setup;
572
+ const { channel, channelName, url } = options;
573
+ // Allocate stream IDs. For predefined channels, use the channel name for the stream ID prefix. For ad-hoc streams, omit it so generateStreamId derives a prefix
574
+ // from the URL (e.g., "foxsports-abc123"), which is more informative in logs.
575
+ const numericStreamId = getNextStreamId();
576
+ const streamIdStr = generateStreamId(channel ? channelName : undefined, url);
577
+ // Register a pending entry in the stream registry. This allows concurrent requests for the same channel to find the stream immediately.
578
+ createPendingEntry({ ...options, hls: createHLSState(), numericStreamId, streamIdStr });
579
+ try {
580
+ return await completeStreamSetup({ ...options, numericStreamId, streamIdStr });
581
+ }
582
+ catch (error) {
583
+ // Skip logging — callers (startHLSStream, handlePlayStream, handleMpegTsStream) handle the re-thrown error with their own error responses and logging.
584
+ handleSetupFailure(numericStreamId, channelName, channel, error, false);
585
+ throw error;
586
+ }
587
+ }
588
+ /**
589
+ * 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
590
+ * 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)
591
+ * or real content arrives from the segmenter/native proxy. This ensures that fast-tuning streams (native, most capture providers) skip preroll entirely, while slow
592
+ * streams (Xfinity/Cox at 13-15s) get preroll content after the delay.
593
+ * @param channelName - The channel key for registration and deduplication.
594
+ * @param channel - The resolved channel definition.
595
+ * @param clientAddress - Client IP address for Channels DVR API integration.
596
+ * @param req - Express request object for deriving the base URL.
597
+ * @param codec - The preroll codec variant to use for this stream.
598
+ * @returns The allocated stream IDs.
599
+ */
600
+ function registerPendingStream(channelName, channel, clientAddress, req, codec) {
601
+ const numericStreamId = getNextStreamId();
602
+ const streamIdStr = generateStreamId(channelName, channel.url);
603
+ // Derive the base URL from the request for absolute preroll segment URLs.
604
+ const protocol = req.protocol;
605
+ const forwardedHost = req.get("x-forwarded-host");
606
+ const host = forwardedHost ? forwardedHost.split(",")[0].trim() : req.get("host");
607
+ const fallbackHost = CONFIG.server.host + ":" + String(CONFIG.server.port);
608
+ const baseUrl = protocol + "://" + (host ?? fallbackHost);
609
+ // Create HLS state with a deferred preroll timer. The timer fires after PREROLL_DELAY_MS — if stream setup hasn't completed by then, preroll is seeded and the
610
+ // playlist response is unblocked. For resume streams, the preroll's MEDIA-SEQUENCE is offset by the saved segment index so it continues from the prior session's
611
+ // sequence range. The timer is cancelled by completeStreamSetup() when the segmenter or native proxy is created, preventing races.
612
+ const hls = createHLSState();
613
+ // Snapshot the resume segment index once at registration. Both the preroll timer callback and completeStreamSetup() use this single snapshot, eliminating the TTL
614
+ // race that would occur if each read the resume map independently at different times.
615
+ hls.resumeSegmentIndex = getResumeSegmentIndex(channelName) ?? 0;
616
+ // Capture the stream start time at registration. This timestamp is used for the registry's startTime field (stream age display, etc.).
617
+ const streamStartTime = new Date();
618
+ if (isPrerollReady(codec)) {
619
+ // 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
620
+ // count, codec, and base URL are structural properties of the preroll system — they determine segment index offsets, codec-aware URL paths, and composite playlist
621
+ // behavior. The timer controls only the client-facing timing (when the standalone preroll playlist begins serving). Decoupling these ensures correct behavior for
622
+ // fast-tune/slow-proxy scenarios (e.g., Fox native at 5s tune but 15s proxy first poll) where setup completes before the timer fires.
623
+ hls.prerollBaseUrl = baseUrl;
624
+ hls.prerollCodec = codec;
625
+ hls.prerollSegmentCount = getPrerollSegmentCount(codec);
626
+ hls.prerollTimer = setTimeout(() => {
627
+ // Record the preroll start time and seed the initial progressive playlist. On subsequent polls, sendPlaylistResponse() regenerates the playlist with an
628
+ // advancing window based on elapsed time from this start time, simulating a live stream.
629
+ hls.prerollStartTime = new Date();
630
+ hls.playlist = generatePrerollPlaylist(baseUrl, codec, hls.resumeSegmentIndex, hls.prerollStartTime);
631
+ hls.signalPlaylistReady();
632
+ }, PREROLL_DELAY_MS);
633
+ }
634
+ // Register the pending entry.
635
+ createPendingEntry({ channel, channelName, clientAddress, hls, numericStreamId, preTuned: false, streamIdStr, streamStartTime, url: channel.url });
636
+ return { numericStreamId, streamIdStr };
637
+ }
638
+ /**
639
+ * Creates a pending stream entry in the registry and sets the channel-to-stream mapping. The entry has null page, profile, segmenter, and other browser-related
640
+ * fields that are filled in asynchronously by completeStreamSetup(). This is the shared core for both the non-blocking preroll path (registerPendingStream) and the
641
+ * blocking path (initializeStream).
642
+ * @param options - Pending entry options.
643
+ */
644
+ function createPendingEntry(options) {
645
+ const { channel, channelName, hls, numericStreamId, streamIdStr, url } = options;
646
+ registerStream({
647
+ captureCodec: null,
648
+ channelName: channel?.name ?? null,
649
+ clientAddress: options.clientAddress ?? null,
650
+ ffmpegProcess: null,
651
+ hardwareAccelerated: false,
652
+ hls,
653
+ id: numericStreamId,
654
+ info: {
655
+ lastPlaylistRequest: Date.now(),
656
+ storeKey: channelName
657
+ },
658
+ mpegTsClientCount: 0,
659
+ nativeBandwidth: 0,
660
+ nativeProxy: null,
661
+ nativeResolution: null,
662
+ page: null,
663
+ preTuned: options.preTuned ?? false,
664
+ profile: null,
665
+ rawCaptureStream: null,
666
+ segmenter: null,
667
+ startTime: options.streamStartTime ?? new Date(),
668
+ stopMonitor: null,
669
+ streamIdStr,
670
+ streamingMode: "capture",
671
+ url
672
+ });
673
+ setChannelStreamId(channelName, numericStreamId);
674
+ }
675
+ /**
676
+ * Handles stream setup failure by marking channel health, terminating the pending entry, and optionally logging the error. The blocking path (initializeStream) skips
677
+ * logging because it re-throws for callers to handle. The non-blocking path (ensureChannelStream .catch) logs because there is no outer handler.
678
+ * @param numericStreamId - The pending entry's numeric stream ID.
679
+ * @param channelName - The channel key for health tracking and termination.
680
+ * @param channel - The channel definition, or undefined for ad-hoc streams (no health tracking).
681
+ * @param error - The error that caused the failure.
682
+ * @param logError - Whether to log the error. False when the caller will re-throw (blocking path), true when fire-and-forget (non-blocking path).
683
+ */
684
+ function handleSetupFailure(numericStreamId, channelName, channel, error, logError = true) {
685
+ // Mark channel health as failed. Only for predefined channels (channel is defined). Ad-hoc URL streams have no persistent channel identity.
686
+ if (channel) {
687
+ const failVariantKey = resolveProviderKey(channelName);
688
+ const failAuthDomain = getAuthDomainForChannel(failVariantKey);
689
+ markChannelFailure(channelName, failAuthDomain);
690
+ }
691
+ // Clean up the pending entry.
692
+ terminateStream(numericStreamId, channelName, "setup failed");
693
+ void emitCurrentSystemStatus();
694
+ // Only log when the error won't be caught and logged by an outer handler (non-blocking fire-and-forget path).
695
+ if (logError) {
696
+ if (error instanceof StreamSetupError) {
697
+ LOG.warn("Stream setup failed for %s: %s", channelName, error.userMessage);
698
+ }
699
+ else {
700
+ LOG.error("Unexpected error during stream setup for %s: %s.", channelName, formatError(error));
701
+ }
702
+ }
703
+ }
704
+ /**
705
+ * Attempts to upgrade the stream from capture to native HLS. Finalizes the manifest interception, probes the manifest, creates a native proxy, stops the capture
706
+ * 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.
707
+ * @param setup - The stream setup result from setupStream().
708
+ * @param numericStreamId - The stream's numeric ID.
709
+ * @param channelName - The channel key for logging and cache operations.
710
+ * @param url - The stream URL for the native proxy.
711
+ * @param mpegTsClient - Whether the client is an MPEG-TS consumer.
712
+ * @returns The native codec and quality info, or null if native streaming was not viable.
713
+ */
714
+ async function startNativeProxy(setup, numericStreamId, channelName, url, mpegTsClient) {
715
+ if (!setup.manifestInterception) {
716
+ return null;
717
+ }
718
+ setup.manifestInterception.finalize(setup.directTune);
719
+ // 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),
720
+ // 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
721
+ // is read dynamically from the stream's HLS state at playlist generation time.
722
+ const pendingForNative = getStream(numericStreamId);
723
+ const nativePrerollSegmentCount = pendingForNative?.hls.prerollSegmentCount ?? 0;
724
+ const nativeResult = await attemptNativeStreaming({
725
+ channelName,
726
+ interceptionPromise: setup.manifestInterception.promise,
727
+ mpegTsClient,
728
+ onError: (error) => {
729
+ if (isTerminationInitiated(numericStreamId)) {
730
+ return;
731
+ }
732
+ // Log the error and clear the probe cache. The proxy has already stopped itself (set errorThresholdReached + stopped). The monitor detects
733
+ // hasErrored() on the next 2-second tick and triggers L3 fallback to capture mode, preserving the stream for the DVR client.
734
+ LOG.warn("Native proxy error for %s: %s. Falling back to capture.", channelName, error);
735
+ clearProbeCache(channelName);
736
+ },
737
+ page: setup.page,
738
+ prerollCodec: pendingForNative?.hls.prerollCodec ?? "h264",
739
+ prerollSegmentCount: nativePrerollSegmentCount,
740
+ streamId: numericStreamId,
741
+ streamIdStr: setup.streamId,
742
+ url
743
+ });
744
+ if (!nativeResult) {
745
+ return null;
746
+ }
747
+ const currentStream = getStream(numericStreamId);
748
+ if (!currentStream) {
749
+ nativeResult.proxy.stop();
750
+ return null;
751
+ }
752
+ // Stop the capture pipeline — native streaming replaces it entirely.
753
+ if (!setup.rawCaptureStream.destroyed) {
754
+ setup.rawCaptureStream.destroy();
755
+ }
756
+ if (setup.ffmpegProcess) {
757
+ setup.ffmpegProcess.kill();
758
+ }
759
+ // 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
760
+ // split into separate renditions. For muxed-audio streams, preserve preroll state so the proxy can build composite playlists with preroll entries.
761
+ currentStream.ffmpegProcess = null;
762
+ currentStream.hls.hasAudio = nativeResult.hasAudio;
763
+ if (nativeResult.hasAudio) {
764
+ currentStream.hls.prerollBaseUrl = null;
765
+ currentStream.hls.prerollCodec = null;
766
+ currentStream.hls.prerollSegmentCount = 0;
767
+ }
768
+ currentStream.captureCodec = nativeResult.codec;
769
+ currentStream.hardwareAccelerated = false;
770
+ currentStream.nativeBandwidth = nativeResult.bandwidth;
771
+ currentStream.nativeProxy = nativeResult.proxy;
772
+ currentStream.nativeResolution = nativeResult.resolution;
773
+ currentStream.rawCaptureStream = null;
774
+ currentStream.streamingMode = "native";
775
+ // Start the native proxy. Signal init segment readiness immediately — native MPEG-TS segments carry their own PAT/PMT codec configuration in every
776
+ // 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
777
+ // first poll cycle completes.
778
+ nativeResult.proxy.start();
779
+ currentStream.hls.signalInitSegmentReady();
780
+ // 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
781
+ // suppression, it plays audibly on the local machine.
782
+ await suppressPageAudio(setup.page);
783
+ LOG.debug("native:coordinator", "Capture pipeline stopped for %s. Native proxy active.", channelName);
784
+ return { codec: nativeResult.codec, quality: formatNativeQuality(nativeResult.bandwidth, nativeResult.codec, nativeResult.resolution) };
785
+ }
786
+ /**
787
+ * Creates the fMP4 segmenter for capture mode streams. Reads resume data, creates the segmenter with preroll and resume configuration, pipes the capture stream,
788
+ * and stores the segmenter in the registry. Resume data is consumed only after the segmenter is successfully stored, ensuring it survives if creation fails.
789
+ * @param setup - The stream setup result from setupStream().
790
+ * @param numericStreamId - The stream's numeric ID.
791
+ * @param channelName - The channel key for resume data and logging.
792
+ * @returns True if the segmenter was created and stored, false if the stream was terminated during setup.
793
+ */
794
+ function createCaptureSegmenter(setup, numericStreamId, channelName) {
795
+ // Peek at resume data from a previous shutdown without consuming it. The data is consumed (deleted) only after the segmenter is successfully created and
796
+ // stored in the registry. This ensures resume data survives if segmenter creation fails — the next stream start retries with the same resume state instead
797
+ // of losing it and causing an HLS sequence reset.
798
+ const resumeData = peekResumeData(channelName);
799
+ const currentStream = getStream(numericStreamId);
800
+ const prerollSegmentCount = currentStream?.hls.prerollSegmentCount ?? 0;
801
+ // 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
802
+ // the preroll playlist's MEDIA-SEQUENCE offset. When preroll is inactive, use the resume data directly — no preroll playlist to be consistent with.
803
+ const baseSegmentIndex = (prerollSegmentCount > 0) ? (currentStream?.hls.resumeSegmentIndex ?? 0) : (resumeData?.segmentIndex ?? 0);
804
+ // Create the fMP4 segmenter. The starting segment index accounts for both the resume offset and the preroll segment range. When preroll is active, the
805
+ // segmenter includes preroll entries in its sliding window via the compositor. The pending discontinuity at the preroll-to-real boundary is always needed —
806
+ // previousInitSegment is only passed without preroll, because the preroll init segment differs from the real init and the discontinuity must not be
807
+ // suppressed by an init-match comparison against the prior session.
808
+ const segmenter = createFMP4Segmenter({
809
+ ...(resumeData ? {
810
+ initialTrackTimestamps: resumeData.trackTimestamps,
811
+ ...((prerollSegmentCount === 0) ? { previousInitSegment: resumeData.initSegment } : {}),
812
+ startingInitVersion: resumeData.initVersion
813
+ } : {}),
814
+ ...((prerollSegmentCount > 0) ? {
815
+ prerollBaseUrl: currentStream?.hls.prerollBaseUrl ?? null,
816
+ prerollCodec: currentStream?.hls.prerollCodec ?? "h264",
817
+ prerollSegmentCount
818
+ } : {}),
819
+ ...((resumeData || (prerollSegmentCount > 0)) ? {
820
+ pendingDiscontinuity: true,
821
+ startingSegmentIndex: baseSegmentIndex + prerollSegmentCount
822
+ } : {}),
823
+ onError: (error) => {
824
+ // Skip error handling if termination was already initiated.
825
+ if (isTerminationInitiated(numericStreamId)) {
826
+ return;
827
+ }
828
+ LOG.error("Segmenter error for %s: %s.", channelName, formatError(error));
829
+ terminateStream(numericStreamId, channelName, "stream processing error");
830
+ void emitCurrentSystemStatus();
831
+ },
832
+ onStop: () => {
833
+ // Skip handling if termination was already initiated.
834
+ if (isTerminationInitiated(numericStreamId)) {
835
+ return;
836
+ }
837
+ LOG.error("Segmenter stopped unexpectedly for %s.", channelName);
838
+ terminateStream(numericStreamId, channelName, "stream ended unexpectedly");
839
+ void emitCurrentSystemStatus();
840
+ },
841
+ streamId: numericStreamId
842
+ });
843
+ // Pipe the capture stream to the segmenter.
844
+ segmenter.pipe(setup.captureStream);
845
+ // Store the segmenter reference in the registry.
846
+ const captureStream = getStream(numericStreamId);
847
+ if (captureStream) {
848
+ captureStream.segmenter = segmenter;
849
+ // 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.
850
+ if (resumeData) {
851
+ deleteResumeData(channelName);
852
+ }
853
+ return true;
854
+ }
855
+ // Stream was terminated during setup (rare race condition). Clean up the orphaned segmenter.
856
+ cleanupOrphanedSetup(segmenter);
857
+ return false;
858
+ }
859
+ /**
860
+ * 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
861
+ * pending registry entry. On failure, cleans up the pending entry via terminateStream().
862
+ *
863
+ * This is the Phase 2 of the two-phase stream initialization. For the non-blocking HLS path, it runs as fire-and-forget via `void`. For the blocking path
864
+ * (initializeStream), it is awaited directly.
865
+ *
866
+ * @param options - Stream setup options including pre-allocated IDs.
867
+ * @returns The stream ID on success, or null if the stream was terminated during setup.
868
+ * @throws StreamSetupError if setup fails, or Error for unexpected failures.
869
+ */
870
+ async function completeStreamSetup(options) {
871
+ const { channel, channelName, channelSelector, clickSelector, clickToPlay, mpegTsClient, numericStreamId, profileOverride, streamIdStr, url } = options;
527
872
  // Circuit breaker callback — terminate the stream on unrecoverable errors.
528
873
  const onCircuitBreak = () => {
529
- const streamId = getChannelStreamId(channelName);
530
- if ((streamId !== undefined) && (streamId !== startupSentinel)) {
531
- terminateStream(streamId, channelName, "too many errors");
874
+ const currentStreamId = getChannelStreamId(channelName);
875
+ if (currentStreamId !== undefined) {
876
+ terminateStream(currentStreamId, channelName, "too many errors");
532
877
  void emitCurrentSystemStatus();
533
878
  }
534
879
  };
535
- // Factory to create the tab replacement handler. Called by setupStream after stream IDs are generated, allowing the handler to be created with access to those IDs.
536
- const tabReplacementFactory = (numericStreamId, streamId, profile, metadataComment) => {
880
+ // Factory to create the tab replacement handler. Called by setupStream after resolving the profile, allowing the handler to be created with access to all context.
881
+ const tabReplacementFactory = (_, streamId, profile, metadataComment) => {
537
882
  return createTabReplacementHandler(numericStreamId, streamId, channelName, url, profile, metadataComment, onCircuitBreak);
538
883
  };
539
884
  // If at capacity, try to reclaim an idle stream before starting setup. This avoids rejecting new requests when idle streams can be freed.
540
885
  if (getStreamCount() >= CONFIG.streaming.maxConcurrentStreams) {
541
886
  reclaimIdleStream();
542
887
  }
543
- try {
544
- // Pass channelName to setupStream only for predefined channels. For ad-hoc streams, omitting it causes generateStreamId to derive the stream ID string from the
545
- // URL (e.g., "foxsports-abc123"), which is more informative in logs than the synthetic hash key.
546
- setup = await setupStream({
547
- channel,
548
- channelName: channel ? channelName : undefined,
549
- channelSelector: channel ? undefined : channelSelector,
550
- clickSelector: channel ? undefined : clickSelector,
551
- clickToPlay: channel ? undefined : clickToPlay,
552
- onTabReplacementFactory: tabReplacementFactory,
553
- profileOverride,
554
- url
555
- }, onCircuitBreak);
556
- }
557
- catch (error) {
558
- // Mark channel health as failed. Only for predefined channels (channel is defined). Ad-hoc URL streams have no persistent channel identity.
559
- if (channel) {
560
- const failVariantKey = resolveProviderKey(channelName);
561
- const failProviderTag = getProviderTagForChannel(failVariantKey);
562
- markChannelFailure(channelName, failProviderTag);
563
- }
564
- // Remove startup sentinel on failure and re-throw for the caller to handle error responses.
565
- deleteChannelStreamId(channelName);
566
- throw error;
888
+ // Pass the pre-allocated IDs to setupStream so it uses them instead of generating new ones. This ensures the abort controller, health monitor, and tab replacement
889
+ // handler all reference the same stream identity as the pending registry entry. Pass channelName only for predefined channels for ad-hoc streams, omitting it
890
+ // causes generateStreamId to derive the stream ID string from the URL (e.g., "foxsports-abc123"), which is more informative in logs.
891
+ const setup = await setupStream({
892
+ channel,
893
+ channelName: channel ? channelName : undefined,
894
+ channelSelector: channel ? undefined : channelSelector,
895
+ clickSelector: channel ? undefined : clickSelector,
896
+ clickToPlay: channel ? undefined : clickToPlay,
897
+ numericStreamId,
898
+ onTabReplacementFactory: tabReplacementFactory,
899
+ profileOverride,
900
+ streamId: streamIdStr,
901
+ url
902
+ }, onCircuitBreak);
903
+ // Fill in the pending registry entry with the real browser state. The entry was registered in Phase 1 (registerPendingStream or initializeStream).
904
+ const stream = getStream(numericStreamId);
905
+ if (!stream) {
906
+ // Stream was terminated during setup (e.g., idle timeout on the pending entry). Clean up setupStream resources.
907
+ setup.cleanup().catch((error) => {
908
+ LOG.debug("streaming:setup", "Cleanup error for terminated pending stream: %s.", formatError(error));
909
+ });
910
+ return null;
567
911
  }
568
- // Update the channel mapping with the real stream ID.
569
- setChannelStreamId(channelName, setup.numericStreamId);
912
+ stream.ffmpegProcess = setup.ffmpegProcess;
913
+ stream.page = setup.page;
914
+ stream.profile = setup.profile;
915
+ stream.rawCaptureStream = setup.rawCaptureStream;
916
+ stream.startTime = setup.startTime;
917
+ stream.stopMonitor = setup.stopMonitor;
918
+ stream.url = setup.url;
570
919
  // Continue within stream context for consistent logging.
571
- return runWithStreamContext({ channelName: channel?.name, streamId: setup.streamId, url: setup.url },
572
- // eslint-disable-next-line @typescript-eslint/require-await
573
- async () => {
574
- // Register with null segmenter first because segmenter callbacks (onError, onStop) need the stream to exist in the registry for cleanup logic. The segmenter is
575
- // assigned immediately after creation below.
576
- registerStream({
577
- channelName: channel?.name ?? null,
578
- clientAddress,
579
- ffmpegProcess: setup.ffmpegProcess,
580
- hls: createHLSState(),
581
- id: setup.numericStreamId,
582
- info: {
583
- lastPlaylistRequest: Date.now(),
584
- storeKey: channelName
585
- },
586
- mpegTsClientCount: 0,
587
- page: setup.page,
588
- profile: setup.profile,
589
- rawCaptureStream: setup.rawCaptureStream,
590
- segmenter: null,
591
- startTime: setup.startTime,
592
- stopMonitor: setup.stopMonitor,
593
- streamIdStr: setup.streamId,
594
- url: setup.url
595
- });
596
- // Check for resume data from a previous shutdown. If available, the segmenter will continue from the saved sequence numbers instead of starting at 0, preventing
597
- // HLS sequence resets that cause Channels DVR to produce broken recording timelines.
598
- const resumeData = consumeResumeData(channelName);
599
- // Create the native fMP4 segmenter to parse the MP4/AAC stream into HLS segments.
600
- const segmenter = createFMP4Segmenter({
601
- ...(resumeData ? {
602
- initialTrackTimestamps: resumeData.trackTimestamps,
603
- pendingDiscontinuity: true,
604
- previousInitSegment: resumeData.initSegment,
605
- startingInitVersion: resumeData.initVersion,
606
- startingSegmentIndex: resumeData.segmentIndex
607
- } : {}),
608
- onError: (error) => {
609
- // Skip error handling if termination was already initiated.
610
- if (isTerminationInitiated(setup.numericStreamId)) {
611
- return;
612
- }
613
- LOG.error("Segmenter error for %s: %s.", channelName, formatError(error));
614
- terminateStream(setup.numericStreamId, channelName, "stream processing error");
615
- void emitCurrentSystemStatus();
616
- },
617
- onStop: () => {
618
- // Skip handling if termination was already initiated.
619
- if (isTerminationInitiated(setup.numericStreamId)) {
620
- return;
621
- }
622
- LOG.error("Segmenter stopped unexpectedly for %s.", channelName);
623
- terminateStream(setup.numericStreamId, channelName, "stream ended unexpectedly");
624
- void emitCurrentSystemStatus();
625
- },
626
- streamId: setup.numericStreamId
627
- });
628
- // Pipe the capture stream to the segmenter.
629
- segmenter.pipe(setup.captureStream);
630
- // Store the segmenter reference in the registry.
631
- const stream = getStream(setup.numericStreamId);
632
- if (stream) {
633
- stream.segmenter = segmenter;
920
+ return runWithStreamContext({ channelName: channel?.name, streamId: setup.streamId, url: setup.url }, async () => {
921
+ // The deferred preroll timer is NOT cancelled here. It continues running until the first real playlist arrives (cancelled in updatePlaylist() in
922
+ // hlsSegments.ts). This is critical for native streams where the browser setup completes quickly but the native proxy's first poll cycle can take 10-15+
923
+ // 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
924
+ // 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
925
+ // the timer is cancelled almost immediately after setup anyway.
926
+ // Attempt native streaming if a manifest interception handle is available. If native is viable, the capture pipeline is stopped and the proxy takes over.
927
+ let nativeCodec = null;
928
+ let nativeQuality = "";
929
+ let streamingMode = "capture";
930
+ const nativeStreamResult = await startNativeProxy(setup, numericStreamId, channelName, url, mpegTsClient);
931
+ if (nativeStreamResult) {
932
+ streamingMode = "native";
933
+ nativeCodec = nativeStreamResult.codec;
934
+ nativeQuality = nativeStreamResult.quality;
634
935
  }
635
- else {
636
- // Stream was terminated during setup (rare race condition). Clean up the orphaned segmenter.
637
- cleanupOrphanedSetup(segmenter);
638
- return null;
936
+ // If native streaming was not viable or not attempted, create the fMP4 segmenter for capture mode.
937
+ if (streamingMode === "capture") {
938
+ if (!createCaptureSegmenter(setup, numericStreamId, channelName)) {
939
+ return null;
940
+ }
639
941
  }
640
- const captureMode = CONFIG.streaming.captureMode === "ffmpeg" ? "FFmpeg" : "Native";
942
+ const gpu = getGpuCapabilities();
943
+ const ffmpegCodec = gpu?.hevcHardwareEncoding ? "\u26A1 HEVC" : (gpu?.h264HardwareEncoding ? "\u26A1 H264" : "H264");
944
+ const captureMode = (streamingMode === "native") ? ("native HLS" + nativeQuality) :
945
+ (CONFIG.streaming.captureMode === "ffmpeg" ? "FFmpeg [" + ffmpegCodec + "]" : "Native fMP4");
641
946
  const displayName = channel?.name ?? url;
642
947
  const tuneTime = ((Date.now() - setup.startTime.getTime()) / 1000).toFixed(1);
643
- LOG.info("Streaming %s (%s, %s). Tuned in %ss%s.", displayName, setup.profileName, captureMode, tuneTime, setup.directTune ? " (direct)" : "");
644
- // Mark channel health as successful. Only for predefined channels (channel is defined). Ad-hoc URL streams have no persistent channel identity. Provider
645
- // auth is conditionally skipped when the provider defines validateTune and the tuned channel does not prove paid access (e.g., Sling Freestream channels).
948
+ LOG.info("Streaming %s: %s, %s, %s. Tuned in %ss%s.", displayName, setup.providerName, setup.profileName, captureMode, tuneTime, setup.directTune ? " (direct)" : "");
949
+ // Mark channel health as successful. Only for predefined channels (channel is defined). Ad-hoc URL streams have no persistent channel identity. Domain auth
950
+ // is conditionally skipped when the provider defines validateTune and the tuned channel does not prove paid access (e.g., Sling Freestream channels).
646
951
  if (channel) {
647
952
  const successVariantKey = resolveProviderKey(channelName);
953
+ const successAuthDomain = getAuthDomainForChannel(successVariantKey);
648
954
  const successProviderTag = getProviderTagForChannel(successVariantKey);
649
955
  const provider = getProviderBySlug(successProviderTag);
650
956
  const markAuth = !provider?.validateTune || provider.validateTune(channel.channelSelector ?? channelName);
651
- markChannelSuccess(channelName, successProviderTag, markAuth);
957
+ markChannelSuccess(channelName, successAuthDomain, markAuth);
958
+ }
959
+ // Update the registry entry with codec and hardware acceleration state, then emit the stream added event for the dashboard. Native streams set their codec
960
+ // and quality fields earlier (when the native proxy is created), so only capture mode needs updating here.
961
+ const streamCodec = (streamingMode === "native") ? nativeCodec : (gpu?.hevcHardwareEncoding ? "HEVC" : "H264");
962
+ const hwAccelerated = (streamingMode !== "native") && (gpu?.h264HardwareEncoding === true);
963
+ const currentEntry = getStream(numericStreamId);
964
+ if (currentEntry) {
965
+ currentEntry.captureCodec = streamCodec;
966
+ currentEntry.hardwareAccelerated = hwAccelerated;
652
967
  }
653
- // Emit stream added event.
654
968
  emitStreamAdded(createInitialStreamStatus({
969
+ captureCodec: streamCodec,
655
970
  channelName: channel?.name ?? null,
656
- numericStreamId: setup.numericStreamId,
971
+ hardwareAccelerated: hwAccelerated,
972
+ numericStreamId,
657
973
  providerName: setup.providerName,
658
974
  startTime: setup.startTime,
975
+ streamingMode,
659
976
  url: setup.url
660
977
  }));
661
978
  void emitCurrentSystemStatus();
662
979
  // Trigger show name lookup for the new stream.
663
980
  triggerShowNameUpdate();
664
- return setup.numericStreamId;
981
+ return numericStreamId;
665
982
  });
666
983
  }
667
984
  // Channel Stream Startup.
668
985
  /**
669
- * Starts a new HLS stream for a predefined channel. Delegates to initializeStream() for the actual setup. Error responses are sent directly to the client, including
670
- * HDHomeRun-specific headers for capacity errors.
986
+ * Starts a new HLS stream for a predefined channel using the blocking path. Used as a fallback when the preroll is not available (no FFmpeg). Delegates to
987
+ * initializeStream() for the actual setup. Error responses are sent directly to the client, including HDHomeRun-specific headers for capacity errors.
671
988
  *
672
989
  * @param channelName - The channel key (canonical key for stream registration and deduplication).
673
990
  * @param url - The URL to stream (from the resolved provider).
@@ -697,53 +1014,42 @@ async function startHLSStream(channelName, url, req, res, channel) {
697
1014
  }
698
1015
  }
699
1016
  // Idle Detection.
1017
+ /**
1018
+ * 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
1019
+ * 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
1020
+ * longest-idle stream for reclamation.
1021
+ * @returns Idle streams sorted by last access time ascending (oldest first).
1022
+ */
1023
+ function getIdleStreams() {
1024
+ const now = Date.now();
1025
+ return getAllStreams()
1026
+ .filter((stream) => !stream.preTuned && (stream.mpegTsClientCount === 0) && ((now - stream.info.lastPlaylistRequest) >= CONFIG.hls.idleTimeout))
1027
+ .sort((a, b) => a.info.lastPlaylistRequest - b.info.lastPlaylistRequest);
1028
+ }
700
1029
  /**
701
1030
  * Checks for idle streams and terminates them. Called periodically by the idle detection interval.
702
1031
  */
703
1032
  export function cleanupIdleStreams() {
704
- const streams = getAllStreams();
705
- const now = Date.now();
706
- let terminatedCount = 0;
707
- for (const stream of streams) {
708
- // Skip streams with active MPEG-TS clients. These streams are still being consumed even if no HLS playlist requests have been made recently.
709
- if (stream.mpegTsClientCount > 0) {
710
- continue;
711
- }
712
- const idleTime = now - stream.info.lastPlaylistRequest;
713
- if (idleTime >= CONFIG.hls.idleTimeout) {
714
- terminateStream(stream.id, stream.info.storeKey, "no active clients");
715
- terminatedCount++;
716
- }
1033
+ const idle = getIdleStreams();
1034
+ for (const stream of idle) {
1035
+ terminateStream(stream.id, stream.info.storeKey, "no active clients");
717
1036
  }
718
1037
  // Emit system status once after all idle streams are terminated.
719
- if (terminatedCount > 0) {
1038
+ if (idle.length > 0) {
720
1039
  void emitCurrentSystemStatus();
721
1040
  }
722
1041
  }
723
1042
  /**
724
- * Attempts to reclaim a single idle stream to free capacity for a new request. Finds the stream that has been idle the longest and terminates it. A stream is
725
- * considered idle when it has no MPEG-TS clients and its last access exceeds the idle timeout. This is called when the concurrent stream limit is reached, allowing
726
- * channel-surfing users to get new streams without being rejected while abandoned streams linger.
1043
+ * 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
1044
+ * concurrent stream limit is reached, allowing channel-surfing users to get new streams without being rejected while abandoned streams linger.
727
1045
  * @returns True if a stream was reclaimed, false if no idle streams exist.
728
1046
  */
729
1047
  function reclaimIdleStream() {
730
- const streams = getAllStreams();
731
- const now = Date.now();
732
- let oldest = null;
733
- for (const stream of streams) {
734
- // Skip streams with active MPEG-TS clients.
735
- if (stream.mpegTsClientCount > 0) {
736
- continue;
737
- }
738
- const idleTime = now - stream.info.lastPlaylistRequest;
739
- // Only consider streams that have exceeded the idle timeout, and pick the one that has been idle the longest.
740
- if ((idleTime >= CONFIG.hls.idleTimeout) && (!oldest || (stream.info.lastPlaylistRequest < oldest.info.lastPlaylistRequest))) {
741
- oldest = stream;
742
- }
743
- }
744
- if (!oldest) {
1048
+ const idle = getIdleStreams();
1049
+ if (idle.length === 0) {
745
1050
  return false;
746
1051
  }
1052
+ const oldest = idle[0];
747
1053
  LOG.info("Reclaiming idle stream %s (%s) to free capacity.", oldest.id, oldest.info.storeKey);
748
1054
  terminateStream(oldest.id, oldest.info.storeKey, "reclaimed for new stream");
749
1055
  void emitCurrentSystemStatus();