@torrent-tv/proxy 2.43.1 → 2.44.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.
@@ -99,7 +99,7 @@ function readHeld(file, start, end) {
99
99
  async function planFor(torrent, fileIndex, key) {
100
100
  let state = byFile.get(key);
101
101
  if (!state) {
102
- state = { plan: null, harvested: new Map(), cues: new Map() };
102
+ state = { plan: null, harvested: new Map(), cues: new Map(), seq: new Map(), walked: new Set() };
103
103
  byFile.set(key, state);
104
104
  }
105
105
  if (state.plan !== null) {
@@ -154,6 +154,31 @@ async function planFor(torrent, fileIndex, key) {
154
154
  return state.plan;
155
155
  }
156
156
 
157
+ /**
158
+ * The order a cue was FOUND in, which is the only cursor a browser can follow.
159
+ *
160
+ * A cue's TIME cannot serve as one. Cues are harvested out of whichever
161
+ * clusters happen to be downloaded, and those are not contiguous, so the set
162
+ * grows in the middle as well as at the end. A browser that remembered "the
163
+ * latest time I hold" and asked for everything past it would never be sent the
164
+ * cues that turn up BEHIND that mark afterwards — which is exactly the stretch
165
+ * it is about to play. Measured 2026-08-20 on a viewer at 272 s: one answer
166
+ * carried cues out to 1176 s, and from then on every cue between the two was
167
+ * filtered away for the rest of the session, with 59 of 276 clusters read.
168
+ *
169
+ * Found-order is monotonic by construction, so `?since=<n>` is exact however
170
+ * the file arrives.
171
+ *
172
+ * @param {{ seq: Map<number, number> }} state
173
+ * @param {number} trackNumber
174
+ * @returns {number}
175
+ */
176
+ function nextSeq(state, trackNumber) {
177
+ const next = (state.seq.get(trackNumber) ?? 0) + 1;
178
+ state.seq.set(trackNumber, next);
179
+ return next;
180
+ }
181
+
157
182
  /**
158
183
  * Every cue of one track that can be read from what is already downloaded.
159
184
  *
@@ -201,7 +226,12 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
201
226
  harvested.add(sample.offset);
202
227
  const text = decodeSubtitleSample(bytes, track.codecId);
203
228
  if (text) {
204
- cues.push({ startSeconds: sample.startSeconds, endSeconds: sample.endSeconds, text });
229
+ cues.push({
230
+ startSeconds: sample.startSeconds,
231
+ endSeconds: sample.endSeconds,
232
+ text,
233
+ seq: nextSeq(state, trackNumber)
234
+ });
205
235
  }
206
236
  }
207
237
  cues.sort((left, right) => left.startSeconds - right.startSeconds);
@@ -213,8 +243,24 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
213
243
  };
214
244
  }
215
245
 
216
- for (const position of track.clusterPositions) {
217
- if (harvested.has(position)) {
246
+ // ONE walk for the whole file, not one per track. A Matroska cluster carries
247
+ // the blocks of every track that has anything to say over its span, so the
248
+ // bytes that answer one track answer them all — and reading them once per
249
+ // track meant the same cluster was fetched and parsed as many times as the
250
+ // film has subtitle tracks. Measured 2026-08-20 on a film with five: five
251
+ // requests every fifteen seconds, each costing 0.2-5.2 s of container
252
+ // reading, for cues that together weigh a few kilobytes.
253
+ //
254
+ // The union of the tracks' cluster lists is what gets walked: each track's
255
+ // list comes from its own Cues entries, so they overlap but do not coincide.
256
+ const positions = new Set();
257
+ for (const candidate of plan.tracks) {
258
+ for (const position of candidate.clusterPositions ?? []) {
259
+ positions.add(position);
260
+ }
261
+ }
262
+ for (const position of [...positions].sort((left, right) => left - right)) {
263
+ if (state.walked.has(position)) {
218
264
  continue;
219
265
  }
220
266
  // The header first: it says how long the cluster is, and a cluster whose
@@ -225,7 +271,7 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
225
271
  const probe = await readHeld(file, position, Math.min(file.length - 1, position + CLUSTER_HEADER_PROBE - 1));
226
272
  const header = probe && [...iterateElements(probe, 0, probe.length)][0];
227
273
  if (!header || header.size <= 0 || header.size > MAX_CLUSTER_BYTES) {
228
- harvested.add(position); // not a cluster we can read; do not look again
274
+ state.walked.add(position); // not a cluster we can read; do not look again
229
275
  continue;
230
276
  }
231
277
  const last = Math.min(file.length - 1, position + header.dataOffset + header.size - 1);
@@ -236,18 +282,33 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
236
282
  if (!bytes) {
237
283
  continue;
238
284
  }
239
- harvested.add(position);
240
- for (const cue of harvestCluster(bytes, trackNumber, plan.secondsPerTick)) {
241
- cues.push(cue);
285
+ state.walked.add(position);
286
+ for (const candidate of plan.tracks) {
287
+ let into = state.cues.get(candidate.trackNumber);
288
+ if (!into) {
289
+ into = [];
290
+ state.cues.set(candidate.trackNumber, into);
291
+ }
292
+ let found = false;
293
+ for (const cue of harvestCluster(bytes, candidate.trackNumber, plan.secondsPerTick)) {
294
+ cue.seq = nextSeq(state, candidate.trackNumber);
295
+ into.push(cue);
296
+ found = true;
297
+ }
298
+ if (found) {
299
+ into.sort((left, right) => left.startSeconds - right.startSeconds);
300
+ }
242
301
  }
243
302
  }
244
- cues.sort((left, right) => left.startSeconds - right.startSeconds);
245
303
  return {
246
- cues,
247
- coveredClusters: harvested.size,
304
+ cues: state.cues.get(trackNumber) ?? [],
305
+ // Every track is filled by the same walk, so this is a fact about the FILE
306
+ // and reads the same whichever track asked.
307
+ coveredClusters: state.walked.size,
248
308
  indexedClusters: track.clusterPositions.length,
249
309
  track
250
310
  };
311
+
251
312
  }
252
313
 
253
314
  /**
@@ -0,0 +1,63 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ /**
5
+ * The rule this pins: a subtitle cursor counts the order cues were FOUND, not
6
+ * where they sit in the film.
7
+ *
8
+ * Cues are read out of whichever clusters happen to be downloaded, and a
9
+ * torrent does not arrive in film order — a seek pulls a later stretch first,
10
+ * and the earlier one fills in afterwards. So the set of known cues grows in
11
+ * the MIDDLE as well as at the end.
12
+ *
13
+ * A cursor in film time cannot survive that: measured 2026-08-20 on a viewer at
14
+ * 272 s, one answer carried cues out to 1176 s, and from that moment every cue
15
+ * between the two was filtered away for the rest of the session — the stretch
16
+ * they were about to watch. 59 of 276 clusters had been read.
17
+ *
18
+ * The filter below is the route's, written out so the property can be checked
19
+ * without a torrent: `?since=<n>` selects by found-order, `?after=<seconds>` is
20
+ * the old behaviour kept for an older browser.
21
+ */
22
+ const bySince = (cues, since) => cues.filter((cue) => (Number(cue.seq) || 0) > since);
23
+ const byAfter = (cues, after) => cues.filter((cue) => cue.startSeconds > after);
24
+
25
+ /** A late-arriving cluster from EARLIER in the film than what is already held. */
26
+ const held = [
27
+ { startSeconds: 40, text: "found first, early in the film", seq: 1 },
28
+ { startSeconds: 1176, text: "found second, far ahead", seq: 2 },
29
+ { startSeconds: 300, text: "found third, behind the furthest held", seq: 3 }
30
+ ];
31
+
32
+ test("a cue found after a further-ahead one is still delivered", () => {
33
+ // The browser holds seq 1 and 2 and asks for what came after.
34
+ const fresh = bySince(held, 2);
35
+ assert.deepEqual(fresh.map((cue) => cue.startSeconds), [300]);
36
+ });
37
+
38
+ test("the same case in film time loses that cue for ever", () => {
39
+ // This is what shipped in 2.43.1 and what the field session showed: the
40
+ // browser's furthest cue is 1176 s, so the 300 s cue can never reach it.
41
+ const fresh = byAfter(held, 1176);
42
+ assert.deepEqual(fresh.map((cue) => cue.startSeconds), []);
43
+ });
44
+
45
+ test("a browser asking for the first time is sent everything", () => {
46
+ assert.equal(bySince(held, 0).length, 3);
47
+ });
48
+
49
+ test("nothing new is answered with nothing", () => {
50
+ assert.deepEqual(bySince(held, 3), []);
51
+ });
52
+
53
+ test("the cursor to send back is the highest found-order held", () => {
54
+ const cursor = held.reduce((highest, cue) => Math.max(highest, Number(cue.seq) || 0), 0);
55
+ assert.equal(cursor, 3);
56
+ });
57
+
58
+ test("a cursor is unaffected by the order cues are sorted into", () => {
59
+ // The list is kept in film order for the WebVTT it becomes; the cursor must
60
+ // not depend on that.
61
+ const sorted = [...held].sort((left, right) => left.startSeconds - right.startSeconds);
62
+ assert.deepEqual(bySince(sorted, 2).map((cue) => cue.startSeconds), [300]);
63
+ });
@@ -39,6 +39,7 @@ function familyAtBoundaryTwo() {
39
39
  runState: ENCODE_RUN_STATE.PRODUCING,
40
40
  segmentBoundaries: [...BOUNDARIES],
41
41
  encodeStartIndex: 2,
42
+ runSerial: 0,
42
43
  audioRenditionSessions: new Map([[1, "sound"]]),
43
44
  indexCheck: null
44
45
  };
@@ -112,3 +113,29 @@ test("a member that is not running is left alone", () => {
112
113
  assert.equal(sound.runState, ENCODE_RUN_STATE.STOPPED);
113
114
  assert.notEqual(INITIAL_RUN_STATE, ENCODE_RUN_STATE.STOPPED);
114
115
  });
116
+
117
+ test("a soundtrack does not move the grid the picture is cut on", () => {
118
+ const { manager, picture, sound } = familyAtBoundaryTwo();
119
+ const pictureBefore = [...picture.segmentBoundaries];
120
+ const soundBefore = [...sound.segmentBoundaries];
121
+
122
+ // The sound reports where IT began, which is where it was asked to begin, to
123
+ // within one audio frame. The picture's own boundary is a keyframe of the
124
+ // file and can be seconds away from that — both readings correct, about
125
+ // different things.
126
+ //
127
+ // Field 2026-08-20: boundary #521 of one film was corrected 2086.084 →
128
+ // 2084.082 by the picture and 2084.082 → 2086.033 by the sound 1.6 s later,
129
+ // 1.951 s apart, each overwriting the other for as long as the film ran. The
130
+ // table never converged, so the guard that stops a correction the table
131
+ // already holds never fired.
132
+ manager.correctBoundaryFromSegment(sound, 2, 10.5);
133
+
134
+ assert.deepEqual(
135
+ picture.segmentBoundaries,
136
+ pictureBefore,
137
+ "the grid is the picture's cut list and a soundtrack may not move it"
138
+ );
139
+ assert.deepEqual(sound.segmentBoundaries, soundBefore);
140
+ assert.equal(picture.runSerial, 0, "and nothing is restarted on a soundtrack's say-so");
141
+ });