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