@torrent-tv/proxy 2.75.0 → 2.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +1504 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11073 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -1,775 +1,806 @@
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 { Container } from "./container/Container.js";
12
- import { buildAudioInventory } from "./audio-inventory.js";
13
- import { countVideoFiles, matchSidecarFiles } from "./sidecar-files.js";
14
- import {
15
- parseFfmpegDurationSeconds,
16
- parseFfmpegStartTimeSeconds,
17
- parseFfmpegBitDepth,
18
- parseFfmpegBitrateKbps,
19
- parseFfmpegVideoFps,
20
- parseFfmpegHdr
21
- } from "./ffmpeg-banner.js";
22
-
23
- /** Audio codecs that browsers can decode natively without transcoding. */
24
- const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
25
-
26
- // Once the plan probe succeeds, warm the START of the file body so the
27
- // transcode session's ffmpeg reads hit downloaded data instead of paying
28
- // piece latency at encode time (the edge prefetch only covers head+tail for
29
- // the codec probe). ~16 MB ≈ the first segments of typical media.
30
- const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
31
-
32
- /**
33
- * How long the plan waits for a file's own header before offering its
34
- * soundtrack without what that header would have said.
35
- *
36
- * Not a measurement, and nothing is derived from it: it is the point past which
37
- * holding the viewer costs more than the language and flags being waited for —
38
- * which the folder name supplies anyway, from the torrent's file list, at no
39
- * cost. The reading itself carries on in the worker and is kept there.
40
- */
41
- const SIDECAR_HEADER_WAIT_MS = 3_000;
42
-
43
- /** Subtitle codecs that can be converted to WebVTT (text-based). */
44
- const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
45
-
46
- /**
47
- * Parse every stream from the ffmpeg `-i` banner: type, codec, language tag,
48
- * default disposition and (when present) the stream's `title` metadata line.
49
- *
50
- * @param {string} ffmpegOutput
51
- * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
52
- */
53
- function parseStreams(ffmpegOutput) {
54
- // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
55
- // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
56
- const inputSection = ffmpegOutput.split(/^(?:Output #|Stream mapping:)/m)[0] ?? ffmpegOutput;
57
- const lines = inputSection.split(/\r?\n/);
58
- const streams = [];
59
- let current = null;
60
- for (const line of lines) {
61
- const streamMatch = line.match(
62
- /^\s*Stream #0:(\d+)(?:\[[^\]]*\])?(?:\(([A-Za-z0-9]{2,3})\))?: (Audio|Video|Subtitle): ([A-Za-z0-9_]+)/
63
- );
64
- if (streamMatch) {
65
- current = {
66
- streamIndex: Number(streamMatch[1]),
67
- type: streamMatch[3].toLowerCase(),
68
- codec: String(streamMatch[4]).toLowerCase(),
69
- language: (streamMatch[2] ?? "").toLowerCase(),
70
- title: "",
71
- isDefault: /\(default\)/.test(line)
72
- };
73
- streams.push(current);
74
- continue;
75
- }
76
- if (current) {
77
- const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
78
- if (titleMatch && current.title.length === 0) {
79
- current.title = titleMatch[1].trim();
80
- continue;
81
- }
82
- // A new top-level section (non-indented line) ends the stream's block.
83
- if (!/^\s/.test(line)) {
84
- current = null;
85
- }
86
- }
87
- }
88
- return streams;
89
- }
90
-
91
- /**
92
- * Parse audio and video codec names from ffmpeg stderr output.
93
- *
94
- * @param {string} ffmpegOutput
95
- * @returns {{ audioCodec: string, videoCodec: string }}
96
- */
97
- function parseStreamCodecs(ffmpegOutput) {
98
- const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
99
- const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
100
- // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
101
- // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
102
- const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
103
- let videoWidth = 0;
104
- let videoHeight = 0;
105
- if (videoLineMatch) {
106
- const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
107
- if (dim) {
108
- videoWidth = Number(dim[1]);
109
- videoHeight = Number(dim[2]);
110
- }
111
- }
112
- const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
113
- const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
114
- let durationSeconds = 0;
115
- if (durationMatch) {
116
- const value =
117
- Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
118
- durationSeconds = Number.isFinite(value) ? value : 0;
119
- }
120
- const streams = parseStreams(ffmpegOutput);
121
- const audioTracks = streams
122
- .filter((s) => s.type === "audio")
123
- .map((s, i) => ({
124
- // Type-relative index — what ffmpeg's `-map 0:a:N` selects.
125
- index: i,
126
- streamIndex: s.streamIndex,
127
- codec: s.codec,
128
- language: s.language,
129
- title: s.title,
130
- isDefault: s.isDefault
131
- }));
132
- const subtitleTracks = streams
133
- .filter((s) => s.type === "subtitle")
134
- .map((s, i) => ({
135
- // Type-relative index — what ffmpeg's `-map 0:s:N` selects.
136
- index: i,
137
- streamIndex: s.streamIndex,
138
- codec: s.codec,
139
- language: s.language,
140
- title: s.title,
141
- isDefault: s.isDefault,
142
- // Image-based subtitles (PGS/VobSub) cannot become WebVTT.
143
- textBased: TEXT_SUBTITLE_CODECS.has(s.codec)
144
- }));
145
- return {
146
- audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
147
- videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
148
- container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
149
- durationSeconds,
150
- videoWidth,
151
- videoHeight,
152
- audioTracks,
153
- subtitleTracks
154
- };
155
- }
156
-
157
- /**
158
- * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
159
- * Times out after `timeoutMs` and returns empty strings on failure.
160
- *
161
- * @param {object} options
162
- * @param {string} options.ffmpegBin
163
- * @param {string} options.inputUrl
164
- * @param {string} [options.userAgent=""]
165
- * @param {number} [options.timeoutMs=8000]
166
- * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
167
- * Parsed banner fields plus the raw `stderr`, so the caller can derive the
168
- * full media info (fps/startTime/HDR) without a second ffmpeg scan.
169
- */
170
- function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
171
- return new Promise((resolve) => {
172
- const args = ["-hide_banner", "-loglevel", "info"];
173
- if (typeof userAgent === "string" && userAgent.trim().length > 0) {
174
- args.push("-user_agent", userAgent.trim());
175
- }
176
- // Decode a tiny slice of all streams (no per-stream -map, so video-only
177
- // files probe correctly too). The ffmpeg banner that precedes decoding
178
- // gives us audio/video codecs, the container format and the duration in a
179
- // single pass.
180
- args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
181
-
182
- const ffmpeg = spawn(ffmpegBin, args, {
183
- stdio: ["ignore", "ignore", "pipe"],
184
- windowsHide: true
185
- });
186
- let stderr = "";
187
- let settled = false;
188
-
189
- const finish = (codecs) => {
190
- if (settled) {
191
- return;
192
- }
193
- settled = true;
194
- resolve(codecs);
195
- };
196
-
197
- const timeoutId = setTimeout(() => {
198
- if (!ffmpeg.killed) {
199
- ffmpeg.kill("SIGTERM");
200
- }
201
- finish({ ...parseStreamCodecs(stderr), stderr });
202
- }, timeoutMs);
203
-
204
- ffmpeg.stderr.on("data", (chunk) => {
205
- stderr += String(chunk);
206
- });
207
-
208
- ffmpeg.on("error", () => {
209
- clearTimeout(timeoutId);
210
- finish({ audioCodec: "", videoCodec: "", stderr: "" });
211
- });
212
-
213
- ffmpeg.on("exit", () => {
214
- clearTimeout(timeoutId);
215
- finish({ ...parseStreamCodecs(stderr), stderr });
216
- });
217
- });
218
- }
219
-
220
- /**
221
- * Resolve after a given number of milliseconds.
222
- *
223
- * @param {number} ms
224
- * @returns {Promise<void>}
225
- */
226
- function delay(ms) {
227
- return new Promise((resolve) => {
228
- setTimeout(resolve, ms);
229
- });
230
- }
231
-
232
- /**
233
- * Build the direct stream URL for a source file served by the local proxy.
234
- *
235
- * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
236
- * @param {string} sourceKey
237
- * @param {number} fileIndex
238
- * @returns {string}
239
- */
240
- function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
241
- const directUrl = new URL("/stream", `${localBaseUrl}/`);
242
- directUrl.searchParams.set("sourceKey", sourceKey);
243
- directUrl.searchParams.set("fileIndex", String(fileIndex));
244
- return directUrl.toString();
245
- }
246
-
247
- /**
248
- * @typedef {Object} PlaybackPlan
249
- * @property {"direct" | "hls"} mode
250
- * @property {string} directUrl
251
- * @property {string} reason - Human-readable explanation of the chosen mode.
252
- * @property {string} audioCodec
253
- * @property {string} videoCodec
254
- * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
255
- * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
256
- * @property {number} videoWidth - Source coded width (0 if unknown).
257
- * @property {number} videoHeight - Source coded height (0 if unknown).
258
- */
259
-
260
- /**
261
- * @typedef {Object} PlaybackPlannerOptions
262
- * @property {string} ffmpegBin
263
- * @property {boolean} transcodeAudioEnabled
264
- * @property {string} localBaseUrl
265
- * @property {ReturnType<import("../store/source-registry.js").createSourceRegistry>} sourceRegistry
266
- * @property {import("./torrent-pool.js").TorrentPool} torrentPool
267
- */
268
-
269
- /**
270
- * Create a playback planner that decides the optimal streaming mode for
271
- * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
272
- *
273
- * @param {PlaybackPlannerOptions} options
274
- * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan> }}
275
- */
276
- export function createPlaybackPlanner({
277
- ffmpegBin,
278
- transcodeAudioEnabled,
279
- localBaseUrl,
280
- sourceRegistry,
281
- torrentPool,
282
- // Optional. Reports what this host typically takes to produce a session's
283
- // first segment. The browser needs it for the gap between "the file is
284
- // downloaded" and "a segment exists": until now it assumed the pipeline
285
- // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
286
- expectedFirstSegmentMs,
287
- expectedSessionCreateMs,
288
- // Optional. Called once the file's edges are downloaded, so the keyframe
289
- // index — which reads the same tail of the file — is fetched alongside the
290
- // codec probe instead of after it. Late-bound to the HLS session manager,
291
- // which owns the cache both of them share.
292
- warmKeyframeIndex,
293
- // Optional. The heights this host could actually serve this source at, for
294
- // both playback branches, so the quality menu is right from the moment the
295
- // file is opened rather than from the moment an encoder exists.
296
- predictOfferedHeights
297
- }) {
298
- /** @type {Map<string, PlaybackPlan>} */
299
- const cache = new Map();
300
- /**
301
- * Full media info parsed from the SAME probe that produced the plan, cached
302
- * under the same key so a transcode session can reuse it instead of running
303
- * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
304
- * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
305
- */
306
- const mediaInfoCache = new Map();
307
-
308
- /**
309
- * Attach what this host currently measures itself taking to create a session
310
- * and to produce a first segment.
311
- *
312
- * Read at RESPONSE time, deliberately. Both are medians of sessions that have
313
- * already finished on this host, so at the moment a plan is BUILT the very
314
- * first file opened after a restart has none and gets `null` — and the plan
315
- * is then cached, so that file kept answering `null` for the life of the
316
- * process however many sessions ran afterwards. Measured 2026-08-05: a fresh
317
- * 2.9.103 answered `null` for both, then produced the session in 6 ms and the
318
- * first segment in 21 479 ms. The figures existed; the plan could not carry
319
- * them, and the browser's estimate fell back to its own guess in exactly the
320
- * cold-start case the feature was built for.
321
- *
322
- * @param {PlaybackPlan} plan
323
- * @returns {PlaybackPlan}
324
- */
325
- /**
326
- * The probe's subtitle tracks, with `FlagDefault` read from the container
327
- * instead of inferred from ffmpeg's banner.
328
- *
329
- * Best-effort by construction: a container that cannot be read this way, or a
330
- * reading that does not line up with the probe, leaves the tracks as they
331
- * were with `declaresDefault: false` — which the browser reads as "the file
332
- * has no opinion", and then nothing is shown unasked.
333
- *
334
- * @param {object} torrent
335
- * @param {number} fileIndex
336
- * @param {object[]} subtitleTracks
337
- * @returns {Promise<object[]>}
338
- */
339
- async function withContainerDefaults(torrent, fileIndex, subtitleTracks) {
340
- if (subtitleTracks.length === 0 || typeof torrentPool?.getDeclaredSubtitleTracks !== "function") {
341
- return subtitleTracks.map((track) => ({ ...track, declaresDefault: false }));
342
- }
343
- let declared = [];
344
- try {
345
- declared = await torrentPool.getDeclaredSubtitleTracks(torrent, fileIndex);
346
- } catch (error) {
347
- logger.info(`subtitle defaults: the container could not be read (${error?.message ?? error})`);
348
- }
349
- const merged = Container.mergeSubtitleFlags(subtitleTracks, declared);
350
- logger.info(
351
- merged.aligned
352
- ? "subtitle defaults: the container wrote FlagDefault on " +
353
- `${merged.tracks.filter((track) => track.declaresDefault).length} of ${merged.tracks.length} ` +
354
- `subtitle tracks, marking ${merged.tracks.filter((track) => track.declaresDefault && track.isDefault).length}`
355
- : `subtitle defaults: using the probe's own flags — ${merged.reason}`
356
- );
357
- return merged.tracks;
358
- }
359
-
360
- /**
361
- * Every soundtrack this file can be watched with, as one numbered list: its
362
- * own tracks and the ones shipped as separate files beside it.
363
- *
364
- * Built here, in the plan, because the plan is what the viewer's menu is drawn
365
- * from — so the offer is complete the moment a file is opened, with nothing
366
- * arriving late and nothing measured while the viewer waits. It is also what
367
- * the master playlist's rendition group is built from, so the number in the
368
- * menu and the number in the `a/<n>/` address are the same number by
369
- * construction rather than by agreement.
370
- *
371
- * @param {object} torrent
372
- * @param {number} fileIndex
373
- * @param {object[]} bannerAudioTracks - The probe's own audio streams.
374
- * @returns {Promise<import("./audio-inventory.js").AudioInventoryEntry[]>}
375
- */
376
- async function buildInventory(torrent, fileIndex, bannerAudioTracks) {
377
- const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
378
- /**
379
- * Read a file's declared audio tracks, or give up quickly.
380
- *
381
- * The plan is on the path to the first frame, and reading a sidecar's header
382
- * waits on the swarm: that file has usually had nothing downloaded when this
383
- * runs, and a header that never arrives would hold the plan — and the
384
- * viewer — for the whole of the read's own patience. What a timeout costs is
385
- * small and deliberate: the track is still offered, still numbered and still
386
- * playable, only without the language and flags its own header would have
387
- * given. The language the viewer actually sees is read from the FOLDER the
388
- * release put it in, which is in the torrent's file list and needs no bytes
389
- * at all.
390
- *
391
- * @param {number} wantedFileIndex
392
- * @param {string} label
393
- * @returns {Promise<object[]>}
394
- */
395
- const declaredAudioOf = async (wantedFileIndex, label) => {
396
- if (typeof torrentPool?.getDeclaredAudioTracks !== "function") {
397
- return [];
398
- }
399
- let timer = null;
400
- try {
401
- return await Promise.race([
402
- torrentPool.getDeclaredAudioTracks(torrent, wantedFileIndex),
403
- new Promise((resolve) => {
404
- timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
405
- timer.unref?.();
406
- })
407
- ]).then((tracks) => {
408
- if (tracks === null) {
409
- logger.info(
410
- `audio tracks: "${label}" did not answer within ` +
411
- `${SIDECAR_HEADER_WAIT_MS / 1000}s — offered without what its header would say`
412
- );
413
- return [];
414
- }
415
- return Array.isArray(tracks) ? tracks : [];
416
- });
417
- } catch (error) {
418
- logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
419
- return [];
420
- } finally {
421
- if (timer !== null) {
422
- clearTimeout(timer);
423
- }
424
- }
425
- };
426
- // The picture's own tracks: ffmpeg numbers them, the container declares what
427
- // they are. Both readings, lined up and checked — see `audio-inventory.js`.
428
- let embedded = banner.map((track) => ({ ...track, declaresDefault: false }));
429
- if (banner.length > 0) {
430
- // The picture's head is already downloaded — the codec probe just read it
431
- // so this is a parse and not a wait, but it is bounded like the rest.
432
- const declared = await declaredAudioOf(fileIndex, "the picture");
433
- const merged = Container.mergeAudioFlags(banner, declared);
434
- embedded = merged.tracks;
435
- logger.info(
436
- merged.aligned
437
- ? `audio tracks: the container describes all ${merged.tracks.length}` +
438
- `${merged.tracks.some((track) => track.isCommentary) ? ", one of them commentary" : ""}` +
439
- `${merged.tracks.some((track) => track.isVisualImpaired) ? ", one of them described" : ""}`
440
- : `audio tracks: using the probe's own fields — ${merged.reason}`
441
- );
442
- }
443
-
444
- const sidecarFiles = matchSidecarFiles({
445
- files: torrent?.files ?? [],
446
- videoIndex: fileIndex,
447
- torrentName: typeof torrent?.name === "string" ? torrent.name : "",
448
- videoCount: countVideoFiles(torrent?.files ?? [])
449
- });
450
- // All of them at once. They are separate files with separate headers, and
451
- // read one after another the waits add up on the path to the first frame.
452
- const sidecars = await Promise.all(
453
- sidecarFiles.audio.map(async (file) => ({
454
- file,
455
- // A bare elementary stream — `.ac3`, `.dts`, `.mp3` — has no table to
456
- // read, so nothing is asked of the swarm for it at all.
457
- tracks: file.declaresTracks ? await declaredAudioOf(file.fileIndex, file.name) : []
458
- }))
459
- );
460
- const inventory = buildAudioInventory({ embedded, videoFileIndex: fileIndex, sidecars });
461
- if (sidecars.length > 0) {
462
- logger.info(
463
- `audio tracks: ${sidecars.length} file(s) beside the picture carry sound — ` +
464
- inventory
465
- .filter((entry) => entry.kind === "sidecar")
466
- .map((entry) =>
467
- `a:${entry.index}=${entry.folders.join("/") || "."}/${entry.fileName}` +
468
- `#${entry.sourceTrackIndex}${entry.codec ? `(${entry.codec})` : ""}`
469
- )
470
- .join(" ")
471
- );
472
- }
473
- return inventory;
474
- }
475
-
476
- function withHostTimings(plan) {
477
- const withOffer = {
478
- ...plan,
479
- expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
480
- expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
481
- // Answered here for the same reason as the two above: a plan is cached for
482
- // the life of the process, and what this host will serve a file at is not.
483
- // It starts as a prediction from the startup benchmarks and is replaced by
484
- // what an encoder running on this very source turns out to cost — frozen
485
- // into the cache, every later open of the file would hand the browser the
486
- // first guess again and undo that. This is the 2.9.106 defect exactly.
487
- offeredHeights: plan.mediaInfoForOffer
488
- ? (predictOfferedHeights?.(plan.mediaInfoForOffer) ?? null)
489
- : null,
490
- mediaInfoForOffer: undefined
491
- };
492
- // Refused rather than served badly. Both lists empty means this machine
493
- // cannot sustain this file at ANY height — not even by copying the picture,
494
- // which costs no encoder at all — so a session made here would produce a
495
- // slideshow and take the swarm and the processor from whoever is already
496
- // watching. Field 2026-08-28: five sessions on one file put every rung at
497
- // 0.04x of realtime and the viewer watched one before the process was
498
- // killed. The viewer is told why, which is a different thing from a spinner
499
- // that never ends.
500
- const offer = withOffer.offeredHeights;
501
- if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
502
- withOffer.cannotServe =
503
- "This proxy cannot keep up with this file at any quality right now.";
504
- // The description travels with the refusal, and only with it. It is what
505
- // lets the browser ask the rest of the pool the same question without
506
- // anybody else adding the torrent, fetching a byte or running ffmpeg —
507
- // the expensive half of finding out what this file IS has been paid here,
508
- // once. Everyone else answers by arithmetic against their own startup
509
- // benchmarks.
510
- withOffer.mediaInfoForOffer = plan.mediaInfoForOffer;
511
- }
512
- return withOffer;
513
- }
514
-
515
- return {
516
- /**
517
- * Media info the planner already probed for this file, or `null`. Lets the
518
- * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
519
- *
520
- * @param {{ sourceKey: string, fileIndex: number }} params
521
- * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
522
- */
523
- /**
524
- * The audio tracks this file was probed to have, or an empty list. The
525
- * master playlist publishes one rendition per track, and the inventory is
526
- * already here — probing again for it would be a second scan of a file the
527
- * proxy is in the middle of serving.
528
- *
529
- * @param {{ sourceKey: string, fileIndex: number }} params
530
- * @returns {object[]}
531
- */
532
- getCachedAudioTracks({ sourceKey, fileIndex }) {
533
- const plan = cache.get(`${sourceKey}:${fileIndex}`);
534
- return Array.isArray(plan?.audioTracks) ? plan.audioTracks : [];
535
- },
536
-
537
- getCachedMediaInfo({ sourceKey, fileIndex }) {
538
- return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
539
- },
540
-
541
- /**
542
- * Return the playback plan for the given source file.
543
- * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
544
- * when the source or file cannot be located.
545
- *
546
- * When the file header has not downloaded yet (cold torrent, peers still
547
- * connecting) the codec probe cannot succeed. Rather than block the HTTP
548
- * response until it can, the planner prioritises the header, probes for at
549
- * most `maxWaitMs`, and if still undetectable returns a plan flagged
550
- * `pending: true` (NOT cached). The caller polls again — each call keeps the
551
- * header prioritised and downloading — until a real plan comes back. This
552
- * avoids a single long request racing the transport's request timeout.
553
- *
554
- * @param {object} params
555
- * @param {string} params.sourceKey
556
- * @param {number} params.fileIndex
557
- * @param {string} [params.userAgent=""]
558
- * @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
559
- * @returns {Promise<PlaybackPlan & { pending?: boolean }>}
560
- */
561
- async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000 }) {
562
- const cacheKey = `${sourceKey}:${fileIndex}`;
563
- const cached = cache.get(cacheKey);
564
- if (cached) {
565
- return withHostTimings(cached);
566
- }
567
- // Where the time before playback goes. `cold-start` already breaks down
568
- // everything from the transcode-session request onwards, but the plan
569
- // runs BEFORE that and was a single opaque wait: a field session spent
570
- // 5.7 s here on a torrent already in the store, with the codec probe
571
- // cached, and nothing said which part of it was slow.
572
- const planEntryMs = Date.now();
573
- let torrentReadyMs = 0;
574
- let edgesReadyMs = 0;
575
-
576
- const sourceRecord = sourceRegistry.get(sourceKey);
577
- if (!sourceRecord) {
578
- const error = new Error("Source key was not found.");
579
- error.code = "SOURCE_NOT_FOUND";
580
- throw error;
581
- }
582
-
583
- const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
584
- torrentReadyMs = Date.now() - planEntryMs;
585
- const file = torrent.files[fileIndex];
586
- if (!file) {
587
- const error = new Error("File index was not found in torrent.");
588
- error.code = "FILE_NOT_FOUND";
589
- throw error;
590
- }
591
-
592
- const directUrl = buildDirectUrl(localBaseUrl, sourceKey, fileIndex);
593
- if (!transcodeAudioEnabled) {
594
- const plan = {
595
- mode: "direct",
596
- directUrl,
597
- reason: "transcode-disabled",
598
- audioCodec: "",
599
- videoCodec: "",
600
- container: "",
601
- durationSeconds: 0,
602
- videoWidth: 0,
603
- videoHeight: 0,
604
- audioTracks: [],
605
- subtitleTracks: []
606
- };
607
- cache.set(cacheKey, plan);
608
- return withHostTimings(plan);
609
- }
610
-
611
- // Pre-fetch file edges (head + tail), then probe — retrying while the
612
- // file header is still downloading. In a multi-file torrent the pieces
613
- // for a given file arrive unevenly, so the first probe can return empty
614
- // codecs. A transient empty probe must NOT be cached: otherwise the wrong
615
- // plan (file treated as directly playable) sticks permanently for this
616
- // file, and an unsupported codec like xvid gets copied → black video.
617
- await torrentPool.prefetchFileEdges(torrent, fileIndex);
618
- edgesReadyMs = Date.now() - planEntryMs;
619
- // The keyframe index reads the tail of the file, which the probe has just
620
- // waited for as well. Started here it overlaps the probe instead of
621
- // following the whole plan — worth 311-430 ms of the time before the
622
- // first segment. Fire and forget: the session reads it itself if this has
623
- // not finished, and both share one cache entry.
624
- warmKeyframeIndex?.({
625
- sourceKey,
626
- fileIndex,
627
- inputUrl: new URL(directUrl),
628
- logName: file.name
629
- });
630
- let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
631
- const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
632
- let attempt = 0;
633
- while (
634
- probe.audioCodec.length === 0 &&
635
- probe.videoCodec.length === 0 &&
636
- Date.now() < probeDeadline
637
- ) {
638
- attempt += 1;
639
- await delay(Math.min(3_000, 500 + attempt * 250));
640
- await torrentPool.prefetchFileEdges(torrent, fileIndex);
641
- probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
642
- }
643
- const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
644
- const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
645
- logger.info(
646
- `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
647
- `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
648
- `total=${Date.now() - planEntryMs}ms attempts=${attempt + 1} ` +
649
- `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
650
- );
651
-
652
- // The picture's own facts come from two readings and only one was ever
653
- // used: every figure the encode is planned from came from ffmpeg's
654
- // banner, while the `VideoTrack` the container declares was read and used
655
- // for nothing but a line in the log.
656
- let declaredVideo = null;
657
- if (typeof torrentPool?.getDeclaredVideoTrack === "function") {
658
- try {
659
- declaredVideo = await torrentPool.getDeclaredVideoTrack(torrent, fileIndex);
660
- } catch (error) {
661
- logger.info(`video track: could not be read (${error?.message ?? error})`);
662
- }
663
- }
664
- // `mode` is advisory only (audio-codec based). The browser makes the
665
- // authoritative decision independently per stream via canPlayType /
666
- // mediaCapabilities, transcoding only what it cannot play.
667
- const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
668
- // The two readings of the picture, lined up. Which one answers is decided
669
- // per field by what each IS see `Container.mergeVideoFacts`.
670
- const videoFacts = Container.mergeVideoFacts(
671
- {
672
- width: videoWidth,
673
- height: videoHeight,
674
- fps: parseFfmpegVideoFps(probe.stderr),
675
- isHdr: parseFfmpegHdr(probe.stderr),
676
- bitDepth: parseFfmpegBitDepth(probe.stderr)
677
- },
678
- declaredVideo
679
- );
680
- if (videoFacts.disagreements.length > 0) {
681
- logger.info(
682
- `video track: the file and the probe disagree — ${videoFacts.disagreements.join("; ")}; ` +
683
- "the size and frame rate are the probe's, the bit depth and HDR the file's"
684
- );
685
- }
686
- const plan = {
687
- mode: requiresTranscode ? "hls" : "direct",
688
- directUrl,
689
- reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
690
- audioCodec,
691
- videoCodec,
692
- container,
693
- durationSeconds,
694
- // Source coded resolution — drives the browser's manual quality menu
695
- // (list of forced resolutions <= source). 0 when unknown.
696
- videoWidth: videoFacts.width ?? 0,
697
- videoHeight: videoFacts.height ?? 0,
698
- // Full track inventory for the browser's audio/subtitle menus. The audio
699
- // half spans the picture's own tracks AND the soundtracks shipped as
700
- // files beside it, under one numbering — see `buildInventory`.
701
- audioTracks: await buildInventory(torrent, fileIndex, audioTracks ?? []),
702
- subtitleTracks: await withContainerDefaults(torrent, fileIndex, subtitleTracks ?? []),
703
- // Both host timings are filled in by `withHostTimings` on the way out,
704
- // never here: read at build time they would be frozen into the cached
705
- // plan, which is the bug fixed in 2.9.106.
706
- expectedFirstSegmentMs: null,
707
- expectedSessionCreateMs: null,
708
- offeredHeights: null,
709
- // What the offer is computed FROM, kept on the cached plan so the offer
710
- // itself can be recomputed on every response. The figures are the
711
- // probe's own and never change for a file; the answer derived from them
712
- // does, as the host learns what this source costs. Stripped on the way
713
- // out it is not part of the plan the browser is given.
714
- mediaInfoForOffer: {
715
- width: videoFacts.width,
716
- height: videoFacts.height,
717
- fps: videoFacts.fps,
718
- bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
719
- // Which family of the decode measurement prices this source. A video
720
- // that has to be re-encoded is one the browser could not play, so it
721
- // is usually NOT H.264, and H.264 constants are wrong for it.
722
- codec: videoCodec,
723
- bitDepth: videoFacts.bitDepth,
724
- // Which file this is, so the offer can be answered from what an
725
- // encoder has already learned about THIS source rather than from the
726
- // startup clips the same correction a live session applies.
727
- sourceKey,
728
- fileIndex
729
- }
730
- };
731
- // Only cache a plan whose codecs were actually detected. An empty probe is
732
- // a "header not downloaded yet" signal, not a valid result — caching it
733
- // would permanently mis-plan the file. In that case flag the plan
734
- // `pending` so the caller polls again (the header keeps downloading,
735
- // prioritised by the prefetch above).
736
- if (codecsDetected) {
737
- cache.set(cacheKey, plan);
738
- // Cache the full media info from THIS probe's banner (same helpers the
739
- // session manager uses) so createSession can skip its own probe.
740
- mediaInfoCache.set(cacheKey, {
741
- // The codecs, because the session manager asks this cache which
742
- // tracks the output will carry and they were never stored here. It
743
- // read `videoCodec`/`audioCodec` off an object that has only ever had
744
- // dimensions and duration, got `undefined` for both, and declared
745
- // `{video: false, audio: false}` for EVERY session since the check was
746
- // written. Measured 2026-08-11: `declared tracks video=false
747
- // audio=false`, which left the browser unable to tell "this file has
748
- // no video" from "the video was lost on the way", and left the init
749
- // guard expecting zero tracks and therefore accepting any header.
750
- videoCodec: plan.videoCodec,
751
- audioCodec: plan.audioCodec,
752
- durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
753
- width: videoFacts.width,
754
- height: videoFacts.height,
755
- bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
756
- fps: videoFacts.fps,
757
- startTime: parseFfmpegStartTimeSeconds(probe.stderr),
758
- isHdr: videoFacts.isHdr,
759
- bitDepth: videoFacts.bitDepth
760
- });
761
- // Warm the file-body start for the transcode session that follows.
762
- // Fire-and-forget: never delays the plan response.
763
- void torrentPool
764
- .prefetchFileEdges(torrent, fileIndex, {
765
- headBytes: BODY_PREFETCH_BYTES,
766
- tailBytes: 0,
767
- timeoutMs: 60_000
768
- })
769
- .catch(() => {});
770
- return withHostTimings(plan);
771
- }
772
- return withHostTimings({ ...plan, pending: true });
773
- }
774
- };
775
- }
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 { Container } from "./container/Container.js";
12
+ import { buildAudioInventory } from "./audio-inventory.js";
13
+ import { countVideoFiles, matchSidecarFiles } from "./torrent/files.js";
14
+ import {
15
+ parseFfmpegDurationSeconds,
16
+ parseFfmpegStartTimeSeconds,
17
+ parseFfmpegBitDepth,
18
+ parseFfmpegBitrateKbps,
19
+ parseFfmpegVideoFps,
20
+ parseFfmpegHdr
21
+ } from "./ffmpeg-banner.js";
22
+
23
+ /** Audio codecs that browsers can decode natively without transcoding. */
24
+ const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
25
+
26
+ // Once the plan probe succeeds, warm the START of the file body so the
27
+ // transcode session's ffmpeg reads hit downloaded data instead of paying
28
+ // piece latency at encode time (the edge prefetch only covers head+tail for
29
+ // the codec probe). ~16 MB ≈ the first segments of typical media.
30
+ const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
31
+
32
+ /**
33
+ * How long the plan waits for a file's own header before offering its
34
+ * soundtrack without what that header would have said.
35
+ *
36
+ * Not a measurement, and nothing is derived from it: it is the point past which
37
+ * holding the viewer costs more than the language and flags being waited for —
38
+ * which the folder name supplies anyway, from the torrent's file list, at no
39
+ * cost. The reading itself carries on in the worker and is kept there.
40
+ */
41
+ const SIDECAR_HEADER_WAIT_MS = 3_000;
42
+
43
+ /** Subtitle codecs that can be converted to WebVTT (text-based). */
44
+ const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
45
+
46
+ /**
47
+ * Parse every stream from the ffmpeg `-i` banner: type, codec, language tag,
48
+ * default disposition and (when present) the stream's `title` metadata line.
49
+ *
50
+ * @param {string} ffmpegOutput
51
+ * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
52
+ */
53
+ function parseStreams(ffmpegOutput) {
54
+ // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
55
+ // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
56
+ const inputSection = ffmpegOutput.split(/^(?:Output #|Stream mapping:)/m)[0] ?? ffmpegOutput;
57
+ const lines = inputSection.split(/\r?\n/);
58
+ const streams = [];
59
+ let current = null;
60
+ for (const line of lines) {
61
+ const streamMatch = line.match(
62
+ /^\s*Stream #0:(\d+)(?:\[[^\]]*\])?(?:\(([A-Za-z0-9]{2,3})\))?: (Audio|Video|Subtitle): ([A-Za-z0-9_]+)/
63
+ );
64
+ if (streamMatch) {
65
+ current = {
66
+ streamIndex: Number(streamMatch[1]),
67
+ type: streamMatch[3].toLowerCase(),
68
+ codec: String(streamMatch[4]).toLowerCase(),
69
+ language: (streamMatch[2] ?? "").toLowerCase(),
70
+ title: "",
71
+ isDefault: /\(default\)/.test(line)
72
+ };
73
+ streams.push(current);
74
+ continue;
75
+ }
76
+ if (current) {
77
+ const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
78
+ if (titleMatch && current.title.length === 0) {
79
+ current.title = titleMatch[1].trim();
80
+ continue;
81
+ }
82
+ // A new top-level section (non-indented line) ends the stream's block.
83
+ if (!/^\s/.test(line)) {
84
+ current = null;
85
+ }
86
+ }
87
+ }
88
+ return streams;
89
+ }
90
+
91
+ /**
92
+ * Parse audio and video codec names from ffmpeg stderr output.
93
+ *
94
+ * @param {string} ffmpegOutput
95
+ * @returns {{ audioCodec: string, videoCodec: string }}
96
+ */
97
+ function parseStreamCodecs(ffmpegOutput) {
98
+ const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
99
+ const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
100
+ // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
101
+ // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
102
+ const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
103
+ let videoWidth = 0;
104
+ let videoHeight = 0;
105
+ if (videoLineMatch) {
106
+ const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
107
+ if (dim) {
108
+ videoWidth = Number(dim[1]);
109
+ videoHeight = Number(dim[2]);
110
+ }
111
+ }
112
+ const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
113
+ const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
114
+ let durationSeconds = 0;
115
+ if (durationMatch) {
116
+ const value =
117
+ Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
118
+ durationSeconds = Number.isFinite(value) ? value : 0;
119
+ }
120
+ const streams = parseStreams(ffmpegOutput);
121
+ const audioTracks = streams
122
+ .filter((s) => s.type === "audio")
123
+ .map((s, i) => ({
124
+ // Type-relative index — what ffmpeg's `-map 0:a:N` selects.
125
+ index: i,
126
+ streamIndex: s.streamIndex,
127
+ codec: s.codec,
128
+ language: s.language,
129
+ title: s.title,
130
+ isDefault: s.isDefault
131
+ }));
132
+ const subtitleTracks = streams
133
+ .filter((s) => s.type === "subtitle")
134
+ .map((s, i) => ({
135
+ // Type-relative index — what ffmpeg's `-map 0:s:N` selects.
136
+ index: i,
137
+ streamIndex: s.streamIndex,
138
+ codec: s.codec,
139
+ language: s.language,
140
+ title: s.title,
141
+ isDefault: s.isDefault,
142
+ // Image-based subtitles (PGS/VobSub) cannot become WebVTT.
143
+ textBased: TEXT_SUBTITLE_CODECS.has(s.codec)
144
+ }));
145
+ return {
146
+ audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
147
+ videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
148
+ container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
149
+ durationSeconds,
150
+ videoWidth,
151
+ videoHeight,
152
+ audioTracks,
153
+ subtitleTracks
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
159
+ * Times out after `timeoutMs` and returns empty strings on failure.
160
+ *
161
+ * @param {object} options
162
+ * @param {string} options.ffmpegBin
163
+ * @param {string} options.inputUrl
164
+ * @param {string} [options.userAgent=""]
165
+ * @param {number} [options.timeoutMs=8000]
166
+ * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
167
+ * Parsed banner fields plus the raw `stderr`, so the caller can derive the
168
+ * full media info (fps/startTime/HDR) without a second ffmpeg scan.
169
+ */
170
+ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
171
+ return new Promise((resolve) => {
172
+ const args = ["-hide_banner", "-loglevel", "info"];
173
+ if (typeof userAgent === "string" && userAgent.trim().length > 0) {
174
+ args.push("-user_agent", userAgent.trim());
175
+ }
176
+ // Decode a tiny slice of all streams (no per-stream -map, so video-only
177
+ // files probe correctly too). The ffmpeg banner that precedes decoding
178
+ // gives us audio/video codecs, the container format and the duration in a
179
+ // single pass.
180
+ args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
181
+
182
+ const ffmpeg = spawn(ffmpegBin, args, {
183
+ stdio: ["ignore", "ignore", "pipe"],
184
+ windowsHide: true
185
+ });
186
+ let stderr = "";
187
+ let settled = false;
188
+
189
+ const finish = (codecs) => {
190
+ if (settled) {
191
+ return;
192
+ }
193
+ settled = true;
194
+ resolve(codecs);
195
+ };
196
+
197
+ const timeoutId = setTimeout(() => {
198
+ if (!ffmpeg.killed) {
199
+ ffmpeg.kill("SIGTERM");
200
+ }
201
+ finish({ ...parseStreamCodecs(stderr), stderr });
202
+ }, timeoutMs);
203
+
204
+ ffmpeg.stderr.on("data", (chunk) => {
205
+ stderr += String(chunk);
206
+ });
207
+
208
+ ffmpeg.on("error", () => {
209
+ clearTimeout(timeoutId);
210
+ finish({ audioCodec: "", videoCodec: "", stderr: "" });
211
+ });
212
+
213
+ ffmpeg.on("exit", () => {
214
+ clearTimeout(timeoutId);
215
+ finish({ ...parseStreamCodecs(stderr), stderr });
216
+ });
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Resolve after a given number of milliseconds.
222
+ *
223
+ * @param {number} ms
224
+ * @returns {Promise<void>}
225
+ */
226
+ function delay(ms) {
227
+ return new Promise((resolve) => {
228
+ setTimeout(resolve, ms);
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Build the direct stream URL for a source file served by the local proxy.
234
+ *
235
+ * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
236
+ * @param {string} sourceKey
237
+ * @param {number} fileIndex
238
+ * @returns {string}
239
+ */
240
+ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
241
+ const directUrl = new URL("/stream", `${localBaseUrl}/`);
242
+ directUrl.searchParams.set("sourceKey", sourceKey);
243
+ directUrl.searchParams.set("fileIndex", String(fileIndex));
244
+ return directUrl.toString();
245
+ }
246
+
247
+ /**
248
+ * @typedef {Object} PlaybackPlan
249
+ * @property {"direct" | "hls"} mode
250
+ * @property {string} directUrl
251
+ * @property {string} reason - Human-readable explanation of the chosen mode.
252
+ * @property {string} audioCodec
253
+ * @property {string} videoCodec
254
+ * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
255
+ * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
256
+ * @property {number} videoWidth - Source coded width (0 if unknown).
257
+ * @property {number} videoHeight - Source coded height (0 if unknown).
258
+ */
259
+
260
+ /**
261
+ * @typedef {Object} PlaybackPlannerOptions
262
+ * @property {string} ffmpegBin
263
+ * @property {boolean} transcodeAudioEnabled
264
+ * @property {string} localBaseUrl
265
+ * @property {ReturnType<import("../store/source-registry.js").createSourceRegistry>} sourceRegistry
266
+ * @property {import("./torrent-pool.js").TorrentPool} torrentPool
267
+ */
268
+
269
+ /**
270
+ * Create a playback planner that decides the optimal streaming mode for
271
+ * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
272
+ *
273
+ * @param {PlaybackPlannerOptions} options
274
+ * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan> }}
275
+ */
276
+ export function createPlaybackPlanner({
277
+ ffmpegBin,
278
+ transcodeAudioEnabled,
279
+ localBaseUrl,
280
+ sourceRegistry,
281
+ torrentPool,
282
+ // Optional. Reports what this host typically takes to produce a session's
283
+ // first segment. The browser needs it for the gap between "the file is
284
+ // downloaded" and "a segment exists": until now it assumed the pipeline
285
+ // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
286
+ expectedFirstSegmentMs,
287
+ expectedSessionCreateMs,
288
+ // Optional. Called once the file's edges are downloaded, so the keyframe
289
+ // index — which reads the same tail of the file — is fetched alongside the
290
+ // codec probe instead of after it. Late-bound to the HLS session manager,
291
+ // which owns the cache both of them share.
292
+ warmKeyframeIndex,
293
+ // Optional. The heights this host could actually serve this source at, for
294
+ // both playback branches, so the quality menu is right from the moment the
295
+ // file is opened rather than from the moment an encoder exists.
296
+ predictOfferedHeights
297
+ }) {
298
+ /** @type {Map<string, PlaybackPlan>} */
299
+ const cache = new Map();
300
+ /**
301
+ * Full media info parsed from the SAME probe that produced the plan, cached
302
+ * under the same key so a transcode session can reuse it instead of running
303
+ * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
304
+ * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
305
+ */
306
+ const mediaInfoCache = new Map();
307
+
308
+ /**
309
+ * Attach what this host currently measures itself taking to create a session
310
+ * and to produce a first segment.
311
+ *
312
+ * Read at RESPONSE time, deliberately. Both are medians of sessions that have
313
+ * already finished on this host, so at the moment a plan is BUILT the very
314
+ * first file opened after a restart has none and gets `null` — and the plan
315
+ * is then cached, so that file kept answering `null` for the life of the
316
+ * process however many sessions ran afterwards. Measured 2026-08-05: a fresh
317
+ * 2.9.103 answered `null` for both, then produced the session in 6 ms and the
318
+ * first segment in 21 479 ms. The figures existed; the plan could not carry
319
+ * them, and the browser's estimate fell back to its own guess in exactly the
320
+ * cold-start case the feature was built for.
321
+ *
322
+ * @param {PlaybackPlan} plan
323
+ * @returns {PlaybackPlan}
324
+ */
325
+ /**
326
+ * The probe's subtitle tracks, with `FlagDefault` read from the container
327
+ * instead of inferred from ffmpeg's banner.
328
+ *
329
+ * Best-effort by construction: a container that cannot be read this way, or a
330
+ * reading that does not line up with the probe, leaves the tracks as they
331
+ * were with `declaresDefault: false` — which the browser reads as "the file
332
+ * has no opinion", and then nothing is shown unasked.
333
+ *
334
+ * @param {object} torrent
335
+ * @param {number} fileIndex
336
+ * @param {object[]} subtitleTracks
337
+ * @returns {Promise<object[]>}
338
+ */
339
+ /**
340
+ * The files beside this picture that belong to it, in three groups.
341
+ *
342
+ * One call site's worth of arguments, spelled once: the file list, the
343
+ * torrent's own name (which WebTorrent prefixes to every path) and how many
344
+ * pictures the torrent holds, which is what decides whether a sidecar with a
345
+ * name in common with nothing can still belong to the only video there is.
346
+ *
347
+ * @param {object} torrent
348
+ * @param {number} fileIndex
349
+ * @returns {{ audio: object[], subtitles: object[], images: object[] }}
350
+ */
351
+ function sidecarsOf(torrent, fileIndex) {
352
+ return matchSidecarFiles({
353
+ files: torrent?.files ?? [],
354
+ videoIndex: fileIndex,
355
+ torrentName: typeof torrent?.name === "string" ? torrent.name : "",
356
+ videoCount: countVideoFiles(torrent?.files ?? [])
357
+ });
358
+ }
359
+
360
+ async function withContainerDefaults(torrent, fileIndex, subtitleTracks) {
361
+ if (subtitleTracks.length === 0 || typeof torrentPool?.getDeclaredSubtitleTracks !== "function") {
362
+ return subtitleTracks.map((track) => ({ ...track, declaresDefault: false }));
363
+ }
364
+ let declared = [];
365
+ try {
366
+ declared = await torrentPool.getDeclaredSubtitleTracks(torrent, fileIndex);
367
+ } catch (error) {
368
+ logger.info(`subtitle defaults: the container could not be read (${error?.message ?? error})`);
369
+ }
370
+ const merged = Container.mergeSubtitleFlags(subtitleTracks, declared);
371
+ logger.info(
372
+ merged.aligned
373
+ ? "subtitle defaults: the container wrote FlagDefault on " +
374
+ `${merged.tracks.filter((track) => track.declaresDefault).length} of ${merged.tracks.length} ` +
375
+ `subtitle tracks, marking ${merged.tracks.filter((track) => track.declaresDefault && track.isDefault).length}`
376
+ : `subtitle defaults: using the probe's own flags — ${merged.reason}`
377
+ );
378
+ return merged.tracks;
379
+ }
380
+
381
+ /**
382
+ * Every soundtrack this file can be watched with, as one numbered list: its
383
+ * own tracks and the ones shipped as separate files beside it.
384
+ *
385
+ * Built here, in the plan, because the plan is what the viewer's menu is drawn
386
+ * from so the offer is complete the moment a file is opened, with nothing
387
+ * arriving late and nothing measured while the viewer waits. It is also what
388
+ * the master playlist's rendition group is built from, so the number in the
389
+ * menu and the number in the `a/<n>/` address are the same number by
390
+ * construction rather than by agreement.
391
+ *
392
+ * @param {object} torrent
393
+ * @param {number} fileIndex
394
+ * @param {object[]} bannerAudioTracks - The probe's own audio streams.
395
+ * @returns {Promise<import("./audio-inventory.js").AudioInventoryEntry[]>}
396
+ */
397
+ async function buildInventory(torrent, fileIndex, bannerAudioTracks) {
398
+ const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
399
+ /**
400
+ * Read a file's declared audio tracks, or give up quickly.
401
+ *
402
+ * The plan is on the path to the first frame, and reading a sidecar's header
403
+ * waits on the swarm: that file has usually had nothing downloaded when this
404
+ * runs, and a header that never arrives would hold the plan — and the
405
+ * viewer — for the whole of the read's own patience. What a timeout costs is
406
+ * small and deliberate: the track is still offered, still numbered and still
407
+ * playable, only without the language and flags its own header would have
408
+ * given. The language the viewer actually sees is read from the FOLDER the
409
+ * release put it in, which is in the torrent's file list and needs no bytes
410
+ * at all.
411
+ *
412
+ * @param {number} wantedFileIndex
413
+ * @param {string} label
414
+ * @returns {Promise<object[]>}
415
+ */
416
+ const declaredAudioOf = async (wantedFileIndex, label) => {
417
+ if (typeof torrentPool?.getDeclaredAudioTracks !== "function") {
418
+ return [];
419
+ }
420
+ let timer = null;
421
+ try {
422
+ return await Promise.race([
423
+ torrentPool.getDeclaredAudioTracks(torrent, wantedFileIndex),
424
+ new Promise((resolve) => {
425
+ timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
426
+ timer.unref?.();
427
+ })
428
+ ]).then((tracks) => {
429
+ if (tracks === null) {
430
+ logger.info(
431
+ `audio tracks: "${label}" did not answer within ` +
432
+ `${SIDECAR_HEADER_WAIT_MS / 1000}s offered without what its header would say`
433
+ );
434
+ return [];
435
+ }
436
+ return Array.isArray(tracks) ? tracks : [];
437
+ });
438
+ } catch (error) {
439
+ logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
440
+ return [];
441
+ } finally {
442
+ if (timer !== null) {
443
+ clearTimeout(timer);
444
+ }
445
+ }
446
+ };
447
+ // The picture's own tracks: ffmpeg numbers them, the container declares what
448
+ // they are. Both readings, lined up and checked — see `audio-inventory.js`.
449
+ let embedded = banner.map((track) => ({ ...track, declaresDefault: false }));
450
+ if (banner.length > 0) {
451
+ // The picture's head is already downloaded the codec probe just read it
452
+ // so this is a parse and not a wait, but it is bounded like the rest.
453
+ const declared = await declaredAudioOf(fileIndex, "the picture");
454
+ const merged = Container.mergeAudioFlags(banner, declared);
455
+ embedded = merged.tracks;
456
+ logger.info(
457
+ merged.aligned
458
+ ? `audio tracks: the container describes all ${merged.tracks.length}` +
459
+ `${merged.tracks.some((track) => track.isCommentary) ? ", one of them commentary" : ""}` +
460
+ `${merged.tracks.some((track) => track.isVisualImpaired) ? ", one of them described" : ""}`
461
+ : `audio tracks: using the probe's own fields — ${merged.reason}`
462
+ );
463
+ }
464
+
465
+ const sidecarFiles = sidecarsOf(torrent, fileIndex);
466
+ // All of them at once. They are separate files with separate headers, and
467
+ // read one after another the waits add up on the path to the first frame.
468
+ const sidecars = await Promise.all(
469
+ sidecarFiles.audio.map(async (file) => ({
470
+ file,
471
+ // A bare elementary stream — `.ac3`, `.dts`, `.mp3` — has no table to
472
+ // read, so nothing is asked of the swarm for it at all.
473
+ tracks: file.declaresTracks ? await declaredAudioOf(file.fileIndex, file.name) : []
474
+ }))
475
+ );
476
+ const inventory = buildAudioInventory({ embedded, videoFileIndex: fileIndex, sidecars });
477
+ if (sidecars.length > 0) {
478
+ logger.info(
479
+ `audio tracks: ${sidecars.length} file(s) beside the picture carry sound — ` +
480
+ inventory
481
+ .filter((entry) => entry.kind === "sidecar")
482
+ .map((entry) =>
483
+ `a:${entry.index}=${entry.folders.join("/") || "."}/${entry.fileName}` +
484
+ `#${entry.sourceTrackIndex}${entry.codec ? `(${entry.codec})` : ""}`
485
+ )
486
+ .join(" ")
487
+ );
488
+ }
489
+ return inventory;
490
+ }
491
+
492
+ function withHostTimings(plan) {
493
+ const withOffer = {
494
+ ...plan,
495
+ expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
496
+ expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
497
+ // Answered here for the same reason as the two above: a plan is cached for
498
+ // the life of the process, and what this host will serve a file at is not.
499
+ // It starts as a prediction from the startup benchmarks and is replaced by
500
+ // what an encoder running on this very source turns out to cost — frozen
501
+ // into the cache, every later open of the file would hand the browser the
502
+ // first guess again and undo that. This is the 2.9.106 defect exactly.
503
+ offeredHeights: plan.mediaInfoForOffer
504
+ ? (predictOfferedHeights?.(plan.mediaInfoForOffer) ?? null)
505
+ : null,
506
+ mediaInfoForOffer: undefined
507
+ };
508
+ // Refused rather than served badly. Both lists empty means this machine
509
+ // cannot sustain this file at ANY height — not even by copying the picture,
510
+ // which costs no encoder at all — so a session made here would produce a
511
+ // slideshow and take the swarm and the processor from whoever is already
512
+ // watching. Field 2026-08-28: five sessions on one file put every rung at
513
+ // 0.04x of realtime and the viewer watched one before the process was
514
+ // killed. The viewer is told why, which is a different thing from a spinner
515
+ // that never ends.
516
+ const offer = withOffer.offeredHeights;
517
+ if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
518
+ withOffer.cannotServe =
519
+ "This proxy cannot keep up with this file at any quality right now.";
520
+ // The description travels with the refusal, and only with it. It is what
521
+ // lets the browser ask the rest of the pool the same question without
522
+ // anybody else adding the torrent, fetching a byte or running ffmpeg —
523
+ // the expensive half of finding out what this file IS has been paid here,
524
+ // once. Everyone else answers by arithmetic against their own startup
525
+ // benchmarks.
526
+ withOffer.mediaInfoForOffer = plan.mediaInfoForOffer;
527
+ }
528
+ return withOffer;
529
+ }
530
+
531
+ return {
532
+ /**
533
+ * Media info the planner already probed for this file, or `null`. Lets the
534
+ * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
535
+ *
536
+ * @param {{ sourceKey: string, fileIndex: number }} params
537
+ * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
538
+ */
539
+ /**
540
+ * The audio tracks this file was probed to have, or an empty list. The
541
+ * master playlist publishes one rendition per track, and the inventory is
542
+ * already here probing again for it would be a second scan of a file the
543
+ * proxy is in the middle of serving.
544
+ *
545
+ * @param {{ sourceKey: string, fileIndex: number }} params
546
+ * @returns {object[]}
547
+ */
548
+ getCachedAudioTracks({ sourceKey, fileIndex }) {
549
+ const plan = cache.get(`${sourceKey}:${fileIndex}`);
550
+ return Array.isArray(plan?.audioTracks) ? plan.audioTracks : [];
551
+ },
552
+
553
+ getCachedMediaInfo({ sourceKey, fileIndex }) {
554
+ return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
555
+ },
556
+
557
+ /**
558
+ * Return the playback plan for the given source file.
559
+ * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
560
+ * when the source or file cannot be located.
561
+ *
562
+ * When the file header has not downloaded yet (cold torrent, peers still
563
+ * connecting) the codec probe cannot succeed. Rather than block the HTTP
564
+ * response until it can, the planner prioritises the header, probes for at
565
+ * most `maxWaitMs`, and if still undetectable returns a plan flagged
566
+ * `pending: true` (NOT cached). The caller polls again — each call keeps the
567
+ * header prioritised and downloading until a real plan comes back. This
568
+ * avoids a single long request racing the transport's request timeout.
569
+ *
570
+ * @param {object} params
571
+ * @param {string} params.sourceKey
572
+ * @param {number} params.fileIndex
573
+ * @param {string} [params.userAgent=""]
574
+ * @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
575
+ * @returns {Promise<PlaybackPlan & { pending?: boolean }>}
576
+ */
577
+ async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000 }) {
578
+ const cacheKey = `${sourceKey}:${fileIndex}`;
579
+ const cached = cache.get(cacheKey);
580
+ if (cached) {
581
+ return withHostTimings(cached);
582
+ }
583
+ // Where the time before playback goes. `cold-start` already breaks down
584
+ // everything from the transcode-session request onwards, but the plan
585
+ // runs BEFORE that and was a single opaque wait: a field session spent
586
+ // 5.7 s here on a torrent already in the store, with the codec probe
587
+ // cached, and nothing said which part of it was slow.
588
+ const planEntryMs = Date.now();
589
+ let torrentReadyMs = 0;
590
+ let edgesReadyMs = 0;
591
+
592
+ const sourceRecord = sourceRegistry.get(sourceKey);
593
+ if (!sourceRecord) {
594
+ const error = new Error("Source key was not found.");
595
+ error.code = "SOURCE_NOT_FOUND";
596
+ throw error;
597
+ }
598
+
599
+ const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
600
+ torrentReadyMs = Date.now() - planEntryMs;
601
+ const file = torrent.files[fileIndex];
602
+ if (!file) {
603
+ const error = new Error("File index was not found in torrent.");
604
+ error.code = "FILE_NOT_FOUND";
605
+ throw error;
606
+ }
607
+
608
+ const directUrl = buildDirectUrl(localBaseUrl, sourceKey, fileIndex);
609
+ if (!transcodeAudioEnabled) {
610
+ const plan = {
611
+ mode: "direct",
612
+ directUrl,
613
+ reason: "transcode-disabled",
614
+ audioCodec: "",
615
+ videoCodec: "",
616
+ container: "",
617
+ durationSeconds: 0,
618
+ videoWidth: 0,
619
+ videoHeight: 0,
620
+ audioTracks: [],
621
+ subtitleTracks: []
622
+ };
623
+ cache.set(cacheKey, plan);
624
+ return withHostTimings(plan);
625
+ }
626
+
627
+ // Pre-fetch file edges (head + tail), then probe — retrying while the
628
+ // file header is still downloading. In a multi-file torrent the pieces
629
+ // for a given file arrive unevenly, so the first probe can return empty
630
+ // codecs. A transient empty probe must NOT be cached: otherwise the wrong
631
+ // plan (file treated as directly playable) sticks permanently for this
632
+ // file, and an unsupported codec like xvid gets copied → black video.
633
+ await torrentPool.prefetchFileEdges(torrent, fileIndex);
634
+ edgesReadyMs = Date.now() - planEntryMs;
635
+ // The keyframe index reads the tail of the file, which the probe has just
636
+ // waited for as well. Started here it overlaps the probe instead of
637
+ // following the whole plan — worth 311-430 ms of the time before the
638
+ // first segment. Fire and forget: the session reads it itself if this has
639
+ // not finished, and both share one cache entry.
640
+ warmKeyframeIndex?.({
641
+ sourceKey,
642
+ fileIndex,
643
+ inputUrl: new URL(directUrl),
644
+ logName: file.name
645
+ });
646
+ let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
647
+ const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
648
+ let attempt = 0;
649
+ while (
650
+ probe.audioCodec.length === 0 &&
651
+ probe.videoCodec.length === 0 &&
652
+ Date.now() < probeDeadline
653
+ ) {
654
+ attempt += 1;
655
+ await delay(Math.min(3_000, 500 + attempt * 250));
656
+ await torrentPool.prefetchFileEdges(torrent, fileIndex);
657
+ probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
658
+ }
659
+ const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
660
+ const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
661
+ logger.info(
662
+ `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
663
+ `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
664
+ `total=${Date.now() - planEntryMs}ms attempts=${attempt + 1} ` +
665
+ `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
666
+ );
667
+
668
+ // The picture's own facts come from two readings and only one was ever
669
+ // used: every figure the encode is planned from came from ffmpeg's
670
+ // banner, while the `VideoTrack` the container declares was read and used
671
+ // for nothing but a line in the log.
672
+ let declaredVideo = null;
673
+ if (typeof torrentPool?.getDeclaredVideoTrack === "function") {
674
+ try {
675
+ declaredVideo = await torrentPool.getDeclaredVideoTrack(torrent, fileIndex);
676
+ } catch (error) {
677
+ logger.info(`video track: could not be read (${error?.message ?? error})`);
678
+ }
679
+ }
680
+ // `mode` is advisory only (audio-codec based). The browser makes the
681
+ // authoritative decision independently per stream via canPlayType /
682
+ // mediaCapabilities, transcoding only what it cannot play.
683
+ const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
684
+ // The two readings of the picture, lined up. Which one answers is decided
685
+ // per field by what each IS — see `Container.mergeVideoFacts`.
686
+ const videoFacts = Container.mergeVideoFacts(
687
+ {
688
+ width: videoWidth,
689
+ height: videoHeight,
690
+ fps: parseFfmpegVideoFps(probe.stderr),
691
+ isHdr: parseFfmpegHdr(probe.stderr),
692
+ bitDepth: parseFfmpegBitDepth(probe.stderr)
693
+ },
694
+ declaredVideo
695
+ );
696
+ if (videoFacts.disagreements.length > 0) {
697
+ logger.info(
698
+ `video track: the file and the probe disagree — ${videoFacts.disagreements.join("; ")}; ` +
699
+ "the size and frame rate are the probe's, the bit depth and HDR the file's"
700
+ );
701
+ }
702
+ const sidecars = sidecarsOf(torrent, fileIndex);
703
+ const plan = {
704
+ mode: requiresTranscode ? "hls" : "direct",
705
+ directUrl,
706
+ reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
707
+ audioCodec,
708
+ videoCodec,
709
+ container,
710
+ durationSeconds,
711
+ // Source coded resolution drives the browser's manual quality menu
712
+ // (list of forced resolutions <= source). 0 when unknown.
713
+ videoWidth: videoFacts.width ?? 0,
714
+ videoHeight: videoFacts.height ?? 0,
715
+ // Full track inventory for the browser's audio/subtitle menus. The audio
716
+ // half spans the picture's own tracks AND the soundtracks shipped as
717
+ // files beside it, under one numbering — see `buildInventory`.
718
+ audioTracks: await buildInventory(torrent, fileIndex, audioTracks ?? []),
719
+ subtitleTracks: await withContainerDefaults(torrent, fileIndex, subtitleTracks ?? []),
720
+ // The files BESIDE this picture that belong to it, and what each one's
721
+ // own path says about the track in it. Both answers are made here, by
722
+ // one grammar, because the browser used to make them again: it paired
723
+ // with a looser rule and read the names with a stricter one, and nothing
724
+ // compared the two. Measured 2026-09-04 over 115 real torrents 1249
725
+ // video files, ten pairings differing and the difference reached the
726
+ // viewer as a subtitle track offered but never warmed.
727
+ //
728
+ // The soundtracks are NOT repeated here: they are already in
729
+ // `audioTracks`, under the one flat numbering the browser addresses them
730
+ // by. What this adds is the two groups that had no place in the plan at
731
+ // all.
732
+ sidecarSubtitles: sidecars.subtitles,
733
+ sidecarImages: sidecars.images,
734
+ // Both host timings are filled in by `withHostTimings` on the way out,
735
+ // never here: read at build time they would be frozen into the cached
736
+ // plan, which is the bug fixed in 2.9.106.
737
+ expectedFirstSegmentMs: null,
738
+ expectedSessionCreateMs: null,
739
+ offeredHeights: null,
740
+ // What the offer is computed FROM, kept on the cached plan so the offer
741
+ // itself can be recomputed on every response. The figures are the
742
+ // probe's own and never change for a file; the answer derived from them
743
+ // does, as the host learns what this source costs. Stripped on the way
744
+ // out it is not part of the plan the browser is given.
745
+ mediaInfoForOffer: {
746
+ width: videoFacts.width,
747
+ height: videoFacts.height,
748
+ fps: videoFacts.fps,
749
+ bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
750
+ // Which family of the decode measurement prices this source. A video
751
+ // that has to be re-encoded is one the browser could not play, so it
752
+ // is usually NOT H.264, and H.264 constants are wrong for it.
753
+ codec: videoCodec,
754
+ bitDepth: videoFacts.bitDepth,
755
+ // Which file this is, so the offer can be answered from what an
756
+ // encoder has already learned about THIS source rather than from the
757
+ // startup clips — the same correction a live session applies.
758
+ sourceKey,
759
+ fileIndex
760
+ }
761
+ };
762
+ // Only cache a plan whose codecs were actually detected. An empty probe is
763
+ // a "header not downloaded yet" signal, not a valid result — caching it
764
+ // would permanently mis-plan the file. In that case flag the plan
765
+ // `pending` so the caller polls again (the header keeps downloading,
766
+ // prioritised by the prefetch above).
767
+ if (codecsDetected) {
768
+ cache.set(cacheKey, plan);
769
+ // Cache the full media info from THIS probe's banner (same helpers the
770
+ // session manager uses) so createSession can skip its own probe.
771
+ mediaInfoCache.set(cacheKey, {
772
+ // The codecs, because the session manager asks this cache which
773
+ // tracks the output will carry — and they were never stored here. It
774
+ // read `videoCodec`/`audioCodec` off an object that has only ever had
775
+ // dimensions and duration, got `undefined` for both, and declared
776
+ // `{video: false, audio: false}` for EVERY session since the check was
777
+ // written. Measured 2026-08-11: `declared tracks video=false
778
+ // audio=false`, which left the browser unable to tell "this file has
779
+ // no video" from "the video was lost on the way", and left the init
780
+ // guard expecting zero tracks and therefore accepting any header.
781
+ videoCodec: plan.videoCodec,
782
+ audioCodec: plan.audioCodec,
783
+ durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
784
+ width: videoFacts.width,
785
+ height: videoFacts.height,
786
+ bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
787
+ fps: videoFacts.fps,
788
+ startTime: parseFfmpegStartTimeSeconds(probe.stderr),
789
+ isHdr: videoFacts.isHdr,
790
+ bitDepth: videoFacts.bitDepth
791
+ });
792
+ // Warm the file-body start for the transcode session that follows.
793
+ // Fire-and-forget: never delays the plan response.
794
+ void torrentPool
795
+ .prefetchFileEdges(torrent, fileIndex, {
796
+ headBytes: BODY_PREFETCH_BYTES,
797
+ tailBytes: 0,
798
+ timeoutMs: 60_000
799
+ })
800
+ .catch(() => {});
801
+ return withHostTimings(plan);
802
+ }
803
+ return withHostTimings({ ...plan, pending: true });
804
+ }
805
+ };
806
+ }