@torrent-tv/proxy 2.9.137 → 2.9.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 2.9.139
2
+
3
+ - **Fix**: The proxy actually states which tracks its output will carry. It has been declaring `{video: false, audio: false}` for every session since the declaration was written, because it reads the codecs off the planner's media-info cache and that cache has only ever stored dimensions, duration, fps, start time and an HDR flag. Reading a field that is not there yields `undefined`, and `Boolean(undefined)` is `false`, so the promise was empty and silently so. Measured 2026-08-11 in the field: `declared tracks video=false audio=false`. Two things depended on it and both were disarmed — the browser could not tell "this file has no video" from "the video was lost on the way", and the init-segment guard computed a requirement of zero tracks and therefore accepted any header at all, including the audio-only one that leaves a session playing sound with no picture. The codecs are now stored where they are read.
4
+ - **Chore**: A test compares the fields the declaration READS with the fields the cache STORES. Nothing caught this: the writer and the reader each look correct in isolation, and no test had ever put the two shapes side by side.
5
+
6
+ ## 2.9.138
7
+
8
+ - **Fix**: The init segment is now required to declare every track before it is cached — the requirement was computed and then ignored. One pass worked out how many tracks a complete header must have; the next returned the FIRST header it found, whatever it declared. A piece written before the video was muxed therefore supplied an audio-only header, and that header is kept for the session's whole life, because the player fetches `#EXT-X-MAP` exactly once and never again. The browser then has no video source buffer however much video arrives afterwards. Measured 2026-08-11 on the field host: `videoWidth=0`, `totalVideoFrames=0`, `readyState=4` — an element perfectly content, playing sound, with no picture in it for as long as the session lasted. A header short of a track is now kept only as a fallback and used only if no complete one is found, which is also when the log says so.
9
+
1
10
  ## 2.9.137
2
11
 
3
12
  - **New**: A session states the track set its output will carry, and sends it to the browser. The proxy knows the set exactly — it chose it: the command maps at most one video and at most one audio, each optional, and subtitles never enter the HLS output. That statement is now used twice, which is the point of making it: the init segment is checked against it here, and the browser checks what it actually received against the same statement (server 0.8.161). A track lost between the encoder and the element was previously noticed only by its absence, minutes later, as a black picture with working sound.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.137",
3
+ "version": "2.9.139",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1619,9 +1619,26 @@ export class HlsSessionManager {
1619
1619
  continue;
1620
1620
  }
1621
1621
  const init = session.segmentFormat.extractInit(cached ?? await readFile(found));
1622
- if (init && init.length > 0) {
1622
+ if (!init || init.length === 0) {
1623
+ continue;
1624
+ }
1625
+ // The requirement computed above is APPLIED here. It was computed and
1626
+ // then ignored: this loop returned the first header it found, so a
1627
+ // piece written before the video was muxed supplied an audio-only
1628
+ // header — and that header is cached for the session's whole life,
1629
+ // because the player fetches `#EXT-X-MAP` once. Measured 2026-08-11:
1630
+ // `videoWidth=0`, `totalVideoFrames=0`, `readyState=4` — sound playing
1631
+ // and no picture, for as long as the session lasted.
1632
+ const tracks = typeof session.segmentFormat.countInitTracks === "function"
1633
+ ? session.segmentFormat.countInitTracks(init)
1634
+ : expectedTracks;
1635
+ if (tracks >= expectedTracks) {
1623
1636
  return init;
1624
1637
  }
1638
+ if (tracks > bestTracks) {
1639
+ best = init;
1640
+ bestTracks = tracks;
1641
+ }
1625
1642
  } catch {
1626
1643
  // Being written right now — try the next one.
1627
1644
  }
@@ -1635,6 +1652,7 @@ export class HlsSessionManager {
1635
1652
  `transcode ${session.id} no piece declared ${expectedTracks} tracks; ` +
1636
1653
  `serving an init with ${bestTracks}`
1637
1654
  );
1655
+ return best;
1638
1656
  }
1639
1657
  return best;
1640
1658
  }
@@ -1,494 +1,505 @@
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
- parseFfmpegVideoFps,
16
- parseFfmpegHdr
17
- } from "./ffmpeg-banner.js";
18
-
19
- /** Audio codecs that browsers can decode natively without transcoding. */
20
- const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
21
-
22
- // Once the plan probe succeeds, warm the START of the file body so the
23
- // transcode session's ffmpeg reads hit downloaded data instead of paying
24
- // piece latency at encode time (the edge prefetch only covers head+tail for
25
- // the codec probe). ~16 MB ≈ the first segments of typical media.
26
- const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
27
-
28
- /** Subtitle codecs that can be converted to WebVTT (text-based). */
29
- const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
30
-
31
- /**
32
- * Parse every stream from the ffmpeg `-i` banner: type, codec, language tag,
33
- * default disposition and (when present) the stream's `title` metadata line.
34
- *
35
- * @param {string} ffmpegOutput
36
- * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
37
- */
38
- function parseStreams(ffmpegOutput) {
39
- // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
40
- // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
41
- const inputSection = ffmpegOutput.split(/^(?:Output #|Stream mapping:)/m)[0] ?? ffmpegOutput;
42
- const lines = inputSection.split(/\r?\n/);
43
- const streams = [];
44
- let current = null;
45
- for (const line of lines) {
46
- const streamMatch = line.match(
47
- /^\s*Stream #0:(\d+)(?:\[[^\]]*\])?(?:\(([A-Za-z0-9]{2,3})\))?: (Audio|Video|Subtitle): ([A-Za-z0-9_]+)/
48
- );
49
- if (streamMatch) {
50
- current = {
51
- streamIndex: Number(streamMatch[1]),
52
- type: streamMatch[3].toLowerCase(),
53
- codec: String(streamMatch[4]).toLowerCase(),
54
- language: (streamMatch[2] ?? "").toLowerCase(),
55
- title: "",
56
- isDefault: /\(default\)/.test(line)
57
- };
58
- streams.push(current);
59
- continue;
60
- }
61
- if (current) {
62
- const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
63
- if (titleMatch && current.title.length === 0) {
64
- current.title = titleMatch[1].trim();
65
- continue;
66
- }
67
- // A new top-level section (non-indented line) ends the stream's block.
68
- if (!/^\s/.test(line)) {
69
- current = null;
70
- }
71
- }
72
- }
73
- return streams;
74
- }
75
-
76
- /**
77
- * Parse audio and video codec names from ffmpeg stderr output.
78
- *
79
- * @param {string} ffmpegOutput
80
- * @returns {{ audioCodec: string, videoCodec: string }}
81
- */
82
- function parseStreamCodecs(ffmpegOutput) {
83
- const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
84
- const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
85
- // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
86
- // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
87
- const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
88
- let videoWidth = 0;
89
- let videoHeight = 0;
90
- if (videoLineMatch) {
91
- const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
92
- if (dim) {
93
- videoWidth = Number(dim[1]);
94
- videoHeight = Number(dim[2]);
95
- }
96
- }
97
- const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
98
- const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
99
- let durationSeconds = 0;
100
- if (durationMatch) {
101
- const value =
102
- Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
103
- durationSeconds = Number.isFinite(value) ? value : 0;
104
- }
105
- const streams = parseStreams(ffmpegOutput);
106
- const audioTracks = streams
107
- .filter((s) => s.type === "audio")
108
- .map((s, i) => ({
109
- // Type-relative index — what ffmpeg's `-map 0:a:N` selects.
110
- index: i,
111
- streamIndex: s.streamIndex,
112
- codec: s.codec,
113
- language: s.language,
114
- title: s.title,
115
- isDefault: s.isDefault
116
- }));
117
- const subtitleTracks = streams
118
- .filter((s) => s.type === "subtitle")
119
- .map((s, i) => ({
120
- // Type-relative index — what ffmpeg's `-map 0:s:N` selects.
121
- index: i,
122
- streamIndex: s.streamIndex,
123
- codec: s.codec,
124
- language: s.language,
125
- title: s.title,
126
- isDefault: s.isDefault,
127
- // Image-based subtitles (PGS/VobSub) cannot become WebVTT.
128
- textBased: TEXT_SUBTITLE_CODECS.has(s.codec)
129
- }));
130
- return {
131
- audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
132
- videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
133
- container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
134
- durationSeconds,
135
- videoWidth,
136
- videoHeight,
137
- audioTracks,
138
- subtitleTracks
139
- };
140
- }
141
-
142
- /**
143
- * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
144
- * Times out after `timeoutMs` and returns empty strings on failure.
145
- *
146
- * @param {object} options
147
- * @param {string} options.ffmpegBin
148
- * @param {string} options.inputUrl
149
- * @param {string} [options.userAgent=""]
150
- * @param {number} [options.timeoutMs=8000]
151
- * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
152
- * Parsed banner fields plus the raw `stderr`, so the caller can derive the
153
- * full media info (fps/startTime/HDR) without a second ffmpeg scan.
154
- */
155
- function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
156
- return new Promise((resolve) => {
157
- const args = ["-hide_banner", "-loglevel", "info"];
158
- if (typeof userAgent === "string" && userAgent.trim().length > 0) {
159
- args.push("-user_agent", userAgent.trim());
160
- }
161
- // Decode a tiny slice of all streams (no per-stream -map, so video-only
162
- // files probe correctly too). The ffmpeg banner that precedes decoding
163
- // gives us audio/video codecs, the container format and the duration in a
164
- // single pass.
165
- args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
166
-
167
- const ffmpeg = spawn(ffmpegBin, args, {
168
- stdio: ["ignore", "ignore", "pipe"],
169
- windowsHide: true
170
- });
171
- let stderr = "";
172
- let settled = false;
173
-
174
- const finish = (codecs) => {
175
- if (settled) {
176
- return;
177
- }
178
- settled = true;
179
- resolve(codecs);
180
- };
181
-
182
- const timeoutId = setTimeout(() => {
183
- if (!ffmpeg.killed) {
184
- ffmpeg.kill("SIGTERM");
185
- }
186
- finish({ ...parseStreamCodecs(stderr), stderr });
187
- }, timeoutMs);
188
-
189
- ffmpeg.stderr.on("data", (chunk) => {
190
- stderr += String(chunk);
191
- });
192
-
193
- ffmpeg.on("error", () => {
194
- clearTimeout(timeoutId);
195
- finish({ audioCodec: "", videoCodec: "", stderr: "" });
196
- });
197
-
198
- ffmpeg.on("exit", () => {
199
- clearTimeout(timeoutId);
200
- finish({ ...parseStreamCodecs(stderr), stderr });
201
- });
202
- });
203
- }
204
-
205
- /**
206
- * Resolve after a given number of milliseconds.
207
- *
208
- * @param {number} ms
209
- * @returns {Promise<void>}
210
- */
211
- function delay(ms) {
212
- return new Promise((resolve) => {
213
- setTimeout(resolve, ms);
214
- });
215
- }
216
-
217
- /**
218
- * Build the direct stream URL for a source file served by the local proxy.
219
- *
220
- * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
221
- * @param {string} sourceKey
222
- * @param {number} fileIndex
223
- * @returns {string}
224
- */
225
- function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
226
- const directUrl = new URL("/stream", `${localBaseUrl}/`);
227
- directUrl.searchParams.set("sourceKey", sourceKey);
228
- directUrl.searchParams.set("fileIndex", String(fileIndex));
229
- return directUrl.toString();
230
- }
231
-
232
- /**
233
- * @typedef {Object} PlaybackPlan
234
- * @property {"direct" | "hls"} mode
235
- * @property {string} directUrl
236
- * @property {string} reason - Human-readable explanation of the chosen mode.
237
- * @property {string} audioCodec
238
- * @property {string} videoCodec
239
- * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
240
- * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
241
- * @property {number} videoWidth - Source coded width (0 if unknown).
242
- * @property {number} videoHeight - Source coded height (0 if unknown).
243
- */
244
-
245
- /**
246
- * @typedef {Object} PlaybackPlannerOptions
247
- * @property {string} ffmpegBin
248
- * @property {boolean} transcodeAudioEnabled
249
- * @property {string} localBaseUrl
250
- * @property {ReturnType<import("../store/source-registry.js").createSourceRegistry>} sourceRegistry
251
- * @property {import("./torrent-pool.js").TorrentPool} torrentPool
252
- */
253
-
254
- /**
255
- * Create a playback planner that decides the optimal streaming mode for
256
- * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
257
- *
258
- * @param {PlaybackPlannerOptions} options
259
- * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan> }}
260
- */
261
- export function createPlaybackPlanner({
262
- ffmpegBin,
263
- transcodeAudioEnabled,
264
- localBaseUrl,
265
- sourceRegistry,
266
- torrentPool,
267
- // Optional. Reports what this host typically takes to produce a session's
268
- // first segment. The browser needs it for the gap between "the file is
269
- // downloaded" and "a segment exists": until now it assumed the pipeline
270
- // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
271
- expectedFirstSegmentMs,
272
- expectedSessionCreateMs,
273
- // Optional. Called once the file's edges are downloaded, so the keyframe
274
- // index — which reads the same tail of the file — is fetched alongside the
275
- // codec probe instead of after it. Late-bound to the HLS session manager,
276
- // which owns the cache both of them share.
277
- warmKeyframeIndex
278
- }) {
279
- /** @type {Map<string, PlaybackPlan>} */
280
- const cache = new Map();
281
- /**
282
- * Full media info parsed from the SAME probe that produced the plan, cached
283
- * under the same key so a transcode session can reuse it instead of running
284
- * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
285
- * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
286
- */
287
- const mediaInfoCache = new Map();
288
-
289
- /**
290
- * Attach what this host currently measures itself taking to create a session
291
- * and to produce a first segment.
292
- *
293
- * Read at RESPONSE time, deliberately. Both are medians of sessions that have
294
- * already finished on this host, so at the moment a plan is BUILT the very
295
- * first file opened after a restart has none and gets `null` — and the plan
296
- * is then cached, so that file kept answering `null` for the life of the
297
- * process however many sessions ran afterwards. Measured 2026-08-05: a fresh
298
- * 2.9.103 answered `null` for both, then produced the session in 6 ms and the
299
- * first segment in 21 479 ms. The figures existed; the plan could not carry
300
- * them, and the browser's estimate fell back to its own guess in exactly the
301
- * cold-start case the feature was built for.
302
- *
303
- * @param {PlaybackPlan} plan
304
- * @returns {PlaybackPlan}
305
- */
306
- function withHostTimings(plan) {
307
- return {
308
- ...plan,
309
- expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
310
- expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null
311
- };
312
- }
313
-
314
- return {
315
- /**
316
- * Media info the planner already probed for this file, or `null`. Lets the
317
- * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
318
- *
319
- * @param {{ sourceKey: string, fileIndex: number }} params
320
- * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
321
- */
322
- getCachedMediaInfo({ sourceKey, fileIndex }) {
323
- return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
324
- },
325
-
326
- /**
327
- * Return the playback plan for the given source file.
328
- * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
329
- * when the source or file cannot be located.
330
- *
331
- * When the file header has not downloaded yet (cold torrent, peers still
332
- * connecting) the codec probe cannot succeed. Rather than block the HTTP
333
- * response until it can, the planner prioritises the header, probes for at
334
- * most `maxWaitMs`, and if still undetectable returns a plan flagged
335
- * `pending: true` (NOT cached). The caller polls again — each call keeps the
336
- * header prioritised and downloading — until a real plan comes back. This
337
- * avoids a single long request racing the transport's request timeout.
338
- *
339
- * @param {object} params
340
- * @param {string} params.sourceKey
341
- * @param {number} params.fileIndex
342
- * @param {string} [params.userAgent=""]
343
- * @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
344
- * @returns {Promise<PlaybackPlan & { pending?: boolean }>}
345
- */
346
- async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000 }) {
347
- const cacheKey = `${sourceKey}:${fileIndex}`;
348
- const cached = cache.get(cacheKey);
349
- if (cached) {
350
- return withHostTimings(cached);
351
- }
352
- // Where the time before playback goes. `cold-start` already breaks down
353
- // everything from the transcode-session request onwards, but the plan
354
- // runs BEFORE that and was a single opaque wait: a field session spent
355
- // 5.7 s here on a torrent already in the store, with the codec probe
356
- // cached, and nothing said which part of it was slow.
357
- const planEntryMs = Date.now();
358
- let torrentReadyMs = 0;
359
- let edgesReadyMs = 0;
360
-
361
- const sourceRecord = sourceRegistry.get(sourceKey);
362
- if (!sourceRecord) {
363
- const error = new Error("Source key was not found.");
364
- error.code = "SOURCE_NOT_FOUND";
365
- throw error;
366
- }
367
-
368
- const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
369
- torrentReadyMs = Date.now() - planEntryMs;
370
- const file = torrent.files[fileIndex];
371
- if (!file) {
372
- const error = new Error("File index was not found in torrent.");
373
- error.code = "FILE_NOT_FOUND";
374
- throw error;
375
- }
376
-
377
- const directUrl = buildDirectUrl(localBaseUrl, sourceKey, fileIndex);
378
- if (!transcodeAudioEnabled) {
379
- const plan = {
380
- mode: "direct",
381
- directUrl,
382
- reason: "transcode-disabled",
383
- audioCodec: "",
384
- videoCodec: "",
385
- container: "",
386
- durationSeconds: 0,
387
- videoWidth: 0,
388
- videoHeight: 0,
389
- audioTracks: [],
390
- subtitleTracks: []
391
- };
392
- cache.set(cacheKey, plan);
393
- return withHostTimings(plan);
394
- }
395
-
396
- // Pre-fetch file edges (head + tail), then probe — retrying while the
397
- // file header is still downloading. In a multi-file torrent the pieces
398
- // for a given file arrive unevenly, so the first probe can return empty
399
- // codecs. A transient empty probe must NOT be cached: otherwise the wrong
400
- // plan (file treated as directly playable) sticks permanently for this
401
- // file, and an unsupported codec like xvid gets copied → black video.
402
- await torrentPool.prefetchFileEdges(torrent, fileIndex);
403
- edgesReadyMs = Date.now() - planEntryMs;
404
- // The keyframe index reads the tail of the file, which the probe has just
405
- // waited for as well. Started here it overlaps the probe instead of
406
- // following the whole plan — worth 311-430 ms of the time before the
407
- // first segment. Fire and forget: the session reads it itself if this has
408
- // not finished, and both share one cache entry.
409
- warmKeyframeIndex?.({
410
- sourceKey,
411
- fileIndex,
412
- inputUrl: new URL(directUrl),
413
- logName: file.name
414
- });
415
- let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
416
- const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
417
- let attempt = 0;
418
- while (
419
- probe.audioCodec.length === 0 &&
420
- probe.videoCodec.length === 0 &&
421
- Date.now() < probeDeadline
422
- ) {
423
- attempt += 1;
424
- await delay(Math.min(3_000, 500 + attempt * 250));
425
- await torrentPool.prefetchFileEdges(torrent, fileIndex);
426
- probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
427
- }
428
- const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
429
- const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
430
- logger.info(
431
- `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
432
- `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
433
- `total=${Date.now() - planEntryMs}ms attempts=${attempt + 1} ` +
434
- `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
435
- );
436
-
437
- // `mode` is advisory only (audio-codec based). The browser makes the
438
- // authoritative decision independently per stream via canPlayType /
439
- // mediaCapabilities, transcoding only what it cannot play.
440
- const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
441
- const plan = {
442
- mode: requiresTranscode ? "hls" : "direct",
443
- directUrl,
444
- reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
445
- audioCodec,
446
- videoCodec,
447
- container,
448
- durationSeconds,
449
- // Source coded resolution — drives the browser's manual quality menu
450
- // (list of forced resolutions <= source). 0 when unknown.
451
- videoWidth,
452
- videoHeight,
453
- // Full track inventory for the browser's audio/subtitle menus.
454
- audioTracks: audioTracks ?? [],
455
- subtitleTracks: subtitleTracks ?? [],
456
- // Both host timings are filled in by `withHostTimings` on the way out,
457
- // never here: read at build time they would be frozen into the cached
458
- // plan, which is the bug fixed in 2.9.106.
459
- expectedFirstSegmentMs: null,
460
- expectedSessionCreateMs: null
461
- };
462
- // Only cache a plan whose codecs were actually detected. An empty probe is
463
- // a "header not downloaded yet" signal, not a valid result — caching it
464
- // would permanently mis-plan the file. In that case flag the plan
465
- // `pending` so the caller polls again (the header keeps downloading,
466
- // prioritised by the prefetch above).
467
- if (codecsDetected) {
468
- cache.set(cacheKey, plan);
469
- // Cache the full media info from THIS probe's banner (same helpers the
470
- // session manager uses) so createSession can skip its own probe.
471
- const dims = parseFfmpegVideoDimensions(probe.stderr);
472
- mediaInfoCache.set(cacheKey, {
473
- durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
474
- width: dims.width,
475
- height: dims.height,
476
- fps: parseFfmpegVideoFps(probe.stderr),
477
- startTime: parseFfmpegStartTimeSeconds(probe.stderr),
478
- isHdr: parseFfmpegHdr(probe.stderr)
479
- });
480
- // Warm the file-body start for the transcode session that follows.
481
- // Fire-and-forget: never delays the plan response.
482
- void torrentPool
483
- .prefetchFileEdges(torrent, fileIndex, {
484
- headBytes: BODY_PREFETCH_BYTES,
485
- tailBytes: 0,
486
- timeoutMs: 60_000
487
- })
488
- .catch(() => {});
489
- return withHostTimings(plan);
490
- }
491
- return withHostTimings({ ...plan, pending: true });
492
- }
493
- };
494
- }
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
+ parseFfmpegVideoFps,
16
+ parseFfmpegHdr
17
+ } from "./ffmpeg-banner.js";
18
+
19
+ /** Audio codecs that browsers can decode natively without transcoding. */
20
+ const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
21
+
22
+ // Once the plan probe succeeds, warm the START of the file body so the
23
+ // transcode session's ffmpeg reads hit downloaded data instead of paying
24
+ // piece latency at encode time (the edge prefetch only covers head+tail for
25
+ // the codec probe). ~16 MB ≈ the first segments of typical media.
26
+ const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
27
+
28
+ /** Subtitle codecs that can be converted to WebVTT (text-based). */
29
+ const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
30
+
31
+ /**
32
+ * Parse every stream from the ffmpeg `-i` banner: type, codec, language tag,
33
+ * default disposition and (when present) the stream's `title` metadata line.
34
+ *
35
+ * @param {string} ffmpegOutput
36
+ * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
37
+ */
38
+ function parseStreams(ffmpegOutput) {
39
+ // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
40
+ // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
41
+ const inputSection = ffmpegOutput.split(/^(?:Output #|Stream mapping:)/m)[0] ?? ffmpegOutput;
42
+ const lines = inputSection.split(/\r?\n/);
43
+ const streams = [];
44
+ let current = null;
45
+ for (const line of lines) {
46
+ const streamMatch = line.match(
47
+ /^\s*Stream #0:(\d+)(?:\[[^\]]*\])?(?:\(([A-Za-z0-9]{2,3})\))?: (Audio|Video|Subtitle): ([A-Za-z0-9_]+)/
48
+ );
49
+ if (streamMatch) {
50
+ current = {
51
+ streamIndex: Number(streamMatch[1]),
52
+ type: streamMatch[3].toLowerCase(),
53
+ codec: String(streamMatch[4]).toLowerCase(),
54
+ language: (streamMatch[2] ?? "").toLowerCase(),
55
+ title: "",
56
+ isDefault: /\(default\)/.test(line)
57
+ };
58
+ streams.push(current);
59
+ continue;
60
+ }
61
+ if (current) {
62
+ const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
63
+ if (titleMatch && current.title.length === 0) {
64
+ current.title = titleMatch[1].trim();
65
+ continue;
66
+ }
67
+ // A new top-level section (non-indented line) ends the stream's block.
68
+ if (!/^\s/.test(line)) {
69
+ current = null;
70
+ }
71
+ }
72
+ }
73
+ return streams;
74
+ }
75
+
76
+ /**
77
+ * Parse audio and video codec names from ffmpeg stderr output.
78
+ *
79
+ * @param {string} ffmpegOutput
80
+ * @returns {{ audioCodec: string, videoCodec: string }}
81
+ */
82
+ function parseStreamCodecs(ffmpegOutput) {
83
+ const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
84
+ const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
85
+ // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
86
+ // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
87
+ const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
88
+ let videoWidth = 0;
89
+ let videoHeight = 0;
90
+ if (videoLineMatch) {
91
+ const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
92
+ if (dim) {
93
+ videoWidth = Number(dim[1]);
94
+ videoHeight = Number(dim[2]);
95
+ }
96
+ }
97
+ const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
98
+ const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
99
+ let durationSeconds = 0;
100
+ if (durationMatch) {
101
+ const value =
102
+ Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
103
+ durationSeconds = Number.isFinite(value) ? value : 0;
104
+ }
105
+ const streams = parseStreams(ffmpegOutput);
106
+ const audioTracks = streams
107
+ .filter((s) => s.type === "audio")
108
+ .map((s, i) => ({
109
+ // Type-relative index — what ffmpeg's `-map 0:a:N` selects.
110
+ index: i,
111
+ streamIndex: s.streamIndex,
112
+ codec: s.codec,
113
+ language: s.language,
114
+ title: s.title,
115
+ isDefault: s.isDefault
116
+ }));
117
+ const subtitleTracks = streams
118
+ .filter((s) => s.type === "subtitle")
119
+ .map((s, i) => ({
120
+ // Type-relative index — what ffmpeg's `-map 0:s:N` selects.
121
+ index: i,
122
+ streamIndex: s.streamIndex,
123
+ codec: s.codec,
124
+ language: s.language,
125
+ title: s.title,
126
+ isDefault: s.isDefault,
127
+ // Image-based subtitles (PGS/VobSub) cannot become WebVTT.
128
+ textBased: TEXT_SUBTITLE_CODECS.has(s.codec)
129
+ }));
130
+ return {
131
+ audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
132
+ videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
133
+ container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
134
+ durationSeconds,
135
+ videoWidth,
136
+ videoHeight,
137
+ audioTracks,
138
+ subtitleTracks
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
144
+ * Times out after `timeoutMs` and returns empty strings on failure.
145
+ *
146
+ * @param {object} options
147
+ * @param {string} options.ffmpegBin
148
+ * @param {string} options.inputUrl
149
+ * @param {string} [options.userAgent=""]
150
+ * @param {number} [options.timeoutMs=8000]
151
+ * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
152
+ * Parsed banner fields plus the raw `stderr`, so the caller can derive the
153
+ * full media info (fps/startTime/HDR) without a second ffmpeg scan.
154
+ */
155
+ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
156
+ return new Promise((resolve) => {
157
+ const args = ["-hide_banner", "-loglevel", "info"];
158
+ if (typeof userAgent === "string" && userAgent.trim().length > 0) {
159
+ args.push("-user_agent", userAgent.trim());
160
+ }
161
+ // Decode a tiny slice of all streams (no per-stream -map, so video-only
162
+ // files probe correctly too). The ffmpeg banner that precedes decoding
163
+ // gives us audio/video codecs, the container format and the duration in a
164
+ // single pass.
165
+ args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
166
+
167
+ const ffmpeg = spawn(ffmpegBin, args, {
168
+ stdio: ["ignore", "ignore", "pipe"],
169
+ windowsHide: true
170
+ });
171
+ let stderr = "";
172
+ let settled = false;
173
+
174
+ const finish = (codecs) => {
175
+ if (settled) {
176
+ return;
177
+ }
178
+ settled = true;
179
+ resolve(codecs);
180
+ };
181
+
182
+ const timeoutId = setTimeout(() => {
183
+ if (!ffmpeg.killed) {
184
+ ffmpeg.kill("SIGTERM");
185
+ }
186
+ finish({ ...parseStreamCodecs(stderr), stderr });
187
+ }, timeoutMs);
188
+
189
+ ffmpeg.stderr.on("data", (chunk) => {
190
+ stderr += String(chunk);
191
+ });
192
+
193
+ ffmpeg.on("error", () => {
194
+ clearTimeout(timeoutId);
195
+ finish({ audioCodec: "", videoCodec: "", stderr: "" });
196
+ });
197
+
198
+ ffmpeg.on("exit", () => {
199
+ clearTimeout(timeoutId);
200
+ finish({ ...parseStreamCodecs(stderr), stderr });
201
+ });
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Resolve after a given number of milliseconds.
207
+ *
208
+ * @param {number} ms
209
+ * @returns {Promise<void>}
210
+ */
211
+ function delay(ms) {
212
+ return new Promise((resolve) => {
213
+ setTimeout(resolve, ms);
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Build the direct stream URL for a source file served by the local proxy.
219
+ *
220
+ * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
221
+ * @param {string} sourceKey
222
+ * @param {number} fileIndex
223
+ * @returns {string}
224
+ */
225
+ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
226
+ const directUrl = new URL("/stream", `${localBaseUrl}/`);
227
+ directUrl.searchParams.set("sourceKey", sourceKey);
228
+ directUrl.searchParams.set("fileIndex", String(fileIndex));
229
+ return directUrl.toString();
230
+ }
231
+
232
+ /**
233
+ * @typedef {Object} PlaybackPlan
234
+ * @property {"direct" | "hls"} mode
235
+ * @property {string} directUrl
236
+ * @property {string} reason - Human-readable explanation of the chosen mode.
237
+ * @property {string} audioCodec
238
+ * @property {string} videoCodec
239
+ * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
240
+ * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
241
+ * @property {number} videoWidth - Source coded width (0 if unknown).
242
+ * @property {number} videoHeight - Source coded height (0 if unknown).
243
+ */
244
+
245
+ /**
246
+ * @typedef {Object} PlaybackPlannerOptions
247
+ * @property {string} ffmpegBin
248
+ * @property {boolean} transcodeAudioEnabled
249
+ * @property {string} localBaseUrl
250
+ * @property {ReturnType<import("../store/source-registry.js").createSourceRegistry>} sourceRegistry
251
+ * @property {import("./torrent-pool.js").TorrentPool} torrentPool
252
+ */
253
+
254
+ /**
255
+ * Create a playback planner that decides the optimal streaming mode for
256
+ * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
257
+ *
258
+ * @param {PlaybackPlannerOptions} options
259
+ * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan> }}
260
+ */
261
+ export function createPlaybackPlanner({
262
+ ffmpegBin,
263
+ transcodeAudioEnabled,
264
+ localBaseUrl,
265
+ sourceRegistry,
266
+ torrentPool,
267
+ // Optional. Reports what this host typically takes to produce a session's
268
+ // first segment. The browser needs it for the gap between "the file is
269
+ // downloaded" and "a segment exists": until now it assumed the pipeline
270
+ // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
271
+ expectedFirstSegmentMs,
272
+ expectedSessionCreateMs,
273
+ // Optional. Called once the file's edges are downloaded, so the keyframe
274
+ // index — which reads the same tail of the file — is fetched alongside the
275
+ // codec probe instead of after it. Late-bound to the HLS session manager,
276
+ // which owns the cache both of them share.
277
+ warmKeyframeIndex
278
+ }) {
279
+ /** @type {Map<string, PlaybackPlan>} */
280
+ const cache = new Map();
281
+ /**
282
+ * Full media info parsed from the SAME probe that produced the plan, cached
283
+ * under the same key so a transcode session can reuse it instead of running
284
+ * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
285
+ * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
286
+ */
287
+ const mediaInfoCache = new Map();
288
+
289
+ /**
290
+ * Attach what this host currently measures itself taking to create a session
291
+ * and to produce a first segment.
292
+ *
293
+ * Read at RESPONSE time, deliberately. Both are medians of sessions that have
294
+ * already finished on this host, so at the moment a plan is BUILT the very
295
+ * first file opened after a restart has none and gets `null` — and the plan
296
+ * is then cached, so that file kept answering `null` for the life of the
297
+ * process however many sessions ran afterwards. Measured 2026-08-05: a fresh
298
+ * 2.9.103 answered `null` for both, then produced the session in 6 ms and the
299
+ * first segment in 21 479 ms. The figures existed; the plan could not carry
300
+ * them, and the browser's estimate fell back to its own guess in exactly the
301
+ * cold-start case the feature was built for.
302
+ *
303
+ * @param {PlaybackPlan} plan
304
+ * @returns {PlaybackPlan}
305
+ */
306
+ function withHostTimings(plan) {
307
+ return {
308
+ ...plan,
309
+ expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
310
+ expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null
311
+ };
312
+ }
313
+
314
+ return {
315
+ /**
316
+ * Media info the planner already probed for this file, or `null`. Lets the
317
+ * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
318
+ *
319
+ * @param {{ sourceKey: string, fileIndex: number }} params
320
+ * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
321
+ */
322
+ getCachedMediaInfo({ sourceKey, fileIndex }) {
323
+ return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
324
+ },
325
+
326
+ /**
327
+ * Return the playback plan for the given source file.
328
+ * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
329
+ * when the source or file cannot be located.
330
+ *
331
+ * When the file header has not downloaded yet (cold torrent, peers still
332
+ * connecting) the codec probe cannot succeed. Rather than block the HTTP
333
+ * response until it can, the planner prioritises the header, probes for at
334
+ * most `maxWaitMs`, and if still undetectable returns a plan flagged
335
+ * `pending: true` (NOT cached). The caller polls again — each call keeps the
336
+ * header prioritised and downloading — until a real plan comes back. This
337
+ * avoids a single long request racing the transport's request timeout.
338
+ *
339
+ * @param {object} params
340
+ * @param {string} params.sourceKey
341
+ * @param {number} params.fileIndex
342
+ * @param {string} [params.userAgent=""]
343
+ * @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
344
+ * @returns {Promise<PlaybackPlan & { pending?: boolean }>}
345
+ */
346
+ async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000 }) {
347
+ const cacheKey = `${sourceKey}:${fileIndex}`;
348
+ const cached = cache.get(cacheKey);
349
+ if (cached) {
350
+ return withHostTimings(cached);
351
+ }
352
+ // Where the time before playback goes. `cold-start` already breaks down
353
+ // everything from the transcode-session request onwards, but the plan
354
+ // runs BEFORE that and was a single opaque wait: a field session spent
355
+ // 5.7 s here on a torrent already in the store, with the codec probe
356
+ // cached, and nothing said which part of it was slow.
357
+ const planEntryMs = Date.now();
358
+ let torrentReadyMs = 0;
359
+ let edgesReadyMs = 0;
360
+
361
+ const sourceRecord = sourceRegistry.get(sourceKey);
362
+ if (!sourceRecord) {
363
+ const error = new Error("Source key was not found.");
364
+ error.code = "SOURCE_NOT_FOUND";
365
+ throw error;
366
+ }
367
+
368
+ const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
369
+ torrentReadyMs = Date.now() - planEntryMs;
370
+ const file = torrent.files[fileIndex];
371
+ if (!file) {
372
+ const error = new Error("File index was not found in torrent.");
373
+ error.code = "FILE_NOT_FOUND";
374
+ throw error;
375
+ }
376
+
377
+ const directUrl = buildDirectUrl(localBaseUrl, sourceKey, fileIndex);
378
+ if (!transcodeAudioEnabled) {
379
+ const plan = {
380
+ mode: "direct",
381
+ directUrl,
382
+ reason: "transcode-disabled",
383
+ audioCodec: "",
384
+ videoCodec: "",
385
+ container: "",
386
+ durationSeconds: 0,
387
+ videoWidth: 0,
388
+ videoHeight: 0,
389
+ audioTracks: [],
390
+ subtitleTracks: []
391
+ };
392
+ cache.set(cacheKey, plan);
393
+ return withHostTimings(plan);
394
+ }
395
+
396
+ // Pre-fetch file edges (head + tail), then probe — retrying while the
397
+ // file header is still downloading. In a multi-file torrent the pieces
398
+ // for a given file arrive unevenly, so the first probe can return empty
399
+ // codecs. A transient empty probe must NOT be cached: otherwise the wrong
400
+ // plan (file treated as directly playable) sticks permanently for this
401
+ // file, and an unsupported codec like xvid gets copied → black video.
402
+ await torrentPool.prefetchFileEdges(torrent, fileIndex);
403
+ edgesReadyMs = Date.now() - planEntryMs;
404
+ // The keyframe index reads the tail of the file, which the probe has just
405
+ // waited for as well. Started here it overlaps the probe instead of
406
+ // following the whole plan — worth 311-430 ms of the time before the
407
+ // first segment. Fire and forget: the session reads it itself if this has
408
+ // not finished, and both share one cache entry.
409
+ warmKeyframeIndex?.({
410
+ sourceKey,
411
+ fileIndex,
412
+ inputUrl: new URL(directUrl),
413
+ logName: file.name
414
+ });
415
+ let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
416
+ const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
417
+ let attempt = 0;
418
+ while (
419
+ probe.audioCodec.length === 0 &&
420
+ probe.videoCodec.length === 0 &&
421
+ Date.now() < probeDeadline
422
+ ) {
423
+ attempt += 1;
424
+ await delay(Math.min(3_000, 500 + attempt * 250));
425
+ await torrentPool.prefetchFileEdges(torrent, fileIndex);
426
+ probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
427
+ }
428
+ const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
429
+ const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
430
+ logger.info(
431
+ `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
432
+ `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
433
+ `total=${Date.now() - planEntryMs}ms attempts=${attempt + 1} ` +
434
+ `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
435
+ );
436
+
437
+ // `mode` is advisory only (audio-codec based). The browser makes the
438
+ // authoritative decision independently per stream via canPlayType /
439
+ // mediaCapabilities, transcoding only what it cannot play.
440
+ const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
441
+ const plan = {
442
+ mode: requiresTranscode ? "hls" : "direct",
443
+ directUrl,
444
+ reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
445
+ audioCodec,
446
+ videoCodec,
447
+ container,
448
+ durationSeconds,
449
+ // Source coded resolution — drives the browser's manual quality menu
450
+ // (list of forced resolutions <= source). 0 when unknown.
451
+ videoWidth,
452
+ videoHeight,
453
+ // Full track inventory for the browser's audio/subtitle menus.
454
+ audioTracks: audioTracks ?? [],
455
+ subtitleTracks: subtitleTracks ?? [],
456
+ // Both host timings are filled in by `withHostTimings` on the way out,
457
+ // never here: read at build time they would be frozen into the cached
458
+ // plan, which is the bug fixed in 2.9.106.
459
+ expectedFirstSegmentMs: null,
460
+ expectedSessionCreateMs: null
461
+ };
462
+ // Only cache a plan whose codecs were actually detected. An empty probe is
463
+ // a "header not downloaded yet" signal, not a valid result — caching it
464
+ // would permanently mis-plan the file. In that case flag the plan
465
+ // `pending` so the caller polls again (the header keeps downloading,
466
+ // prioritised by the prefetch above).
467
+ if (codecsDetected) {
468
+ cache.set(cacheKey, plan);
469
+ // Cache the full media info from THIS probe's banner (same helpers the
470
+ // session manager uses) so createSession can skip its own probe.
471
+ const dims = parseFfmpegVideoDimensions(probe.stderr);
472
+ mediaInfoCache.set(cacheKey, {
473
+ // The codecs, because the session manager asks this cache which
474
+ // tracks the output will carry — and they were never stored here. It
475
+ // read `videoCodec`/`audioCodec` off an object that has only ever had
476
+ // dimensions and duration, got `undefined` for both, and declared
477
+ // `{video: false, audio: false}` for EVERY session since the check was
478
+ // written. Measured 2026-08-11: `declared tracks video=false
479
+ // audio=false`, which left the browser unable to tell "this file has
480
+ // no video" from "the video was lost on the way", and left the init
481
+ // guard expecting zero tracks and therefore accepting any header.
482
+ videoCodec: plan.videoCodec,
483
+ audioCodec: plan.audioCodec,
484
+ durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
485
+ width: dims.width,
486
+ height: dims.height,
487
+ fps: parseFfmpegVideoFps(probe.stderr),
488
+ startTime: parseFfmpegStartTimeSeconds(probe.stderr),
489
+ isHdr: parseFfmpegHdr(probe.stderr)
490
+ });
491
+ // Warm the file-body start for the transcode session that follows.
492
+ // Fire-and-forget: never delays the plan response.
493
+ void torrentPool
494
+ .prefetchFileEdges(torrent, fileIndex, {
495
+ headBytes: BODY_PREFETCH_BYTES,
496
+ tailBytes: 0,
497
+ timeoutMs: 60_000
498
+ })
499
+ .catch(() => {});
500
+ return withHostTimings(plan);
501
+ }
502
+ return withHostTimings({ ...plan, pending: true });
503
+ }
504
+ };
505
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @file What the proxy promises the browser must be answerable.
3
+ *
4
+ * The session manager asks the planner's media-info cache which tracks the
5
+ * output will carry. That cache never held the codecs — it holds dimensions,
6
+ * duration, fps, start time and an HDR flag — so the read produced `undefined`
7
+ * for both and the declaration came out `{video: false, audio: false}` for
8
+ * EVERY session since the check existed. Measured 2026-08-11 in the field.
9
+ *
10
+ * Nothing caught it: the writer and the reader each looked correct alone, and
11
+ * no test ever compared the shape of what is stored with the shape of what is
12
+ * read. That comparison is this file's whole job.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { readFileSync } from "node:fs";
18
+
19
+ const planner = readFileSync(new URL("../services/playback-planner.js", import.meta.url), "utf8");
20
+ const sessions = readFileSync(new URL("../services/hls-session-manager.js", import.meta.url), "utf8");
21
+
22
+ test("every field the declaration reads is a field the cache stores", () => {
23
+ // What `declaredTracks` reads off the cached media info.
24
+ const read = [...sessions.matchAll(/probed\?\.([A-Za-z]+)/g)].map((match) => match[1]);
25
+ assert.ok(read.length > 0, "the declaration must read something, or it declares nothing");
26
+
27
+ const stored = planner.slice(planner.indexOf("mediaInfoCache.set("));
28
+ for (const field of new Set(read)) {
29
+ assert.ok(
30
+ stored.includes(`${field}:`),
31
+ `the declaration reads "${field}" and the media-info cache never stores it — ` +
32
+ "the read yields undefined and the promise silently becomes false"
33
+ );
34
+ }
35
+ });