prismcast 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/dist/app.js +29 -2
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.js +1 -1
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +11 -1
- package/dist/browser/channelSelection.js +45 -980
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/index.d.ts +18 -4
- package/dist/browser/index.js +202 -36
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/tuning/fox.d.ts +19 -0
- package/dist/browser/tuning/fox.js +61 -0
- package/dist/browser/tuning/fox.js.map +1 -0
- package/dist/browser/tuning/hbo.d.ts +22 -0
- package/dist/browser/tuning/hbo.js +182 -0
- package/dist/browser/tuning/hbo.js.map +1 -0
- package/dist/browser/tuning/hulu.d.ts +30 -0
- package/dist/browser/tuning/hulu.js +514 -0
- package/dist/browser/tuning/hulu.js.map +1 -0
- package/dist/browser/tuning/sling.d.ts +22 -0
- package/dist/browser/tuning/sling.js +287 -0
- package/dist/browser/tuning/sling.js.map +1 -0
- package/dist/browser/tuning/thumbnailRow.d.ts +18 -0
- package/dist/browser/tuning/thumbnailRow.js +107 -0
- package/dist/browser/tuning/thumbnailRow.js.map +1 -0
- package/dist/browser/tuning/tileClick.d.ts +18 -0
- package/dist/browser/tuning/tileClick.js +102 -0
- package/dist/browser/tuning/tileClick.js.map +1 -0
- package/dist/browser/tuning/youtubeTv.d.ts +17 -0
- package/dist/browser/tuning/youtubeTv.js +99 -0
- package/dist/browser/tuning/youtubeTv.js.map +1 -0
- package/dist/browser/video.d.ts +2 -2
- package/dist/browser/video.js +18 -8
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.js +53 -7
- package/dist/channels/index.js.map +1 -1
- package/dist/config/profiles.js +11 -0
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providers.d.ts +46 -1
- package/dist/config/providers.js +178 -13
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +2 -0
- package/dist/config/sites.js +43 -14
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.js +21 -3
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +2 -1
- package/dist/config/userConfig.js +12 -4
- package/dist/config/userConfig.js.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/routes/auth.js +6 -0
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/config.d.ts +5 -0
- package/dist/routes/config.js +273 -35
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/debug.d.ts +6 -0
- package/dist/routes/debug.js +274 -0
- package/dist/routes/debug.js.map +1 -0
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +3 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +17 -5
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/root.js +506 -127
- package/dist/routes/root.js.map +1 -1
- package/dist/routes/theme.js +7 -0
- package/dist/routes/theme.js.map +1 -1
- package/dist/service/commands.js +8 -8
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.js +22 -16
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +7 -0
- package/dist/streaming/fmp4Segmenter.js +124 -15
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +74 -6
- package/dist/streaming/hls.js +210 -129
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsSegments.js +11 -4
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +1 -1
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.d.ts +1 -1
- package/dist/streaming/monitor.js +120 -49
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +29 -1
- package/dist/streaming/mp4Parser.js +243 -18
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +5 -2
- package/dist/streaming/mpegts.js +122 -29
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/registry.d.ts +1 -1
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +13 -1
- package/dist/streaming/setup.js +136 -36
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +9 -9
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.js +5 -6
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/index.d.ts +12 -3
- package/dist/types/index.js +5 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/debugFilter.d.ts +38 -0
- package/dist/utils/debugFilter.js +157 -0
- package/dist/utils/debugFilter.js.map +1 -0
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +5 -0
- package/dist/utils/ffmpeg.js +13 -2
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/fileLogger.d.ts +2 -1
- package/dist/utils/fileLogger.js +4 -2
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logEmitter.d.ts +1 -0
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/logger.d.ts +9 -8
- package/dist/utils/logger.js +52 -28
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/retry.d.ts +1 -1
- package/dist/utils/retry.js +3 -3
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/version.js +4 -9
- package/dist/utils/version.js.map +1 -1
- package/package.json +6 -6
package/dist/streaming/mpegts.js
CHANGED
|
@@ -1,30 +1,36 @@
|
|
|
1
|
-
/* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
-
*
|
|
3
|
-
* mpegts.ts: MPEG-TS streaming handler for PrismCast.
|
|
4
|
-
*/
|
|
5
1
|
import { LOG, formatError, spawnMpegTsRemuxer } from "../utils/index.js";
|
|
2
|
+
import { awaitStreamReadySilent, initializeStream, sendValidationError, validateChannel } from "./hls.js";
|
|
6
3
|
import { getStream, updateLastAccess } from "./registry.js";
|
|
7
4
|
import { registerClient, unregisterClient } from "./clients.js";
|
|
8
5
|
import { CONFIG } from "../config/index.js";
|
|
9
|
-
import {
|
|
6
|
+
import { StreamSetupError } from "./setup.js";
|
|
7
|
+
import { getChannelStreamId } from "./lifecycle.js";
|
|
10
8
|
import { waitForInitSegment } from "./hlsSegments.js";
|
|
11
9
|
/* This module provides a continuous MPEG-TS byte stream from the same capture pipeline used for HLS. It is designed for HDHomeRun-compatible clients (such as Plex)
|
|
12
10
|
* that expect raw MPEG-TS when tuning a channel. The existing capture → segmenter → HLS segments flow is unchanged. Each MPEG-TS client gets its own FFmpeg remuxer
|
|
13
11
|
* that converts stored fMP4 segments to MPEG-TS with codec copy (no transcoding).
|
|
14
12
|
*
|
|
15
13
|
* Data flow per client:
|
|
16
|
-
* 1.
|
|
17
|
-
* 2.
|
|
18
|
-
* 3.
|
|
19
|
-
* 4.
|
|
20
|
-
* 5.
|
|
21
|
-
* 6.
|
|
22
|
-
* 7.
|
|
14
|
+
* 1. Validate channel and check for existing stream
|
|
15
|
+
* 2. If new stream needed, flush HTTP 200 headers immediately (so the client sees "connection accepted")
|
|
16
|
+
* 3. initializeStream() starts the capture, or awaitStreamReadySilent() waits for an in-progress startup
|
|
17
|
+
* 4. Wait for the init segment (ftyp+moov codec configuration)
|
|
18
|
+
* 5. Spawn FFmpeg: -f mp4 -i pipe:0 -c copy -f mpegts pipe:1
|
|
19
|
+
* 6. Write init segment + existing media segments to FFmpeg stdin
|
|
20
|
+
* 7. Subscribe to segment events for new segments in real time
|
|
21
|
+
* 8. Pipe FFmpeg stdout to the HTTP response as video/mp2t
|
|
22
|
+
* 9. On client disconnect or stream termination, kill FFmpeg and clean up
|
|
23
|
+
*
|
|
24
|
+
* The header flush in step 2 prevents client timeouts. Without it, the client receives zero bytes until the entire stream setup completes (4-10+ seconds), which may
|
|
25
|
+
* exceed the client's connection timeout.
|
|
23
26
|
*/
|
|
24
27
|
// Public Endpoint Handler.
|
|
25
28
|
/**
|
|
26
|
-
* Handles MPEG-TS stream requests.
|
|
27
|
-
*
|
|
29
|
+
* Handles MPEG-TS stream requests. Validates the channel, flushes HTTP headers early for new streams, then ensures a capture is running, waits for the init segment,
|
|
30
|
+
* spawns a per-client FFmpeg remuxer, and streams the output.
|
|
31
|
+
*
|
|
32
|
+
* For new streams, headers are flushed before stream setup begins so the client sees an immediate 200 response. This prevents timeout failures during the 4-10+
|
|
33
|
+
* second startup sequence. The trade-off is that error responses cannot be sent after the flush — failures are logged server-side and the connection is closed.
|
|
28
34
|
*
|
|
29
35
|
* Route: GET /stream/:name
|
|
30
36
|
*
|
|
@@ -37,23 +43,108 @@ export async function handleMpegTsStream(req, res) {
|
|
|
37
43
|
res.status(400).send("Channel name is required.");
|
|
38
44
|
return;
|
|
39
45
|
}
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
// Check for an existing stream first. If one exists, we can skip validation and header flushing.
|
|
47
|
+
const existingStreamId = getChannelStreamId(channelName);
|
|
48
|
+
// Fast path: a real stream already exists. No early flush needed — the stream data will flow quickly.
|
|
49
|
+
if ((existingStreamId !== undefined) && (existingStreamId !== -1)) {
|
|
50
|
+
await serveMpegTsStream(existingStreamId, channelName, req, res);
|
|
44
51
|
return;
|
|
45
52
|
}
|
|
53
|
+
// If no existing stream or startup in progress, validate the channel before flushing headers. This ensures we can still return proper error responses for invalid
|
|
54
|
+
// channels, disabled channels, and login mode. Store the validated channel for use during stream initialization below.
|
|
55
|
+
let validatedChannel;
|
|
56
|
+
if (existingStreamId === undefined) {
|
|
57
|
+
const validation = validateChannel(channelName);
|
|
58
|
+
if (!validation.valid) {
|
|
59
|
+
sendValidationError(validation, res);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
validatedChannel = validation.channel;
|
|
63
|
+
}
|
|
64
|
+
// Flush HTTP 200 headers immediately. The client sees "connection accepted, data coming" and waits patiently. After this point, we cannot send error status codes —
|
|
65
|
+
// failures will close the connection with no data.
|
|
66
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
67
|
+
res.setHeader("Connection", "close");
|
|
68
|
+
res.setHeader("Content-Type", "video/mp2t");
|
|
69
|
+
res.flushHeaders();
|
|
70
|
+
// Acquire the stream. If a startup is in progress (another request started it), poll silently. Otherwise, start a new stream via initializeStream().
|
|
71
|
+
let streamId;
|
|
72
|
+
if (existingStreamId === -1) {
|
|
73
|
+
// Another request is already starting this stream. Wait silently (no error responses possible after flush).
|
|
74
|
+
streamId = await awaitStreamReadySilent(channelName);
|
|
75
|
+
if (streamId === null) {
|
|
76
|
+
LOG.warn("MPEG-TS stream startup failed for %s (startup did not complete).", channelName);
|
|
77
|
+
res.end();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Start a new stream directly. validatedChannel is guaranteed set: this branch runs only when existingStreamId === undefined, which requires successful
|
|
83
|
+
// validation above. Since headers are already flushed, errors are logged and the connection is closed.
|
|
84
|
+
if (!validatedChannel) {
|
|
85
|
+
res.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
streamId = await initializeStream({
|
|
90
|
+
channel: validatedChannel,
|
|
91
|
+
channelName,
|
|
92
|
+
clientAddress: req.ip ?? req.socket.remoteAddress ?? null,
|
|
93
|
+
profileOverride: req.query.profile,
|
|
94
|
+
url: validatedChannel.url
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error instanceof StreamSetupError) {
|
|
99
|
+
LOG.warn("MPEG-TS stream startup failed for %s: %s.", channelName, error.userMessage);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
LOG.warn("MPEG-TS stream startup failed for %s: %s.", channelName, formatError(error));
|
|
103
|
+
}
|
|
104
|
+
res.end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (streamId === null) {
|
|
108
|
+
LOG.warn("MPEG-TS stream startup failed for %s (terminated during setup).", channelName);
|
|
109
|
+
res.end();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await serveMpegTsStream(streamId, channelName, req, res);
|
|
114
|
+
}
|
|
115
|
+
// Internal Helpers.
|
|
116
|
+
/**
|
|
117
|
+
* Serves the MPEG-TS stream once a stream ID is available. Waits for the init segment, spawns the FFmpeg remuxer, and pipes the output to the response. This is the
|
|
118
|
+
* shared implementation used by both the fast path (existing stream) and the flush path (new stream).
|
|
119
|
+
*
|
|
120
|
+
* @param streamId - The numeric stream ID.
|
|
121
|
+
* @param channelName - The channel name for logging.
|
|
122
|
+
* @param req - Express request object.
|
|
123
|
+
* @param res - Express response object.
|
|
124
|
+
*/
|
|
125
|
+
async function serveMpegTsStream(streamId, channelName, req, res) {
|
|
46
126
|
// Wait for the init segment to be available. The init segment contains codec configuration (ftyp+moov boxes) that FFmpeg needs before it can process media segments.
|
|
47
127
|
const initReady = await waitForInitSegment(streamId, CONFIG.streaming.navigationTimeout);
|
|
48
128
|
if (!initReady) {
|
|
49
|
-
res.
|
|
50
|
-
|
|
129
|
+
if (!res.headersSent) {
|
|
130
|
+
res.setHeader("Retry-After", "5");
|
|
131
|
+
res.status(503).send("Stream is starting. Please retry.");
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
LOG.warn("MPEG-TS init segment timeout for %s.", channelName);
|
|
135
|
+
res.end();
|
|
136
|
+
}
|
|
51
137
|
return;
|
|
52
138
|
}
|
|
53
139
|
// Get the stream from the registry and verify it's still alive with a valid init segment.
|
|
54
140
|
const stream = getStream(streamId);
|
|
55
|
-
if (!stream
|
|
56
|
-
res.
|
|
141
|
+
if (!stream?.hls.initSegment) {
|
|
142
|
+
if (!res.headersSent) {
|
|
143
|
+
res.status(500).send("Stream no longer available.");
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
res.end();
|
|
147
|
+
}
|
|
57
148
|
return;
|
|
58
149
|
}
|
|
59
150
|
// Capture client address for client tracking. Captured before any async operations so it remains consistent in the cleanup closure, even if the request object
|
|
@@ -74,7 +165,7 @@ export async function handleMpegTsStream(req, res) {
|
|
|
74
165
|
// Spawn an FFmpeg process to remux fMP4 to MPEG-TS. The process reads concatenated fMP4 (init segment + media segments) from stdin and outputs a continuous
|
|
75
166
|
// MPEG-TS stream on stdout. Video (H264) and audio (AAC) are copied without transcoding.
|
|
76
167
|
const remuxer = spawnMpegTsRemuxer((error) => {
|
|
77
|
-
streamLog.debug("MPEG-TS remuxer error: %s.", formatError(error));
|
|
168
|
+
streamLog.debug("streaming:mpegts", "MPEG-TS remuxer error: %s.", formatError(error));
|
|
78
169
|
cleanup();
|
|
79
170
|
if (!res.writableEnded) {
|
|
80
171
|
res.end();
|
|
@@ -121,7 +212,7 @@ export async function handleMpegTsStream(req, res) {
|
|
|
121
212
|
stream.hls.segmentEmitter.off("segment", onSegment);
|
|
122
213
|
stream.hls.segmentEmitter.off("terminated", onTerminated);
|
|
123
214
|
remuxer.kill();
|
|
124
|
-
streamLog.debug("MPEG-TS client disconnected.");
|
|
215
|
+
streamLog.debug("streaming:mpegts", "MPEG-TS client disconnected.");
|
|
125
216
|
};
|
|
126
217
|
// Clean up when the client disconnects. Registered immediately after cleanup is assigned to minimize the window where a disconnect could be missed.
|
|
127
218
|
req.on("close", () => {
|
|
@@ -130,10 +221,12 @@ export async function handleMpegTsStream(req, res) {
|
|
|
130
221
|
// Subscribe to segment events BEFORE writing existing segments to avoid missing any segments added during the catchup phase.
|
|
131
222
|
stream.hls.segmentEmitter.on("segment", onSegment);
|
|
132
223
|
stream.hls.segmentEmitter.on("terminated", onTerminated);
|
|
133
|
-
// Set response headers for
|
|
134
|
-
res.
|
|
135
|
-
|
|
136
|
-
|
|
224
|
+
// Set response headers if they haven't been flushed yet (fast path for existing streams).
|
|
225
|
+
if (!res.headersSent) {
|
|
226
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
227
|
+
res.setHeader("Connection", "close");
|
|
228
|
+
res.setHeader("Content-Type", "video/mp2t");
|
|
229
|
+
}
|
|
137
230
|
// Pipe FFmpeg stdout to the HTTP response. When FFmpeg exits (either from stdin ending or being killed), stdout closes and the response ends automatically.
|
|
138
231
|
remuxer.stdout.pipe(res);
|
|
139
232
|
// Write the init segment first — FFmpeg needs the ftyp and moov boxes before it can process any media segments.
|
|
@@ -148,6 +241,6 @@ export async function handleMpegTsStream(req, res) {
|
|
|
148
241
|
sentSegments.add(filename);
|
|
149
242
|
remuxer.stdin.write(data);
|
|
150
243
|
}
|
|
151
|
-
streamLog.debug("MPEG-TS client connected.");
|
|
244
|
+
streamLog.debug("streaming:mpegts", "MPEG-TS client connected.");
|
|
152
245
|
}
|
|
153
246
|
//# sourceMappingURL=mpegts.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mpegts.js","sourceRoot":"","sources":["../../src/streaming/mpegts.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"mpegts.js","sourceRoot":"","sources":["../../src/streaming/mpegts.ts"],"names":[],"mappings":"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"}
|
|
@@ -116,4 +116,4 @@ export declare function getTotalSegmentMemory(): number;
|
|
|
116
116
|
* @param entry - The stream registry entry to query.
|
|
117
117
|
* @returns Segment size in bytes, or null if no segmenter exists.
|
|
118
118
|
*/
|
|
119
|
-
export declare function getLastSegmentSize(entry: StreamRegistryEntry): number
|
|
119
|
+
export declare function getLastSegmentSize(entry: StreamRegistryEntry): Nullable<number>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/streaming/registry.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAuH3C,SAAS;AAET,0EAA0E;AAC1E,MAAM,cAAc,GAAG,IAAI,GAAG,EAA+B,CAAC;AAE9D,6EAA6E;AAC7E,IAAI,eAAe,GAAG,CAAC,CAAC;AAExB,cAAc;AAEd;;;GAGG;AACH,MAAM,UAAU,eAAe;IAE7B,OAAO,EAAE,eAAe,CAAC;AAC3B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,KAA0B;IAEvD,cAAc,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,EAAU;IAEzC,cAAc,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AAC5B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,EAAU;IAElC,OAAO,cAAc,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;AAChC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa;IAE3B,OAAO,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,MAAM,EAAE,CAAC,CAAC;AAC7C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc;IAE5B,OAAO,cAAc,CAAC,IAAI,CAAC;AAC7B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,EAAU;IAEzC,MAAM,KAAK,GAAG,cAAc,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAErC,IAAG,KAAK,EAAE,CAAC;QAET,KAAK,CAAC,IAAI,CAAC,mBAAmB,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC9C,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc;IAE5B,IAAI,sBAAsB,GAAe,GAAG,EAAE,
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/streaming/registry.ts"],"names":[],"mappings":"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"}
|
|
@@ -7,7 +7,7 @@ import type { Readable } from "node:stream";
|
|
|
7
7
|
* Factory function type for creating tab replacement handlers. Called by setupStream after generating stream IDs and resolving the profile, allowing the caller to
|
|
8
8
|
* create a handler with access to all necessary context.
|
|
9
9
|
*/
|
|
10
|
-
export type TabReplacementHandlerFactory = (numericStreamId: number, streamId: string, profile: ResolvedSiteProfile, metadataComment: string | undefined) => () => Promise<TabReplacementResult
|
|
10
|
+
export type TabReplacementHandlerFactory = (numericStreamId: number, streamId: string, profile: ResolvedSiteProfile, metadataComment: string | undefined) => () => Promise<Nullable<TabReplacementResult>>;
|
|
11
11
|
/**
|
|
12
12
|
* Options for setting up a stream.
|
|
13
13
|
*/
|
|
@@ -58,6 +58,7 @@ export interface CreatePageWithCaptureOptions {
|
|
|
58
58
|
profile: ResolvedSiteProfile;
|
|
59
59
|
streamId: string;
|
|
60
60
|
url: string;
|
|
61
|
+
_pageClosedRetries?: number;
|
|
61
62
|
}
|
|
62
63
|
/**
|
|
63
64
|
* Result from creating a page with capture. Contains everything needed to create a segmenter and continue with stream setup.
|
|
@@ -117,3 +118,14 @@ export declare function createPageWithCapture(options: CreatePageWithCaptureOpti
|
|
|
117
118
|
* @throws StreamSetupError if setup fails with appropriate status code and message.
|
|
118
119
|
*/
|
|
119
120
|
export declare function setupStream(options: StreamSetupOptions, onCircuitBreak: () => void): Promise<StreamSetupResult>;
|
|
121
|
+
/**
|
|
122
|
+
* Verifies that Chrome's capture system is functional before the server starts accepting requests. This detects stale tabCapture state left over from a previous
|
|
123
|
+
* Chrome process — common during quick service restarts where the old process hasn't fully exited before the new one launches. Without this probe, the first stream
|
|
124
|
+
* request would trigger the runtime stale capture handler, which exits the process because the puppeteer-stream mutex is permanently leaked.
|
|
125
|
+
*
|
|
126
|
+
* The probe creates a temporary page, attempts a short capture, and tears down both cleanly. A 500ms delay after destroying the capture stream allows
|
|
127
|
+
* puppeteer-stream's fire-and-forget STOP_RECORDING chain to complete before closing the page, preventing the stale capture cascade on the first real request.
|
|
128
|
+
*
|
|
129
|
+
* If stale capture state is detected, the process exits immediately — Chrome restart cannot fix the leaked mutex, only a fresh process can.
|
|
130
|
+
*/
|
|
131
|
+
export declare function verifyCaptureSystem(): Promise<void>;
|
package/dist/streaming/setup.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { LOG, delay, extractDomain, formatError, registerAbortController, retryOperation, runWithStreamContext, spawnFFmpeg } from "../utils/index.js";
|
|
2
|
-
import {
|
|
1
|
+
import { LOG, delay, extractDomain, formatError, registerAbortController, retryOperation, runWithStreamContext, spawnFFmpeg, startTimer } from "../utils/index.js";
|
|
2
|
+
import { getCurrentBrowser, getStream, minimizeBrowserWindow, registerManagedPage, unregisterManagedPage } from "../browser/index.js";
|
|
3
3
|
import { getNextStreamId, getStreamCount } from "./registry.js";
|
|
4
4
|
import { getProfileForChannel, getProfileForUrl, getProfiles, resolveProfile } from "../config/profiles.js";
|
|
5
5
|
import { initializePlayback, navigateToPage } from "../browser/video.js";
|
|
@@ -41,10 +41,9 @@ const WEBM_FFMPEG_MIME_TYPE = "video/webm;codecs=h264,opus";
|
|
|
41
41
|
// tab with an active stream." We serialize capture initialization using a promise chain so requests execute sequentially. Once a capture is established, it runs
|
|
42
42
|
// concurrently with other captures without issue.
|
|
43
43
|
let captureQueue = Promise.resolve();
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
let staleCaptureRecoveryAttempts = 0;
|
|
44
|
+
// Maximum number of times createPageWithCapture() will retry when it detects that the page was closed while waiting in the capture queue (e.g., due to a browser
|
|
45
|
+
// crash). An explicit guard prevents unbounded recursion.
|
|
46
|
+
const MAX_PAGE_CLOSED_RETRIES = 3;
|
|
48
47
|
/**
|
|
49
48
|
* Error thrown when stream setup fails. Includes HTTP status code and user-friendly message for the response.
|
|
50
49
|
*/
|
|
@@ -144,6 +143,7 @@ export function validateStreamUrl(url) {
|
|
|
144
143
|
* @throws Error if page creation, capture initialization, or navigation fails.
|
|
145
144
|
*/
|
|
146
145
|
export async function createPageWithCapture(options) {
|
|
146
|
+
const captureElapsed = startTimer();
|
|
147
147
|
const { comment, onFFmpegError, profile, streamId, url } = options;
|
|
148
148
|
// Create browser page.
|
|
149
149
|
const browser = await getCurrentBrowser();
|
|
@@ -158,6 +158,16 @@ export async function createPageWithCapture(options) {
|
|
|
158
158
|
let outputStream;
|
|
159
159
|
let rawCaptureStream = null;
|
|
160
160
|
let ffmpegProcess = null;
|
|
161
|
+
// Capture queue release function, hoisted here so both the try and catch blocks can access it. Initialized in the try block when the queue entry is created.
|
|
162
|
+
// The once-guard prevents double-releasing from multiple code paths (success handler, catch block, timeout).
|
|
163
|
+
let captureQueueReleased = false;
|
|
164
|
+
let releaseCaptureQueue = () => { };
|
|
165
|
+
const releaseCaptureOnce = () => {
|
|
166
|
+
if (!captureQueueReleased) {
|
|
167
|
+
captureQueueReleased = true;
|
|
168
|
+
releaseCaptureQueue();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
161
171
|
// Initialize media stream capture.
|
|
162
172
|
try {
|
|
163
173
|
const streamOptions = {
|
|
@@ -171,17 +181,15 @@ export async function createPageWithCapture(options) {
|
|
|
171
181
|
maxFrameRate: 60,
|
|
172
182
|
maxHeight: getEffectiveViewport(CONFIG).height,
|
|
173
183
|
maxWidth: getEffectiveViewport(CONFIG).width,
|
|
174
|
-
minFrameRate: CONFIG.streaming.frameRate,
|
|
184
|
+
minFrameRate: Math.max(30, Math.min(60, CONFIG.streaming.frameRate)),
|
|
175
185
|
minHeight: getEffectiveViewport(CONFIG).height,
|
|
176
186
|
minWidth: getEffectiveViewport(CONFIG).width
|
|
177
187
|
}
|
|
178
188
|
}
|
|
179
189
|
};
|
|
180
190
|
// Serialize capture initialization. Wait for any previous capture to finish before calling getStream(), because Chrome's tabCapture extension rejects
|
|
181
|
-
// concurrent initialization attempts.
|
|
182
|
-
//
|
|
183
|
-
// which releases the lock and allows the next queued capture to proceed.
|
|
184
|
-
let releaseCaptureQueue = () => { };
|
|
191
|
+
// concurrent initialization attempts. On success, the lock is released immediately so the next caller can proceed. On failure, the lock is held until the
|
|
192
|
+
// catch block decides what to do — the catch block releases the lock after handling the error.
|
|
185
193
|
const previousCapture = captureQueue;
|
|
186
194
|
captureQueue = new Promise((resolve) => {
|
|
187
195
|
releaseCaptureQueue = resolve;
|
|
@@ -200,12 +208,24 @@ export async function createPageWithCapture(options) {
|
|
|
200
208
|
}
|
|
201
209
|
catch (error) {
|
|
202
210
|
// Release our queue position so subsequent captures aren't blocked by our failure.
|
|
203
|
-
|
|
211
|
+
releaseCaptureOnce();
|
|
204
212
|
throw error;
|
|
205
213
|
}
|
|
214
|
+
// After the queue wait, verify our page is still connected. If Chrome crashed while we were waiting, our page is dead and we need to start over with a
|
|
215
|
+
// fresh page on the new browser. Release our queue position first so subsequent callers aren't blocked.
|
|
216
|
+
if (page.isClosed()) {
|
|
217
|
+
releaseCaptureOnce();
|
|
218
|
+
unregisterManagedPage(page);
|
|
219
|
+
const retryCount = options._pageClosedRetries ?? 0;
|
|
220
|
+
if (retryCount >= MAX_PAGE_CLOSED_RETRIES) {
|
|
221
|
+
throw new Error("Browser crashed too many times during capture initialization.");
|
|
222
|
+
}
|
|
223
|
+
return await createPageWithCapture({ ...options, _pageClosedRetries: retryCount + 1 });
|
|
224
|
+
}
|
|
206
225
|
const streamPromise = getStream(page, streamOptions);
|
|
207
|
-
// Release the queue
|
|
208
|
-
|
|
226
|
+
// Release the queue on success only. On failure, the catch block handles the release. The rejection handler is a no-op to suppress unhandled rejection
|
|
227
|
+
// warnings; the actual error handling happens in the catch block below.
|
|
228
|
+
void streamPromise.then(() => { releaseCaptureOnce(); }, () => { });
|
|
209
229
|
const timeoutPromise = new Promise((_, reject) => {
|
|
210
230
|
setTimeout(() => {
|
|
211
231
|
reject(new Error("Stream initialization timed out."));
|
|
@@ -227,7 +247,7 @@ export async function createPageWithCapture(options) {
|
|
|
227
247
|
ffmpeg.stdout.on("error", (error) => {
|
|
228
248
|
const errorMessage = formatError(error);
|
|
229
249
|
if (errorMessage.includes("EPIPE")) {
|
|
230
|
-
LOG.debug("FFmpeg stdout pipe closed: %s.", errorMessage);
|
|
250
|
+
LOG.debug("streaming:ffmpeg", "FFmpeg stdout pipe closed: %s.", errorMessage);
|
|
231
251
|
}
|
|
232
252
|
else {
|
|
233
253
|
LOG.error("FFmpeg stdout pipe error: %s.", errorMessage);
|
|
@@ -249,7 +269,7 @@ export async function createPageWithCapture(options) {
|
|
|
249
269
|
LOG.error("Capture pipeline error: %s.", errorMessage);
|
|
250
270
|
ffmpeg.kill();
|
|
251
271
|
if (onFFmpegError) {
|
|
252
|
-
onFFmpegError(error);
|
|
272
|
+
onFFmpegError(error instanceof Error ? error : new Error(String(error)));
|
|
253
273
|
}
|
|
254
274
|
});
|
|
255
275
|
// Use FFmpeg's stdout (fMP4 output) as the output stream for segmentation.
|
|
@@ -269,27 +289,25 @@ export async function createPageWithCapture(options) {
|
|
|
269
289
|
if (!page.isClosed()) {
|
|
270
290
|
page.close().catch(() => { });
|
|
271
291
|
}
|
|
272
|
-
// Check for stale capture state error. This occurs after a crash when Chrome's tabCapture retains state from the previous process. Restarting Chrome clears
|
|
273
|
-
// the stale state. We limit recovery attempts to prevent infinite loops if the issue persists.
|
|
274
292
|
const errorMessage = formatError(error);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// Retry the capture with a fresh browser.
|
|
285
|
-
return createPageWithCapture(options);
|
|
293
|
+
// Stale capture state is unrecoverable. The "Cannot capture a tab with an active stream" error occurs inside puppeteer-stream's second lock section, which
|
|
294
|
+
// has no try/finally. The internal mutex is permanently leaked — all subsequent getStream() calls will hang on it. Chrome restart cannot fix module-level
|
|
295
|
+
// state, so the only recourse is a full process restart. Release the capture queue so other callers aren't left hanging, then exit.
|
|
296
|
+
if (errorMessage.includes("Cannot capture a tab with an active stream")) {
|
|
297
|
+
LOG.error("Stale capture state detected. puppeteer-stream's internal capture mutex is now permanently locked. The capture system is unrecoverable. " +
|
|
298
|
+
"Exiting so the service manager can restart with a clean module state.");
|
|
299
|
+
releaseCaptureOnce();
|
|
300
|
+
setTimeout(() => process.exit(1), 100);
|
|
301
|
+
throw error;
|
|
286
302
|
}
|
|
303
|
+
// For non-stale errors, release the capture queue so subsequent callers can proceed.
|
|
304
|
+
releaseCaptureOnce();
|
|
287
305
|
throw error;
|
|
288
306
|
}
|
|
289
307
|
// Navigate and set up playback. For noVideo profiles, just navigate without video setup.
|
|
290
308
|
let context;
|
|
291
309
|
try {
|
|
292
|
-
if (profile.noVideo
|
|
310
|
+
if (!profile.noVideo) {
|
|
293
311
|
// Phase 1: Navigate to the page with retry. The 10-second navigationTimeout is appropriate for page loads, and retryOperation correctly reloads the page on
|
|
294
312
|
// genuine navigation failures. Navigation is wrapped in retryOperation separately from channel selection so the timeout does not race with the internal click
|
|
295
313
|
// retry loops in channel selection strategies (guideGrid can take 15-20 seconds for binary search + click retries).
|
|
@@ -297,9 +315,10 @@ export async function createPageWithCapture(options) {
|
|
|
297
315
|
await navigateToPage(page, url, profile);
|
|
298
316
|
}, CONFIG.streaming.maxNavigationRetries, CONFIG.streaming.navigationTimeout, "page navigation for " + url, undefined, () => page.isClosed());
|
|
299
317
|
// Phase 2: Channel selection + video setup. Runs after navigation succeeds with no outer timeout racing against internal click retries. Each sub-step
|
|
300
|
-
// (selectChannel, waitForVideoReady, etc.) has its own internal timeout via videoTimeout and click retry constants.
|
|
301
|
-
//
|
|
302
|
-
|
|
318
|
+
// (selectChannel, waitForVideoReady, etc.) has its own internal timeout via videoTimeout and click retry constants. For guideGrid strategies, a channel
|
|
319
|
+
// selection failure triggers an overlay dismiss and retry, which doubles the channel selection time budget. The 45-second safety-net timeout accommodates
|
|
320
|
+
// this retry while still preventing pathological hangs if multiple internal timeouts chain sequentially.
|
|
321
|
+
const PLAYBACK_INIT_TIMEOUT = 45000;
|
|
303
322
|
const tuneResult = await Promise.race([
|
|
304
323
|
initializePlayback(page, profile),
|
|
305
324
|
new Promise((_, reject) => {
|
|
@@ -327,10 +346,14 @@ export async function createPageWithCapture(options) {
|
|
|
327
346
|
if (!page.isClosed()) {
|
|
328
347
|
page.close().catch(() => { });
|
|
329
348
|
}
|
|
349
|
+
// Re-minimize the browser window. Navigation may have un-minimized it (new tab activation on macOS), and without this the window stays visible after the
|
|
350
|
+
// failed attempt. Fire-and-forget since we're about to throw.
|
|
351
|
+
minimizeBrowserWindow().catch(() => { });
|
|
330
352
|
throw error;
|
|
331
353
|
}
|
|
332
354
|
// Resize and minimize window.
|
|
333
355
|
await resizeAndMinimizeWindow(page, !profile.noVideo);
|
|
356
|
+
LOG.debug("timing:startup", "Page with capture ready. Total: %sms.", captureElapsed());
|
|
334
357
|
return {
|
|
335
358
|
captureStream: outputStream,
|
|
336
359
|
context,
|
|
@@ -392,7 +415,7 @@ export async function setupStream(options, onCircuitBreak) {
|
|
|
392
415
|
const redirectResult = getProfileForUrl(resolvedUrl);
|
|
393
416
|
if (redirectResult.profileName !== "default") {
|
|
394
417
|
profileResult = redirectResult;
|
|
395
|
-
LOG.
|
|
418
|
+
LOG.debug("streaming:setup", "Resolved redirect for profile detection: %s → %s (%s).", urlToResolve, resolvedUrl, redirectResult.profileName);
|
|
396
419
|
}
|
|
397
420
|
}
|
|
398
421
|
}
|
|
@@ -407,7 +430,7 @@ export async function setupStream(options, onCircuitBreak) {
|
|
|
407
430
|
if (validProfiles.includes(profileOverride)) {
|
|
408
431
|
profile = resolveProfile(profileOverride);
|
|
409
432
|
profileName = profileOverride;
|
|
410
|
-
LOG.
|
|
433
|
+
LOG.debug("streaming:setup", "Profile overridden to '%s' via query parameter.", profileOverride);
|
|
411
434
|
}
|
|
412
435
|
else {
|
|
413
436
|
LOG.warn("Unknown profile override '%s', using resolved profile.", profileOverride);
|
|
@@ -501,7 +524,7 @@ export async function setupStream(options, onCircuitBreak) {
|
|
|
501
524
|
// Close the browser page (fire-and-forget to avoid blocking on stuck pages).
|
|
502
525
|
if (!page.isClosed()) {
|
|
503
526
|
page.close().catch((error) => {
|
|
504
|
-
LOG.
|
|
527
|
+
LOG.debug("streaming:setup", "Page close error during cleanup: %s.", formatError(error));
|
|
505
528
|
});
|
|
506
529
|
}
|
|
507
530
|
// Re-minimize the browser window.
|
|
@@ -526,4 +549,81 @@ export async function setupStream(options, onCircuitBreak) {
|
|
|
526
549
|
};
|
|
527
550
|
});
|
|
528
551
|
}
|
|
552
|
+
// Startup Capture Verification.
|
|
553
|
+
/**
|
|
554
|
+
* Verifies that Chrome's capture system is functional before the server starts accepting requests. This detects stale tabCapture state left over from a previous
|
|
555
|
+
* Chrome process — common during quick service restarts where the old process hasn't fully exited before the new one launches. Without this probe, the first stream
|
|
556
|
+
* request would trigger the runtime stale capture handler, which exits the process because the puppeteer-stream mutex is permanently leaked.
|
|
557
|
+
*
|
|
558
|
+
* The probe creates a temporary page, attempts a short capture, and tears down both cleanly. A 500ms delay after destroying the capture stream allows
|
|
559
|
+
* puppeteer-stream's fire-and-forget STOP_RECORDING chain to complete before closing the page, preventing the stale capture cascade on the first real request.
|
|
560
|
+
*
|
|
561
|
+
* If stale capture state is detected, the process exits immediately — Chrome restart cannot fix the leaked mutex, only a fresh process can.
|
|
562
|
+
*/
|
|
563
|
+
export async function verifyCaptureSystem() {
|
|
564
|
+
const PROBE_TIMEOUT = 5000;
|
|
565
|
+
const browser = await getCurrentBrowser();
|
|
566
|
+
const page = await browser.newPage();
|
|
567
|
+
registerManagedPage(page);
|
|
568
|
+
try {
|
|
569
|
+
// Use the same capture MIME type and viewport constraints as the runtime. The stale state error occurs at the tabCapture API level before encoding matters,
|
|
570
|
+
// but matching the runtime configuration ensures the probe exercises the exact same getStream() parameters.
|
|
571
|
+
const useFFmpeg = CONFIG.streaming.captureMode === "ffmpeg";
|
|
572
|
+
const captureMimeType = useFFmpeg ? WEBM_FFMPEG_MIME_TYPE : NATIVE_FMP4_MIME_TYPE;
|
|
573
|
+
const streamOptions = {
|
|
574
|
+
audio: true,
|
|
575
|
+
mimeType: captureMimeType,
|
|
576
|
+
video: true,
|
|
577
|
+
videoConstraints: {
|
|
578
|
+
mandatory: {
|
|
579
|
+
maxFrameRate: 30,
|
|
580
|
+
maxHeight: getEffectiveViewport(CONFIG).height,
|
|
581
|
+
maxWidth: getEffectiveViewport(CONFIG).width,
|
|
582
|
+
minFrameRate: 30,
|
|
583
|
+
minHeight: getEffectiveViewport(CONFIG).height,
|
|
584
|
+
minWidth: getEffectiveViewport(CONFIG).width
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
const stream = await Promise.race([
|
|
589
|
+
getStream(page, streamOptions),
|
|
590
|
+
new Promise((_, reject) => {
|
|
591
|
+
setTimeout(() => {
|
|
592
|
+
reject(new Error("Capture probe timed out."));
|
|
593
|
+
}, PROBE_TIMEOUT);
|
|
594
|
+
})
|
|
595
|
+
]);
|
|
596
|
+
// Capture succeeded — the system is functional. Destroy the stream before closing the page to ensure chrome.tabCapture releases the capture cleanly.
|
|
597
|
+
const readable = stream;
|
|
598
|
+
readable.destroy();
|
|
599
|
+
// Wait for puppeteer-stream's capture cleanup chain to complete. readable.destroy() triggers STOP_RECORDING via the close handler, but the call is
|
|
600
|
+
// fire-and-forget. The async chain (STOP_RECORDING → recorder.stop() → onstop → track.stop()) must finish before closing the page, or Chrome's tabCapture
|
|
601
|
+
// state may linger and cause "Cannot capture a tab with an active stream" errors on the first real stream request.
|
|
602
|
+
await delay(500);
|
|
603
|
+
unregisterManagedPage(page);
|
|
604
|
+
if (!page.isClosed()) {
|
|
605
|
+
await page.close();
|
|
606
|
+
}
|
|
607
|
+
LOG.info("Capture system verified successfully.");
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
const errorMessage = formatError(error);
|
|
611
|
+
// Clean up the test page.
|
|
612
|
+
unregisterManagedPage(page);
|
|
613
|
+
if (!page.isClosed()) {
|
|
614
|
+
page.close().catch(() => { });
|
|
615
|
+
}
|
|
616
|
+
// Stale capture state is unrecoverable. The error occurs inside puppeteer-stream's second lock section, which has no try/finally — the internal mutex is
|
|
617
|
+
// permanently leaked. All subsequent getStream() calls will hang on it. Chrome restart cannot fix module-level state, so exit and let the service manager
|
|
618
|
+
// restart with a clean process.
|
|
619
|
+
if (errorMessage.includes("Cannot capture a tab with an active stream")) {
|
|
620
|
+
LOG.error("Startup probe detected stale capture state. puppeteer-stream's internal capture mutex is now permanently locked. Exiting so the service " +
|
|
621
|
+
"manager can restart with a clean module state.");
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
// Non-stale error. Fail immediately — retrying without restarting Chrome won't change anything, and a non-stale getStream() failure at startup indicates a
|
|
625
|
+
// fundamental problem (extension not loaded, Chrome misconfigured, etc.).
|
|
626
|
+
throw new Error("Capture system verification failed: " + errorMessage);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
529
629
|
//# sourceMappingURL=setup.js.map
|