@bnhf/prismcast 1.3.4-2026.2.19

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 (234) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +347 -0
  3. package/bin/prismcast +6 -0
  4. package/dist/app.d.ts +6 -0
  5. package/dist/app.js +315 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/browser/cdp.d.ts +38 -0
  8. package/dist/browser/cdp.js +155 -0
  9. package/dist/browser/cdp.js.map +1 -0
  10. package/dist/browser/channelSelection.d.ts +65 -0
  11. package/dist/browser/channelSelection.js +202 -0
  12. package/dist/browser/channelSelection.js.map +1 -0
  13. package/dist/browser/display.d.ts +34 -0
  14. package/dist/browser/display.js +54 -0
  15. package/dist/browser/display.js.map +1 -0
  16. package/dist/browser/index.d.ts +205 -0
  17. package/dist/browser/index.js +1205 -0
  18. package/dist/browser/index.js.map +1 -0
  19. package/dist/browser/tuning/fox.d.ts +2 -0
  20. package/dist/browser/tuning/fox.js +83 -0
  21. package/dist/browser/tuning/fox.js.map +1 -0
  22. package/dist/browser/tuning/hbo.d.ts +2 -0
  23. package/dist/browser/tuning/hbo.js +237 -0
  24. package/dist/browser/tuning/hbo.js.map +1 -0
  25. package/dist/browser/tuning/hulu.d.ts +2 -0
  26. package/dist/browser/tuning/hulu.js +550 -0
  27. package/dist/browser/tuning/hulu.js.map +1 -0
  28. package/dist/browser/tuning/sling.d.ts +2 -0
  29. package/dist/browser/tuning/sling.js +518 -0
  30. package/dist/browser/tuning/sling.js.map +1 -0
  31. package/dist/browser/tuning/thumbnailRow.d.ts +2 -0
  32. package/dist/browser/tuning/thumbnailRow.js +108 -0
  33. package/dist/browser/tuning/thumbnailRow.js.map +1 -0
  34. package/dist/browser/tuning/tileClick.d.ts +2 -0
  35. package/dist/browser/tuning/tileClick.js +103 -0
  36. package/dist/browser/tuning/tileClick.js.map +1 -0
  37. package/dist/browser/tuning/youtubeTv.d.ts +2 -0
  38. package/dist/browser/tuning/youtubeTv.js +182 -0
  39. package/dist/browser/tuning/youtubeTv.js.map +1 -0
  40. package/dist/browser/video.d.ts +289 -0
  41. package/dist/browser/video.js +996 -0
  42. package/dist/browser/video.js.map +1 -0
  43. package/dist/channels/index.d.ts +3 -0
  44. package/dist/channels/index.js +392 -0
  45. package/dist/channels/index.js.map +1 -0
  46. package/dist/config/index.d.ts +53 -0
  47. package/dist/config/index.js +233 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/config/presets.d.ts +98 -0
  50. package/dist/config/presets.js +241 -0
  51. package/dist/config/presets.js.map +1 -0
  52. package/dist/config/profiles.d.ts +79 -0
  53. package/dist/config/profiles.js +245 -0
  54. package/dist/config/profiles.js.map +1 -0
  55. package/dist/config/providers.d.ts +120 -0
  56. package/dist/config/providers.js +450 -0
  57. package/dist/config/providers.js.map +1 -0
  58. package/dist/config/sites.d.ts +22 -0
  59. package/dist/config/sites.js +377 -0
  60. package/dist/config/sites.js.map +1 -0
  61. package/dist/config/userChannels.d.ts +178 -0
  62. package/dist/config/userChannels.js +543 -0
  63. package/dist/config/userChannels.js.map +1 -0
  64. package/dist/config/userConfig.d.ts +235 -0
  65. package/dist/config/userConfig.js +913 -0
  66. package/dist/config/userConfig.js.map +1 -0
  67. package/dist/hdhr/channelMap.d.ts +21 -0
  68. package/dist/hdhr/channelMap.js +82 -0
  69. package/dist/hdhr/channelMap.js.map +1 -0
  70. package/dist/hdhr/deviceId.d.ts +11 -0
  71. package/dist/hdhr/deviceId.js +84 -0
  72. package/dist/hdhr/deviceId.js.map +1 -0
  73. package/dist/hdhr/discover.d.ts +6 -0
  74. package/dist/hdhr/discover.js +155 -0
  75. package/dist/hdhr/discover.js.map +1 -0
  76. package/dist/hdhr/index.d.ts +9 -0
  77. package/dist/hdhr/index.js +87 -0
  78. package/dist/hdhr/index.js.map +1 -0
  79. package/dist/index.d.ts +1 -0
  80. package/dist/index.js +144 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/routes/assets.d.ts +6 -0
  83. package/dist/routes/assets.js +79 -0
  84. package/dist/routes/assets.js.map +1 -0
  85. package/dist/routes/auth.d.ts +6 -0
  86. package/dist/routes/auth.js +77 -0
  87. package/dist/routes/auth.js.map +1 -0
  88. package/dist/routes/channels.d.ts +6 -0
  89. package/dist/routes/channels.js +40 -0
  90. package/dist/routes/channels.js.map +1 -0
  91. package/dist/routes/components.d.ts +138 -0
  92. package/dist/routes/components.js +210 -0
  93. package/dist/routes/components.js.map +1 -0
  94. package/dist/routes/config.d.ts +72 -0
  95. package/dist/routes/config.js +1977 -0
  96. package/dist/routes/config.js.map +1 -0
  97. package/dist/routes/debug.d.ts +6 -0
  98. package/dist/routes/debug.js +274 -0
  99. package/dist/routes/debug.js.map +1 -0
  100. package/dist/routes/health.d.ts +6 -0
  101. package/dist/routes/health.js +85 -0
  102. package/dist/routes/health.js.map +1 -0
  103. package/dist/routes/hls.d.ts +6 -0
  104. package/dist/routes/hls.js +25 -0
  105. package/dist/routes/hls.js.map +1 -0
  106. package/dist/routes/index.d.ts +19 -0
  107. package/dist/routes/index.js +49 -0
  108. package/dist/routes/index.js.map +1 -0
  109. package/dist/routes/logs.d.ts +6 -0
  110. package/dist/routes/logs.js +164 -0
  111. package/dist/routes/logs.js.map +1 -0
  112. package/dist/routes/mpegts.d.ts +6 -0
  113. package/dist/routes/mpegts.js +19 -0
  114. package/dist/routes/mpegts.js.map +1 -0
  115. package/dist/routes/play.d.ts +6 -0
  116. package/dist/routes/play.js +18 -0
  117. package/dist/routes/play.js.map +1 -0
  118. package/dist/routes/playlist.d.ts +36 -0
  119. package/dist/routes/playlist.js +134 -0
  120. package/dist/routes/playlist.js.map +1 -0
  121. package/dist/routes/root.d.ts +6 -0
  122. package/dist/routes/root.js +2920 -0
  123. package/dist/routes/root.js.map +1 -0
  124. package/dist/routes/streams.d.ts +6 -0
  125. package/dist/routes/streams.js +88 -0
  126. package/dist/routes/streams.js.map +1 -0
  127. package/dist/routes/theme.d.ts +15 -0
  128. package/dist/routes/theme.js +275 -0
  129. package/dist/routes/theme.js.map +1 -0
  130. package/dist/routes/ui.d.ts +56 -0
  131. package/dist/routes/ui.js +354 -0
  132. package/dist/routes/ui.js.map +1 -0
  133. package/dist/service/commands.d.ts +41 -0
  134. package/dist/service/commands.js +391 -0
  135. package/dist/service/commands.js.map +1 -0
  136. package/dist/service/generators.d.ts +33 -0
  137. package/dist/service/generators.js +432 -0
  138. package/dist/service/generators.js.map +1 -0
  139. package/dist/service/index.d.ts +2 -0
  140. package/dist/service/index.js +7 -0
  141. package/dist/service/index.js.map +1 -0
  142. package/dist/streaming/clients.d.ts +48 -0
  143. package/dist/streaming/clients.js +114 -0
  144. package/dist/streaming/clients.js.map +1 -0
  145. package/dist/streaming/fmp4Segmenter.d.ts +61 -0
  146. package/dist/streaming/fmp4Segmenter.js +461 -0
  147. package/dist/streaming/fmp4Segmenter.js.map +1 -0
  148. package/dist/streaming/hls.d.ts +120 -0
  149. package/dist/streaming/hls.js +722 -0
  150. package/dist/streaming/hls.js.map +1 -0
  151. package/dist/streaming/hlsSegments.d.ts +54 -0
  152. package/dist/streaming/hlsSegments.js +162 -0
  153. package/dist/streaming/hlsSegments.js.map +1 -0
  154. package/dist/streaming/lifecycle.d.ts +33 -0
  155. package/dist/streaming/lifecycle.js +185 -0
  156. package/dist/streaming/lifecycle.js.map +1 -0
  157. package/dist/streaming/monitor.d.ts +74 -0
  158. package/dist/streaming/monitor.js +1310 -0
  159. package/dist/streaming/monitor.js.map +1 -0
  160. package/dist/streaming/mp4Parser.d.ts +74 -0
  161. package/dist/streaming/mp4Parser.js +566 -0
  162. package/dist/streaming/mp4Parser.js.map +1 -0
  163. package/dist/streaming/mpegts.d.ts +14 -0
  164. package/dist/streaming/mpegts.js +248 -0
  165. package/dist/streaming/mpegts.js.map +1 -0
  166. package/dist/streaming/registry.d.ts +119 -0
  167. package/dist/streaming/registry.js +127 -0
  168. package/dist/streaming/registry.js.map +1 -0
  169. package/dist/streaming/setup.d.ts +135 -0
  170. package/dist/streaming/setup.js +670 -0
  171. package/dist/streaming/setup.js.map +1 -0
  172. package/dist/streaming/showInfo.d.ts +30 -0
  173. package/dist/streaming/showInfo.js +362 -0
  174. package/dist/streaming/showInfo.js.map +1 -0
  175. package/dist/streaming/statusEmitter.d.ts +125 -0
  176. package/dist/streaming/statusEmitter.js +139 -0
  177. package/dist/streaming/statusEmitter.js.map +1 -0
  178. package/dist/types/index.d.ts +403 -0
  179. package/dist/types/index.js +6 -0
  180. package/dist/types/index.js.map +1 -0
  181. package/dist/utils/debugFilter.d.ts +38 -0
  182. package/dist/utils/debugFilter.js +157 -0
  183. package/dist/utils/debugFilter.js.map +1 -0
  184. package/dist/utils/delay.d.ts +6 -0
  185. package/dist/utils/delay.js +15 -0
  186. package/dist/utils/delay.js.map +1 -0
  187. package/dist/utils/errors.d.ts +15 -0
  188. package/dist/utils/errors.js +40 -0
  189. package/dist/utils/errors.js.map +1 -0
  190. package/dist/utils/evaluate.d.ts +51 -0
  191. package/dist/utils/evaluate.js +124 -0
  192. package/dist/utils/evaluate.js.map +1 -0
  193. package/dist/utils/ffmpeg.d.ts +65 -0
  194. package/dist/utils/ffmpeg.js +317 -0
  195. package/dist/utils/ffmpeg.js.map +1 -0
  196. package/dist/utils/fileLogger.d.ts +25 -0
  197. package/dist/utils/fileLogger.js +248 -0
  198. package/dist/utils/fileLogger.js.map +1 -0
  199. package/dist/utils/format.d.ts +16 -0
  200. package/dist/utils/format.js +46 -0
  201. package/dist/utils/format.js.map +1 -0
  202. package/dist/utils/html.d.ts +6 -0
  203. package/dist/utils/html.js +24 -0
  204. package/dist/utils/html.js.map +1 -0
  205. package/dist/utils/index.d.ts +15 -0
  206. package/dist/utils/index.js +20 -0
  207. package/dist/utils/index.js.map +1 -0
  208. package/dist/utils/logEmitter.d.ts +17 -0
  209. package/dist/utils/logEmitter.js +30 -0
  210. package/dist/utils/logEmitter.js.map +1 -0
  211. package/dist/utils/logger.d.ts +82 -0
  212. package/dist/utils/logger.js +219 -0
  213. package/dist/utils/logger.js.map +1 -0
  214. package/dist/utils/m3u.d.ts +32 -0
  215. package/dist/utils/m3u.js +148 -0
  216. package/dist/utils/m3u.js.map +1 -0
  217. package/dist/utils/morganStream.d.ts +7 -0
  218. package/dist/utils/morganStream.js +33 -0
  219. package/dist/utils/morganStream.js.map +1 -0
  220. package/dist/utils/platform.d.ts +64 -0
  221. package/dist/utils/platform.js +157 -0
  222. package/dist/utils/platform.js.map +1 -0
  223. package/dist/utils/retry.d.ts +15 -0
  224. package/dist/utils/retry.js +82 -0
  225. package/dist/utils/retry.js.map +1 -0
  226. package/dist/utils/streamContext.d.ts +28 -0
  227. package/dist/utils/streamContext.js +33 -0
  228. package/dist/utils/streamContext.js.map +1 -0
  229. package/dist/utils/version.d.ts +37 -0
  230. package/dist/utils/version.js +228 -0
  231. package/dist/utils/version.js.map +1 -0
  232. package/package.json +92 -0
  233. package/prismcast.png +0 -0
  234. package/prismcast.svg +74 -0
@@ -0,0 +1,722 @@
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";
4
+ import { createInitialStreamStatus, emitStreamAdded } from "./statusEmitter.js";
5
+ import { deleteChannelStreamId, getChannelStreamId, isTerminationInitiated, setChannelStreamId, terminateStream } from "./lifecycle.js";
6
+ import { emitCurrentSystemStatus, isLoginModeActive, unregisterManagedPage } from "../browser/index.js";
7
+ import { getAllChannels, isPredefinedChannelDisabled } from "../config/userChannels.js";
8
+ import { getInitSegment, getPlaylist, getSegment, waitForPlaylist } from "./hlsSegments.js";
9
+ import { getResolvedChannel, resolveProviderKey } from "../config/providers.js";
10
+ import { CONFIG } from "../config/index.js";
11
+ import { createFMP4Segmenter } from "./fmp4Segmenter.js";
12
+ import { createHash } from "node:crypto";
13
+ import { registerClient } from "./clients.js";
14
+ import { triggerShowNameUpdate } from "./showInfo.js";
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.
20
+ *
21
+ * This is extracted from ensureChannelStream() so it can be called by both HLS and MPEG-TS code paths without duplicating the validation logic.
22
+ *
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.
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
+ }
66
+ // Public Endpoint Handlers.
67
+ /**
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).
70
+ *
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.
73
+ *
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.
75
+ * The stream is registered under the canonical key (channelName) for deduplication, but uses the resolved provider's URL and settings.
76
+ *
77
+ * @param channelName - The channel key (or synthetic ad-hoc key) to stream.
78
+ * @param req - Express request object (for profile override and client IP).
79
+ * @param res - Express response object (for error responses).
80
+ * @returns The stream ID if a stream is running, or null if an error occurred.
81
+ */
82
+ export async function ensureChannelStream(channelName, req, res) {
83
+ // 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
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
85
+ // started, so no re-validation is needed.
86
+ const streamId = getChannelStreamId(channelName);
87
+ // If a stream is already running (not a startup-in-progress sentinel), return it directly.
88
+ if ((streamId !== undefined) && (streamId !== -1)) {
89
+ return streamId;
90
+ }
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.
92
+ if (streamId === -1) {
93
+ return awaitStreamReady(channelName, res);
94
+ }
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
96
+ // at startup time.
97
+ const validation = validateChannel(channelName);
98
+ if (!validation.valid) {
99
+ sendValidationError(validation, res);
100
+ return null;
101
+ }
102
+ // Start the stream using the resolved channel's URL. The stream is registered under channelName (canonical key) for deduplication, but uses the resolved
103
+ // provider's definition.
104
+ const newStreamId = await startHLSStream(channelName, validation.channel.url, req, res, validation.channel);
105
+ if (newStreamId === null) {
106
+ // Error response already sent by startHLSStream.
107
+ return null;
108
+ }
109
+ return newStreamId;
110
+ }
111
+ /**
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.
113
+ *
114
+ * Route: GET /hls/:name/stream.m3u8
115
+ *
116
+ * @param req - Express request object.
117
+ * @param res - Express response object.
118
+ */
119
+ export async function handleHLSPlaylist(req, res) {
120
+ const channelName = req.params.name;
121
+ if (!channelName) {
122
+ res.status(400).send("Channel name is required.");
123
+ return;
124
+ }
125
+ const clientAddress = req.ip ?? req.socket.remoteAddress ?? "unknown";
126
+ const streamId = await ensureChannelStream(channelName, req, res);
127
+ if (streamId === null) {
128
+ return;
129
+ }
130
+ await sendPlaylistResponse(streamId, clientAddress, res);
131
+ }
132
+ /**
133
+ * Handles HLS segment requests. Returns the requested segment from memory. Supports both the fMP4 initialization segment (init.mp4) and media segments (.m4s).
134
+ *
135
+ * Route: GET /hls/:name/:segment
136
+ *
137
+ * @param req - Express request object.
138
+ * @param res - Express response object.
139
+ */
140
+ export function handleHLSSegment(req, res) {
141
+ const channelName = req.params.name;
142
+ const segmentName = req.params.segment;
143
+ if (!channelName || !segmentName) {
144
+ res.status(400).send("Channel name and segment name are required.");
145
+ return;
146
+ }
147
+ const streamId = getChannelStreamId(channelName);
148
+ if ((streamId === undefined) || (streamId === -1)) {
149
+ res.status(404).send("Stream not found.");
150
+ return;
151
+ }
152
+ // Handle init segment (init.mp4) separately from media segments (.m4s).
153
+ if (segmentName === "init.mp4") {
154
+ const initSegment = getInitSegment(streamId);
155
+ if (!initSegment) {
156
+ res.status(404).send("Init segment not found.");
157
+ return;
158
+ }
159
+ updateLastAccess(streamId);
160
+ sendSegment(initSegment, res);
161
+ return;
162
+ }
163
+ // Handle media segments (.m4s).
164
+ const segment = getSegment(streamId, segmentName);
165
+ if (!segment) {
166
+ res.status(404).send("Segment not found.");
167
+ return;
168
+ }
169
+ updateLastAccess(streamId);
170
+ sendSegment(segment, res);
171
+ }
172
+ // Ad-Hoc Streaming.
173
+ /**
174
+ * Handles ad-hoc stream requests for arbitrary URLs. Generates a deterministic synthetic key from the URL and profile, starts a stream if none exists, and redirects
175
+ * to the standard HLS playlist path. This enables streaming URLs that are not predefined as channels.
176
+ *
177
+ * The synthetic key includes the profile so that the same URL with different profiles produces separate streams. The "play-" prefix prevents collisions with
178
+ * predefined channel names.
179
+ *
180
+ * Route: GET /play?url=<url>&profile=<name>
181
+ *
182
+ * @param req - Express request object.
183
+ * @param res - Express response object.
184
+ */
185
+ export async function handlePlayStream(req, res) {
186
+ const url = req.query.url?.trim();
187
+ if (!url) {
188
+ res.status(400).send("The url query parameter is required.");
189
+ return;
190
+ }
191
+ const clickSelector = req.query.clickSelector;
192
+ const clickToPlay = req.query.clickToPlay === "true";
193
+ const profileOverride = req.query.profile;
194
+ const selector = req.query.selector;
195
+ // Generate a deterministic synthetic key from the trimmed URL, profile, selector, clickToPlay, and clickSelector. Including these ensures that the same URL with
196
+ // different options produces separate streams. The newline delimiter is safe since URLs cannot contain literal newlines.
197
+ const channelName = "play-" + createHash("sha256").update(url + "\n" + (profileOverride ?? "") + "\n" + (selector ?? "") + "\n" + (clickToPlay ? "1" : "") + "\n" + (clickSelector ?? "")).digest("hex").slice(0, 8);
198
+ // Check for an existing stream.
199
+ const streamId = getChannelStreamId(channelName);
200
+ // If a stream is already running, redirect immediately.
201
+ if ((streamId !== undefined) && (streamId !== -1)) {
202
+ res.redirect(302, "/hls/" + channelName + "/stream.m3u8");
203
+ return;
204
+ }
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.
206
+ if (streamId === -1) {
207
+ const resolvedId = await awaitStreamReady(channelName, res);
208
+ if (resolvedId === null) {
209
+ // Error response already sent by awaitStreamReady.
210
+ return;
211
+ }
212
+ res.redirect(302, "/hls/" + channelName + "/stream.m3u8");
213
+ return;
214
+ }
215
+ // Block new stream requests while login mode is active.
216
+ if (isLoginModeActive()) {
217
+ res.status(503).json(LOGIN_MODE_BODY);
218
+ return;
219
+ }
220
+ // Capture client IP for Channels DVR API integration.
221
+ const clientAddress = req.ip ?? req.socket.remoteAddress ?? null;
222
+ // Start a new ad-hoc stream. initializeStream handles capture setup, segmenter creation, and event emission.
223
+ try {
224
+ const newStreamId = await initializeStream({ channelName, channelSelector: selector, clickSelector, clickToPlay, clientAddress, profileOverride, url });
225
+ if (newStreamId === null) {
226
+ res.status(500).send("Stream terminated during startup.");
227
+ return;
228
+ }
229
+ }
230
+ catch (error) {
231
+ if (error instanceof StreamSetupError) {
232
+ if (error.statusCode === 503) {
233
+ res.setHeader("Retry-After", "10");
234
+ }
235
+ res.status(error.statusCode).send(error.userMessage);
236
+ return;
237
+ }
238
+ LOG.error("Unexpected error during ad-hoc stream setup: %s.", formatError(error));
239
+ res.status(500).send("Internal server error.");
240
+ return;
241
+ }
242
+ res.redirect(302, "/hls/" + channelName + "/stream.m3u8");
243
+ }
244
+ // Startup Polling.
245
+ /**
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.
248
+ *
249
+ * This is the shared inner polling loop used by both awaitStreamReady() (which sends error responses) and awaitStreamReadySilent() (which does not).
250
+ *
251
+ * @param channelName - The channel name (or synthetic ad-hoc key) to poll.
252
+ * @returns The resolved stream ID, null if startup failed, or undefined if timed out.
253
+ */
254
+ async function pollStreamReady(channelName) {
255
+ const pollInterval = 200;
256
+ const deadline = Date.now() + CONFIG.streaming.navigationTimeout;
257
+ while (Date.now() < deadline) {
258
+ // eslint-disable-next-line no-await-in-loop
259
+ await delay(pollInterval);
260
+ const streamId = getChannelStreamId(channelName);
261
+ // The startup failed and the sentinel was removed.
262
+ if (streamId === undefined) {
263
+ return null;
264
+ }
265
+ // Real stream ID is now available.
266
+ if (streamId !== -1) {
267
+ return streamId;
268
+ }
269
+ }
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);
368
+ }
369
+ // Stream Lifecycle.
370
+ /**
371
+ * Cleans up resources from a stream that was terminated during setup before the segmenter was stored in the registry. This handles the rare race condition where
372
+ * another code path (e.g., circuit breaker) calls terminateStream() between registerStream() and the segmenter assignment.
373
+ *
374
+ * terminateStream() already cleaned up the page, monitor, channel mapping, and registry entry. We only need to stop the segmenter, which was created after
375
+ * termination occurred and therefore wasn't cleaned up.
376
+ * @param segmenter - The orphaned fMP4 segmenter instance to stop.
377
+ */
378
+ function cleanupOrphanedSetup(segmenter) {
379
+ LOG.debug("streaming:setup", "Stream was terminated during setup. Stopping orphaned segmenter.");
380
+ segmenter.stop();
381
+ }
382
+ /**
383
+ * Creates a tab replacement handler for recovery from unresponsive browser tabs. When the monitor detects 3+ consecutive evaluate timeouts, it calls this handler to:
384
+ * 1. Stop the current segmenter and FFmpeg process
385
+ * 2. Close the unresponsive page
386
+ * 3. Create a fresh page with new capture
387
+ * 4. Create a new segmenter piped to the new capture
388
+ * 5. Update the registry with the new resources
389
+ * 6. Return the new page and context for the monitor to continue
390
+ *
391
+ * The handler preserves existing HLS segments and marks a discontinuity so clients know the stream parameters may have changed.
392
+ *
393
+ * @param numericStreamId - The stream's numeric ID for registry lookups.
394
+ * @param streamId - The stream's string ID for logging.
395
+ * @param channelName - The channel name (or synthetic ad-hoc key like "play-a1b2c3d4") used as the store key for error callbacks and termination.
396
+ * @param url - The URL to navigate to.
397
+ * @param profile - The site profile for video handling.
398
+ * @param metadataComment - Optional comment to embed in FFmpeg output metadata.
399
+ * @param onCircuitBreak - Callback for circuit breaker trips during replacement.
400
+ * @returns A handler function that performs tab replacement, or null if the stream no longer exists.
401
+ */
402
+ function createTabReplacementHandler(numericStreamId, streamId, channelName, url, profile, metadataComment, onCircuitBreak) {
403
+ return async () => {
404
+ const tabElapsed = startTimer();
405
+ // Get the current stream entry.
406
+ const stream = getStream(numericStreamId);
407
+ if (!stream) {
408
+ LOG.debug("recovery:tab", "Tab replacement requested but stream %s no longer exists.", streamId);
409
+ return null;
410
+ }
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;
416
+ const currentSegmentIndex = stream.segmenter?.getSegmentIndex() ?? 0;
417
+ const currentTrackTimestamps = stream.segmenter?.getTrackTimestamps();
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
419
+ // getStream() call would hang with "Cannot capture a tab with an active stream" error.
420
+ if (stream.rawCaptureStream && !stream.rawCaptureStream.destroyed) {
421
+ LOG.debug("recovery:tab", "Destroying old capture stream for tab replacement.");
422
+ stream.rawCaptureStream.destroy();
423
+ }
424
+ // Stop the current segmenter if it exists.
425
+ if (stream.segmenter) {
426
+ LOG.debug("recovery:tab", "Stopping current segmenter for tab replacement.");
427
+ stream.segmenter.stop();
428
+ }
429
+ // Stop the FFmpeg process if it exists.
430
+ if (stream.ffmpegProcess) {
431
+ LOG.debug("recovery:tab", "Stopping FFmpeg process for tab replacement.");
432
+ stream.ffmpegProcess.kill();
433
+ }
434
+ // Close the current page.
435
+ const oldPage = stream.page;
436
+ unregisterManagedPage(oldPage);
437
+ if (!oldPage.isClosed()) {
438
+ LOG.debug("recovery:tab", "Closing unresponsive page for tab replacement.");
439
+ oldPage.close().catch((error) => {
440
+ LOG.debug("recovery:tab", "Page close error during tab replacement: %s.", formatError(error));
441
+ });
442
+ }
443
+ LOG.debug("timing:tab", "Old tab cleanup complete. (+%sms)", tabElapsed());
444
+ // Create a new page with capture.
445
+ let captureResult;
446
+ try {
447
+ captureResult = await createPageWithCapture({
448
+ comment: metadataComment,
449
+ onFFmpegError: (error) => {
450
+ LOG.error("FFmpeg error during tab replacement recovery: %s.", formatError(error));
451
+ onCircuitBreak();
452
+ },
453
+ profile,
454
+ streamId,
455
+ url
456
+ });
457
+ }
458
+ catch (error) {
459
+ LOG.warn("Failed to create new page during tab replacement: %s.", formatError(error));
460
+ return null;
461
+ }
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.
465
+ const newSegmenter = createFMP4Segmenter({
466
+ initialTrackTimestamps: currentTrackTimestamps,
467
+ onError: (error) => {
468
+ if (isTerminationInitiated(numericStreamId)) {
469
+ return;
470
+ }
471
+ LOG.error("Segmenter error after tab replacement for %s: %s.", channelName, formatError(error));
472
+ terminateStream(numericStreamId, channelName, "stream processing error after recovery");
473
+ void emitCurrentSystemStatus();
474
+ },
475
+ onStop: () => {
476
+ if (isTerminationInitiated(numericStreamId)) {
477
+ return;
478
+ }
479
+ LOG.error("Segmenter stopped unexpectedly after tab replacement for %s.", channelName);
480
+ terminateStream(numericStreamId, channelName, "stream ended unexpectedly after recovery");
481
+ void emitCurrentSystemStatus();
482
+ },
483
+ pendingDiscontinuity: true,
484
+ previousInitSegment: currentInitSegment,
485
+ startingInitVersion: currentInitVersion,
486
+ startingSegmentIndex: currentSegmentIndex,
487
+ streamId: numericStreamId
488
+ });
489
+ // Pipe the new capture to the new segmenter.
490
+ newSegmenter.pipe(captureResult.captureStream);
491
+ // Update the registry entry with the new resources.
492
+ stream.ffmpegProcess = captureResult.ffmpegProcess;
493
+ stream.page = captureResult.page;
494
+ stream.rawCaptureStream = captureResult.rawCaptureStream;
495
+ stream.segmenter = newSegmenter;
496
+ LOG.info("Tab replacement complete. New capture started with segment continuity.");
497
+ LOG.debug("timing:tab", "Tab replacement complete. Total: %sms.", tabElapsed());
498
+ return {
499
+ context: captureResult.context,
500
+ page: captureResult.page
501
+ };
502
+ };
503
+ }
504
+ /**
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.
507
+ *
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).
511
+ *
512
+ * @param options - Stream initialization options.
513
+ * @returns The stream ID on success, or null if the stream was terminated during the narrow setup window (orphaned setup race condition).
514
+ * @throws StreamSetupError if setup fails, or Error for unexpected failures.
515
+ */
516
+ export async function initializeStream(options) {
517
+ const { channel, channelName, channelSelector, clickSelector, clickToPlay, clientAddress, profileOverride, url } = options;
518
+ // Set a -1 sentinel to prevent duplicate stream starts while we're setting up.
519
+ const startupSentinel = -1;
520
+ setChannelStreamId(channelName, startupSentinel);
521
+ let setup;
522
+ // Circuit breaker callback — terminate the stream on unrecoverable errors.
523
+ const onCircuitBreak = () => {
524
+ const streamId = getChannelStreamId(channelName);
525
+ if ((streamId !== undefined) && (streamId !== startupSentinel)) {
526
+ terminateStream(streamId, channelName, "too many errors");
527
+ void emitCurrentSystemStatus();
528
+ }
529
+ };
530
+ // 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.
531
+ const tabReplacementFactory = (numericStreamId, streamId, profile, metadataComment) => {
532
+ return createTabReplacementHandler(numericStreamId, streamId, channelName, url, profile, metadataComment, onCircuitBreak);
533
+ };
534
+ // If at capacity, try to reclaim an idle stream before starting setup. This avoids rejecting new requests when idle streams can be freed.
535
+ if (getStreamCount() >= CONFIG.streaming.maxConcurrentStreams) {
536
+ reclaimIdleStream();
537
+ }
538
+ try {
539
+ // Pass channelName to setupStream only for predefined channels. For ad-hoc streams, omitting it causes generateStreamId to derive the stream ID string from the
540
+ // URL (e.g., "foxsports-abc123"), which is more informative in logs than the synthetic hash key.
541
+ setup = await setupStream({
542
+ channel,
543
+ channelName: channel ? channelName : undefined,
544
+ channelSelector: channel ? undefined : channelSelector,
545
+ clickSelector: channel ? undefined : clickSelector,
546
+ clickToPlay: channel ? undefined : clickToPlay,
547
+ onTabReplacementFactory: tabReplacementFactory,
548
+ profileOverride,
549
+ url
550
+ }, onCircuitBreak);
551
+ }
552
+ catch (error) {
553
+ // Remove startup sentinel on failure and re-throw for the caller to handle error responses.
554
+ deleteChannelStreamId(channelName);
555
+ throw error;
556
+ }
557
+ // Update the channel mapping with the real stream ID.
558
+ setChannelStreamId(channelName, setup.numericStreamId);
559
+ // Continue within stream context for consistent logging.
560
+ return runWithStreamContext({ channelName: channel?.name, streamId: setup.streamId, url: setup.url },
561
+ // eslint-disable-next-line @typescript-eslint/require-await
562
+ async () => {
563
+ // Register with null segmenter first because segmenter callbacks (onError, onStop) need the stream to exist in the registry for cleanup logic. The segmenter is
564
+ // assigned immediately after creation below.
565
+ registerStream({
566
+ channelName: channel?.name ?? null,
567
+ clientAddress,
568
+ ffmpegProcess: setup.ffmpegProcess,
569
+ hls: createHLSState(),
570
+ id: setup.numericStreamId,
571
+ info: {
572
+ lastPlaylistRequest: Date.now(),
573
+ storeKey: channelName
574
+ },
575
+ mpegTsClientCount: 0,
576
+ page: setup.page,
577
+ profile: setup.profile,
578
+ rawCaptureStream: setup.rawCaptureStream,
579
+ segmenter: null,
580
+ startTime: setup.startTime,
581
+ stopMonitor: setup.stopMonitor,
582
+ streamIdStr: setup.streamId,
583
+ url: setup.url
584
+ });
585
+ // Create the native fMP4 segmenter to parse the MP4/AAC stream into HLS segments.
586
+ const segmenter = createFMP4Segmenter({
587
+ onError: (error) => {
588
+ // Skip error handling if termination was already initiated.
589
+ if (isTerminationInitiated(setup.numericStreamId)) {
590
+ return;
591
+ }
592
+ LOG.error("Segmenter error for %s: %s.", channelName, formatError(error));
593
+ terminateStream(setup.numericStreamId, channelName, "stream processing error");
594
+ void emitCurrentSystemStatus();
595
+ },
596
+ onStop: () => {
597
+ // Skip handling if termination was already initiated.
598
+ if (isTerminationInitiated(setup.numericStreamId)) {
599
+ return;
600
+ }
601
+ LOG.error("Segmenter stopped unexpectedly for %s.", channelName);
602
+ terminateStream(setup.numericStreamId, channelName, "stream ended unexpectedly");
603
+ void emitCurrentSystemStatus();
604
+ },
605
+ streamId: setup.numericStreamId
606
+ });
607
+ // Pipe the capture stream to the segmenter.
608
+ segmenter.pipe(setup.captureStream);
609
+ // Store the segmenter reference in the registry.
610
+ const stream = getStream(setup.numericStreamId);
611
+ if (stream) {
612
+ stream.segmenter = segmenter;
613
+ }
614
+ else {
615
+ // Stream was terminated during setup (rare race condition). Clean up the orphaned segmenter.
616
+ cleanupOrphanedSetup(segmenter);
617
+ return null;
618
+ }
619
+ const captureMode = CONFIG.streaming.captureMode === "ffmpeg" ? "FFmpeg" : "Native";
620
+ const displayName = channel?.name ?? url;
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);
623
+ // Emit stream added event.
624
+ emitStreamAdded(createInitialStreamStatus({
625
+ channelName: channel?.name ?? null,
626
+ numericStreamId: setup.numericStreamId,
627
+ providerName: setup.providerName,
628
+ startTime: setup.startTime,
629
+ url: setup.url
630
+ }));
631
+ void emitCurrentSystemStatus();
632
+ // Trigger show name lookup for the new stream.
633
+ triggerShowNameUpdate();
634
+ return setup.numericStreamId;
635
+ });
636
+ }
637
+ // Channel Stream Startup.
638
+ /**
639
+ * 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
640
+ * HDHomeRun-specific headers for capacity errors.
641
+ *
642
+ * @param channelName - The channel key (canonical key for stream registration and deduplication).
643
+ * @param url - The URL to stream (from the resolved provider).
644
+ * @param req - Express request object (for profile override and client IP).
645
+ * @param res - Express response object (for error responses).
646
+ * @param channel - The resolved channel definition (with inheritance applied for provider variants).
647
+ * @returns The stream ID if successful, null if an error occurred (error response already sent).
648
+ */
649
+ async function startHLSStream(channelName, url, req, res, channel) {
650
+ const profileOverride = req.query.profile;
651
+ const clientAddress = req.ip ?? req.socket.remoteAddress ?? null;
652
+ try {
653
+ return await initializeStream({ channel, channelName, clientAddress, profileOverride, url });
654
+ }
655
+ catch (error) {
656
+ if (error instanceof StreamSetupError) {
657
+ if (error.statusCode === 503) {
658
+ res.setHeader("Retry-After", "10");
659
+ res.setHeader("X-HDHomeRun-Error", "All Tuners In Use");
660
+ }
661
+ res.status(error.statusCode).send(error.userMessage);
662
+ return null;
663
+ }
664
+ LOG.error("Unexpected error during HLS stream setup: %s.", formatError(error));
665
+ res.status(500).send("Internal server error.");
666
+ return null;
667
+ }
668
+ }
669
+ // Idle Detection.
670
+ /**
671
+ * Checks for idle streams and terminates them. Called periodically by the idle detection interval.
672
+ */
673
+ export function cleanupIdleStreams() {
674
+ const streams = getAllStreams();
675
+ const now = Date.now();
676
+ let terminatedCount = 0;
677
+ for (const stream of streams) {
678
+ // Skip streams with active MPEG-TS clients. These streams are still being consumed even if no HLS playlist requests have been made recently.
679
+ if (stream.mpegTsClientCount > 0) {
680
+ continue;
681
+ }
682
+ const idleTime = now - stream.info.lastPlaylistRequest;
683
+ if (idleTime >= CONFIG.hls.idleTimeout) {
684
+ terminateStream(stream.id, stream.info.storeKey, "no active clients");
685
+ terminatedCount++;
686
+ }
687
+ }
688
+ // Emit system status once after all idle streams are terminated.
689
+ if (terminatedCount > 0) {
690
+ void emitCurrentSystemStatus();
691
+ }
692
+ }
693
+ /**
694
+ * 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
695
+ * 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
696
+ * channel-surfing users to get new streams without being rejected while abandoned streams linger.
697
+ * @returns True if a stream was reclaimed, false if no idle streams exist.
698
+ */
699
+ function reclaimIdleStream() {
700
+ const streams = getAllStreams();
701
+ const now = Date.now();
702
+ let oldest = null;
703
+ for (const stream of streams) {
704
+ // Skip streams with active MPEG-TS clients.
705
+ if (stream.mpegTsClientCount > 0) {
706
+ continue;
707
+ }
708
+ const idleTime = now - stream.info.lastPlaylistRequest;
709
+ // Only consider streams that have exceeded the idle timeout, and pick the one that has been idle the longest.
710
+ if ((idleTime >= CONFIG.hls.idleTimeout) && (!oldest || (stream.info.lastPlaylistRequest < oldest.info.lastPlaylistRequest))) {
711
+ oldest = stream;
712
+ }
713
+ }
714
+ if (!oldest) {
715
+ return false;
716
+ }
717
+ LOG.info("Reclaiming idle stream %s (%s) to free capacity.", oldest.id, oldest.info.storeKey);
718
+ terminateStream(oldest.id, oldest.info.storeKey, "reclaimed for new stream");
719
+ void emitCurrentSystemStatus();
720
+ return true;
721
+ }
722
+ //# sourceMappingURL=hls.js.map