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