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