@torrent-tv/proxy 2.74.0 → 2.75.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.
@@ -91,10 +91,257 @@ export class Container {
91
91
  * @param {object[]} declared - This container's own subtitle tracks, in its order.
92
92
  * @returns {object[]}
93
93
  */
94
+ /**
95
+ * The picture's facts, from the two readings that state them.
96
+ *
97
+ * Audio and subtitles have had this since the flags were first read from the
98
+ * file; video never did. Every figure the encode is planned from — the size,
99
+ * the frame rate, whether it is HDR, how many bits a sample carries — was
100
+ * taken from ffmpeg's `-i` banner alone, and the `VideoTrack` the container
101
+ * declares was read and then used for nothing but a line in the log.
102
+ *
103
+ * Which reading wins is decided per field by what each one IS, and the rule
104
+ * is the one `readMediaInfo` already states: a fact the container DECLARES is
105
+ * read from the container; a fact only the media has is measured from the
106
+ * media.
107
+ *
108
+ * - the coded size and the frame rate are the BANNER's. Both are declared by
109
+ * the container as well, but what the encoder receives is what the decoder
110
+ * produced, and the ladder and the scale filter have to be sized to that. A
111
+ * container that declares something else is mis-declaring, and using its
112
+ * numbers would size the encode to a picture that never arrives;
113
+ * - the bit depth and the HDR signalling are the CONTAINER's where it states
114
+ * them. They are not properties of the decoded frames at all — they are the
115
+ * file saying how its samples are to be read — and ffmpeg prints them only
116
+ * as a side effect of naming a pixel format. HDR is not compared for
117
+ * disagreement: both sides give a boolean, and a boolean cannot say "I did
118
+ * not look";
119
+ * - the display size is the container's alone; the banner has no such field.
120
+ *
121
+ * Where only one side states a field, that side answers whatever the rule
122
+ * would have preferred. Where both state it and they DISAGREE, the
123
+ * disagreement is reported: it is a fact about the file, and until now
124
+ * nothing could see it.
125
+ *
126
+ * @param {{ width?: number|null, height?: number|null, fps?: number|null, isHdr?: boolean, bitDepth?: number|null }} banner
127
+ * @param {object | null} declared - The container's own `VideoTrack`.
128
+ * @returns {{ width: number|null, height: number|null, fps: number|null, isHdr: boolean, bitDepth: number|null, displayWidth: number|null, displayHeight: number|null, disagreements: string[] }}
129
+ */
130
+ static mergeVideoFacts(banner, declared) {
131
+ const number = (value) => (Number.isFinite(value) && value > 0 ? Number(value) : null);
132
+ const fromBanner = {
133
+ width: number(banner?.width),
134
+ height: number(banner?.height),
135
+ fps: number(banner?.fps),
136
+ isHdr: banner?.isHdr === true,
137
+ bitDepth: number(banner?.bitDepth)
138
+ };
139
+ if (!declared) {
140
+ return { ...fromBanner, displayWidth: null, displayHeight: null, disagreements: [] };
141
+ }
142
+ const fromContainer = {
143
+ width: number(declared.width),
144
+ height: number(declared.height),
145
+ fps: number(declared.fps),
146
+ isHdr: declared.isHdr === true,
147
+ bitDepth: number(declared.bitDepth)
148
+ };
149
+ const disagreements = [];
150
+ const note = (field, mine, theirs) => {
151
+ if (mine !== null && theirs !== null && mine !== theirs) {
152
+ disagreements.push(`${field} ${theirs} in the container against ${mine} in the probe`);
153
+ }
154
+ };
155
+ note("width", fromBanner.width, fromContainer.width);
156
+ note("height", fromBanner.height, fromContainer.height);
157
+ if (fromBanner.bitDepth !== null && fromContainer.bitDepth !== null && fromBanner.bitDepth !== fromContainer.bitDepth) {
158
+ disagreements.push(
159
+ `bit depth ${fromContainer.bitDepth} in the container against ${fromBanner.bitDepth} in the probe`
160
+ );
161
+ }
162
+ // HDR is deliberately NOT compared. Both sides give it as a boolean, and a
163
+ // boolean cannot say "I did not look": a container with no Colour element
164
+ // and one that states SDR are the same `false`, as are a probe that printed
165
+ // no colour metadata and one that printed BT.709. Reporting that as a
166
+ // disagreement would report it on almost every file. Either side saying yes
167
+ // is taken as yes, which is the safe direction — the cost of tone mapping a
168
+ // picture that did not need it is smaller than showing a washed-out one.
169
+ return {
170
+ width: fromBanner.width ?? fromContainer.width,
171
+ height: fromBanner.height ?? fromContainer.height,
172
+ fps: fromBanner.fps ?? fromContainer.fps,
173
+ // The container declares these; the probe only reflects them.
174
+ bitDepth: fromContainer.bitDepth ?? fromBanner.bitDepth,
175
+ isHdr: fromContainer.isHdr || fromBanner.isHdr,
176
+ displayWidth: number(declared.displayWidth),
177
+ displayHeight: number(declared.displayHeight),
178
+ disagreements
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Line ffmpeg's banner up with what the container declares, and say whether the
184
+ * two are describing the same thing in the same order.
185
+ *
186
+ * ffmpeg numbers a file's streams `0:a:N` / `0:s:N` over every stream of that
187
+ * kind, in the order the container declares them; the container reading is a
188
+ * list in that same order. So position is the correspondence — but a position
189
+ * match that is merely assumed is worth nothing, and it is CHECKED: each pair
190
+ * has to agree on language or on title. One pair that agrees on neither, or a
191
+ * length that differs, means the two readings are not about the same thing, and
192
+ * then the container reading is not used AT ALL. A wrong flag is worse than a
193
+ * missing one, because `0:a:N` is what the encoder is given.
194
+ *
195
+ * One alignment for every media kind, because it is one rule. It was written
196
+ * twice — once for subtitles, once for audio — down to a `pairingHolds` that
197
+ * was byte-for-byte the same function under two names.
198
+ *
199
+ * @param {object[]} bannerTracks
200
+ * @param {object[]} declared
201
+ * @param {string} noun - "subtitle" or "audio"; only for the reason text.
202
+ * @returns {{ aligned: boolean, reason: string, banner: object[], container: object[] }}
203
+ */
204
+ static alignWithBanner(bannerTracks, declared, noun) {
205
+ const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
206
+ const container = Array.isArray(declared) ? declared : [];
207
+ if (banner.length === 0) {
208
+ return { aligned: false, reason: `the probe found no ${noun} stream`, banner, container };
209
+ }
210
+ if (container.length === 0) {
211
+ return { aligned: false, reason: `the container declares no ${noun} track`, banner, container };
212
+ }
213
+ if (container.length !== banner.length) {
214
+ return {
215
+ aligned: false,
216
+ reason: `the container declares ${container.length} ${noun} tracks and the probe found ${banner.length}`,
217
+ banner,
218
+ container
219
+ };
220
+ }
221
+ for (const [order, track] of banner.entries()) {
222
+ if (!Container.pairingHolds(track, container[order])) {
223
+ return {
224
+ aligned: false,
225
+ reason:
226
+ `${noun} ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
227
+ `in the probe and "${normalise(container[order]?.name) || "-"}"/` +
228
+ `${normalise(container[order]?.language) || "-"} in the container`,
229
+ banner,
230
+ container
231
+ };
232
+ }
233
+ }
234
+ return { aligned: true, reason: "", banner, container };
235
+ }
236
+
237
+ /**
238
+ * What the banner says, with the flags only the container knows added — or the
239
+ * banner alone where the two could not be lined up.
240
+ *
241
+ * `take` says which of the container's fields this kind of track wants, and is
242
+ * the only part that differs between them.
243
+ *
244
+ * @param {object[]} bannerTracks
245
+ * @param {object[]} declared
246
+ * @param {string} noun
247
+ * @param {(containerTrack: object, bannerTrack: object) => object} take
248
+ * @param {object} absent - The same fields as `take` returns, for a track whose
249
+ * container reading could not be used. Not "the container says no" — the
250
+ * container has not been heard from.
251
+ * @returns {{ tracks: object[], aligned: boolean, reason: string }}
252
+ */
253
+ static mergeDeclaredFlags(bannerTracks, declared, noun, take, absent) {
254
+ const { aligned, reason, banner, container } = Container.alignWithBanner(bannerTracks, declared, noun);
255
+ if (!aligned) {
256
+ return {
257
+ tracks: banner.map((track) => ({ ...track, ...absent })),
258
+ aligned,
259
+ reason
260
+ };
261
+ }
262
+ return {
263
+ tracks: banner.map((track, order) => ({ ...track, ...take(container[order], track) })),
264
+ aligned: true,
265
+ reason: ""
266
+ };
267
+ }
268
+
94
269
  static mergeSubtitleFlags(bannerTracks, declared) {
95
- return mergeContainerSubtitleFlags(bannerTracks, declared);
270
+ return Container.mergeDeclaredFlags(
271
+ bannerTracks,
272
+ declared,
273
+ "subtitle",
274
+ (track) => ({
275
+ isDefault: track.isDefault === true,
276
+ declaresDefault: track.declaresDefault === true,
277
+ // Read from the file rather than guessed from the track's name. Both
278
+ // are stated by the container itself (RFC 9559 §5.1.4.1) and neither
279
+ // reaches ffmpeg's `-i` banner, which is where every other field here
280
+ // comes from.
281
+ isForced: track.isForced === true,
282
+ isHearingImpaired: track.isHearingImpaired === true,
283
+ // FlagEnabled, so the browser can leave an unusable track out of the
284
+ // menu. It stays in the list and keeps its number: ffmpeg creates a
285
+ // stream for it either way.
286
+ isEnabled: track.isEnabled !== false,
287
+ // The RFC 5646 tag where the file writes one, kept BESIDE the code
288
+ // rather than replacing it: this list is aligned against ffmpeg's
289
+ // banner, which prints the three-letter form.
290
+ languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : ""
291
+ }),
292
+ {
293
+ declaresDefault: false,
294
+ isForced: false,
295
+ isHearingImpaired: false,
296
+ isEnabled: true,
297
+ languageBcp47: ""
298
+ }
299
+ );
300
+ }
301
+
302
+ /**
303
+ * The same for audio, with the flags RFC 9559 §5.1.4.1 defines for it.
304
+ *
305
+ * `FlagOriginal`, `FlagCommentary` and `FlagVisualImpaired` do not appear in
306
+ * the banner at all, so without this the audio menu cannot tell a director's
307
+ * commentary from the film.
308
+ *
309
+ * @param {object[]} bannerTracks
310
+ * @param {object[]} declared
311
+ * @returns {{ tracks: object[], aligned: boolean, reason: string }}
312
+ */
313
+ static mergeAudioFlags(bannerTracks, declared) {
314
+ return Container.mergeDeclaredFlags(
315
+ bannerTracks,
316
+ declared,
317
+ "audio",
318
+ (track, banner) => ({
319
+ isOriginal: track.isOriginal === true,
320
+ isCommentary: track.isCommentary === true,
321
+ isVisualImpaired: track.isVisualImpaired === true,
322
+ isEnabled: track.isEnabled !== false,
323
+ isDefault: track.isDefault === true,
324
+ declaresDefault: track.declaresDefault === true,
325
+ languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : "",
326
+ channels: Number.isFinite(track.channels) ? track.channels : null,
327
+ title:
328
+ typeof banner?.title === "string" && banner.title.length > 0
329
+ ? banner.title
330
+ : (typeof track.name === "string" ? track.name : "")
331
+ }),
332
+ {
333
+ declaresDefault: false,
334
+ isOriginal: false,
335
+ isCommentary: false,
336
+ isVisualImpaired: false,
337
+ isEnabled: true,
338
+ languageBcp47: "",
339
+ channels: null
340
+ }
341
+ );
96
342
  }
97
343
 
344
+
98
345
  /** @returns {string} Human name: "matroska" | "mp4" | "avi" | "unknown" */
99
346
  get formatName() {
100
347
  return "unknown";
@@ -114,6 +361,52 @@ export class Container {
114
361
  throw new Error("readTracks not implemented");
115
362
  }
116
363
 
364
+
365
+ /**
366
+ * This container's subtitle tracks and where their cues are, in ONE shape
367
+ * whichever container answers.
368
+ *
369
+ * `tracks` carries a `clusterPositions` list for a container that stores cues
370
+ * in clusters and a `samples` list for one that states each cue's own byte
371
+ * range; a caller reads neither, and asks {@link Container#readHeldCues}
372
+ * instead. `declared` is what the container says about its subtitle tracks in
373
+ * its own order, and empty means the container said nothing — a real answer,
374
+ * not a missing one.
375
+ *
376
+ * @returns {Promise<{ tracks: object[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
377
+ * Null where this container declares no subtitles at all.
378
+ */
379
+ async readSubtitlePlan() {
380
+ return null;
381
+ }
382
+
383
+ /**
384
+ * The cues this container can read RIGHT NOW for one track, without fetching.
385
+ *
386
+ * Every container answers this, and each reads what its own specification
387
+ * says: Matroska walks the clusters its Cues table names, an MP4 reads the
388
+ * samples its table states. The caller therefore chooses a container once —
389
+ * from the bytes — and never again. It used to choose twice, once by file
390
+ * extension for the container and once by whether a track carried a sample
391
+ * list for the reading, and two choices that must agree and are made from
392
+ * different evidence are a disagreement waiting to happen.
393
+ *
394
+ * `progress` is what has already been read, kept by the caller because it
395
+ * belongs to the file rather than to one pass: `walked` holds cluster
396
+ * positions, `harvested` holds sample offsets per track. Each container adds
397
+ * to the one it uses.
398
+ *
399
+ * @param {object} _plan - This file's subtitle plan.
400
+ * @param {object} _track - The track asked about.
401
+ * @param {{ walked: Set<number>, harvested: Map<number, Set<number>> }} _progress
402
+ * @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number }>}
403
+ * `found` is track number to the cues found in THIS pass — Matroska fills
404
+ * every track from one walk, so it is a map and not a list.
405
+ */
406
+ async readHeldCues(_plan, _track, _progress) {
407
+ return { found: new Map(), covered: 0, indexed: 0 };
408
+ }
409
+
117
410
  /**
118
411
  * Keyframe times for the video track, ascending seconds. Null when index absent (MPEG-TS, fragmented MP4, truncated).
119
412
  * @returns {Promise<{times:number[],tolerance:number}|null>}
@@ -274,81 +567,3 @@ function pairingHolds(banner, container) {
274
567
  );
275
568
  }
276
569
 
277
- /**
278
- * The banner's subtitle tracks, with what the container says about each.
279
- *
280
- * Every returned track gains `declaresDefault`: whether the FILE wrote the flag
281
- * for it. When the container reading cannot be trusted — no declarations, a
282
- * different number of them, or a pair that agrees on neither language nor name
283
- * — every track gets `declaresDefault: false` and its `isDefault` is left as
284
- * the banner had it. That is the honest answer for a file we cannot read this
285
- * way: the container has not been heard from, so nothing is shown unasked.
286
- *
287
- * @param {Array<{ index?: number, language?: string, title?: string, isDefault?: boolean }>} bannerTracks
288
- * @param {Array<{ language?: string, name?: string, isDefault?: boolean, declaresDefault?: boolean }>} declared
289
- * @returns {{ tracks: object[], aligned: boolean, reason: string }}
290
- */
291
- function mergeContainerSubtitleFlags(bannerTracks, declared) {
292
- const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
293
- const container = Array.isArray(declared) ? declared : [];
294
- const undecided = () => ({
295
- // The container reading could not be lined up, so nothing of it is used —
296
- // including the flags, which would otherwise be attributed to the wrong
297
- // track.
298
- tracks: banner.map((track) => ({
299
- ...track,
300
- declaresDefault: false,
301
- isForced: false,
302
- isHearingImpaired: false,
303
- // Not "the container says this track is unusable" — nothing of the
304
- // container is being used here. A track is offered unless it was read to
305
- // say otherwise.
306
- isEnabled: true,
307
- languageBcp47: ""
308
- }))
309
- });
310
- if (container.length === 0) {
311
- return { ...undecided(), aligned: false, reason: "the container declares no subtitle track" };
312
- }
313
- if (container.length !== banner.length) {
314
- return {
315
- ...undecided(),
316
- aligned: false,
317
- reason: `the container declares ${container.length} subtitle tracks and the probe found ${banner.length}`
318
- };
319
- }
320
- for (const [order, track] of banner.entries()) {
321
- if (!pairingHolds(track, container[order])) {
322
- return {
323
- ...undecided(),
324
- aligned: false,
325
- reason:
326
- `subtitle ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
327
- `in the probe and "${normalise(container[order]?.name) || "-"}"/` +
328
- `${normalise(container[order]?.language) || "-"} in the container`
329
- };
330
- }
331
- }
332
- return {
333
- tracks: banner.map((track, order) => ({
334
- ...track,
335
- isDefault: container[order].isDefault === true,
336
- declaresDefault: container[order].declaresDefault === true,
337
- // Read from the file rather than guessed from the track's name. Both are
338
- // stated by the container itself (RFC 9559 §5.1.4.1) and neither reaches
339
- // ffmpeg's `-i` banner, which is where every other field here comes from.
340
- isForced: container[order].isForced === true,
341
- isHearingImpaired: container[order].isHearingImpaired === true,
342
- // FlagEnabled, so the browser can leave an unusable track out of the
343
- // menu. It stays in this list and keeps its number: ffmpeg creates a
344
- // stream for it either way.
345
- isEnabled: container[order].isEnabled !== false,
346
- // The RFC 5646 tag, where the file writes one. Kept beside the code
347
- // rather than replacing it: what this list is aligned against is ffmpeg's
348
- // banner, which prints the three-letter form.
349
- languageBcp47: typeof container[order].languageBcp47 === "string" ? container[order].languageBcp47 : ""
350
- })),
351
- aligned: true,
352
- reason: ""
353
- };
354
- }
@@ -1,31 +1,122 @@
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
+ import { logger } from "../../utils/logger.js";
13
+
14
+ const SNIFF_BYTES = 16;
15
+
16
+ export class ContainerFactory {
17
+ /**
18
+ * The container these bytes are, built over them.
19
+ *
20
+ * `params` is passed through whole, so a caller with a torrent's two readers
21
+ * gets a container that has both — see {@link Container}'s constructor.
22
+ *
23
+ * @param {{ readRange: (start:number,end:number)=>Promise<Buffer|null>, fileSize: number, label?: string, readHeld?: Function, isHeld?: Function }} params
24
+ * @returns {Promise<import("./Container.js").Container|null>}
25
+ */
26
+ static async create(params) {
27
+ const { readRange, fileSize } = params;
28
+ if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) return null;
29
+ const head = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
30
+ if (!head) return null;
31
+ if (MatroskaContainer.detect(head)) return new MatroskaContainer(params);
32
+ if (Mp4Container.detect(head)) return new Mp4Container(params);
33
+ if (AviContainer.detect(head)) return new AviContainer(params);
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * The container a file NAME suggests, for the moment the bytes cannot be
39
+ * sniffed.
40
+ *
41
+ * The head of a file nobody has opened is not downloaded, and the cue walk
42
+ * asks the swarm for nothing — so on that one path the name is all there is.
43
+ * It is a fallback and never a preference: the bytes decide wherever they can
44
+ * be read, because a name is what somebody typed and a header is what the
45
+ * muxer wrote.
46
+ *
47
+ * @param {string} name
48
+ * @returns {typeof MatroskaContainer | typeof Mp4Container | null}
49
+ */
50
+
51
+ /**
52
+ * Where a file's real keyframes are, read from the container's own tables
53
+ * rather than by scanning the media.
54
+ *
55
+ * The problem it solves: on the video-COPY path ffmpeg can only cut segments
56
+ * at the source's existing keyframes. A playlist declaring an even grid
57
+ * instead is false, and players punish it — either walking the whole file to
58
+ * rebuild the timeline, or presenting audio with no picture because a segment
59
+ * begins with nothing decodable (both seen in the field 2026-08-02; the file
60
+ * measured had 10.43 s keyframe spacing against our declared 4 s).
61
+ *
62
+ * Scanning is not an option: the file is served from a torrent, and a full
63
+ * packet scan of 5.5 GB found 77 keyframes in 45 s without finishing.
64
+ * Containers already store the table, and a couple of point reads get it —
65
+ * 16 KB and 0.8 s for 570 keyframes on that same file.
66
+ *
67
+ * This is the sniff plus the container's own reading, which is why it lives
68
+ * on the factory: doing it anywhere else meant a third place that decided
69
+ * what a file is.
70
+ *
71
+ * @param {{ readRange: (start:number,end:number)=>Promise<Buffer|null>, fileSize: number, label?: string }} params
72
+ * @returns {Promise<{ times: number[] | null, format: string, tolerance: number }>}
73
+ * Ascending seconds, or null times where this file has no readable index —
74
+ * the caller must then not claim to know the grid. The format is which
75
+ * container answered, reported whether or not it produced anything: how
76
+ * often an index disagrees with its own file is a question about the
77
+ * CONTAINER, and a measurement that does not say which one cannot answer it.
78
+ */
79
+ static async readKeyframeIndex({ readRange, fileSize, label = "" }) {
80
+ if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
81
+ return { times: null, format: "unknown", tolerance: 0 };
82
+ }
83
+ const startedAt = Date.now();
84
+ let container = null;
85
+ try {
86
+ container = await ContainerFactory.create({ readRange, fileSize, label });
87
+ } catch (error) {
88
+ logger.warn(`container-index: failed to read "${label}": ${error?.message ?? error}`);
89
+ return { times: null, format: "unrecognised", tolerance: 0 };
90
+ }
91
+ if (!container) {
92
+ return { times: null, format: "unrecognised", tolerance: 0 };
93
+ }
94
+ const format = container.formatName;
95
+ let index = null;
96
+ try {
97
+ index = await container.readKeyframeIndex();
98
+ } catch (error) {
99
+ // A malformed or partly-downloaded index must never take playback down —
100
+ // it only means the grid is unknown, which the caller already handles.
101
+ logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
102
+ return { times: null, format, tolerance: 0 };
103
+ }
104
+ const elapsedMs = Date.now() - startedAt;
105
+ const times = index && Array.isArray(index.times) ? index.times : null;
106
+ if (times) {
107
+ logger.info(
108
+ `container-index: ${times.length} keyframes from the ${format} index in ${elapsedMs}ms for "${label}"`
109
+ );
110
+ } else {
111
+ logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
112
+ }
113
+ return { times, format, tolerance: Number.isFinite(index?.tolerance) ? index.tolerance : 0 };
114
+ }
115
+
116
+ static byName(name) {
117
+ const text = String(name ?? "");
118
+ if (/\.(mp4|m4v|m4a)$/i.test(text)) return Mp4Container;
119
+ if (/\.(mkv|mka|webm)$/i.test(text)) return MatroskaContainer;
120
+ return null;
121
+ }
122
+ }