@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,633 +1,549 @@
1
- /**
2
- * @file Subtitle cues gathered from the clusters a viewer has already brought
3
- * in, never from clusters they have not.
4
- *
5
- * The rule this file exists to keep (stated by the user 2026-08-20): subtitles
6
- * arrive the way the picture does, or they are not offered. So nothing here
7
- * requests a byte. It looks at what the torrent already holds, reads the
8
- * clusters inside it, and returns what it found; the region the viewer is
9
- * watching is downloaded before they reach it, so its cues are ready before
10
- * they are needed. A region nobody has watched has no cues, and that is
11
- * correct — there is nobody to show them to.
12
- *
13
- * Why not ffmpeg: measured 2026-08-19, extracting one subtitle track of
14
- * `Minions.and.Monsters.1080p.mkv` took **752 seconds** and pulled the download
15
- * from 2.7 % to 81 % of a 6.5 GB film, because a subtitle stream is sparse and
16
- * the demuxer walks the container to the end whatever range is asked of it.
17
- * Reading the clusters costs nothing extra at all.
18
- */
19
-
20
- import { readSubtitlePlan, harvestCluster } from "../container-index/matroska-subtitles.js";
21
- import { readMp4SubtitlePlan } from "../container-index/mp4-subtitles.js";
22
- import { iterateElements } from "../container-index/ebml-reader.js";
23
- import { MatroskaContainer } from "../container/MatroskaContainer.js";
24
- import { Mp4Container } from "../container/Mp4Container.js";
25
- import { finalizeCues } from "../subtitle-convert.js";
26
- import { detectLanguage } from "../language-detect.js";
27
- import { logger } from "../../utils/logger.js";
28
-
29
- /** Enough to read any cluster's own element header. */
30
- const CLUSTER_HEADER_PROBE = 64;
31
- /**
32
- * The largest cluster this will read whole. Real muxers write clusters of a few
33
- * megabytes; anything past this is not a cluster boundary we recognised and
34
- * reading it would be a large read for nothing.
35
- */
36
- const MAX_CLUSTER_BYTES = 32 * 1024 * 1024;
37
- /** How long a read of already-held bytes may take before it is given up. */
38
- const READ_ABANDON_MS = 30_000;
39
-
40
- /** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
41
- const byFile = new Map();
42
-
43
- /**
44
- * Whether every piece covering a byte range is already downloaded.
45
- *
46
- * @param {object} torrent
47
- * @param {object} file
48
- * @param {number} start - Offset within the FILE.
49
- * @param {number} end - Inclusive.
50
- * @returns {boolean}
51
- */
52
- function rangeIsHeld(torrent, file, start, end) {
53
- const pieceLength = Number(torrent?.pieceLength);
54
- const offset = Number(file?.offset) || 0;
55
- if (!Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
56
- return false;
57
- }
58
- const first = Math.floor((offset + start) / pieceLength);
59
- const last = Math.floor((offset + end) / pieceLength);
60
- for (let index = first; index <= last; index += 1) {
61
- if (!torrent.bitfield.get(index)) {
62
- return false;
63
- }
64
- }
65
- return true;
66
- }
67
-
68
- /**
69
- * Read a byte range of a file straight from the store, without asking the swarm
70
- * for anything.
71
- *
72
- * @param {object} file
73
- * @param {number} start
74
- * @param {number} end - Inclusive.
75
- * @returns {Promise<Buffer | null>}
76
- */
77
- function readHeld(file, start, end) {
78
- return new Promise((resolve) => {
79
- const chunks = [];
80
- let stream;
81
- try {
82
- stream = file.createReadStream({ start, end });
83
- } catch {
84
- resolve(null);
85
- return;
86
- }
87
- let settled = false;
88
- /** @type {ReturnType<typeof setTimeout> | null} */
89
- let abandon = null;
90
- const settle = (value) => {
91
- if (settled) {
92
- return;
93
- }
94
- settled = true;
95
- if (abandon !== null) {
96
- clearTimeout(abandon);
97
- }
98
- if (value === null) {
99
- stream.destroy?.();
100
- }
101
- resolve(value);
102
- };
103
- // A read of bytes the torrent already holds either answers or it does not.
104
- // This is not a measurement of anything and no figure is derived from it:
105
- // it is the point past which such a read is presumed lost, so that one
106
- // stream which never ends cannot hold this file's walk — and with it the
107
- // browser's own request for its subtitles — for the rest of the session.
108
- abandon = setTimeout(() => {
109
- logger.info(
110
- `subtitles: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
111
- `did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
112
- );
113
- settle(null);
114
- }, READ_ABANDON_MS);
115
- abandon.unref?.();
116
- stream.on("data", (chunk) => chunks.push(chunk));
117
- stream.on("end", () => settle(Buffer.concat(chunks)));
118
- stream.on("error", () => settle(null));
119
- });
120
- }
121
-
122
- /**
123
- * The subtitle tracks of a file, read once and kept.
124
- *
125
- * The head and the Cues table are two short reads, and they ARE fetched if
126
- * missing they are kilobytes, they are needed before anything can be offered,
127
- * and the codec probe has already pulled the head for every file that plays.
128
- *
129
- * @param {object} torrent
130
- * @param {number} fileIndex
131
- * @param {string} key - `sourceKey:fileIndex`.
132
- * @returns {Promise<object | null>}
133
- */
134
- async function planFor(torrent, fileIndex, key) {
135
- const state = stateFor(key);
136
- if (state.plan !== null) {
137
- return state.plan;
138
- }
139
- // The head and the Cues table are two reads that DO wait on the swarm, so two
140
- // callers arriving together would both make them. One promise, awaited by
141
- // whoever asks while it is in flight.
142
- if (!state.planPromise) {
143
- state.planPromise = readPlan(torrent, fileIndex, state).finally(() => {
144
- state.planPromise = null;
145
- });
146
- }
147
- return state.planPromise;
148
- }
149
-
150
- /**
151
- * The state kept for one file, created on first use.
152
- *
153
- * @param {string} key - `sourceKey:fileIndex`.
154
- * @returns {object}
155
- */
156
- function stateFor(key) {
157
- let state = byFile.get(key);
158
- // A state that has been forgotten is not handed out again, even in the moment
159
- // between the call and the walk that was still running finishing.
160
- if (state?.forgotten === true) {
161
- state = undefined;
162
- }
163
- if (!state) {
164
- state = {
165
- plan: null,
166
- planPromise: null,
167
- forgotten: false,
168
- // One walk of a file at a time — see `serialize`.
169
- chain: Promise.resolve(),
170
- harvested: new Map(),
171
- cues: new Map(),
172
- seq: new Map(),
173
- walked: new Set(),
174
- // The found-order cursor of the last cue PUSHED for each track, so a
175
- // second warmup pass sends only what a first one did not — the same
176
- // found-order idea `?since=` uses for a browser's own pull.
177
- pushed: new Map()
178
- };
179
- byFile.set(key, state);
180
- }
181
- return state;
182
- }
183
-
184
- /**
185
- * Run `work` after every walk of this file already started, and before any
186
- * started after it.
187
- *
188
- * Both entry points here a browser's own pull and the warmup that runs ahead
189
- * of it mark a cluster as walked only AFTER reading and parsing it, which is
190
- * two suspension points later. Until 2.56.0 nothing stopped a second call
191
- * arriving in between: `warmActiveFiles` runs on every verified piece AND on a
192
- * 3 s timer, so on a fast download the same cluster was read and parsed several
193
- * times over and the same line could be pushed twice under different `seq`
194
- * numbers. Each of those reads is a WebTorrent file stream, which selects and
195
- * deselects its pieces, so the repetition reached the piece picker as well.
196
- *
197
- * @template T
198
- * @param {object} state
199
- * @param {() => Promise<T>} work
200
- * @returns {Promise<T>}
201
- */
202
- function serialize(state, work) {
203
- const run = state.chain.then(work, work);
204
- // The queue must survive a failed walk, so what is chained is the settled
205
- // form; the caller still sees the rejection.
206
- state.chain = run.then(() => undefined, () => undefined);
207
- return run;
208
- }
209
-
210
- /**
211
- * Read one file's subtitle plan — the tracks it declares and where the clusters
212
- * holding them are. Called once per file; see `planFor`.
213
- *
214
- * @param {object} torrent
215
- * @param {number} fileIndex
216
- * @param {object} state
217
- * @returns {Promise<object>}
218
- */
219
- async function readPlan(torrent, fileIndex, state) {
220
- const file = torrent?.files?.[fileIndex];
221
- // `declared` is what the container itself says about its subtitle tracks, in
222
- // its own order. Empty means the container said nothing which is a real
223
- // answer and not a missing one: nothing is then shown unasked. An MP4 has no
224
- // element that means "show this subtitle track by default", so it declares
225
- // nothing however many tracks it carries.
226
- const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
227
- if (!file) {
228
- state.plan = empty;
229
- return state.plan;
230
- }
231
- const readRange = async (start, end) => readHeld(file, start, Math.min(end, file.length - 1));
232
- const name = String(file.name);
233
- if (/\.mp4$/i.test(name) || /\.m4v$/i.test(name)) {
234
- // An MP4 states every sample's byte range in its own table, so a cue costs
235
- // its own few dozen bytes rather than the cluster around it. The samples
236
- // are carried as `clusterPositions` of one byte range each, so the harvest
237
- // treats both containers the same way.
238
- const mp4 = await readMp4SubtitlePlan(readRange, file.length);
239
- state.plan = mp4
240
- ? {
241
- ...empty,
242
- tracks: mp4.tracks.map((track, order) => ({
243
- trackNumber: track.trackId,
244
- declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
245
- codecId: track.format,
246
- language: track.language,
247
- name: "",
248
- isDefault: order === 0,
249
- codecPrivate: "",
250
- clusterPositions: [],
251
- samples: track.samples
252
- }))
253
- }
254
- : empty;
255
- return state.plan;
256
- }
257
- if (!/\.mkv$/i.test(name) && !/\.webm$/i.test(name)) {
258
- state.plan = empty;
259
- return state.plan;
260
- }
261
- const plan = await readSubtitlePlan(readRange, file.length);
262
- state.plan = plan ?? empty;
263
- if (state.plan.tracks.length > 0) {
264
- logger.info(
265
- `subtitles: "${String(file.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
266
- `of ${state.plan.declared.length} declared — ` +
267
- state.plan.tracks
268
- // `s:N` is the number the browser names (ffmpeg's own), and it differs
269
- // from the file's track number whenever a picture track sits among them.
270
- .map((track) => `s:${track.declaredIndex}=${track.trackNumber}:${track.language || "?"}` +
271
- `${track.name ? `/${track.name}` : ""}(${track.clusterPositions.length} indexed)`)
272
- .join(" ")
273
- );
274
- }
275
- return state.plan;
276
- }
277
-
278
- /**
279
- * The order a cue was FOUND in, which is the only cursor a browser can follow.
280
- *
281
- * A cue's TIME cannot serve as one. Cues are harvested out of whichever
282
- * clusters happen to be downloaded, and those are not contiguous, so the set
283
- * grows in the middle as well as at the end. A browser that remembered "the
284
- * latest time I hold" and asked for everything past it would never be sent the
285
- * cues that turn up BEHIND that mark afterwards — which is exactly the stretch
286
- * it is about to play. Measured 2026-08-20 on a viewer at 272 s: one answer
287
- * carried cues out to 1176 s, and from then on every cue between the two was
288
- * filtered away for the rest of the session, with 59 of 276 clusters read.
289
- *
290
- * Found-order is monotonic by construction, so `?since=<n>` is exact however
291
- * the file arrives.
292
- *
293
- * @param {{ seq: Map<number, number> }} state
294
- * @param {number} trackNumber
295
- * @returns {number}
296
- */
297
- function nextSeq(state, trackNumber) {
298
- const next = (state.seq.get(trackNumber) ?? 0) + 1;
299
- state.seq.set(trackNumber, next);
300
- return next;
301
- }
302
-
303
- /**
304
- * Every cue of one track that can be read from what is already downloaded.
305
- *
306
- * @param {object} torrent
307
- * @param {number} fileIndex
308
- * @param {string} sourceKey
309
- * @param {number} trackNumber
310
- * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
311
- */
312
- export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
313
- const key = `${sourceKey}:${fileIndex}`;
314
- // A torrent that cannot say which pieces it holds makes every range read as
315
- // "not downloaded", so the walk reads nothing and returns an empty list —
316
- // which is also what a file with no cues yet returns, and that is how this
317
- // went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
318
- // the browser served `WEBVTT` and nothing else). The stand-in the main thread
319
- // holds is exactly such a torrent; only the thread that owns the object has
320
- // the bitfield. Nothing here can repair that, so it says so instead.
321
- if (!torrent?.bitfield || !(Number(torrent?.pieceLength) > 0)) {
322
- logger.warn(
323
- `subtitles: asked for cues of "${String(torrent?.name ?? sourceKey).slice(0, 40)}" ` +
324
- "on a torrent that cannot say which pieces it holds no cluster can be read here, " +
325
- "and the answer would be an empty document indistinguishable from a file with no cues"
326
- );
327
- return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
328
- }
329
- const plan = await planFor(torrent, fileIndex, key);
330
- const state = stateFor(key);
331
- const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
332
- if (!track) {
333
- return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
334
- }
335
- return serialize(state, () => walkFor(torrent, fileIndex, state, plan, track, trackNumber));
336
- }
337
-
338
- /**
339
- * The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
340
- * one file to one walk at a time.
341
- *
342
- * @param {object} torrent
343
- * @param {number} fileIndex
344
- * @param {object} state
345
- * @param {object} plan
346
- * @param {object} track
347
- * @param {number} trackNumber
348
- * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
349
- */
350
- async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
351
- const file = torrent.files[fileIndex];
352
- let harvested = state.harvested.get(trackNumber);
353
- if (!harvested) {
354
- harvested = new Set();
355
- state.harvested.set(trackNumber, harvested);
356
- }
357
- let cues = state.cues.get(trackNumber);
358
- if (!cues) {
359
- cues = [];
360
- state.cues.set(trackNumber, cues);
361
- }
362
-
363
- if (Array.isArray(track.samples)) {
364
- // An MP4: every cue's bytes are stated, so only those bytes are read, and
365
- // only where they are already downloaded.
366
- for (const sample of track.samples) {
367
- if (harvested.has(sample.offset)) {
368
- continue;
369
- }
370
- const last = Math.min(file.length - 1, sample.offset + sample.size - 1);
371
- if (!rangeIsHeld(torrent, file, sample.offset, last)) {
372
- continue;
373
- }
374
- const bytes = await readHeld(file, sample.offset, last);
375
- if (!bytes) {
376
- continue;
377
- }
378
- harvested.add(sample.offset);
379
- // The MP4 has framed this cue and is the one that unframes it.
380
- const text = Mp4Container.cueTextOf(bytes, track.codecId);
381
- if (text) {
382
- cues.push({
383
- startSeconds: sample.startSeconds,
384
- endSeconds: sample.endSeconds,
385
- text,
386
- seq: nextSeq(state, trackNumber)
387
- });
388
- }
389
- }
390
- cues.sort((left, right) => left.startSeconds - right.startSeconds);
391
- return {
392
- cues,
393
- coveredClusters: harvested.size,
394
- indexedClusters: track.samples.length,
395
- track
396
- };
397
- }
398
-
399
- // ONE walk for the whole file, not one per track. A Matroska cluster carries
400
- // the blocks of every track that has anything to say over its span, so the
401
- // bytes that answer one track answer them all and reading them once per
402
- // track meant the same cluster was fetched and parsed as many times as the
403
- // film has subtitle tracks. Measured 2026-08-20 on a film with five: five
404
- // requests every fifteen seconds, each costing 0.2-5.2 s of container
405
- // reading, for cues that together weigh a few kilobytes.
406
- //
407
- // The union of the tracks' cluster lists is what gets walked: each track's
408
- // list comes from its own Cues entries, so they overlap but do not coincide.
409
- const positions = new Set();
410
- for (const candidate of plan.tracks) {
411
- for (const position of candidate.clusterPositions ?? []) {
412
- positions.add(position);
413
- }
414
- }
415
- for (const position of [...positions].sort((left, right) => left - right)) {
416
- if (state.walked.has(position)) {
417
- continue;
418
- }
419
- // The header first: it says how long the cluster is, and a cluster whose
420
- // bytes are not all here is left for the next time round.
421
- if (!rangeIsHeld(torrent, file, position, Math.min(file.length - 1, position + CLUSTER_HEADER_PROBE - 1))) {
422
- continue;
423
- }
424
- const probe = await readHeld(file, position, Math.min(file.length - 1, position + CLUSTER_HEADER_PROBE - 1));
425
- const header = probe && [...iterateElements(probe, 0, probe.length)][0];
426
- if (!header || header.size <= 0 || header.size > MAX_CLUSTER_BYTES) {
427
- state.walked.add(position); // not a cluster we can read; do not look again
428
- continue;
429
- }
430
- const last = Math.min(file.length - 1, position + header.dataOffset + header.size - 1);
431
- if (!rangeIsHeld(torrent, file, position, last)) {
432
- continue;
433
- }
434
- const bytes = await readHeld(file, position, last);
435
- if (!bytes) {
436
- continue;
437
- }
438
- state.walked.add(position);
439
- for (const candidate of plan.tracks) {
440
- let into = state.cues.get(candidate.trackNumber);
441
- if (!into) {
442
- into = [];
443
- state.cues.set(candidate.trackNumber, into);
444
- }
445
- let found = false;
446
- for (const block of harvestCluster(bytes, candidate.trackNumber, plan.secondsPerTick)) {
447
- // The block's bytes become this track's text HERE, where the container
448
- // that framed them is known. A cue kept in its framed form and unframed
449
- // later cannot be unframed at all: nothing downstream knows which
450
- // container it came out of, and guessing from the field count is what
451
- // showed the dialogue row's own fields to the viewer.
452
- into.push({
453
- startSeconds: block.startSeconds,
454
- endSeconds: block.endSeconds,
455
- text: MatroskaContainer.cueTextOf(block.payload, candidate.codecId),
456
- seq: nextSeq(state, candidate.trackNumber)
457
- });
458
- found = true;
459
- }
460
- if (found) {
461
- into.sort((left, right) => left.startSeconds - right.startSeconds);
462
- }
463
- }
464
- }
465
- return {
466
- cues: state.cues.get(trackNumber) ?? [],
467
- // Every track is filled by the same walk, so this is a fact about the FILE
468
- // and reads the same whichever track asked.
469
- coveredClusters: state.walked.size,
470
- indexedClusters: track.clusterPositions.length,
471
- track
472
- };
473
-
474
- }
475
-
476
- /**
477
- * Walk whatever clusters have newly arrived, for every text track a file
478
- * carries, and report what is new since the last call — so the cues can be
479
- * PUSHED to a browser rather than left for it to come back and ask.
480
- *
481
- * `cuesHeldFor` already skips positions it has walked before (`state.walked`),
482
- * so calling this on a timer or on every verified piece is cheap once a file
483
- * is caught up: the only cost is deciding there is nothing new to read. It is
484
- * `getSubtitleCues` run ahead of being asked, on the same state that call
485
- * itself would build — nothing is duplicated, and a file nobody has opened
486
- * costs nothing beyond this.
487
- *
488
- * @param {object} torrent
489
- * @param {number} fileIndex
490
- * @param {string} sourceKey
491
- * @returns {Promise<{ trackIndex: number, cues: object[], language: string }[]>}
492
- * One entry per track that gained at least one cue since the last call.
493
- * `trackIndex` is `declaredIndex` — the track's position among ALL the file's
494
- * subtitle tracks, which is ffmpeg's `0:s:N` and the only number the browser
495
- * knows. NOT the container's own track number, and not the position among the
496
- * readable tracks either: counting those alone puts every text track after a
497
- * picture-based one in the wrong place.
498
- */
499
- export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
500
- const key = `${sourceKey}:${fileIndex}`;
501
- const plan = await planFor(torrent, fileIndex, key);
502
- const state = stateFor(key);
503
- const fresh = [];
504
- const tracks = plan?.tracks ?? [];
505
- for (let order = 0; order < tracks.length; order += 1) {
506
- const track = tracks[order];
507
- const held = await cuesHeldFor(torrent, fileIndex, sourceKey, track.trackNumber);
508
- const since = state.pushed.get(track.trackNumber) ?? 0;
509
- const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
510
- if (newCues.length === 0) {
511
- continue;
512
- }
513
- const highest = newCues.reduce((max, cue) => Math.max(max, Number(cue.seq) || 0), since);
514
- state.pushed.set(track.trackNumber, highest);
515
- const codecId = held.track?.codecId ?? track.codecId;
516
- const cues = finalizeCues(newCues, codecId);
517
- fresh.push({
518
- // ffmpeg's own numbering, which is the only one the browser knows.
519
- trackIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
520
- cues,
521
- language: held.track?.language ?? "",
522
- // What the CUES say the language is, re-read on every push over every cue
523
- // held so far rather than over this batch. A track whose container states
524
- // no language is unreadable at the start of a session a handful of cues
525
- // is not a sample of a language, and the detector refuses to answer on one
526
- // so the answer has to be re-taken as the film downloads, and the label
527
- // moved when it arrives. Costs about 6 ms per push, measured; pushes
528
- // arrive about once a second per file being read.
529
- detectedLanguage: detectLanguage(
530
- finalizeCues(held.cues, codecId).map((cue) => cue.text).join("\n")
531
- ),
532
- // Where the browser should resume from if it has to ask again — after a
533
- // reconnect, which loses the subscription these pushes ride on.
534
- cursor: highest,
535
- // What this batch is ABOUT, in film time, so a log can be read against
536
- // the position being played.
537
- spanStartSeconds: cues.length > 0 ? cues[0].startSeconds : null,
538
- spanEndSeconds: cues.length > 0 ? cues[cues.length - 1].endSeconds : null,
539
- walkedClusters: held.coveredClusters ?? 0,
540
- indexedClusters: held.indexedClusters ?? 0
541
- });
542
- }
543
- return fresh;
544
- }
545
-
546
- /**
547
- * The text subtitle tracks of a file, for the menu the viewer sees.
548
- *
549
- * @param {object} torrent
550
- * @param {number} fileIndex
551
- * @param {string} sourceKey
552
- * @returns {Promise<object[]>}
553
- */
554
- export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
555
- const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
556
- return (plan?.tracks ?? []).map((track, order) => ({
557
- trackNumber: track.trackNumber,
558
- declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
559
- codecId: track.codecId,
560
- language: track.language,
561
- name: track.name,
562
- isDefault: track.isDefault,
563
- indexedClusters: track.clusterPositions.length
564
- }));
565
- }
566
-
567
- /**
568
- * What the container itself says about its subtitle tracks, in its own order
569
- * and including the picture-based ones.
570
- *
571
- * Separate from `subtitleTracksOf`, which lists only what can be turned into
572
- * WebVTT and is indexed by position in the subtitle API. This one exists to be
573
- * lined up against ffmpeg's `0:s:N` numbering, which counts every subtitle
574
- * stream, so leaving the picture ones out would shift it.
575
- *
576
- * @param {object} torrent
577
- * @param {number} fileIndex
578
- * @param {string} sourceKey
579
- * @returns {Promise<object[]>}
580
- */
581
- export async function declaredSubtitleTracksOf(torrent, fileIndex, sourceKey) {
582
- const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
583
- return plan?.declared ?? [];
584
- }
585
-
586
- /**
587
- * Forget a file's cues — the torrent is gone, and holding them would keep the
588
- * text of a film nobody is watching.
589
- *
590
- * @param {string} sourceKey
591
- * @param {number} [fileIndex]
592
- * @returns {void}
593
- */
594
- export function forgetSubtitles(sourceKey, fileIndex) {
595
- if (fileIndex === undefined) {
596
- for (const key of [...byFile.keys()]) {
597
- if (key.startsWith(`${sourceKey}:`)) {
598
- forgetOne(key);
599
- }
600
- }
601
- return;
602
- }
603
- forgetOne(`${sourceKey}:${fileIndex}`);
604
- }
605
-
606
- /**
607
- * Drop one file's state, but not while a walk of it is still running: the
608
- * record of which clusters have been read lives in that state, and a walk left
609
- * writing into a discarded copy while a new one starts beside it is the one
610
- * path that defeats the serialization above.
611
- *
612
- * @param {string} key
613
- * @returns {void}
614
- */
615
- function forgetOne(key) {
616
- const state = byFile.get(key);
617
- if (!state) {
618
- return;
619
- }
620
- // Held, so that a walk started before this call is not left orphaned; the
621
- // entry is dropped the moment the queue empties, and nothing is handed this
622
- // state in the meantime.
623
- state.forgotten = true;
624
- void state.chain.then(() => {
625
- if (byFile.get(key) === state) {
626
- byFile.delete(key);
627
- }
628
- }, () => {
629
- if (byFile.get(key) === state) {
630
- byFile.delete(key);
631
- }
632
- });
633
- }
1
+ /**
2
+ * @file Subtitle cues gathered from the clusters a viewer has already brought
3
+ * in, never from clusters they have not.
4
+ *
5
+ * The rule this file exists to keep (stated by the user 2026-08-20): subtitles
6
+ * arrive the way the picture does, or they are not offered. So nothing here
7
+ * requests a byte. It looks at what the torrent already holds, reads the
8
+ * clusters inside it, and returns what it found; the region the viewer is
9
+ * watching is downloaded before they reach it, so its cues are ready before
10
+ * they are needed. A region nobody has watched has no cues, and that is
11
+ * correct — there is nobody to show them to.
12
+ *
13
+ * Why not ffmpeg: measured 2026-08-19, extracting one subtitle track of
14
+ * `Minions.and.Monsters.1080p.mkv` took **752 seconds** and pulled the download
15
+ * from 2.7 % to 81 % of a 6.5 GB film, because a subtitle stream is sparse and
16
+ * the demuxer walks the container to the end whatever range is asked of it.
17
+ * Reading the clusters costs nothing extra at all.
18
+ */
19
+
20
+ import { ContainerFactory } from "../container/ContainerFactory.js";
21
+ import { TextSubtitleTrack } from "../tracks/TextSubtitleTrack.js";
22
+ import { detectLanguage } from "../language-detect.js";
23
+ import { logger } from "../../utils/logger.js";
24
+
25
+ /** How long a read of already-held bytes may take before it is given up. */
26
+ const READ_ABANDON_MS = 30_000;
27
+
28
+ /** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
29
+ const byFile = new Map();
30
+
31
+ /**
32
+ * Whether every piece covering a byte range is already downloaded.
33
+ *
34
+ * @param {object} torrent
35
+ * @param {object} file
36
+ * @param {number} start - Offset within the FILE.
37
+ * @param {number} end - Inclusive.
38
+ * @returns {boolean}
39
+ */
40
+ function rangeIsHeld(torrent, file, start, end) {
41
+ const pieceLength = Number(torrent?.pieceLength);
42
+ const offset = Number(file?.offset) || 0;
43
+ if (!Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
44
+ return false;
45
+ }
46
+ const first = Math.floor((offset + start) / pieceLength);
47
+ const last = Math.floor((offset + end) / pieceLength);
48
+ for (let index = first; index <= last; index += 1) {
49
+ if (!torrent.bitfield.get(index)) {
50
+ return false;
51
+ }
52
+ }
53
+ return true;
54
+ }
55
+
56
+ /**
57
+ * Read a byte range of a file straight from the store, without asking the swarm
58
+ * for anything.
59
+ *
60
+ * @param {object} file
61
+ * @param {number} start
62
+ * @param {number} end - Inclusive.
63
+ * @returns {Promise<Buffer | null>}
64
+ */
65
+ function readHeld(file, start, end) {
66
+ return new Promise((resolve) => {
67
+ const chunks = [];
68
+ let stream;
69
+ try {
70
+ stream = file.createReadStream({ start, end });
71
+ } catch {
72
+ resolve(null);
73
+ return;
74
+ }
75
+ let settled = false;
76
+ /** @type {ReturnType<typeof setTimeout> | null} */
77
+ let abandon = null;
78
+ const settle = (value) => {
79
+ if (settled) {
80
+ return;
81
+ }
82
+ settled = true;
83
+ if (abandon !== null) {
84
+ clearTimeout(abandon);
85
+ }
86
+ if (value === null) {
87
+ stream.destroy?.();
88
+ }
89
+ resolve(value);
90
+ };
91
+ // A read of bytes the torrent already holds either answers or it does not.
92
+ // This is not a measurement of anything and no figure is derived from it:
93
+ // it is the point past which such a read is presumed lost, so that one
94
+ // stream which never ends cannot hold this file's walk — and with it the
95
+ // browser's own request for its subtitles — for the rest of the session.
96
+ abandon = setTimeout(() => {
97
+ logger.info(
98
+ `subtitles: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
99
+ `did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
100
+ );
101
+ settle(null);
102
+ }, READ_ABANDON_MS);
103
+ abandon.unref?.();
104
+ stream.on("data", (chunk) => chunks.push(chunk));
105
+ stream.on("end", () => settle(Buffer.concat(chunks)));
106
+ stream.on("error", () => settle(null));
107
+ });
108
+ }
109
+
110
+ /**
111
+ * A container over one file of a torrent, told how to read it.
112
+ *
113
+ * The container is given two readers and a predicate and never the torrent:
114
+ * whether a byte range is already downloaded, how to read one without asking
115
+ * the swarm, and how to read one that may need fetching. That is the whole of
116
+ * what this layer knows and the container does not.
117
+ *
118
+ * WHICH container is decided from the bytes, by the factory's sniff — the
119
+ * header is what the muxer wrote, and a file name is what somebody typed. Only
120
+ * where the head is not downloaded, and so cannot be sniffed without asking the
121
+ * swarm, does the name answer instead. One choice either way: the caller never
122
+ * decides a second time from a track's shape, which is two decisions from
123
+ * different evidence that have to agree.
124
+ *
125
+ * @param {object} state - This file's state; the container is kept on it.
126
+ * @param {object} torrent
127
+ * @param {object} file
128
+ * @returns {Promise<import("../container/Container.js").Container | null>}
129
+ */
130
+ async function containerOver(state, torrent, file) {
131
+ if (state.container !== null) {
132
+ return state.container;
133
+ }
134
+ const held = async (start, end) => readHeld(file, start, Math.min(end, file.length - 1));
135
+ const params = {
136
+ readRange: held,
137
+ readHeld: held,
138
+ isHeld: (start, end) => rangeIsHeld(torrent, file, start, Math.min(end, file.length - 1)),
139
+ fileSize: file.length,
140
+ label: String(file.name ?? "")
141
+ };
142
+ const sniffed = await ContainerFactory.create(params);
143
+ if (sniffed) {
144
+ state.container = sniffed;
145
+ return state.container;
146
+ }
147
+ const ByName = ContainerFactory.byName(file.name);
148
+ state.container = ByName ? new ByName(params) : null;
149
+ return state.container;
150
+ }
151
+
152
+ /**
153
+ * The subtitle tracks of a file, read once and kept.
154
+ *
155
+ * The head and the Cues table are two short reads, and they ARE fetched if
156
+ * missing — they are kilobytes, they are needed before anything can be offered,
157
+ * and the codec probe has already pulled the head for every file that plays.
158
+ *
159
+ * @param {object} torrent
160
+ * @param {number} fileIndex
161
+ * @param {string} key - `sourceKey:fileIndex`.
162
+ * @returns {Promise<object | null>}
163
+ */
164
+ async function planFor(torrent, fileIndex, key) {
165
+ const state = stateFor(key);
166
+ if (state.plan !== null) {
167
+ return state.plan;
168
+ }
169
+ // The head and the Cues table are two reads that DO wait on the swarm, so two
170
+ // callers arriving together would both make them. One promise, awaited by
171
+ // whoever asks while it is in flight.
172
+ if (!state.planPromise) {
173
+ state.planPromise = readPlan(torrent, fileIndex, state).finally(() => {
174
+ state.planPromise = null;
175
+ });
176
+ }
177
+ return state.planPromise;
178
+ }
179
+
180
+ /**
181
+ * The state kept for one file, created on first use.
182
+ *
183
+ * @param {string} key - `sourceKey:fileIndex`.
184
+ * @returns {object}
185
+ */
186
+ function stateFor(key) {
187
+ let state = byFile.get(key);
188
+ // A state that has been forgotten is not handed out again, even in the moment
189
+ // between the call and the walk that was still running finishing.
190
+ if (state?.forgotten === true) {
191
+ state = undefined;
192
+ }
193
+ if (!state) {
194
+ state = {
195
+ plan: null,
196
+ planPromise: null,
197
+ forgotten: false,
198
+ // One walk of a file at a time — see `serialize`.
199
+ chain: Promise.resolve(),
200
+ // The container over this file, built once. It caches what it has parsed
201
+ // — a `moov` box is tens of megabytes off a torrent — so building a fresh
202
+ // one per call would throw that away on every request.
203
+ container: null,
204
+ harvested: new Map(),
205
+ cues: new Map(),
206
+ seq: new Map(),
207
+ walked: new Set(),
208
+ // The found-order cursor of the last cue PUSHED for each track, so a
209
+ // second warmup pass sends only what a first one did not — the same
210
+ // found-order idea `?since=` uses for a browser's own pull.
211
+ pushed: new Map()
212
+ };
213
+ byFile.set(key, state);
214
+ }
215
+ return state;
216
+ }
217
+
218
+ /**
219
+ * Run `work` after every walk of this file already started, and before any
220
+ * started after it.
221
+ *
222
+ * Both entry points here a browser's own pull and the warmup that runs ahead
223
+ * of it — mark a cluster as walked only AFTER reading and parsing it, which is
224
+ * two suspension points later. Until 2.56.0 nothing stopped a second call
225
+ * arriving in between: `warmActiveFiles` runs on every verified piece AND on a
226
+ * 3 s timer, so on a fast download the same cluster was read and parsed several
227
+ * times over and the same line could be pushed twice under different `seq`
228
+ * numbers. Each of those reads is a WebTorrent file stream, which selects and
229
+ * deselects its pieces, so the repetition reached the piece picker as well.
230
+ *
231
+ * @template T
232
+ * @param {object} state
233
+ * @param {() => Promise<T>} work
234
+ * @returns {Promise<T>}
235
+ */
236
+ function serialize(state, work) {
237
+ const run = state.chain.then(work, work);
238
+ // The queue must survive a failed walk, so what is chained is the settled
239
+ // form; the caller still sees the rejection.
240
+ state.chain = run.then(() => undefined, () => undefined);
241
+ return run;
242
+ }
243
+
244
+ /**
245
+ * Read one file's subtitle plan — the tracks it declares and where the clusters
246
+ * holding them are. Called once per file; see `planFor`.
247
+ *
248
+ * @param {object} torrent
249
+ * @param {number} fileIndex
250
+ * @param {object} state
251
+ * @returns {Promise<object>}
252
+ */
253
+ async function readPlan(torrent, fileIndex, state) {
254
+ const file = torrent?.files?.[fileIndex];
255
+ // `declared` is what the container itself says about its subtitle tracks, in
256
+ // its own order. Empty means the container said nothing — which is a real
257
+ // answer and not a missing one: nothing is then shown unasked. An MP4 has no
258
+ // element that means "show this subtitle track by default", so it declares
259
+ // nothing however many tracks it carries.
260
+ const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
261
+ if (!file) {
262
+ state.plan = empty;
263
+ return state.plan;
264
+ }
265
+ const container = await containerOver(state, torrent, file);
266
+ if (!container) {
267
+ state.plan = empty;
268
+ return state.plan;
269
+ }
270
+ const plan = await container.readSubtitlePlan();
271
+ state.plan = plan ?? empty;
272
+ if (state.plan.tracks.length > 0) {
273
+ logger.info(
274
+ `subtitles: "${String(file.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
275
+ `of ${state.plan.declared.length} declared — ` +
276
+ state.plan.tracks
277
+ // `s:N` is the number the browser names (ffmpeg's own), and it differs
278
+ // from the file's track number whenever a picture track sits among them.
279
+ .map((track) => `s:${track.declaredIndex}=${track.trackNumber}:${track.language || "?"}` +
280
+ `${track.name ? `/${track.name}` : ""}(${track.clusterPositions.length} indexed)`)
281
+ .join(" ")
282
+ );
283
+ }
284
+ return state.plan;
285
+ }
286
+
287
+ /**
288
+ * The order a cue was FOUND in, which is the only cursor a browser can follow.
289
+ *
290
+ * A cue's TIME cannot serve as one. Cues are harvested out of whichever
291
+ * clusters happen to be downloaded, and those are not contiguous, so the set
292
+ * grows in the middle as well as at the end. A browser that remembered "the
293
+ * latest time I hold" and asked for everything past it would never be sent the
294
+ * cues that turn up BEHIND that mark afterwards — which is exactly the stretch
295
+ * it is about to play. Measured 2026-08-20 on a viewer at 272 s: one answer
296
+ * carried cues out to 1176 s, and from then on every cue between the two was
297
+ * filtered away for the rest of the session, with 59 of 276 clusters read.
298
+ *
299
+ * Found-order is monotonic by construction, so `?since=<n>` is exact however
300
+ * the file arrives.
301
+ *
302
+ * @param {{ seq: Map<number, number> }} state
303
+ * @param {number} trackNumber
304
+ * @returns {number}
305
+ */
306
+ function nextSeq(state, trackNumber) {
307
+ const next = (state.seq.get(trackNumber) ?? 0) + 1;
308
+ state.seq.set(trackNumber, next);
309
+ return next;
310
+ }
311
+
312
+ /**
313
+ * Every cue of one track that can be read from what is already downloaded.
314
+ *
315
+ * @param {object} torrent
316
+ * @param {number} fileIndex
317
+ * @param {string} sourceKey
318
+ * @param {number} trackNumber
319
+ * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
320
+ */
321
+ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
322
+ const key = `${sourceKey}:${fileIndex}`;
323
+ // A torrent that cannot say which pieces it holds makes every range read as
324
+ // "not downloaded", so the walk reads nothing and returns an empty list
325
+ // which is also what a file with no cues yet returns, and that is how this
326
+ // went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
327
+ // the browser served `WEBVTT` and nothing else). The stand-in the main thread
328
+ // holds is exactly such a torrent; only the thread that owns the object has
329
+ // the bitfield. Nothing here can repair that, so it says so instead.
330
+ if (!torrent?.bitfield || !(Number(torrent?.pieceLength) > 0)) {
331
+ logger.warn(
332
+ `subtitles: asked for cues of "${String(torrent?.name ?? sourceKey).slice(0, 40)}" ` +
333
+ "on a torrent that cannot say which pieces it holds — no cluster can be read here, " +
334
+ "and the answer would be an empty document indistinguishable from a file with no cues"
335
+ );
336
+ return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
337
+ }
338
+ const plan = await planFor(torrent, fileIndex, key);
339
+ const state = stateFor(key);
340
+ const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
341
+ if (!track) {
342
+ return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
343
+ }
344
+ return serialize(state, () => walkFor(torrent, fileIndex, state, plan, track, trackNumber));
345
+ }
346
+
347
+ /**
348
+ * The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
349
+ * one file to one walk at a time.
350
+ *
351
+ * @param {object} torrent
352
+ * @param {number} fileIndex
353
+ * @param {object} state
354
+ * @param {object} plan
355
+ * @param {object} track
356
+ * @param {number} trackNumber
357
+ * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
358
+ */
359
+ async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
360
+ const file = torrent.files[fileIndex];
361
+ const container = await containerOver(state, torrent, file);
362
+ if (!container) {
363
+ return { cues: [], coveredClusters: 0, indexedClusters: 0, track };
364
+ }
365
+
366
+ // One question, whichever container this is. Matroska walks the clusters its
367
+ // Cues table names and fills every track from one walk; an MP4 reads the
368
+ // samples its own table states for this track. Nothing here chooses between
369
+ // them, which is the point: the container was chosen once, from the bytes.
370
+ const { found, covered, indexed } = await container.readHeldCues(plan, track, state);
371
+
372
+ for (const [number, cues] of found) {
373
+ let into = state.cues.get(number);
374
+ if (!into) {
375
+ into = [];
376
+ state.cues.set(number, into);
377
+ }
378
+ for (const cue of cues) {
379
+ into.push({ ...cue, seq: nextSeq(state, number) });
380
+ }
381
+ into.sort((left, right) => left.startSeconds - right.startSeconds);
382
+ }
383
+
384
+ return {
385
+ cues: state.cues.get(trackNumber) ?? [],
386
+ coveredClusters: covered,
387
+ indexedClusters: indexed,
388
+ track
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Walk whatever clusters have newly arrived, for every text track a file
394
+ * carries, and report what is new since the last call — so the cues can be
395
+ * PUSHED to a browser rather than left for it to come back and ask.
396
+ *
397
+ * `cuesHeldFor` already skips positions it has walked before (`state.walked`),
398
+ * so calling this on a timer or on every verified piece is cheap once a file
399
+ * is caught up: the only cost is deciding there is nothing new to read. It is
400
+ * `getSubtitleCues` run ahead of being asked, on the same state that call
401
+ * itself would build nothing is duplicated, and a file nobody has opened
402
+ * costs nothing beyond this.
403
+ *
404
+ * @param {object} torrent
405
+ * @param {number} fileIndex
406
+ * @param {string} sourceKey
407
+ * @returns {Promise<{ trackIndex: number, cues: object[], language: string }[]>}
408
+ * One entry per track that gained at least one cue since the last call.
409
+ * `trackIndex` is `declaredIndex` — the track's position among ALL the file's
410
+ * subtitle tracks, which is ffmpeg's `0:s:N` and the only number the browser
411
+ * knows. NOT the container's own track number, and not the position among the
412
+ * readable tracks either: counting those alone puts every text track after a
413
+ * picture-based one in the wrong place.
414
+ */
415
+ export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
416
+ const key = `${sourceKey}:${fileIndex}`;
417
+ const plan = await planFor(torrent, fileIndex, key);
418
+ const state = stateFor(key);
419
+ const fresh = [];
420
+ const tracks = plan?.tracks ?? [];
421
+ for (let order = 0; order < tracks.length; order += 1) {
422
+ const track = tracks[order];
423
+ const held = await cuesHeldFor(torrent, fileIndex, sourceKey, track.trackNumber);
424
+ const since = state.pushed.get(track.trackNumber) ?? 0;
425
+ const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
426
+ if (newCues.length === 0) {
427
+ continue;
428
+ }
429
+ const highest = newCues.reduce((max, cue) => Math.max(max, Number(cue.seq) || 0), since);
430
+ state.pushed.set(track.trackNumber, highest);
431
+ const codecId = held.track?.codecId ?? track.codecId;
432
+ const cues = TextSubtitleTrack.finalizeCues(newCues, codecId);
433
+ fresh.push({
434
+ // ffmpeg's own numbering, which is the only one the browser knows.
435
+ trackIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
436
+ cues,
437
+ language: held.track?.language ?? "",
438
+ // What the CUES say the language is, re-read on every push over every cue
439
+ // held so far rather than over this batch. A track whose container states
440
+ // no language is unreadable at the start of a session — a handful of cues
441
+ // is not a sample of a language, and the detector refuses to answer on one
442
+ // so the answer has to be re-taken as the film downloads, and the label
443
+ // moved when it arrives. Costs about 6 ms per push, measured; pushes
444
+ // arrive about once a second per file being read.
445
+ detectedLanguage: detectLanguage(
446
+ TextSubtitleTrack.finalizeCues(held.cues, codecId).map((cue) => cue.text).join("\n")
447
+ ),
448
+ // Where the browser should resume from if it has to ask again after a
449
+ // reconnect, which loses the subscription these pushes ride on.
450
+ cursor: highest,
451
+ // What this batch is ABOUT, in film time, so a log can be read against
452
+ // the position being played.
453
+ spanStartSeconds: cues.length > 0 ? cues[0].startSeconds : null,
454
+ spanEndSeconds: cues.length > 0 ? cues[cues.length - 1].endSeconds : null,
455
+ walkedClusters: held.coveredClusters ?? 0,
456
+ indexedClusters: held.indexedClusters ?? 0
457
+ });
458
+ }
459
+ return fresh;
460
+ }
461
+
462
+ /**
463
+ * The text subtitle tracks of a file, for the menu the viewer sees.
464
+ *
465
+ * @param {object} torrent
466
+ * @param {number} fileIndex
467
+ * @param {string} sourceKey
468
+ * @returns {Promise<object[]>}
469
+ */
470
+ export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
471
+ const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
472
+ return (plan?.tracks ?? []).map((track, order) => ({
473
+ trackNumber: track.trackNumber,
474
+ declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
475
+ codecId: track.codecId,
476
+ language: track.language,
477
+ name: track.name,
478
+ isDefault: track.isDefault,
479
+ indexedClusters: track.clusterPositions.length
480
+ }));
481
+ }
482
+
483
+ /**
484
+ * What the container itself says about its subtitle tracks, in its own order
485
+ * and including the picture-based ones.
486
+ *
487
+ * Separate from `subtitleTracksOf`, which lists only what can be turned into
488
+ * WebVTT and is indexed by position in the subtitle API. This one exists to be
489
+ * lined up against ffmpeg's `0:s:N` numbering, which counts every subtitle
490
+ * stream, so leaving the picture ones out would shift it.
491
+ *
492
+ * @param {object} torrent
493
+ * @param {number} fileIndex
494
+ * @param {string} sourceKey
495
+ * @returns {Promise<object[]>}
496
+ */
497
+ export async function declaredSubtitleTracksOf(torrent, fileIndex, sourceKey) {
498
+ const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
499
+ return plan?.declared ?? [];
500
+ }
501
+
502
+ /**
503
+ * Forget a file's cues — the torrent is gone, and holding them would keep the
504
+ * text of a film nobody is watching.
505
+ *
506
+ * @param {string} sourceKey
507
+ * @param {number} [fileIndex]
508
+ * @returns {void}
509
+ */
510
+ export function forgetSubtitles(sourceKey, fileIndex) {
511
+ if (fileIndex === undefined) {
512
+ for (const key of [...byFile.keys()]) {
513
+ if (key.startsWith(`${sourceKey}:`)) {
514
+ forgetOne(key);
515
+ }
516
+ }
517
+ return;
518
+ }
519
+ forgetOne(`${sourceKey}:${fileIndex}`);
520
+ }
521
+
522
+ /**
523
+ * Drop one file's state, but not while a walk of it is still running: the
524
+ * record of which clusters have been read lives in that state, and a walk left
525
+ * writing into a discarded copy while a new one starts beside it is the one
526
+ * path that defeats the serialization above.
527
+ *
528
+ * @param {string} key
529
+ * @returns {void}
530
+ */
531
+ function forgetOne(key) {
532
+ const state = byFile.get(key);
533
+ if (!state) {
534
+ return;
535
+ }
536
+ // Held, so that a walk started before this call is not left orphaned; the
537
+ // entry is dropped the moment the queue empties, and nothing is handed this
538
+ // state in the meantime.
539
+ state.forgotten = true;
540
+ void state.chain.then(() => {
541
+ if (byFile.get(key) === state) {
542
+ byFile.delete(key);
543
+ }
544
+ }, () => {
545
+ if (byFile.get(key) === state) {
546
+ byFile.delete(key);
547
+ }
548
+ });
549
+ }