@torrent-tv/proxy 2.44.0 → 2.46.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.
@@ -1,339 +1,376 @@
1
- /**
2
- * @file Playback planner service.
3
- *
4
- * Determines whether a torrent file can be served directly or requires
5
- * HLS audio transcoding by probing the stream codecs with ffmpeg.
6
- * Results are cached indefinitely (keyed by source + file index).
7
- */
8
-
9
- import { spawn } from "node:child_process";
10
- import { logger } from "../utils/logger.js";
11
- import {
12
- parseFfmpegDurationSeconds,
13
- parseFfmpegStartTimeSeconds,
14
- parseFfmpegVideoDimensions,
15
- parseFfmpegBitrateKbps,
16
- parseFfmpegVideoFps,
17
- parseFfmpegHdr
18
- } from "./ffmpeg-banner.js";
19
-
20
- /** Audio codecs that browsers can decode natively without transcoding. */
21
- const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
22
-
23
- // Once the plan probe succeeds, warm the START of the file body so the
24
- // transcode session's ffmpeg reads hit downloaded data instead of paying
25
- // piece latency at encode time (the edge prefetch only covers head+tail for
26
- // the codec probe). ~16 MB the first segments of typical media.
27
- const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
28
-
29
- /** Subtitle codecs that can be converted to WebVTT (text-based). */
30
- const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
31
-
32
- /**
33
- * Parse every stream from the ffmpeg `-i` banner: type, codec, language tag,
34
- * default disposition and (when present) the stream's `title` metadata line.
35
- *
36
- * @param {string} ffmpegOutput
37
- * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
38
- */
39
- function parseStreams(ffmpegOutput) {
40
- // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
41
- // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
42
- const inputSection = ffmpegOutput.split(/^(?:Output #|Stream mapping:)/m)[0] ?? ffmpegOutput;
43
- const lines = inputSection.split(/\r?\n/);
44
- const streams = [];
45
- let current = null;
46
- for (const line of lines) {
47
- const streamMatch = line.match(
48
- /^\s*Stream #0:(\d+)(?:\[[^\]]*\])?(?:\(([A-Za-z0-9]{2,3})\))?: (Audio|Video|Subtitle): ([A-Za-z0-9_]+)/
49
- );
50
- if (streamMatch) {
51
- current = {
52
- streamIndex: Number(streamMatch[1]),
53
- type: streamMatch[3].toLowerCase(),
54
- codec: String(streamMatch[4]).toLowerCase(),
55
- language: (streamMatch[2] ?? "").toLowerCase(),
56
- title: "",
57
- isDefault: /\(default\)/.test(line)
58
- };
59
- streams.push(current);
60
- continue;
61
- }
62
- if (current) {
63
- const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
64
- if (titleMatch && current.title.length === 0) {
65
- current.title = titleMatch[1].trim();
66
- continue;
67
- }
68
- // A new top-level section (non-indented line) ends the stream's block.
69
- if (!/^\s/.test(line)) {
70
- current = null;
71
- }
72
- }
73
- }
74
- return streams;
75
- }
76
-
77
- /**
78
- * Parse audio and video codec names from ffmpeg stderr output.
79
- *
80
- * @param {string} ffmpegOutput
81
- * @returns {{ audioCodec: string, videoCodec: string }}
82
- */
83
- function parseStreamCodecs(ffmpegOutput) {
84
- const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
85
- const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
86
- // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
87
- // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
88
- const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
89
- let videoWidth = 0;
90
- let videoHeight = 0;
91
- if (videoLineMatch) {
92
- const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
93
- if (dim) {
94
- videoWidth = Number(dim[1]);
95
- videoHeight = Number(dim[2]);
96
- }
97
- }
98
- const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
99
- const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
100
- let durationSeconds = 0;
101
- if (durationMatch) {
102
- const value =
103
- Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
104
- durationSeconds = Number.isFinite(value) ? value : 0;
105
- }
106
- const streams = parseStreams(ffmpegOutput);
107
- const audioTracks = streams
108
- .filter((s) => s.type === "audio")
109
- .map((s, i) => ({
110
- // Type-relative index — what ffmpeg's `-map 0:a:N` selects.
111
- index: i,
112
- streamIndex: s.streamIndex,
113
- codec: s.codec,
114
- language: s.language,
115
- title: s.title,
116
- isDefault: s.isDefault
117
- }));
118
- const subtitleTracks = streams
119
- .filter((s) => s.type === "subtitle")
120
- .map((s, i) => ({
121
- // Type-relative index — what ffmpeg's `-map 0:s:N` selects.
122
- index: i,
123
- streamIndex: s.streamIndex,
124
- codec: s.codec,
125
- language: s.language,
126
- title: s.title,
127
- isDefault: s.isDefault,
128
- // Image-based subtitles (PGS/VobSub) cannot become WebVTT.
129
- textBased: TEXT_SUBTITLE_CODECS.has(s.codec)
130
- }));
131
- return {
132
- audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
133
- videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
134
- container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
135
- durationSeconds,
136
- videoWidth,
137
- videoHeight,
138
- audioTracks,
139
- subtitleTracks
140
- };
141
- }
142
-
143
- /**
144
- * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
145
- * Times out after `timeoutMs` and returns empty strings on failure.
146
- *
147
- * @param {object} options
148
- * @param {string} options.ffmpegBin
149
- * @param {string} options.inputUrl
150
- * @param {string} [options.userAgent=""]
151
- * @param {number} [options.timeoutMs=8000]
152
- * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
153
- * Parsed banner fields plus the raw `stderr`, so the caller can derive the
154
- * full media info (fps/startTime/HDR) without a second ffmpeg scan.
155
- */
156
- function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
157
- return new Promise((resolve) => {
158
- const args = ["-hide_banner", "-loglevel", "info"];
159
- if (typeof userAgent === "string" && userAgent.trim().length > 0) {
160
- args.push("-user_agent", userAgent.trim());
161
- }
162
- // Decode a tiny slice of all streams (no per-stream -map, so video-only
163
- // files probe correctly too). The ffmpeg banner that precedes decoding
164
- // gives us audio/video codecs, the container format and the duration in a
165
- // single pass.
166
- args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
167
-
168
- const ffmpeg = spawn(ffmpegBin, args, {
169
- stdio: ["ignore", "ignore", "pipe"],
170
- windowsHide: true
171
- });
172
- let stderr = "";
173
- let settled = false;
174
-
175
- const finish = (codecs) => {
176
- if (settled) {
177
- return;
178
- }
179
- settled = true;
180
- resolve(codecs);
181
- };
182
-
183
- const timeoutId = setTimeout(() => {
184
- if (!ffmpeg.killed) {
185
- ffmpeg.kill("SIGTERM");
186
- }
187
- finish({ ...parseStreamCodecs(stderr), stderr });
188
- }, timeoutMs);
189
-
190
- ffmpeg.stderr.on("data", (chunk) => {
191
- stderr += String(chunk);
192
- });
193
-
194
- ffmpeg.on("error", () => {
195
- clearTimeout(timeoutId);
196
- finish({ audioCodec: "", videoCodec: "", stderr: "" });
197
- });
198
-
199
- ffmpeg.on("exit", () => {
200
- clearTimeout(timeoutId);
201
- finish({ ...parseStreamCodecs(stderr), stderr });
202
- });
203
- });
204
- }
205
-
206
- /**
207
- * Resolve after a given number of milliseconds.
208
- *
209
- * @param {number} ms
210
- * @returns {Promise<void>}
211
- */
212
- function delay(ms) {
213
- return new Promise((resolve) => {
214
- setTimeout(resolve, ms);
215
- });
216
- }
217
-
218
- /**
219
- * Build the direct stream URL for a source file served by the local proxy.
220
- *
221
- * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
222
- * @param {string} sourceKey
223
- * @param {number} fileIndex
224
- * @returns {string}
225
- */
226
- function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
227
- const directUrl = new URL("/stream", `${localBaseUrl}/`);
228
- directUrl.searchParams.set("sourceKey", sourceKey);
229
- directUrl.searchParams.set("fileIndex", String(fileIndex));
230
- return directUrl.toString();
231
- }
232
-
233
- /**
234
- * @typedef {Object} PlaybackPlan
235
- * @property {"direct" | "hls"} mode
236
- * @property {string} directUrl
237
- * @property {string} reason - Human-readable explanation of the chosen mode.
238
- * @property {string} audioCodec
239
- * @property {string} videoCodec
240
- * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
241
- * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
242
- * @property {number} videoWidth - Source coded width (0 if unknown).
243
- * @property {number} videoHeight - Source coded height (0 if unknown).
244
- */
245
-
246
- /**
247
- * @typedef {Object} PlaybackPlannerOptions
248
- * @property {string} ffmpegBin
249
- * @property {boolean} transcodeAudioEnabled
250
- * @property {string} localBaseUrl
251
- * @property {ReturnType<import("../store/source-registry.js").createSourceRegistry>} sourceRegistry
252
- * @property {import("./torrent-pool.js").TorrentPool} torrentPool
253
- */
254
-
255
- /**
256
- * Create a playback planner that decides the optimal streaming mode for
257
- * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
258
- *
259
- * @param {PlaybackPlannerOptions} options
260
- * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan> }}
261
- */
262
- export function createPlaybackPlanner({
263
- ffmpegBin,
264
- transcodeAudioEnabled,
265
- localBaseUrl,
266
- sourceRegistry,
267
- torrentPool,
268
- // Optional. Reports what this host typically takes to produce a session's
269
- // first segment. The browser needs it for the gap between "the file is
270
- // downloaded" and "a segment exists": until now it assumed the pipeline
271
- // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
272
- expectedFirstSegmentMs,
273
- expectedSessionCreateMs,
274
- // Optional. Called once the file's edges are downloaded, so the keyframe
275
- // index — which reads the same tail of the file — is fetched alongside the
276
- // codec probe instead of after it. Late-bound to the HLS session manager,
277
- // which owns the cache both of them share.
278
- warmKeyframeIndex,
279
- // Optional. The heights this host could actually serve this source at, for
280
- // both playback branches, so the quality menu is right from the moment the
281
- // file is opened rather than from the moment an encoder exists.
282
- predictOfferedHeights
283
- }) {
284
- /** @type {Map<string, PlaybackPlan>} */
285
- const cache = new Map();
286
- /**
287
- * Full media info parsed from the SAME probe that produced the plan, cached
288
- * under the same key so a transcode session can reuse it instead of running
289
- * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
290
- * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
291
- */
292
- const mediaInfoCache = new Map();
293
-
294
- /**
295
- * Attach what this host currently measures itself taking to create a session
296
- * and to produce a first segment.
297
- *
298
- * Read at RESPONSE time, deliberately. Both are medians of sessions that have
299
- * already finished on this host, so at the moment a plan is BUILT the very
300
- * first file opened after a restart has none and gets `null` — and the plan
301
- * is then cached, so that file kept answering `null` for the life of the
302
- * process however many sessions ran afterwards. Measured 2026-08-05: a fresh
303
- * 2.9.103 answered `null` for both, then produced the session in 6 ms and the
304
- * first segment in 21 479 ms. The figures existed; the plan could not carry
305
- * them, and the browser's estimate fell back to its own guess in exactly the
306
- * cold-start case the feature was built for.
307
- *
308
- * @param {PlaybackPlan} plan
309
- * @returns {PlaybackPlan}
310
- */
311
- function withHostTimings(plan) {
312
- return {
313
- ...plan,
314
- expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
315
- expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
316
- // Answered here for the same reason as the two above: a plan is cached for
317
- // the life of the process, and what this host will serve a file at is not.
318
- // It starts as a prediction from the startup benchmarks and is replaced by
319
- // what an encoder running on this very source turns out to cost — frozen
320
- // into the cache, every later open of the file would hand the browser the
321
- // first guess again and undo that. This is the 2.9.106 defect exactly.
322
- offeredHeights: plan.mediaInfoForOffer
323
- ? (predictOfferedHeights?.(plan.mediaInfoForOffer) ?? null)
324
- : null,
325
- mediaInfoForOffer: undefined
326
- };
327
- }
328
-
329
- return {
330
- /**
331
- * Media info the planner already probed for this file, or `null`. Lets the
332
- * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
333
- *
334
- * @param {{ sourceKey: string, fileIndex: number }} params
335
- * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
336
- */
1
+ /**
2
+ * @file Playback planner service.
3
+ *
4
+ * Determines whether a torrent file can be served directly or requires
5
+ * HLS audio transcoding by probing the stream codecs with ffmpeg.
6
+ * Results are cached indefinitely (keyed by source + file index).
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import { logger } from "../utils/logger.js";
11
+ import { mergeContainerSubtitleFlags } from "./subtitle-defaults.js";
12
+ import {
13
+ parseFfmpegDurationSeconds,
14
+ parseFfmpegStartTimeSeconds,
15
+ parseFfmpegVideoDimensions,
16
+ parseFfmpegBitDepth,
17
+ parseFfmpegBitrateKbps,
18
+ parseFfmpegVideoFps,
19
+ parseFfmpegHdr
20
+ } from "./ffmpeg-banner.js";
21
+
22
+ /** Audio codecs that browsers can decode natively without transcoding. */
23
+ const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
24
+
25
+ // Once the plan probe succeeds, warm the START of the file body so the
26
+ // transcode session's ffmpeg reads hit downloaded data instead of paying
27
+ // piece latency at encode time (the edge prefetch only covers head+tail for
28
+ // the codec probe). ~16 MB ≈ the first segments of typical media.
29
+ const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
30
+
31
+ /** Subtitle codecs that can be converted to WebVTT (text-based). */
32
+ const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
33
+
34
+ /**
35
+ * Parse every stream from the ffmpeg `-i` banner: type, codec, language tag,
36
+ * default disposition and (when present) the stream's `title` metadata line.
37
+ *
38
+ * @param {string} ffmpegOutput
39
+ * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
40
+ */
41
+ function parseStreams(ffmpegOutput) {
42
+ // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
43
+ // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
44
+ const inputSection = ffmpegOutput.split(/^(?:Output #|Stream mapping:)/m)[0] ?? ffmpegOutput;
45
+ const lines = inputSection.split(/\r?\n/);
46
+ const streams = [];
47
+ let current = null;
48
+ for (const line of lines) {
49
+ const streamMatch = line.match(
50
+ /^\s*Stream #0:(\d+)(?:\[[^\]]*\])?(?:\(([A-Za-z0-9]{2,3})\))?: (Audio|Video|Subtitle): ([A-Za-z0-9_]+)/
51
+ );
52
+ if (streamMatch) {
53
+ current = {
54
+ streamIndex: Number(streamMatch[1]),
55
+ type: streamMatch[3].toLowerCase(),
56
+ codec: String(streamMatch[4]).toLowerCase(),
57
+ language: (streamMatch[2] ?? "").toLowerCase(),
58
+ title: "",
59
+ isDefault: /\(default\)/.test(line)
60
+ };
61
+ streams.push(current);
62
+ continue;
63
+ }
64
+ if (current) {
65
+ const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
66
+ if (titleMatch && current.title.length === 0) {
67
+ current.title = titleMatch[1].trim();
68
+ continue;
69
+ }
70
+ // A new top-level section (non-indented line) ends the stream's block.
71
+ if (!/^\s/.test(line)) {
72
+ current = null;
73
+ }
74
+ }
75
+ }
76
+ return streams;
77
+ }
78
+
79
+ /**
80
+ * Parse audio and video codec names from ffmpeg stderr output.
81
+ *
82
+ * @param {string} ffmpegOutput
83
+ * @returns {{ audioCodec: string, videoCodec: string }}
84
+ */
85
+ function parseStreamCodecs(ffmpegOutput) {
86
+ const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
87
+ const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
88
+ // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
89
+ // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
90
+ const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
91
+ let videoWidth = 0;
92
+ let videoHeight = 0;
93
+ if (videoLineMatch) {
94
+ const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
95
+ if (dim) {
96
+ videoWidth = Number(dim[1]);
97
+ videoHeight = Number(dim[2]);
98
+ }
99
+ }
100
+ const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
101
+ const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
102
+ let durationSeconds = 0;
103
+ if (durationMatch) {
104
+ const value =
105
+ Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
106
+ durationSeconds = Number.isFinite(value) ? value : 0;
107
+ }
108
+ const streams = parseStreams(ffmpegOutput);
109
+ const audioTracks = streams
110
+ .filter((s) => s.type === "audio")
111
+ .map((s, i) => ({
112
+ // Type-relative index — what ffmpeg's `-map 0:a:N` selects.
113
+ index: i,
114
+ streamIndex: s.streamIndex,
115
+ codec: s.codec,
116
+ language: s.language,
117
+ title: s.title,
118
+ isDefault: s.isDefault
119
+ }));
120
+ const subtitleTracks = streams
121
+ .filter((s) => s.type === "subtitle")
122
+ .map((s, i) => ({
123
+ // Type-relative index — what ffmpeg's `-map 0:s:N` selects.
124
+ index: i,
125
+ streamIndex: s.streamIndex,
126
+ codec: s.codec,
127
+ language: s.language,
128
+ title: s.title,
129
+ isDefault: s.isDefault,
130
+ // Image-based subtitles (PGS/VobSub) cannot become WebVTT.
131
+ textBased: TEXT_SUBTITLE_CODECS.has(s.codec)
132
+ }));
133
+ return {
134
+ audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
135
+ videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
136
+ container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
137
+ durationSeconds,
138
+ videoWidth,
139
+ videoHeight,
140
+ audioTracks,
141
+ subtitleTracks
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
147
+ * Times out after `timeoutMs` and returns empty strings on failure.
148
+ *
149
+ * @param {object} options
150
+ * @param {string} options.ffmpegBin
151
+ * @param {string} options.inputUrl
152
+ * @param {string} [options.userAgent=""]
153
+ * @param {number} [options.timeoutMs=8000]
154
+ * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
155
+ * Parsed banner fields plus the raw `stderr`, so the caller can derive the
156
+ * full media info (fps/startTime/HDR) without a second ffmpeg scan.
157
+ */
158
+ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
159
+ return new Promise((resolve) => {
160
+ const args = ["-hide_banner", "-loglevel", "info"];
161
+ if (typeof userAgent === "string" && userAgent.trim().length > 0) {
162
+ args.push("-user_agent", userAgent.trim());
163
+ }
164
+ // Decode a tiny slice of all streams (no per-stream -map, so video-only
165
+ // files probe correctly too). The ffmpeg banner that precedes decoding
166
+ // gives us audio/video codecs, the container format and the duration in a
167
+ // single pass.
168
+ args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
169
+
170
+ const ffmpeg = spawn(ffmpegBin, args, {
171
+ stdio: ["ignore", "ignore", "pipe"],
172
+ windowsHide: true
173
+ });
174
+ let stderr = "";
175
+ let settled = false;
176
+
177
+ const finish = (codecs) => {
178
+ if (settled) {
179
+ return;
180
+ }
181
+ settled = true;
182
+ resolve(codecs);
183
+ };
184
+
185
+ const timeoutId = setTimeout(() => {
186
+ if (!ffmpeg.killed) {
187
+ ffmpeg.kill("SIGTERM");
188
+ }
189
+ finish({ ...parseStreamCodecs(stderr), stderr });
190
+ }, timeoutMs);
191
+
192
+ ffmpeg.stderr.on("data", (chunk) => {
193
+ stderr += String(chunk);
194
+ });
195
+
196
+ ffmpeg.on("error", () => {
197
+ clearTimeout(timeoutId);
198
+ finish({ audioCodec: "", videoCodec: "", stderr: "" });
199
+ });
200
+
201
+ ffmpeg.on("exit", () => {
202
+ clearTimeout(timeoutId);
203
+ finish({ ...parseStreamCodecs(stderr), stderr });
204
+ });
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Resolve after a given number of milliseconds.
210
+ *
211
+ * @param {number} ms
212
+ * @returns {Promise<void>}
213
+ */
214
+ function delay(ms) {
215
+ return new Promise((resolve) => {
216
+ setTimeout(resolve, ms);
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Build the direct stream URL for a source file served by the local proxy.
222
+ *
223
+ * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
224
+ * @param {string} sourceKey
225
+ * @param {number} fileIndex
226
+ * @returns {string}
227
+ */
228
+ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
229
+ const directUrl = new URL("/stream", `${localBaseUrl}/`);
230
+ directUrl.searchParams.set("sourceKey", sourceKey);
231
+ directUrl.searchParams.set("fileIndex", String(fileIndex));
232
+ return directUrl.toString();
233
+ }
234
+
235
+ /**
236
+ * @typedef {Object} PlaybackPlan
237
+ * @property {"direct" | "hls"} mode
238
+ * @property {string} directUrl
239
+ * @property {string} reason - Human-readable explanation of the chosen mode.
240
+ * @property {string} audioCodec
241
+ * @property {string} videoCodec
242
+ * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
243
+ * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
244
+ * @property {number} videoWidth - Source coded width (0 if unknown).
245
+ * @property {number} videoHeight - Source coded height (0 if unknown).
246
+ */
247
+
248
+ /**
249
+ * @typedef {Object} PlaybackPlannerOptions
250
+ * @property {string} ffmpegBin
251
+ * @property {boolean} transcodeAudioEnabled
252
+ * @property {string} localBaseUrl
253
+ * @property {ReturnType<import("../store/source-registry.js").createSourceRegistry>} sourceRegistry
254
+ * @property {import("./torrent-pool.js").TorrentPool} torrentPool
255
+ */
256
+
257
+ /**
258
+ * Create a playback planner that decides the optimal streaming mode for
259
+ * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
260
+ *
261
+ * @param {PlaybackPlannerOptions} options
262
+ * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan> }}
263
+ */
264
+ export function createPlaybackPlanner({
265
+ ffmpegBin,
266
+ transcodeAudioEnabled,
267
+ localBaseUrl,
268
+ sourceRegistry,
269
+ torrentPool,
270
+ // Optional. Reports what this host typically takes to produce a session's
271
+ // first segment. The browser needs it for the gap between "the file is
272
+ // downloaded" and "a segment exists": until now it assumed the pipeline
273
+ // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
274
+ expectedFirstSegmentMs,
275
+ expectedSessionCreateMs,
276
+ // Optional. Called once the file's edges are downloaded, so the keyframe
277
+ // index — which reads the same tail of the file — is fetched alongside the
278
+ // codec probe instead of after it. Late-bound to the HLS session manager,
279
+ // which owns the cache both of them share.
280
+ warmKeyframeIndex,
281
+ // Optional. The heights this host could actually serve this source at, for
282
+ // both playback branches, so the quality menu is right from the moment the
283
+ // file is opened rather than from the moment an encoder exists.
284
+ predictOfferedHeights
285
+ }) {
286
+ /** @type {Map<string, PlaybackPlan>} */
287
+ const cache = new Map();
288
+ /**
289
+ * Full media info parsed from the SAME probe that produced the plan, cached
290
+ * under the same key so a transcode session can reuse it instead of running
291
+ * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
292
+ * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
293
+ */
294
+ const mediaInfoCache = new Map();
295
+
296
+ /**
297
+ * Attach what this host currently measures itself taking to create a session
298
+ * and to produce a first segment.
299
+ *
300
+ * Read at RESPONSE time, deliberately. Both are medians of sessions that have
301
+ * already finished on this host, so at the moment a plan is BUILT the very
302
+ * first file opened after a restart has none and gets `null` — and the plan
303
+ * is then cached, so that file kept answering `null` for the life of the
304
+ * process however many sessions ran afterwards. Measured 2026-08-05: a fresh
305
+ * 2.9.103 answered `null` for both, then produced the session in 6 ms and the
306
+ * first segment in 21 479 ms. The figures existed; the plan could not carry
307
+ * them, and the browser's estimate fell back to its own guess in exactly the
308
+ * cold-start case the feature was built for.
309
+ *
310
+ * @param {PlaybackPlan} plan
311
+ * @returns {PlaybackPlan}
312
+ */
313
+ /**
314
+ * The probe's subtitle tracks, with `FlagDefault` read from the container
315
+ * instead of inferred from ffmpeg's banner.
316
+ *
317
+ * Best-effort by construction: a container that cannot be read this way, or a
318
+ * reading that does not line up with the probe, leaves the tracks as they
319
+ * were with `declaresDefault: false` which the browser reads as "the file
320
+ * has no opinion", and then nothing is shown unasked.
321
+ *
322
+ * @param {object} torrent
323
+ * @param {number} fileIndex
324
+ * @param {object[]} subtitleTracks
325
+ * @returns {Promise<object[]>}
326
+ */
327
+ async function withContainerDefaults(torrent, fileIndex, subtitleTracks) {
328
+ if (subtitleTracks.length === 0 || typeof torrentPool?.getDeclaredSubtitleTracks !== "function") {
329
+ return subtitleTracks.map((track) => ({ ...track, declaresDefault: false }));
330
+ }
331
+ let declared = [];
332
+ try {
333
+ declared = await torrentPool.getDeclaredSubtitleTracks(torrent, fileIndex);
334
+ } catch (error) {
335
+ logger.info(`subtitle defaults: the container could not be read (${error?.message ?? error})`);
336
+ }
337
+ const merged = mergeContainerSubtitleFlags(subtitleTracks, declared);
338
+ logger.info(
339
+ merged.aligned
340
+ ? "subtitle defaults: the container wrote FlagDefault on " +
341
+ `${merged.tracks.filter((track) => track.declaresDefault).length} of ${merged.tracks.length} ` +
342
+ `subtitle tracks, marking ${merged.tracks.filter((track) => track.declaresDefault && track.isDefault).length}`
343
+ : `subtitle defaults: using the probe's own flags — ${merged.reason}`
344
+ );
345
+ return merged.tracks;
346
+ }
347
+
348
+ function withHostTimings(plan) {
349
+ return {
350
+ ...plan,
351
+ expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
352
+ expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
353
+ // Answered here for the same reason as the two above: a plan is cached for
354
+ // the life of the process, and what this host will serve a file at is not.
355
+ // It starts as a prediction from the startup benchmarks and is replaced by
356
+ // what an encoder running on this very source turns out to cost — frozen
357
+ // into the cache, every later open of the file would hand the browser the
358
+ // first guess again and undo that. This is the 2.9.106 defect exactly.
359
+ offeredHeights: plan.mediaInfoForOffer
360
+ ? (predictOfferedHeights?.(plan.mediaInfoForOffer) ?? null)
361
+ : null,
362
+ mediaInfoForOffer: undefined
363
+ };
364
+ }
365
+
366
+ return {
367
+ /**
368
+ * Media info the planner already probed for this file, or `null`. Lets the
369
+ * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
370
+ *
371
+ * @param {{ sourceKey: string, fileIndex: number }} params
372
+ * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
373
+ */
337
374
  /**
338
375
  * The audio tracks this file was probed to have, or an empty list. The
339
376
  * master playlist publishes one rendition per track, and the inventory is
@@ -348,205 +385,211 @@ export function createPlaybackPlanner({
348
385
  return Array.isArray(plan?.audioTracks) ? plan.audioTracks : [];
349
386
  },
350
387
 
351
- getCachedMediaInfo({ sourceKey, fileIndex }) {
352
- return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
353
- },
354
-
355
- /**
356
- * Return the playback plan for the given source file.
357
- * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
358
- * when the source or file cannot be located.
359
- *
360
- * When the file header has not downloaded yet (cold torrent, peers still
361
- * connecting) the codec probe cannot succeed. Rather than block the HTTP
362
- * response until it can, the planner prioritises the header, probes for at
363
- * most `maxWaitMs`, and if still undetectable returns a plan flagged
364
- * `pending: true` (NOT cached). The caller polls again — each call keeps the
365
- * header prioritised and downloading — until a real plan comes back. This
366
- * avoids a single long request racing the transport's request timeout.
367
- *
368
- * @param {object} params
369
- * @param {string} params.sourceKey
370
- * @param {number} params.fileIndex
371
- * @param {string} [params.userAgent=""]
372
- * @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
373
- * @returns {Promise<PlaybackPlan & { pending?: boolean }>}
374
- */
375
- async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000 }) {
376
- const cacheKey = `${sourceKey}:${fileIndex}`;
377
- const cached = cache.get(cacheKey);
378
- if (cached) {
379
- return withHostTimings(cached);
380
- }
381
- // Where the time before playback goes. `cold-start` already breaks down
382
- // everything from the transcode-session request onwards, but the plan
383
- // runs BEFORE that and was a single opaque wait: a field session spent
384
- // 5.7 s here on a torrent already in the store, with the codec probe
385
- // cached, and nothing said which part of it was slow.
386
- const planEntryMs = Date.now();
387
- let torrentReadyMs = 0;
388
- let edgesReadyMs = 0;
389
-
390
- const sourceRecord = sourceRegistry.get(sourceKey);
391
- if (!sourceRecord) {
392
- const error = new Error("Source key was not found.");
393
- error.code = "SOURCE_NOT_FOUND";
394
- throw error;
395
- }
396
-
397
- const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
398
- torrentReadyMs = Date.now() - planEntryMs;
399
- const file = torrent.files[fileIndex];
400
- if (!file) {
401
- const error = new Error("File index was not found in torrent.");
402
- error.code = "FILE_NOT_FOUND";
403
- throw error;
404
- }
405
-
406
- const directUrl = buildDirectUrl(localBaseUrl, sourceKey, fileIndex);
407
- if (!transcodeAudioEnabled) {
408
- const plan = {
409
- mode: "direct",
410
- directUrl,
411
- reason: "transcode-disabled",
412
- audioCodec: "",
413
- videoCodec: "",
414
- container: "",
415
- durationSeconds: 0,
416
- videoWidth: 0,
417
- videoHeight: 0,
418
- audioTracks: [],
419
- subtitleTracks: []
420
- };
421
- cache.set(cacheKey, plan);
422
- return withHostTimings(plan);
423
- }
424
-
425
- // Pre-fetch file edges (head + tail), then probe — retrying while the
426
- // file header is still downloading. In a multi-file torrent the pieces
427
- // for a given file arrive unevenly, so the first probe can return empty
428
- // codecs. A transient empty probe must NOT be cached: otherwise the wrong
429
- // plan (file treated as directly playable) sticks permanently for this
430
- // file, and an unsupported codec like xvid gets copied → black video.
431
- await torrentPool.prefetchFileEdges(torrent, fileIndex);
432
- edgesReadyMs = Date.now() - planEntryMs;
433
- // The keyframe index reads the tail of the file, which the probe has just
434
- // waited for as well. Started here it overlaps the probe instead of
435
- // following the whole plan — worth 311-430 ms of the time before the
436
- // first segment. Fire and forget: the session reads it itself if this has
437
- // not finished, and both share one cache entry.
438
- warmKeyframeIndex?.({
439
- sourceKey,
440
- fileIndex,
441
- inputUrl: new URL(directUrl),
442
- logName: file.name
443
- });
444
- let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
445
- const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
446
- let attempt = 0;
447
- while (
448
- probe.audioCodec.length === 0 &&
449
- probe.videoCodec.length === 0 &&
450
- Date.now() < probeDeadline
451
- ) {
452
- attempt += 1;
453
- await delay(Math.min(3_000, 500 + attempt * 250));
454
- await torrentPool.prefetchFileEdges(torrent, fileIndex);
455
- probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
456
- }
457
- const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
458
- const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
459
- logger.info(
460
- `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
461
- `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
462
- `total=${Date.now() - planEntryMs}ms attempts=${attempt + 1} ` +
463
- `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
464
- );
465
-
466
- // `mode` is advisory only (audio-codec based). The browser makes the
467
- // authoritative decision independently per stream via canPlayType /
468
- // mediaCapabilities, transcoding only what it cannot play.
469
- const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
470
- const plan = {
471
- mode: requiresTranscode ? "hls" : "direct",
472
- directUrl,
473
- reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
474
- audioCodec,
475
- videoCodec,
476
- container,
477
- durationSeconds,
478
- // Source coded resolution — drives the browser's manual quality menu
479
- // (list of forced resolutions <= source). 0 when unknown.
480
- videoWidth,
481
- videoHeight,
482
- // Full track inventory for the browser's audio/subtitle menus.
483
- audioTracks: audioTracks ?? [],
484
- subtitleTracks: subtitleTracks ?? [],
485
- // Both host timings are filled in by `withHostTimings` on the way out,
486
- // never here: read at build time they would be frozen into the cached
487
- // plan, which is the bug fixed in 2.9.106.
488
- expectedFirstSegmentMs: null,
489
- expectedSessionCreateMs: null,
490
- offeredHeights: null,
491
- // What the offer is computed FROM, kept on the cached plan so the offer
492
- // itself can be recomputed on every response. The figures are the
493
- // probe's own and never change for a file; the answer derived from them
494
- // does, as the host learns what this source costs. Stripped on the way
495
- // out — it is not part of the plan the browser is given.
496
- mediaInfoForOffer: {
497
- width: videoWidth,
498
- height: videoHeight,
499
- fps: parseFfmpegVideoFps(probe.stderr),
500
- bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
501
- // Which file this is, so the offer can be answered from what an
502
- // encoder has already learned about THIS source rather than from the
503
- // startup clips the same correction a live session applies.
504
- sourceKey,
505
- fileIndex
506
- }
507
- };
508
- // Only cache a plan whose codecs were actually detected. An empty probe is
509
- // a "header not downloaded yet" signal, not a valid result — caching it
510
- // would permanently mis-plan the file. In that case flag the plan
511
- // `pending` so the caller polls again (the header keeps downloading,
512
- // prioritised by the prefetch above).
513
- if (codecsDetected) {
514
- cache.set(cacheKey, plan);
515
- // Cache the full media info from THIS probe's banner (same helpers the
516
- // session manager uses) so createSession can skip its own probe.
517
- const dims = parseFfmpegVideoDimensions(probe.stderr);
518
- mediaInfoCache.set(cacheKey, {
519
- // The codecs, because the session manager asks this cache which
520
- // tracks the output will carry and they were never stored here. It
521
- // read `videoCodec`/`audioCodec` off an object that has only ever had
522
- // dimensions and duration, got `undefined` for both, and declared
523
- // `{video: false, audio: false}` for EVERY session since the check was
524
- // written. Measured 2026-08-11: `declared tracks video=false
525
- // audio=false`, which left the browser unable to tell "this file has
526
- // no video" from "the video was lost on the way", and left the init
527
- // guard expecting zero tracks and therefore accepting any header.
528
- videoCodec: plan.videoCodec,
529
- audioCodec: plan.audioCodec,
530
- durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
531
- width: dims.width,
532
- height: dims.height,
533
- bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
534
- fps: parseFfmpegVideoFps(probe.stderr),
535
- startTime: parseFfmpegStartTimeSeconds(probe.stderr),
536
- isHdr: parseFfmpegHdr(probe.stderr)
537
- });
538
- // Warm the file-body start for the transcode session that follows.
539
- // Fire-and-forget: never delays the plan response.
540
- void torrentPool
541
- .prefetchFileEdges(torrent, fileIndex, {
542
- headBytes: BODY_PREFETCH_BYTES,
543
- tailBytes: 0,
544
- timeoutMs: 60_000
545
- })
546
- .catch(() => {});
547
- return withHostTimings(plan);
548
- }
549
- return withHostTimings({ ...plan, pending: true });
550
- }
551
- };
552
- }
388
+ getCachedMediaInfo({ sourceKey, fileIndex }) {
389
+ return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
390
+ },
391
+
392
+ /**
393
+ * Return the playback plan for the given source file.
394
+ * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
395
+ * when the source or file cannot be located.
396
+ *
397
+ * When the file header has not downloaded yet (cold torrent, peers still
398
+ * connecting) the codec probe cannot succeed. Rather than block the HTTP
399
+ * response until it can, the planner prioritises the header, probes for at
400
+ * most `maxWaitMs`, and if still undetectable returns a plan flagged
401
+ * `pending: true` (NOT cached). The caller polls again — each call keeps the
402
+ * header prioritised and downloading — until a real plan comes back. This
403
+ * avoids a single long request racing the transport's request timeout.
404
+ *
405
+ * @param {object} params
406
+ * @param {string} params.sourceKey
407
+ * @param {number} params.fileIndex
408
+ * @param {string} [params.userAgent=""]
409
+ * @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
410
+ * @returns {Promise<PlaybackPlan & { pending?: boolean }>}
411
+ */
412
+ async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000 }) {
413
+ const cacheKey = `${sourceKey}:${fileIndex}`;
414
+ const cached = cache.get(cacheKey);
415
+ if (cached) {
416
+ return withHostTimings(cached);
417
+ }
418
+ // Where the time before playback goes. `cold-start` already breaks down
419
+ // everything from the transcode-session request onwards, but the plan
420
+ // runs BEFORE that and was a single opaque wait: a field session spent
421
+ // 5.7 s here on a torrent already in the store, with the codec probe
422
+ // cached, and nothing said which part of it was slow.
423
+ const planEntryMs = Date.now();
424
+ let torrentReadyMs = 0;
425
+ let edgesReadyMs = 0;
426
+
427
+ const sourceRecord = sourceRegistry.get(sourceKey);
428
+ if (!sourceRecord) {
429
+ const error = new Error("Source key was not found.");
430
+ error.code = "SOURCE_NOT_FOUND";
431
+ throw error;
432
+ }
433
+
434
+ const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
435
+ torrentReadyMs = Date.now() - planEntryMs;
436
+ const file = torrent.files[fileIndex];
437
+ if (!file) {
438
+ const error = new Error("File index was not found in torrent.");
439
+ error.code = "FILE_NOT_FOUND";
440
+ throw error;
441
+ }
442
+
443
+ const directUrl = buildDirectUrl(localBaseUrl, sourceKey, fileIndex);
444
+ if (!transcodeAudioEnabled) {
445
+ const plan = {
446
+ mode: "direct",
447
+ directUrl,
448
+ reason: "transcode-disabled",
449
+ audioCodec: "",
450
+ videoCodec: "",
451
+ container: "",
452
+ durationSeconds: 0,
453
+ videoWidth: 0,
454
+ videoHeight: 0,
455
+ audioTracks: [],
456
+ subtitleTracks: []
457
+ };
458
+ cache.set(cacheKey, plan);
459
+ return withHostTimings(plan);
460
+ }
461
+
462
+ // Pre-fetch file edges (head + tail), then probe — retrying while the
463
+ // file header is still downloading. In a multi-file torrent the pieces
464
+ // for a given file arrive unevenly, so the first probe can return empty
465
+ // codecs. A transient empty probe must NOT be cached: otherwise the wrong
466
+ // plan (file treated as directly playable) sticks permanently for this
467
+ // file, and an unsupported codec like xvid gets copied → black video.
468
+ await torrentPool.prefetchFileEdges(torrent, fileIndex);
469
+ edgesReadyMs = Date.now() - planEntryMs;
470
+ // The keyframe index reads the tail of the file, which the probe has just
471
+ // waited for as well. Started here it overlaps the probe instead of
472
+ // following the whole plan — worth 311-430 ms of the time before the
473
+ // first segment. Fire and forget: the session reads it itself if this has
474
+ // not finished, and both share one cache entry.
475
+ warmKeyframeIndex?.({
476
+ sourceKey,
477
+ fileIndex,
478
+ inputUrl: new URL(directUrl),
479
+ logName: file.name
480
+ });
481
+ let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
482
+ const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
483
+ let attempt = 0;
484
+ while (
485
+ probe.audioCodec.length === 0 &&
486
+ probe.videoCodec.length === 0 &&
487
+ Date.now() < probeDeadline
488
+ ) {
489
+ attempt += 1;
490
+ await delay(Math.min(3_000, 500 + attempt * 250));
491
+ await torrentPool.prefetchFileEdges(torrent, fileIndex);
492
+ probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
493
+ }
494
+ const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
495
+ const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
496
+ logger.info(
497
+ `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
498
+ `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
499
+ `total=${Date.now() - planEntryMs}ms attempts=${attempt + 1} ` +
500
+ `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
501
+ );
502
+
503
+ // `mode` is advisory only (audio-codec based). The browser makes the
504
+ // authoritative decision independently per stream via canPlayType /
505
+ // mediaCapabilities, transcoding only what it cannot play.
506
+ const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
507
+ const plan = {
508
+ mode: requiresTranscode ? "hls" : "direct",
509
+ directUrl,
510
+ reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
511
+ audioCodec,
512
+ videoCodec,
513
+ container,
514
+ durationSeconds,
515
+ // Source coded resolution — drives the browser's manual quality menu
516
+ // (list of forced resolutions <= source). 0 when unknown.
517
+ videoWidth,
518
+ videoHeight,
519
+ // Full track inventory for the browser's audio/subtitle menus.
520
+ audioTracks: audioTracks ?? [],
521
+ subtitleTracks: await withContainerDefaults(torrent, fileIndex, subtitleTracks ?? []),
522
+ // Both host timings are filled in by `withHostTimings` on the way out,
523
+ // never here: read at build time they would be frozen into the cached
524
+ // plan, which is the bug fixed in 2.9.106.
525
+ expectedFirstSegmentMs: null,
526
+ expectedSessionCreateMs: null,
527
+ offeredHeights: null,
528
+ // What the offer is computed FROM, kept on the cached plan so the offer
529
+ // itself can be recomputed on every response. The figures are the
530
+ // probe's own and never change for a file; the answer derived from them
531
+ // does, as the host learns what this source costs. Stripped on the way
532
+ // out — it is not part of the plan the browser is given.
533
+ mediaInfoForOffer: {
534
+ width: videoWidth,
535
+ height: videoHeight,
536
+ fps: parseFfmpegVideoFps(probe.stderr),
537
+ bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
538
+ // Which family of the decode measurement prices this source. A video
539
+ // that has to be re-encoded is one the browser could not play, so it
540
+ // is usually NOT H.264, and H.264 constants are wrong for it.
541
+ codec: videoCodec,
542
+ bitDepth: parseFfmpegBitDepth(probe.stderr),
543
+ // Which file this is, so the offer can be answered from what an
544
+ // encoder has already learned about THIS source rather than from the
545
+ // startup clips the same correction a live session applies.
546
+ sourceKey,
547
+ fileIndex
548
+ }
549
+ };
550
+ // Only cache a plan whose codecs were actually detected. An empty probe is
551
+ // a "header not downloaded yet" signal, not a valid result — caching it
552
+ // would permanently mis-plan the file. In that case flag the plan
553
+ // `pending` so the caller polls again (the header keeps downloading,
554
+ // prioritised by the prefetch above).
555
+ if (codecsDetected) {
556
+ cache.set(cacheKey, plan);
557
+ // Cache the full media info from THIS probe's banner (same helpers the
558
+ // session manager uses) so createSession can skip its own probe.
559
+ const dims = parseFfmpegVideoDimensions(probe.stderr);
560
+ mediaInfoCache.set(cacheKey, {
561
+ // The codecs, because the session manager asks this cache which
562
+ // tracks the output will carry and they were never stored here. It
563
+ // read `videoCodec`/`audioCodec` off an object that has only ever had
564
+ // dimensions and duration, got `undefined` for both, and declared
565
+ // `{video: false, audio: false}` for EVERY session since the check was
566
+ // written. Measured 2026-08-11: `declared tracks video=false
567
+ // audio=false`, which left the browser unable to tell "this file has
568
+ // no video" from "the video was lost on the way", and left the init
569
+ // guard expecting zero tracks and therefore accepting any header.
570
+ videoCodec: plan.videoCodec,
571
+ audioCodec: plan.audioCodec,
572
+ durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
573
+ width: dims.width,
574
+ height: dims.height,
575
+ bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
576
+ fps: parseFfmpegVideoFps(probe.stderr),
577
+ startTime: parseFfmpegStartTimeSeconds(probe.stderr),
578
+ isHdr: parseFfmpegHdr(probe.stderr),
579
+ bitDepth: parseFfmpegBitDepth(probe.stderr)
580
+ });
581
+ // Warm the file-body start for the transcode session that follows.
582
+ // Fire-and-forget: never delays the plan response.
583
+ void torrentPool
584
+ .prefetchFileEdges(torrent, fileIndex, {
585
+ headBytes: BODY_PREFETCH_BYTES,
586
+ tailBytes: 0,
587
+ timeoutMs: 60_000
588
+ })
589
+ .catch(() => {});
590
+ return withHostTimings(plan);
591
+ }
592
+ return withHostTimings({ ...plan, pending: true });
593
+ }
594
+ };
595
+ }