@torrent-tv/proxy 2.75.0 → 2.76.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 (113) hide show
  1. package/CHANGELOG.md +1509 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11100 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -1,549 +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 { 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
- }
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 "../tracks/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
+ }