@torrent-tv/proxy 2.73.1 → 2.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +1453 -1437
  2. package/CLAUDE.md +165 -160
  3. package/docs/container-architecture.md +192 -184
  4. package/package.json +1 -1
  5. package/routes/api/subtitles/get.js +205 -205
  6. package/services/container/Container.js +400 -135
  7. package/services/container/ContainerFactory.js +55 -31
  8. package/services/container/MatroskaContainer.js +1166 -516
  9. package/services/container/Mp4Container.js +898 -392
  10. package/services/container/SubtitleFileContainer.js +323 -261
  11. package/services/controllers/SubtitleController.js +128 -127
  12. package/services/delivery-probe.js +64 -6
  13. package/services/hls-session-manager.js +32 -35
  14. package/services/language-detect.js +174 -228
  15. package/services/playback-planner.js +747 -747
  16. package/services/produced-index.js +300 -0
  17. package/services/torrent-worker/subtitle-cues.js +549 -633
  18. package/services/tracks/TextSubtitleTrack.js +287 -47
  19. package/services/tracks/index.js +14 -14
  20. package/test/delivery-probe.test.js +67 -0
  21. package/test/matroska-blocks.test.js +0 -0
  22. package/test/mp4-subtitles.test.js +173 -127
  23. package/test/produced-index.test.js +188 -0
  24. package/test/subtitle-cue-framing.test.js +200 -202
  25. package/test/subtitle-cue-walk.test.js +369 -0
  26. package/test/subtitle-defaults.test.js +97 -97
  27. package/test/subtitle-language.test.js +252 -252
  28. package/test/subtitle-track-numbering.test.js +370 -370
  29. package/services/container-index/matroska-blocks.js +0 -202
  30. package/services/container-index/matroska-subtitles.js +0 -372
  31. package/services/container-index/mp4-subtitles.js +0 -404
  32. package/services/subtitle-convert.js +0 -144
  33. package/services/subtitle-defaults.js +0 -157
  34. package/services/tracks/subtitle-markup.js +0 -104
@@ -1,135 +1,400 @@
1
- /**
2
- * @file Base Container — abstract per RFC 9559 / ISO 14496-12.
3
- *
4
- * A Container knows how to read its own format's track table and index.
5
- * Concrete containers (MatroskaContainer, Mp4Container, AviContainer) implement
6
- * spec-specific parsing. All byte access goes through `readRange(start,end)` so
7
- * the class works over torrent piece windows.
8
- *
9
- * Spec refs:
10
- * - Matroska RFC 9559 §5: EBML, Segment, SeekHead, Tracks, Cues, Clusters
11
- * - MP4 ISO/IEC 14496-12 §8: ftyp, moov, trak, tkhd, mdhd, hdlr, elng, stbl
12
- * - AVI RIFF §: LIST hdrl, idx1
13
- */
14
-
15
- /**
16
- * What one file declares about itself. Every field is either a value the
17
- * container states or `null`, which means the container does not state it —
18
- * a defined absence, not "nobody has looked yet".
19
- *
20
- * @typedef {object} ContainerMediaInfo
21
- * @property {string} format - "matroska" | "mp4" | "avi" | "unknown".
22
- * @property {number | null} durationSeconds
23
- * @property {number | null} startTimeSeconds - Where this file's own timeline
24
- * begins. Two files of one release need not agree on it, and the difference
25
- * is what keeps a soundtrack shipped separately aligned with its picture.
26
- */
27
-
28
- export class Container {
29
- /**
30
- * @param {object} params
31
- * @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
32
- * @param {number} params.fileSize
33
- * @param {string} [params.label]
34
- */
35
- constructor({ readRange, fileSize, label = "" }) {
36
- this.readRange = readRange;
37
- this.fileSize = fileSize;
38
- this.label = label;
39
- }
40
-
41
- /** @returns {string} Human name: "matroska" | "mp4" | "avi" | "unknown" */
42
- get formatName() {
43
- return "unknown";
44
- }
45
-
46
- /** Whether `head` (first bytes) looks like this container. */
47
- static detect(_head) {
48
- return false;
49
- }
50
-
51
- /**
52
- * All tracks declared by the container, in container order.
53
- * Includes disabled tracks (isEnabled=false) to preserve declaredIndex alignment with ffmpeg.
54
- * @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
55
- */
56
- async readTracks() {
57
- throw new Error("readTracks not implemented");
58
- }
59
-
60
- /**
61
- * Keyframe times for the video track, ascending seconds. Null when index absent (MPEG-TS, fragmented MP4, truncated).
62
- * @returns {Promise<{times:number[],tolerance:number}|null>}
63
- */
64
- async readKeyframeIndex() {
65
- return null;
66
- }
67
-
68
- /**
69
- * What this file DECLARES about itself as a whole, as distinct from what its
70
- * individual tracks declare.
71
- *
72
- * The rule this method exists to hold: a fact the container declares is read
73
- * from the container; a fact only the media itself has is measured from the
74
- * media. Both halves used to be asked of ffmpeg, so the same header was read
75
- * twice — measured 2026-09-03, this layer read one `.mka` header in 8 ms while
76
- * a second ffmpeg read the same header over HTTP for 8121 ms, in the same
77
- * second, on the same file.
78
- *
79
- * `null` is not "unknown, ask someone else". It means the container does not
80
- * declare the field, which is a final answer about the container and the point
81
- * at which a caller may go to the media — see `docs/container-architecture.md`.
82
- *
83
- * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
84
- */
85
- async readMediaInfo() {
86
- if (!this.mediaInfo) {
87
- this.mediaInfo = {
88
- format: this.formatName,
89
- durationSeconds: null,
90
- startTimeSeconds: null
91
- };
92
- }
93
- return this.mediaInfo;
94
- }
95
-
96
- /**
97
- * Subtitle-specific: where cues live (Matroska cluster positions or MP4 sample ranges).
98
- * Returned via track objects' clusterPositions/samples, so base has no extra method — tracks carry it.
99
- */
100
-
101
- /**
102
- * The TEXT FIELD of one subtitle cue, taken out of this container's framing.
103
- *
104
- * How a cue's bytes are wrapped is stated by the container's own
105
- * specification, so each subclass answers for itself: Matroska reorders an ASS
106
- * dialogue row, drops its two timing fields and prepends a read order
107
- * (`matroska.org/technical/subtitles.html`); an MP4 prefixes a `tx3g` sample
108
- * with its length (ISO/IEC 14496-12 §12.6); a subtitle FILE states its own
109
- * field order in `[Events]`. None of that is a fact about the subtitle format,
110
- * and the format's own markup — `{\pos()}`, `\N` is not a fact about the
111
- * container. The second half is `tracks/subtitle-markup.js`; this is the
112
- * first, and the two are applied in that order.
113
- *
114
- * Static because de-framing reads no instance state: a caller that has bytes
115
- * and knows the format needs no container built over the whole file. The
116
- * instance form below exists so a caller that DOES hold a container gets the
117
- * right answer without naming the subclass.
118
- *
119
- * @param {Buffer} _payload - The cue's bytes as the container stores them.
120
- * @param {string} _codecId - CodecID / sample entry type / file extension.
121
- * @returns {string} The text field, markup still in place.
122
- */
123
- static cueTextOf(_payload, _codecId) {
124
- throw new Error("cueTextOf not implemented");
125
- }
126
-
127
- /**
128
- * @param {Buffer} payload
129
- * @param {string} codecId
130
- * @returns {string}
131
- */
132
- cueTextOf(payload, codecId) {
133
- return /** @type {typeof Container} */ (this.constructor).cueTextOf(payload, codecId);
134
- }
135
- }
1
+ /**
2
+ * @file Base Container — abstract per RFC 9559 / ISO 14496-12.
3
+ *
4
+ * A Container knows how to read its own format's track table and index.
5
+ * Concrete containers (MatroskaContainer, Mp4Container, AviContainer) implement
6
+ * spec-specific parsing. All byte access goes through `readRange(start,end)` so
7
+ * the class works over torrent piece windows.
8
+ *
9
+ * Spec refs:
10
+ * - Matroska RFC 9559 §5: EBML, Segment, SeekHead, Tracks, Cues, Clusters
11
+ * - MP4 ISO/IEC 14496-12 §8: ftyp, moov, trak, tkhd, mdhd, hdlr, elng, stbl
12
+ * - AVI RIFF §: LIST hdrl, idx1
13
+ */
14
+
15
+ /**
16
+ * What one file declares about itself. Every field is either a value the
17
+ * container states or `null`, which means the container does not state it —
18
+ * a defined absence, not "nobody has looked yet".
19
+ *
20
+ * @typedef {object} ContainerMediaInfo
21
+ * @property {string} format - "matroska" | "mp4" | "avi" | "unknown".
22
+ * @property {number | null} durationSeconds
23
+ * @property {number | null} startTimeSeconds - Where this file's own timeline
24
+ * begins. Two files of one release need not agree on it, and the difference
25
+ * is what keeps a soundtrack shipped separately aligned with its picture.
26
+ */
27
+
28
+ export class Container {
29
+ /**
30
+ * Two ways of reading, because two readers of one file want different things
31
+ * and the file is opened once.
32
+ *
33
+ * `readRange` fetches what is missing: the head and the track table are
34
+ * kilobytes, they are needed before anything can be offered, and the codec
35
+ * probe has already pulled the head of every file that plays. `readHeld`
36
+ * reads only what is already downloaded and asks the swarm for nothing, which
37
+ * is what the cue walk needs — turning subtitles on must not pull bytes the
38
+ * viewer is not waiting for. `isHeld` says whether a range can be read that
39
+ * way at all, so the walk can leave a cluster for next time instead of
40
+ * blocking on it.
41
+ *
42
+ * They are given per container rather than per call because a container is
43
+ * cached per file and both readers want the same parsed head. Where the
44
+ * caller supplies only `readRange`, the held reader is that one and every
45
+ * range counts as held — which is the right answer for a local file, and for
46
+ * a torrent it is the caller's job to say otherwise.
47
+ *
48
+ * The torrent is NOT passed in and must not be: `readHeld` and `isHeld` are
49
+ * the only two facts about it this layer needs, and reducing it to two
50
+ * functions is what keeps the container ignorant of where its bytes live.
51
+ *
52
+ * @param {object} params
53
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
54
+ * @param {number} params.fileSize
55
+ * @param {string} [params.label]
56
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} [params.readHeld]
57
+ * @param {(start:number,end:number)=>boolean} [params.isHeld]
58
+ */
59
+ constructor({ readRange, fileSize, label = "", readHeld, isHeld }) {
60
+ this.readRange = readRange;
61
+ this.fileSize = fileSize;
62
+ this.label = label;
63
+ this.readHeld = typeof readHeld === "function" ? readHeld : readRange;
64
+ this.isHeld = typeof isHeld === "function" ? isHeld : () => true;
65
+ }
66
+
67
+
68
+ /**
69
+ * Whether one of ffmpeg's banner streams and one of this container's tracks
70
+ * can be the same track.
71
+ *
72
+ * @param {{ language?: string, title?: string }} banner
73
+ * @param {{ language?: string, name?: string }} container
74
+ * @returns {boolean}
75
+ */
76
+ static pairingHolds(banner, container) {
77
+ return pairingHolds(banner, container);
78
+ }
79
+
80
+ /**
81
+ * ffmpeg's description of a file's subtitle streams, corrected by what the
82
+ * container itself declares.
83
+ *
84
+ * The banner cannot express the difference between "no track is marked" and
85
+ * "every track is marked", because Matroska's `FlagDefault` defaults to 1 and
86
+ * ffmpeg has already applied that default by the time it prints. The
87
+ * container can be asked, and this is where the two readings are lined up —
88
+ * by position, checked pair by pair rather than assumed.
89
+ *
90
+ * @param {object[]} bannerTracks
91
+ * @param {object[]} declared - This container's own subtitle tracks, in its order.
92
+ * @returns {object[]}
93
+ */
94
+ static mergeSubtitleFlags(bannerTracks, declared) {
95
+ return mergeContainerSubtitleFlags(bannerTracks, declared);
96
+ }
97
+
98
+ /** @returns {string} Human name: "matroska" | "mp4" | "avi" | "unknown" */
99
+ get formatName() {
100
+ return "unknown";
101
+ }
102
+
103
+ /** Whether `head` (first bytes) looks like this container. */
104
+ static detect(_head) {
105
+ return false;
106
+ }
107
+
108
+ /**
109
+ * All tracks declared by the container, in container order.
110
+ * Includes disabled tracks (isEnabled=false) to preserve declaredIndex alignment with ffmpeg.
111
+ * @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
112
+ */
113
+ async readTracks() {
114
+ throw new Error("readTracks not implemented");
115
+ }
116
+
117
+
118
+ /**
119
+ * This container's subtitle tracks and where their cues are, in ONE shape
120
+ * whichever container answers.
121
+ *
122
+ * `tracks` carries a `clusterPositions` list for a container that stores cues
123
+ * in clusters and a `samples` list for one that states each cue's own byte
124
+ * range; a caller reads neither, and asks {@link Container#readHeldCues}
125
+ * instead. `declared` is what the container says about its subtitle tracks in
126
+ * its own order, and empty means the container said nothing — a real answer,
127
+ * not a missing one.
128
+ *
129
+ * @returns {Promise<{ tracks: object[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
130
+ * Null where this container declares no subtitles at all.
131
+ */
132
+ async readSubtitlePlan() {
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * The cues this container can read RIGHT NOW for one track, without fetching.
138
+ *
139
+ * Every container answers this, and each reads what its own specification
140
+ * says: Matroska walks the clusters its Cues table names, an MP4 reads the
141
+ * samples its table states. The caller therefore chooses a container once —
142
+ * from the bytes — and never again. It used to choose twice, once by file
143
+ * extension for the container and once by whether a track carried a sample
144
+ * list for the reading, and two choices that must agree and are made from
145
+ * different evidence are a disagreement waiting to happen.
146
+ *
147
+ * `progress` is what has already been read, kept by the caller because it
148
+ * belongs to the file rather than to one pass: `walked` holds cluster
149
+ * positions, `harvested` holds sample offsets per track. Each container adds
150
+ * to the one it uses.
151
+ *
152
+ * @param {object} _plan - This file's subtitle plan.
153
+ * @param {object} _track - The track asked about.
154
+ * @param {{ walked: Set<number>, harvested: Map<number, Set<number>> }} _progress
155
+ * @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number }>}
156
+ * `found` is track number to the cues found in THIS pass — Matroska fills
157
+ * every track from one walk, so it is a map and not a list.
158
+ */
159
+ async readHeldCues(_plan, _track, _progress) {
160
+ return { found: new Map(), covered: 0, indexed: 0 };
161
+ }
162
+
163
+ /**
164
+ * Keyframe times for the video track, ascending seconds. Null when index absent (MPEG-TS, fragmented MP4, truncated).
165
+ * @returns {Promise<{times:number[],tolerance:number}|null>}
166
+ */
167
+ async readKeyframeIndex() {
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * What this file DECLARES about itself as a whole, as distinct from what its
173
+ * individual tracks declare.
174
+ *
175
+ * The rule this method exists to hold: a fact the container declares is read
176
+ * from the container; a fact only the media itself has is measured from the
177
+ * media. Both halves used to be asked of ffmpeg, so the same header was read
178
+ * twice — measured 2026-09-03, this layer read one `.mka` header in 8 ms while
179
+ * a second ffmpeg read the same header over HTTP for 8121 ms, in the same
180
+ * second, on the same file.
181
+ *
182
+ * `null` is not "unknown, ask someone else". It means the container does not
183
+ * declare the field, which is a final answer about the container and the point
184
+ * at which a caller may go to the media — see `docs/container-architecture.md`.
185
+ *
186
+ * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
187
+ */
188
+ async readMediaInfo() {
189
+ if (!this.mediaInfo) {
190
+ this.mediaInfo = {
191
+ format: this.formatName,
192
+ durationSeconds: null,
193
+ startTimeSeconds: null
194
+ };
195
+ }
196
+ return this.mediaInfo;
197
+ }
198
+
199
+ /**
200
+ * Subtitle-specific: where cues live (Matroska cluster positions or MP4 sample ranges).
201
+ * Returned via track objects' clusterPositions/samples, so base has no extra method — tracks carry it.
202
+ */
203
+
204
+ /**
205
+ * The TEXT FIELD of one subtitle cue, taken out of this container's framing.
206
+ *
207
+ * How a cue's bytes are wrapped is stated by the container's own
208
+ * specification, so each subclass answers for itself: Matroska reorders an ASS
209
+ * dialogue row, drops its two timing fields and prepends a read order
210
+ * (`matroska.org/technical/subtitles.html`); an MP4 prefixes a `tx3g` sample
211
+ * with its length (ISO/IEC 14496-12 §12.6); a subtitle FILE states its own
212
+ * field order in `[Events]`. None of that is a fact about the subtitle format,
213
+ * and the format's own markup — `{\pos(…)}`, `\N` — is not a fact about the
214
+ * container. The second half is `TextSubtitleTrack`; this is the
215
+ * first, and the two are applied in that order.
216
+ *
217
+ * Static because de-framing reads no instance state: a caller that has bytes
218
+ * and knows the format needs no container built over the whole file. The
219
+ * instance form below exists so a caller that DOES hold a container gets the
220
+ * right answer without naming the subclass.
221
+ *
222
+ * @param {Buffer} _payload - The cue's bytes as the container stores them.
223
+ * @param {string} _codecId - CodecID / sample entry type / file extension.
224
+ * @returns {string} The text field, markup still in place.
225
+ */
226
+ static cueTextOf(_payload, _codecId) {
227
+ throw new Error("cueTextOf not implemented");
228
+ }
229
+
230
+ /**
231
+ * @param {Buffer} payload
232
+ * @param {string} codecId
233
+ * @returns {string}
234
+ */
235
+ cueTextOf(payload, codecId) {
236
+ return /** @type {typeof Container} */ (this.constructor).cueTextOf(payload, codecId);
237
+ }
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Lining ffmpeg's banner up with what a container declares. Here because the
242
+ // correction is about what a CONTAINER states and the banner cannot.
243
+ // ---------------------------------------------------------------------------
244
+ /**
245
+ * Which subtitle track the FILE says to show, read from the file rather than
246
+ * from ffmpeg's description of it.
247
+ *
248
+ * Why this exists. The browser decides which subtitle track to turn on from
249
+ * `isDefault`, and until now that came from ffmpeg's `-i` banner, which prints
250
+ * `(default)`. In Matroska `FlagDefault` DEFAULTS TO 1 and ffmpeg has already
251
+ * applied that default by the time it prints — so a file whose muxer wrote the
252
+ * flag on no track arrives looking exactly like one that wrote it on every
253
+ * track: everything marked. The banner cannot tell the two apart, and the
254
+ * difference is the whole question, because one of them means "show this one"
255
+ * and the other means "the file has no opinion".
256
+ *
257
+ * The container itself can be asked, and the EBML reader already walks the
258
+ * Tracks element for subtitle extraction. What it now also records is whether
259
+ * the element was WRITTEN, which is the fact the banner destroys.
260
+ *
261
+ * The awkward part is lining the two readings up. ffmpeg numbers its subtitle
262
+ * streams `0:s:0`, `0:s:1`, … over EVERY subtitle stream, picture-based ones
263
+ * included, in the order the container declares them; the container reading is
264
+ * a list in that same order. So position is the correspondence — but a position
265
+ * match that is merely assumed is worth nothing, so it is CHECKED: each pair
266
+ * has to agree on language or on title. One pair that agrees on neither, or a
267
+ * length that differs, means the two readings are not describing the same
268
+ * thing in the same order, and then the container reading is not used at all.
269
+ */
270
+
271
+ /**
272
+ * Language codes that carry no information, and so cannot confirm a pairing.
273
+ *
274
+ * ffmpeg prints `und` for a stream with no language; Matroska's own default
275
+ * for `Language` is `eng`, which is why an absent element cannot be read as a
276
+ * statement either — but `eng` is also a real answer, so it is not listed here
277
+ * and is compared like any other.
278
+ */
279
+ const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
280
+
281
+ /**
282
+ * @param {unknown} value
283
+ * @returns {string}
284
+ */
285
+ function normalise(value) {
286
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
287
+ }
288
+
289
+ /**
290
+ * Whether one banner stream and one container track can be the same track.
291
+ *
292
+ * Agreement on either the language or the name is enough; both being empty is
293
+ * not agreement, because two tracks that say nothing about themselves say
294
+ * nothing about their pairing either.
295
+ *
296
+ * @param {{ language?: string, title?: string }} banner
297
+ * @param {{ language?: string, name?: string }} container
298
+ * @returns {boolean}
299
+ */
300
+ function pairingHolds(banner, container) {
301
+ const bannerLanguage = normalise(banner?.language);
302
+ const containerLanguage = normalise(container?.language);
303
+ if (
304
+ !EMPTY_LANGUAGES.has(bannerLanguage) &&
305
+ !EMPTY_LANGUAGES.has(containerLanguage) &&
306
+ bannerLanguage === containerLanguage
307
+ ) {
308
+ return true;
309
+ }
310
+ const bannerTitle = normalise(banner?.title);
311
+ const containerName = normalise(container?.name);
312
+ if (bannerTitle.length > 0 && bannerTitle === containerName) {
313
+ return true;
314
+ }
315
+ // Nothing to compare on either side. Not a disagreement — a file may name
316
+ // neither — so it does not break the alignment; it simply adds no support.
317
+ return (
318
+ (EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
319
+ (bannerTitle.length === 0 || containerName.length === 0)
320
+ );
321
+ }
322
+
323
+ /**
324
+ * The banner's subtitle tracks, with what the container says about each.
325
+ *
326
+ * Every returned track gains `declaresDefault`: whether the FILE wrote the flag
327
+ * for it. When the container reading cannot be trusted — no declarations, a
328
+ * different number of them, or a pair that agrees on neither language nor name
329
+ * — every track gets `declaresDefault: false` and its `isDefault` is left as
330
+ * the banner had it. That is the honest answer for a file we cannot read this
331
+ * way: the container has not been heard from, so nothing is shown unasked.
332
+ *
333
+ * @param {Array<{ index?: number, language?: string, title?: string, isDefault?: boolean }>} bannerTracks
334
+ * @param {Array<{ language?: string, name?: string, isDefault?: boolean, declaresDefault?: boolean }>} declared
335
+ * @returns {{ tracks: object[], aligned: boolean, reason: string }}
336
+ */
337
+ function mergeContainerSubtitleFlags(bannerTracks, declared) {
338
+ const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
339
+ const container = Array.isArray(declared) ? declared : [];
340
+ const undecided = () => ({
341
+ // The container reading could not be lined up, so nothing of it is used —
342
+ // including the flags, which would otherwise be attributed to the wrong
343
+ // track.
344
+ tracks: banner.map((track) => ({
345
+ ...track,
346
+ declaresDefault: false,
347
+ isForced: false,
348
+ isHearingImpaired: false,
349
+ // Not "the container says this track is unusable" — nothing of the
350
+ // container is being used here. A track is offered unless it was read to
351
+ // say otherwise.
352
+ isEnabled: true,
353
+ languageBcp47: ""
354
+ }))
355
+ });
356
+ if (container.length === 0) {
357
+ return { ...undecided(), aligned: false, reason: "the container declares no subtitle track" };
358
+ }
359
+ if (container.length !== banner.length) {
360
+ return {
361
+ ...undecided(),
362
+ aligned: false,
363
+ reason: `the container declares ${container.length} subtitle tracks and the probe found ${banner.length}`
364
+ };
365
+ }
366
+ for (const [order, track] of banner.entries()) {
367
+ if (!pairingHolds(track, container[order])) {
368
+ return {
369
+ ...undecided(),
370
+ aligned: false,
371
+ reason:
372
+ `subtitle ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
373
+ `in the probe and "${normalise(container[order]?.name) || "-"}"/` +
374
+ `${normalise(container[order]?.language) || "-"} in the container`
375
+ };
376
+ }
377
+ }
378
+ return {
379
+ tracks: banner.map((track, order) => ({
380
+ ...track,
381
+ isDefault: container[order].isDefault === true,
382
+ declaresDefault: container[order].declaresDefault === true,
383
+ // Read from the file rather than guessed from the track's name. Both are
384
+ // stated by the container itself (RFC 9559 §5.1.4.1) and neither reaches
385
+ // ffmpeg's `-i` banner, which is where every other field here comes from.
386
+ isForced: container[order].isForced === true,
387
+ isHearingImpaired: container[order].isHearingImpaired === true,
388
+ // FlagEnabled, so the browser can leave an unusable track out of the
389
+ // menu. It stays in this list and keeps its number: ffmpeg creates a
390
+ // stream for it either way.
391
+ isEnabled: container[order].isEnabled !== false,
392
+ // The RFC 5646 tag, where the file writes one. Kept beside the code
393
+ // rather than replacing it: what this list is aligned against is ffmpeg's
394
+ // banner, which prints the three-letter form.
395
+ languageBcp47: typeof container[order].languageBcp47 === "string" ? container[order].languageBcp47 : ""
396
+ })),
397
+ aligned: true,
398
+ reason: ""
399
+ };
400
+ }
@@ -1,31 +1,55 @@
1
- /**
2
- * @file Container factory — detects format and returns the precise Container subclass.
3
- *
4
- * Sniffs first 16 bytes (same as container-index/index.js) and instantiates
5
- * MatroskaContainer / Mp4Container / AviContainer. Falls back to null (unknown).
6
- * Orchestrators depend on this, not on concrete constructors.
7
- */
8
-
9
- import { MatroskaContainer } from "./MatroskaContainer.js";
10
- import { Mp4Container } from "./Mp4Container.js";
11
- import { AviContainer } from "./AviContainer.js";
12
-
13
- const SNIFF_BYTES = 16;
14
-
15
- export class ContainerFactory {
16
- /**
17
- * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
18
- * @param {number} fileSize
19
- * @param {string} label
20
- * @returns {Promise<import("./Container.js").Container|null>}
21
- */
22
- static async create({ readRange, fileSize, label = "" }) {
23
- if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) return null;
24
- const head = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
25
- if (!head) return null;
26
- if (MatroskaContainer.detect(head)) return new MatroskaContainer({ readRange, fileSize, label });
27
- if (Mp4Container.detect(head)) return new Mp4Container({ readRange, fileSize, label });
28
- if (AviContainer.detect(head)) return new AviContainer({ readRange, fileSize, label });
29
- return null;
30
- }
31
- }
1
+ /**
2
+ * @file Container factory — detects format and returns the precise Container subclass.
3
+ *
4
+ * Sniffs first 16 bytes (same as container-index/index.js) and instantiates
5
+ * MatroskaContainer / Mp4Container / AviContainer. Falls back to null (unknown).
6
+ * Orchestrators depend on this, not on concrete constructors.
7
+ */
8
+
9
+ import { MatroskaContainer } from "./MatroskaContainer.js";
10
+ import { Mp4Container } from "./Mp4Container.js";
11
+ import { AviContainer } from "./AviContainer.js";
12
+
13
+ const SNIFF_BYTES = 16;
14
+
15
+ export class ContainerFactory {
16
+ /**
17
+ * The container these bytes are, built over them.
18
+ *
19
+ * `params` is passed through whole, so a caller with a torrent's two readers
20
+ * gets a container that has both — see {@link Container}'s constructor.
21
+ *
22
+ * @param {{ readRange: (start:number,end:number)=>Promise<Buffer|null>, fileSize: number, label?: string, readHeld?: Function, isHeld?: Function }} params
23
+ * @returns {Promise<import("./Container.js").Container|null>}
24
+ */
25
+ static async create(params) {
26
+ const { readRange, fileSize } = params;
27
+ if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) return null;
28
+ const head = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
29
+ if (!head) return null;
30
+ if (MatroskaContainer.detect(head)) return new MatroskaContainer(params);
31
+ if (Mp4Container.detect(head)) return new Mp4Container(params);
32
+ if (AviContainer.detect(head)) return new AviContainer(params);
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * The container a file NAME suggests, for the moment the bytes cannot be
38
+ * sniffed.
39
+ *
40
+ * The head of a file nobody has opened is not downloaded, and the cue walk
41
+ * asks the swarm for nothing — so on that one path the name is all there is.
42
+ * It is a fallback and never a preference: the bytes decide wherever they can
43
+ * be read, because a name is what somebody typed and a header is what the
44
+ * muxer wrote.
45
+ *
46
+ * @param {string} name
47
+ * @returns {typeof MatroskaContainer | typeof Mp4Container | null}
48
+ */
49
+ static byName(name) {
50
+ const text = String(name ?? "");
51
+ if (/\.(mp4|m4v|m4a)$/i.test(text)) return Mp4Container;
52
+ if (/\.(mkv|mka|webm)$/i.test(text)) return MatroskaContainer;
53
+ return null;
54
+ }
55
+ }