@torrent-tv/proxy 2.88.6 → 2.89.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 (45) hide show
  1. package/AGENTS.md +21 -12
  2. package/CHANGELOG.md +17 -3
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/docs/container-architecture.md +77 -22
  6. package/docs/playback-start-forecast.md +56 -56
  7. package/main2.cpuprofile +1 -0
  8. package/package.json +1 -1
  9. package/scripts/build-audio-samples.mjs +31 -31
  10. package/server.js +42 -27
  11. package/services/encode/OutputOpening.js +5 -4
  12. package/services/encode/audio-calibration.js +67 -67
  13. package/services/encode/audio-work-rate.js +15 -15
  14. package/services/media/ContainerOrchestrator.js +99 -46
  15. package/services/media/KeyframeTables.js +61 -21
  16. package/services/media/SubtitleCues.js +255 -183
  17. package/services/media/container/AviContainer.js +5 -3
  18. package/services/media/container/Container.js +66 -43
  19. package/services/media/container/ContainerFactory.js +8 -26
  20. package/services/media/container/MatroskaContainer.js +719 -1182
  21. package/services/media/container/Mp4Container.js +83 -73
  22. package/services/media/container/ebml-stream.js +174 -0
  23. package/services/media/container/matroska-clusters.js +1165 -0
  24. package/services/media/container/unavailable.js +77 -0
  25. package/services/media/tracks/ContainerTrack.js +9 -0
  26. package/services/media/tracks/TextSubtitleTrack.js +9 -1
  27. package/services/server/playback-readiness.js +516 -516
  28. package/services/torrent/worker/client.js +6 -2
  29. package/services/torrent/worker/held-bytes.js +84 -8
  30. package/services/torrent/worker/worker.js +10 -2
  31. package/services/transport/data-channel-handler.js +7 -3
  32. package/services/viewer/PriorityMap.js +31 -0
  33. package/test/audio-calibration.test.js +23 -23
  34. package/test/coalesce.test.js +39 -0
  35. package/test/held-read-ownership.test.js +77 -0
  36. package/test/helpers/held-file.js +50 -2
  37. package/test/helpers/matroska-file.js +296 -0
  38. package/test/keyframe-table-budget.test.js +21 -0
  39. package/test/matroska-cluster-discovery.test.js +323 -0
  40. package/test/reads-not-yet-arrived.test.js +218 -0
  41. package/test/subtitle-cue-walk.test.js +16 -1
  42. package/test/subtitle-plan-waits.test.js +125 -0
  43. package/test/subtitle-track-numbering.test.js +8 -3
  44. package/test/viewer-starts-on-map.test.js +27 -0
  45. package/utils/coalesce.js +41 -0
@@ -5,48 +5,58 @@
5
5
  * It walks ONLY what is downloaded: switching subtitles on must never pull
6
6
  * bytes the viewer is not waiting for. The head and the Cues table are the one
7
7
  * exception and are fetched — they are kilobytes, they are needed before
8
- * anything can be offered, and the codec probe has already pulled the head for
9
- * every file that plays.
8
+ * anything can be offered — and they are read by the file's ONE container, the
9
+ * one `ContainerOrchestrator` keeps. This file used to build a second container
10
+ * of its own over the same file and read both again; when its read of the Cues
11
+ * table had not arrived it kept "no clusters" for the life of the process, and
12
+ * an embedded track showed nothing for a whole session
13
+ * (`research/subtitles-never-appear-2026-10-01.md`).
14
+ *
15
+ * **A plan is kept only once it has been read.** While the bytes it needs have
16
+ * not arrived the plan is "not ready": nothing is kept, the walk does nothing,
17
+ * and the next pass — on the next arrival of pieces — reads again.
10
18
  *
11
19
  * **The torrent is not here, and that is the point.** This layer is handed a
12
- * `HeldFile`: a name, a length, which ranges are downloaded whole, and how to
13
- * read one of them without fetching. Piece length, file offsets and the
14
- * bitfield are the torrent's words and stay in the torrent's thread
15
- * (`torrent/worker/held-bytes.js`). Until 2026-09-15 this whole file lived
16
- * there, which put a container parse — the media layer's work — in the thread
17
- * that owns the swarm, and made every answer something to carry back across a
18
- * channel.
20
+ * `HeldFile`: a name, a length, the file's container, which ranges are
21
+ * downloaded whole, how to read one of them without fetching, the portion a
22
+ * read may span, and where the viewers stand. Piece length, file offsets and
23
+ * the bitfield are the torrent's words and stay in the torrent's thread.
19
24
  *
20
- * What is genuinely this file's own, and travels with it: the found-order
21
- * cursor a browser follows, the per-file state, and one walk of a file at a
22
- * time.
25
+ * What is genuinely this file's own: the found-order cursor a browser follows,
26
+ * the per-file state, one walk of a file at a time, and taking back the cues of
27
+ * a cluster the container withdraws.
23
28
  */
24
29
 
25
- import { ContainerFactory } from "./container/ContainerFactory.js";
26
30
  import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
27
31
  import { detectLanguage } from "./tracks/language-detect.js";
32
+ import { isUnavailable, strictReader } from "./container/unavailable.js";
28
33
  import { logger } from "../../utils/logger.js";
29
34
 
30
-
31
35
  /**
32
36
  * One file of one torrent, as much of it as this layer is allowed to know.
33
37
  *
34
- * Four plain values and two functions: what it is called, how long it is, which
35
- * of it is downloaded whole, and how to read a range of that without asking the
36
- * swarm for anything. No torrent, no bitfield, no piece length — those are the
37
- * torrent's words, and turning them into these is its job.
38
- *
39
38
  * @typedef {object} HeldFile
40
39
  * @property {string} sourceKey
41
40
  * @property {number} fileIndex
42
41
  * @property {string} name
43
42
  * @property {number} length
43
+ * @property {() => Promise<import("./container/Container.js").Container | null>} container -
44
+ * The file's one container. Throws `BytesUnavailable` while its head has not
45
+ * arrived; null where the bytes are no format this proxy knows.
44
46
  * @property {() => Promise<Array<[number, number]>>} heldRanges - Ascending,
45
47
  * non-overlapping, inclusive offsets within the file.
46
48
  * @property {(start: number, end: number) => Promise<Buffer | null>} readHeld -
47
49
  * Null where those bytes are not there, which is never fetched.
50
+ * @property {number} [portionBytes] - The largest read one step may make.
51
+ * @property {() => number[]} [wantedSeconds] - Where viewers stand, in seconds.
52
+ * @property {() => void} [askAgain] - Run the walk again: said when a pass left
53
+ * readable clusters for the next one, so they are read without waiting for
54
+ * another arrival of pieces — the file may be downloaded whole.
48
55
  */
49
56
 
57
+ /** How often one file's walk says what it has done, at most. */
58
+ const WALK_REPORT_INTERVAL_MS = 60_000;
59
+
50
60
  /**
51
61
  * Whether a range falls entirely inside one of the held runs.
52
62
  *
@@ -67,79 +77,43 @@ function rangeHolds(ranges, start, end) {
67
77
  return false;
68
78
  }
69
79
 
70
- /** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
80
+ /** @type {Map<string, object>} */
71
81
  const byFile = new Map();
72
82
 
73
-
74
-
75
- /**
76
- * A container over one file of a torrent, told how to read it.
77
- *
78
- * The container is given two readers and a predicate and never the torrent:
79
- * whether a byte range is already downloaded, how to read one without asking
80
- * the swarm, and how to read one that may need fetching. That is the whole of
81
- * what this layer knows and the container does not.
82
- *
83
- * WHICH container is decided from the bytes, by the factory's sniff — the
84
- * header is what the muxer wrote, and a file name is what somebody typed. Only
85
- * where the head is not downloaded, and so cannot be sniffed without asking the
86
- * swarm, does the name answer instead. One choice either way: the caller never
87
- * decides a second time from a track's shape, which is two decisions from
88
- * different evidence that have to agree.
89
- *
90
- * @param {object} state - This file's state; the container is kept on it.
91
- * @param {import("./SubtitleCues.js").HeldFile} source
92
- * @returns {Promise<import("./container/Container.js").Container | null>}
93
- */
94
- async function containerOver(state, source) {
95
- if (state.container !== null) {
96
- return state.container;
97
- }
98
- const last = Number(source.length) - 1;
99
- const held = async (start, end) => source.readHeld(start, Math.min(end, last));
100
- const params = {
101
- readRange: held,
102
- readHeld: held,
103
- // Answered from the list of ranges this file has downloaded WHOLE, taken
104
- // once per pass rather than asked per cluster: a pass asks about every
105
- // cluster of the file, and the torrent that knows is on the other thread.
106
- isHeld: (start, end) => rangeHolds(state.held, start, Math.min(end, last)),
107
- fileSize: source.length,
108
- label: String(source.name ?? "")
109
- };
110
- const sniffed = await ContainerFactory.create(params);
111
- if (sniffed) {
112
- state.container = sniffed;
113
- return state.container;
114
- }
115
- const ByName = ContainerFactory.byName(source.name);
116
- state.container = ByName ? new ByName(params) : null;
117
- return state.container;
118
- }
119
-
120
83
  /**
121
- * The subtitle tracks of a file, read once and kept.
122
- *
123
- * The head and the Cues table are two short reads, and they ARE fetched if
124
- * missing — they are kilobytes, they are needed before anything can be offered,
125
- * and the codec probe has already pulled the head for every file that plays.
84
+ * The subtitle plan of a file, read once it can be.
126
85
  *
127
- * @param {import("./SubtitleCues.js").HeldFile} source
86
+ * @param {HeldFile} source
128
87
  * @param {string} key - `sourceKey:fileIndex`.
129
- * @returns {Promise<object | null>}
88
+ * @returns {Promise<object | null>} Null while the plan's bytes have not
89
+ * arrived — "not ready", not "no subtitles".
130
90
  */
131
91
  async function planFor(source, key) {
132
92
  const state = stateFor(key);
133
93
  if (state.plan !== null) {
134
94
  return state.plan;
135
95
  }
136
- // The head and the Cues table are two reads that DO wait on the swarm, so two
96
+ // The head and the Cues table are reads that DO wait on the swarm, so two
137
97
  // callers arriving together would both make them. One promise, awaited by
138
98
  // whoever asks while it is in flight.
139
99
  if (!state.planPromise) {
140
- state.planPromise = readPlan(source, state).finally(() => {
141
- state.planPromise = null;
142
- });
100
+ state.planPromise = readPlan(source, state)
101
+ .catch((error) => {
102
+ if (!isUnavailable(error)) {
103
+ throw error;
104
+ }
105
+ if (!state.waitingSaid) {
106
+ state.waitingSaid = true;
107
+ logger.info(
108
+ `subtitles: the plan of "${String(source.name).slice(0, 40)}" waits for its bytes ` +
109
+ `(${error.message}); read again when pieces arrive`
110
+ );
111
+ }
112
+ return null;
113
+ })
114
+ .finally(() => {
115
+ state.planPromise = null;
116
+ });
143
117
  }
144
118
  return state.planPromise;
145
119
  }
@@ -161,26 +135,26 @@ function stateFor(key) {
161
135
  state = {
162
136
  plan: null,
163
137
  planPromise: null,
138
+ waitingSaid: false,
164
139
  forgotten: false,
165
140
  // One walk of a file at a time — see `serialize`.
166
141
  chain: Promise.resolve(),
167
- // The container over this file, built once. It caches what it has parsed
168
- // — a `moov` box is tens of megabytes off a torrent — so building a fresh
169
- // one per call would throw that away on every request.
170
- container: null,
171
- // Which byte ranges of this file are downloaded WHOLE, as of the start of
172
- // the pass now running. Taken once and read many times: a pass asks about
173
- // every cluster of the file, and what knows the answer is on the other
174
- // thread.
175
- held: null,
176
- harvested: new Map(),
142
+ // What the container has read of this file between passes. Each
143
+ // container keeps its own part under its own name.
144
+ progress: {},
177
145
  cues: new Map(),
178
146
  seq: new Map(),
179
- walked: new Set(),
180
147
  // The found-order cursor of the last cue PUSHED for each track, so a
181
- // second warmup pass sends only what a first one did not — the same
182
- // found-order idea `?since=` uses for a browser's own pull.
183
- pushed: new Map()
148
+ // second warmup pass sends only what a first one did not.
149
+ pushed: new Map(),
150
+ // Cues taken back since the last push, by track: their found-order
151
+ // numbers, which is how a browser names the cues it holds.
152
+ withdrawn: new Map(),
153
+ // The tracks whose container-default language the text was seen to
154
+ // contradict — said once each.
155
+ disagreementSaid: new Set(),
156
+ lastReportMs: 0,
157
+ lastStats: null
184
158
  };
185
159
  byFile.set(key, state);
186
160
  }
@@ -193,12 +167,9 @@ function stateFor(key) {
193
167
  *
194
168
  * Both entry points here — a browser's own pull and the warmup that runs ahead
195
169
  * of it — mark a cluster as walked only AFTER reading and parsing it, which is
196
- * two suspension points later. Until 2.56.0 nothing stopped a second call
197
- * arriving in between: `warmActiveFiles` runs on every verified piece AND on a
198
- * 3 s timer, so on a fast download the same cluster was read and parsed several
199
- * times over and the same line could be pushed twice under different `seq`
200
- * numbers. Each of those reads is a WebTorrent file stream, which selects and
201
- * deselects its pieces, so the repetition reached the piece picker as well.
170
+ * several suspension points later; without one walk at a time, two calls
171
+ * arriving in between read and parsed the same cluster and could push the
172
+ * same line twice under different `seq` numbers.
202
173
  *
203
174
  * @template T
204
175
  * @param {object} state
@@ -214,41 +185,43 @@ function serialize(state, work) {
214
185
  }
215
186
 
216
187
  /**
217
- * Read one file's subtitle plan — the tracks it declares and where the clusters
218
- * holding them are. Called once per file; see `planFor`.
188
+ * Read one file's subtitle plan — the tracks it declares and where the cues
189
+ * holding them are. Throws `BytesUnavailable` while those bytes are not here.
219
190
  *
220
- * @param {object} torrent
191
+ * @param {HeldFile} source
221
192
  * @param {object} state
222
193
  * @returns {Promise<object>}
223
194
  */
224
195
  async function readPlan(source, state) {
225
196
  // `declared` is what the container itself says about its subtitle tracks, in
226
197
  // its own order. Empty means the container said nothing — which is a real
227
- // answer and not a missing one: nothing is then shown unasked. An MP4 has no
228
- // element that means "show this subtitle track by default", so it declares
229
- // nothing however many tracks it carries.
198
+ // answer and not a missing one.
230
199
  const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
231
- if (!source || !(Number(source.length) > 0)) {
200
+ if (!source || !(Number(source.length) > 0) || typeof source.container !== "function") {
232
201
  state.plan = empty;
233
202
  return state.plan;
234
203
  }
235
- state.held = await source.heldRanges();
236
- const container = await containerOver(state, source);
204
+ const container = await source.container();
237
205
  if (!container) {
206
+ // The bytes were read and are no format this proxy knows.
238
207
  state.plan = empty;
239
208
  return state.plan;
240
209
  }
241
210
  const plan = await container.readSubtitlePlan();
211
+ state.container = container;
242
212
  state.plan = plan ?? empty;
243
213
  if (state.plan.tracks.length > 0) {
244
214
  logger.info(
245
215
  `subtitles: "${String(source.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
246
- `of ${state.plan.declared.length} declared — ` +
216
+ `of ${state.plan.declared.length} declared` +
217
+ (state.plan.cuesState ? `, Cues ${state.plan.cuesState}` : "") +
218
+ " — " +
247
219
  state.plan.tracks
248
220
  // `s:N` is the number the browser names (ffmpeg's own), and it differs
249
221
  // from the file's track number whenever a picture track sits among them.
250
222
  .map((track) => `s:${track.declaredIndex}=${track.trackNumber}:${track.language || "?"}` +
251
- `${track.name ? `/${track.name}` : ""}(${track.clusterPositions.length} indexed)`)
223
+ `${track.languageSource === "default" ? "(default)" : ""}` +
224
+ `${track.name ? `/${track.name}` : ""}(${(track.clusterPositions ?? track.samples ?? []).length} indexed)`)
252
225
  .join(" ")
253
226
  );
254
227
  }
@@ -260,15 +233,8 @@ async function readPlan(source, state) {
260
233
  *
261
234
  * A cue's TIME cannot serve as one. Cues are harvested out of whichever
262
235
  * clusters happen to be downloaded, and those are not contiguous, so the set
263
- * grows in the middle as well as at the end. A browser that remembered "the
264
- * latest time I hold" and asked for everything past it would never be sent the
265
- * cues that turn up BEHIND that mark afterwards — which is exactly the stretch
266
- * it is about to play. Measured 2026-08-20 on a viewer at 272 s: one answer
267
- * carried cues out to 1176 s, and from then on every cue between the two was
268
- * filtered away for the rest of the session, with 59 of 276 clusters read.
269
- *
270
- * Found-order is monotonic by construction, so `?since=<n>` is exact however
271
- * the file arrives.
236
+ * grows in the middle as well as at the end. Found-order is monotonic by
237
+ * construction, so `?since=<n>` is exact however the file arrives.
272
238
  *
273
239
  * @param {{ seq: Map<number, number> }} state
274
240
  * @param {number} trackNumber
@@ -280,12 +246,51 @@ function nextSeq(state, trackNumber) {
280
246
  return next;
281
247
  }
282
248
 
249
+ /**
250
+ * What may be read on this pass: the held ranges taken once, a strict read of
251
+ * them, the portion a read may span, and where the viewers stand.
252
+ *
253
+ * @param {HeldFile} source
254
+ * @returns {Promise<import("./container/Container.js").HeldReader>}
255
+ */
256
+ async function heldReaderOf(source) {
257
+ const ranges = await source.heldRanges();
258
+ const last = Number(source.length) - 1;
259
+ // Each read hands the loop back before the walk goes on. The answer comes from
260
+ // the torrent's thread, which replies faster than this thread empties its
261
+ // message port, and without the turn the port takes reply after reply in one
262
+ // go: measured on the addon host 2026-10-01, timers then fired 8-12 ms late
263
+ // at the 99th percentile during a walk, against 2-5 ms with it and 0-3 ms
264
+ // with no walk at all.
265
+ const read = strictReader(async (start, end) => {
266
+ try {
267
+ return await source.readHeld(start, Math.min(end, last));
268
+ } finally {
269
+ await new Promise((resolve) => setImmediate(resolve));
270
+ }
271
+ }, Number(source.length));
272
+ // Read again between clusters, so a pass can stop when a viewer has moved.
273
+ const wantedSeconds = () => {
274
+ try {
275
+ const stated = source.wantedSeconds?.();
276
+ return Array.isArray(stated) ? stated.filter((value) => Number.isFinite(value)) : [];
277
+ } catch {
278
+ return [];
279
+ }
280
+ };
281
+ return {
282
+ ranges,
283
+ isHeld: (start, end) => rangeHolds(ranges, start, Math.min(end, last)),
284
+ read,
285
+ portionBytes: Number.isFinite(source.portionBytes) && source.portionBytes > 0 ? source.portionBytes : Number.POSITIVE_INFINITY,
286
+ wantedSeconds
287
+ };
288
+ }
289
+
283
290
  /**
284
291
  * Every cue of one track that can be read from what is already downloaded.
285
292
  *
286
- * @param {object} torrent
287
- * @param {number} fileIndex
288
- * @param {string} sourceKey
293
+ * @param {HeldFile} source
289
294
  * @param {number} trackNumber
290
295
  * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
291
296
  */
@@ -294,9 +299,8 @@ export async function cuesHeldFor(source, trackNumber) {
294
299
  // A source that cannot say which of itself is downloaded makes every range
295
300
  // read as "not there", so the walk reads nothing and returns an empty list —
296
301
  // which is also what a file with no cues yet returns, and that is how this
297
- // went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
298
- // the browser served `WEBVTT` and nothing else). It is not repairable here,
299
- // so it is said rather than swallowed.
302
+ // went unnoticed for a session (2026-09-03). It is not repairable here, so it
303
+ // is said rather than swallowed.
300
304
  if (typeof source.heldRanges !== "function" || typeof source.readHeld !== "function") {
301
305
  logger.warn(
302
306
  `subtitles: asked for cues of "${String(source.name ?? key).slice(0, 40)}" ` +
@@ -318,7 +322,7 @@ export async function cuesHeldFor(source, trackNumber) {
318
322
  * The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
319
323
  * one file to one walk at a time.
320
324
  *
321
- * @param {import("./SubtitleCues.js").HeldFile} source
325
+ * @param {HeldFile} source
322
326
  * @param {object} state
323
327
  * @param {object} plan
324
328
  * @param {object} track
@@ -326,20 +330,24 @@ export async function cuesHeldFor(source, trackNumber) {
326
330
  * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
327
331
  */
328
332
  async function walkFor(source, state, plan, track, trackNumber) {
329
- // Taken once per pass: pieces keep arriving, and what may be read now is a
330
- // different list from what could be read when the plan was built.
331
- state.held = await source.heldRanges();
332
- const container = await containerOver(state, source);
333
+ const container = state.container;
333
334
  if (!container) {
334
335
  return { cues: [], coveredClusters: 0, indexedClusters: 0, track };
335
336
  }
337
+ // Taken once per pass: pieces keep arriving, and what may be read now is a
338
+ // different list from what could be read when the plan was built.
339
+ const held = await heldReaderOf(source);
340
+ const { found, covered, indexed, withdrawn, more, stats } = await container.readHeldCues(plan, track, state.progress, held);
341
+ if (more) {
342
+ // Readable clusters were left for the next pass — the ones viewers stand in
343
+ // were read first and are pushed now. Asked for here, and not by a timer:
344
+ // nothing else would run the walk again on a file downloaded whole.
345
+ source.askAgain?.();
346
+ }
336
347
 
337
- // One question, whichever container this is. Matroska walks the clusters its
338
- // Cues table names and fills every track from one walk; an MP4 reads the
339
- // samples its own table states for this track. Nothing here chooses between
340
- // them, which is the point: the container was chosen once, from the bytes.
341
- const { found, covered, indexed } = await container.readHeldCues(plan, track, state);
342
-
348
+ for (const at of withdrawn ?? []) {
349
+ takeBack(state, at);
350
+ }
343
351
  for (const [number, cues] of found) {
344
352
  let into = state.cues.get(number);
345
353
  if (!into) {
@@ -351,6 +359,7 @@ async function walkFor(source, state, plan, track, trackNumber) {
351
359
  }
352
360
  into.sort((left, right) => left.startSeconds - right.startSeconds);
353
361
  }
362
+ reportWalk(source, state, stats, covered, indexed);
354
363
 
355
364
  return {
356
365
  cues: state.cues.get(trackNumber) ?? [],
@@ -360,31 +369,80 @@ async function walkFor(source, state, plan, track, trackNumber) {
360
369
  };
361
370
  }
362
371
 
372
+ /**
373
+ * Take back every cue read from one position the container has withdrawn.
374
+ *
375
+ * @param {object} state
376
+ * @param {number} at
377
+ * @returns {void}
378
+ */
379
+ function takeBack(state, at) {
380
+ for (const [number, cues] of state.cues) {
381
+ const kept = [];
382
+ for (const cue of cues) {
383
+ if (cue.source === at) {
384
+ const list = state.withdrawn.get(number) ?? [];
385
+ list.push(cue.seq);
386
+ state.withdrawn.set(number, list);
387
+ } else {
388
+ kept.push(cue);
389
+ }
390
+ }
391
+ state.cues.set(number, kept);
392
+ }
393
+ }
394
+
395
+ /**
396
+ * One line per file at most once a minute: what the walk has read, how, and
397
+ * what it could not.
398
+ *
399
+ * @param {HeldFile} source
400
+ * @param {object} state
401
+ * @param {object | undefined} stats
402
+ * @param {number} covered
403
+ * @param {number} indexed
404
+ * @returns {void}
405
+ */
406
+ function reportWalk(source, state, stats, covered, indexed) {
407
+ if (!stats) {
408
+ return;
409
+ }
410
+ const now = Date.now();
411
+ const summary = JSON.stringify({ covered, indexed, ...stats });
412
+ if (summary === state.lastStats || now - state.lastReportMs < WALK_REPORT_INTERVAL_MS) {
413
+ return;
414
+ }
415
+ state.lastStats = summary;
416
+ state.lastReportMs = now;
417
+ logger.info(
418
+ `subtitles: walk of "${String(source.name).slice(0, 40)}" — ${covered} of ${indexed} known cluster(s) read, ` +
419
+ `${stats.fromSearch ?? 0} found by searching the bytes, ${stats.rejectedCandidates ?? 0} candidate(s) refused, ` +
420
+ `${stats.pendingCandidates ?? 0} waiting for bytes, ${stats.contradictions ?? 0} withdrawn, ` +
421
+ `${stats.unreadable ?? 0} unreadable, ${stats.refusedElements ?? 0} block(s) over one read, ` +
422
+ `${Math.round((stats.bytesWalked ?? 0) / 1048576)}MB read`
423
+ );
424
+ }
425
+
363
426
  /**
364
427
  * Walk whatever clusters have newly arrived, for every text track a file
365
428
  * carries, and report what is new since the last call — so the cues can be
366
429
  * PUSHED to a browser rather than left for it to come back and ask.
367
430
  *
368
- * `cuesHeldFor` already skips positions it has walked before (`state.walked`),
369
- * so calling this on a timer or on every verified piece is cheap once a file
370
- * is caught up: the only cost is deciding there is nothing new to read. It is
371
- * `getSubtitleCues` run ahead of being asked, on the same state that call
372
- * itself would build — nothing is duplicated, and a file nobody has opened
373
- * costs nothing beyond this.
431
+ * Only for a file somebody has asked about: its plan, its tracks or its cues.
432
+ * A file of the torrent nobody has opened costs nothing, and its head and Cues
433
+ * table are not fetched for this.
374
434
  *
375
- * @param {object} torrent
376
- * @param {number} fileIndex
377
- * @param {string} sourceKey
378
- * @returns {Promise<{ trackIndex: number, cues: object[], language: string }[]>}
379
- * One entry per track that gained at least one cue since the last call.
380
- * `trackIndex` is `declaredIndex` — the track's position among ALL the file's
381
- * subtitle tracks, which is ffmpeg's `0:s:N` and the only number the browser
382
- * knows. NOT the container's own track number, and not the position among the
383
- * readable tracks either: counting those alone puts every text track after a
384
- * picture-based one in the wrong place.
435
+ * @param {HeldFile} source
436
+ * @returns {Promise<{ trackIndex: number, cues: object[], withdrawn: number[], language: string }[]>}
437
+ * One entry per track that gained or lost a cue since the last call.
438
+ * `trackIndex` is `declaredIndex` — ffmpeg's `0:s:N`, the only number the
439
+ * browser knows.
385
440
  */
386
441
  export async function warmSubtitleCues(source) {
387
442
  const key = `${source.sourceKey}:${source.fileIndex}`;
443
+ if (!byFile.has(key) || byFile.get(key).forgotten === true) {
444
+ return [];
445
+ }
388
446
  const plan = await planFor(source, key);
389
447
  const state = stateFor(key);
390
448
  const fresh = [];
@@ -394,49 +452,75 @@ export async function warmSubtitleCues(source) {
394
452
  const held = await cuesHeldFor(source, track.trackNumber);
395
453
  const since = state.pushed.get(track.trackNumber) ?? 0;
396
454
  const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
397
- if (newCues.length === 0) {
455
+ const withdrawn = state.withdrawn.get(track.trackNumber) ?? [];
456
+ state.withdrawn.delete(track.trackNumber);
457
+ if (newCues.length === 0 && withdrawn.length === 0) {
398
458
  continue;
399
459
  }
400
460
  const highest = newCues.reduce((max, cue) => Math.max(max, Number(cue.seq) || 0), since);
401
461
  state.pushed.set(track.trackNumber, highest);
402
462
  const codecId = held.track?.codecId ?? track.codecId;
403
463
  const cues = TextSubtitleTrack.finalizeCues(newCues, codecId);
404
- fresh.push({
464
+ const entry = {
405
465
  // ffmpeg's own numbering, which is the only one the browser knows.
406
466
  trackIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
407
467
  cues,
468
+ withdrawn,
408
469
  language: held.track?.language ?? "",
409
470
  // What the CUES say the language is, re-read on every push over every cue
410
- // held so far rather than over this batch. A track whose container states
411
- // no language is unreadable at the start of a session — a handful of cues
412
- // is not a sample of a language, and the detector refuses to answer on one
413
- // — so the answer has to be re-taken as the film downloads, and the label
414
- // moved when it arrives. Costs about 6 ms per push, measured; pushes
415
- // arrive about once a second per file being read.
471
+ // held so far. Used by the browser only where the container states none.
416
472
  detectedLanguage: detectLanguage(
417
473
  TextSubtitleTrack.finalizeCues(held.cues, codecId).map((cue) => cue.text).join("\n")
418
474
  ),
419
475
  // Where the browser should resume from if it has to ask again — after a
420
476
  // reconnect, which loses the subscription these pushes ride on.
421
477
  cursor: highest,
422
- // What this batch is ABOUT, in film time, so a log can be read against
423
- // the position being played.
424
478
  spanStartSeconds: cues.length > 0 ? cues[0].startSeconds : null,
425
479
  spanEndSeconds: cues.length > 0 ? cues[cues.length - 1].endSeconds : null,
426
480
  walkedClusters: held.coveredClusters ?? 0,
427
481
  indexedClusters: held.indexedClusters ?? 0
428
- });
482
+ };
483
+ noteDisagreement(source, state, track, entry.detectedLanguage);
484
+ fresh.push(entry);
429
485
  }
430
486
  return fresh;
431
487
  }
432
488
 
489
+ /**
490
+ * Say once when the text reads as another language than the format's default
491
+ * the container fell back to.
492
+ *
493
+ * The label does not move: RFC 8794 §11.1.19 makes the default the file's own
494
+ * statement. A disagreement is a muxer that left the element out for a track
495
+ * that is not English, and this line is what counts how often that happens.
496
+ *
497
+ * @param {HeldFile} source
498
+ * @param {object} state
499
+ * @param {object} track
500
+ * @param {{ code: string } | null} detected
501
+ * @returns {void}
502
+ */
503
+ function noteDisagreement(source, state, track, detected) {
504
+ if (track.languageSource !== "default" || !detected?.code || state.disagreementSaid.has(track.trackNumber)) {
505
+ return;
506
+ }
507
+ const stated = String(track.language || "").toLowerCase();
508
+ const read = String(detected.code).toLowerCase();
509
+ if (stated === read || stated.startsWith(`${read}-`) || (stated === "eng" && read === "en")) {
510
+ return;
511
+ }
512
+ state.disagreementSaid.add(track.trackNumber);
513
+ logger.info(
514
+ `subtitles: "${String(source.name).slice(0, 40)}" track ${track.trackNumber} has no Language element, ` +
515
+ `so the file states ${track.language} by default, and its text reads as ${detected.code}`
516
+ );
517
+ }
518
+
433
519
  /**
434
520
  * The text subtitle tracks of a file, for the menu the viewer sees.
435
521
  *
436
- * @param {object} torrent
437
- * @param {number} fileIndex
438
- * @param {string} sourceKey
439
- * @returns {Promise<object[]>}
522
+ * @param {HeldFile} source
523
+ * @returns {Promise<object[]>} Empty while the plan's bytes have not arrived.
440
524
  */
441
525
  export async function subtitleTracksOf(source) {
442
526
  const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
@@ -445,24 +529,18 @@ export async function subtitleTracksOf(source) {
445
529
  declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
446
530
  codecId: track.codecId,
447
531
  language: track.language,
532
+ languageSource: track.languageSource ?? "",
448
533
  name: track.name,
449
534
  isDefault: track.isDefault,
450
- indexedClusters: track.clusterPositions.length
535
+ indexedClusters: (track.clusterPositions ?? track.samples ?? []).length
451
536
  }));
452
537
  }
453
538
 
454
539
  /**
455
540
  * What the container itself says about its subtitle tracks, in its own order
456
- * and including the picture-based ones.
541
+ * and including the picture-based ones — lined up against ffmpeg's `0:s:N`.
457
542
  *
458
- * Separate from `subtitleTracksOf`, which lists only what can be turned into
459
- * WebVTT and is indexed by position in the subtitle API. This one exists to be
460
- * lined up against ffmpeg's `0:s:N` numbering, which counts every subtitle
461
- * stream, so leaving the picture ones out would shift it.
462
- *
463
- * @param {object} torrent
464
- * @param {number} fileIndex
465
- * @param {string} sourceKey
543
+ * @param {HeldFile} source
466
544
  * @returns {Promise<object[]>}
467
545
  */
468
546
  export async function declaredSubtitleTracksOf(source) {
@@ -491,10 +569,7 @@ export function forgetSubtitles(sourceKey, fileIndex) {
491
569
  }
492
570
 
493
571
  /**
494
- * Drop one file's state, but not while a walk of it is still running: the
495
- * record of which clusters have been read lives in that state, and a walk left
496
- * writing into a discarded copy while a new one starts beside it is the one
497
- * path that defeats the serialization above.
572
+ * Drop one file's state, but not while a walk of it is still running.
498
573
  *
499
574
  * @param {string} key
500
575
  * @returns {void}
@@ -504,9 +579,6 @@ function forgetOne(key) {
504
579
  if (!state) {
505
580
  return;
506
581
  }
507
- // Held, so that a walk started before this call is not left orphaned; the
508
- // entry is dropped the moment the queue empties, and nothing is handed this
509
- // state in the meantime.
510
582
  state.forgotten = true;
511
583
  void state.chain.then(() => {
512
584
  if (byFile.get(key) === state) {