@torrent-tv/proxy 2.73.0 → 2.73.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 2.73.1
2
+
3
+ - **Fix**: The catch-up pull for embedded subtitles is answered by the thread that owns the torrent, so a viewer no longer loses the beginning of an episode. Field 2026-09-03 on `[HorribleSubs] Drifters - 04 [1080p].mkv`: the file had been downloaded in an earlier sitting, so the worker's cluster walk found cues 1.5 s after it was opened and pushed four batches — cursor 1 to 31, everything up to 81.7 s — before the browser had subscribed. The browser then did the pull that exists for exactly that case and got a seven-byte `WEBVTT` with `x-subtitle-covered-clusters: 0` against 283 indexed; it held 330 cues spanning 81.7-1310.2 s, which is precisely the first push it saw. The parsing was never at fault — the same file put through this proxy's own reader produces clean English from 5.4 s onward — the pull was simply running on the MAIN thread, where the torrent is a stand-in with no `bitfield` and no `pieceLength`, so every range read as "not downloaded" and nothing was walked. It goes to the worker now, over the command that already existed and had no caller. That also settles a second thing: the found-order cursor the browser follows comes from ONE register, so a cursor from a pull and a cursor from a push are comparable, which two separate walks could not guarantee.
4
+ - **Fix**: A walk asked of a torrent that cannot say which pieces it holds says so in the log instead of returning an empty document. Emptiness is the right answer for a file with no cues yet, and that is how the defect above stayed invisible for a whole session.
5
+
1
6
  ## 2.73.0
2
7
 
3
8
  - **New**: `Container.readMediaInfo()` — what a file declares about ITSELF: its format, its duration, and where its own timeline begins. Matroska reads Duration and TimestampScale from `Info` and takes the start from the first Cluster's timestamp (RFC 9559 states no start-time element, so that IS the start); MP4 reads `mvhd` and the empty edit of `elst` (ISO/IEC 14496-12 §8.6.6); AVI multiplies microseconds per frame by the frame count. `null` means the container does not declare the field, which is a final answer rather than "ask someone else".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.73.0",
3
+ "version": "2.73.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -84,7 +84,7 @@ export class SubtitleController {
84
84
  const target = track ?? domainTracks.find((t) => t.declaredIndex === idx) ?? null;
85
85
  const trackNumber = target?.trackNumber ?? track?.trackNumber;
86
86
  if (trackNumber != null) {
87
- held = await this.orchestrator.getCues(torrent, fileIndex, sourceKey, trackNumber);
87
+ held = await this.orchestrator.getCues(this.torrentPool, torrent, fileIndex, sourceKey, trackNumber);
88
88
  }
89
89
  } catch {}
90
90
  if (held && Array.isArray(held.cues)) {
@@ -13,7 +13,6 @@
13
13
 
14
14
  import { containerOrchestrator } from "./ContainerOrchestrator.js";
15
15
  import {
16
- cuesHeldFor as domainCuesHeldFor,
17
16
  warmSubtitleCues as domainWarm,
18
17
  subtitleTracksOf,
19
18
  declaredSubtitleTracksOf,
@@ -65,18 +64,78 @@ export class SubtitleOrchestrator {
65
64
  }
66
65
 
67
66
  /**
68
- * Cues already downloaded for one track.
67
+ * Cues already downloaded for one track — ASKED OF THE TORRENT WORKER, never
68
+ * walked here.
69
+ *
70
+ * The walk decides what it may read from `torrent.bitfield` and
71
+ * `torrent.pieceLength`, and a torrent stand-in on the main thread has
72
+ * neither: it carries `infoHash`, `name` and a `files` list whose reads go
73
+ * back across the boundary (`torrent-worker/client.js`). So the same code
74
+ * called here answers that nothing is downloaded, walks no clusters, and
75
+ * returns an empty document — which is not a failure anything reports,
76
+ * because "no cues held" is a legitimate answer.
77
+ *
78
+ * Field 2026-09-03, and it is the whole reason this method changed. An
79
+ * episode already downloaded from an earlier sitting had its cues found
80
+ * within a second and a half of the file being opened, and the first four
81
+ * pushes — everything before 81.7 s — went out before the browser had
82
+ * subscribed. The catch-up pull that exists for exactly that case answered
83
+ * `WEBVTT` and nothing else, with `x-subtitle-covered-clusters: 0` against
84
+ * 283 indexed, so the viewer watched the opening of the episode with no
85
+ * subtitles and the rest of it with them.
86
+ *
87
+ * There is a second reason, independent of the bitfield. The register of what
88
+ * has been walked, what has been found and in what ORDER lives in the module
89
+ * that does the walking, and the worker already keeps one — the push path
90
+ * fills it. Walking again on the main thread would build a SECOND register
91
+ * with its own `seq` counter, and the browser mixes the cursors from both
92
+ * paths (`#rememberCursor`): two counters would make a cursor from a pull and
93
+ * a cursor from a push incomparable. One register, one walk, one cursor.
94
+ *
95
+ * @param {{ getSubtitleCues?: Function }} pool - The torrent pool, which is
96
+ * what holds the channel to the worker.
69
97
  * @param {object} torrent
70
98
  * @param {number} fileIndex
71
99
  * @param {string} sourceKey
72
100
  * @param {number} trackNumber - Container trackNumber
101
+ * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
73
102
  */
74
- async getCues(torrent, fileIndex, sourceKey, trackNumber) {
103
+ async getCues(pool, torrent, fileIndex, sourceKey, trackNumber) {
104
+ // Built fresh on each of the three paths that need it. One shared literal
105
+ // returned by reference would hand every caller the same array, and a
106
+ // single one of them appending to it would change what the next caller
107
+ // reads — which in a method about cue registers not leaking into each
108
+ // other would be a poor thing to introduce.
109
+ const empty = () => ({ cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
110
+ if (typeof pool?.getSubtitleCues !== "function") {
111
+ // Nothing here can read pieces, and answering an empty document would be
112
+ // indistinguishable from a file that genuinely holds no cues.
113
+ logger.warn(
114
+ "subtitle-orchestrator: the torrent pool cannot be asked for cues, " +
115
+ "so none can be served — the walk needs the thread that owns the torrent"
116
+ );
117
+ return empty();
118
+ }
75
119
  try {
76
- return await domainCuesHeldFor(torrent, fileIndex, sourceKey, trackNumber);
120
+ const answer = await pool.getSubtitleCues(torrent, fileIndex, trackNumber);
121
+ if (!answer) {
122
+ return empty();
123
+ }
124
+ return {
125
+ cues: Array.isArray(answer.cues) ? answer.cues : [],
126
+ coveredClusters: answer.coveredClusters ?? 0,
127
+ indexedClusters: answer.indexedClusters ?? 0,
128
+ // The worker answers with the track's own fields flat, because a
129
+ // `ContainerTrack` is a class and only plain objects cross the boundary.
130
+ track: {
131
+ codecId: answer.codecId ?? "",
132
+ codecPrivate: answer.codecPrivate ?? "",
133
+ language: answer.language ?? ""
134
+ }
135
+ };
77
136
  } catch (e) {
78
137
  logger.warn(`subtitle-orchestrator: getCues failed: ${e?.message ?? e}`);
79
- return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
138
+ return empty();
80
139
  }
81
140
  }
82
141
 
@@ -311,6 +311,21 @@ function nextSeq(state, trackNumber) {
311
311
  */
312
312
  export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
313
313
  const key = `${sourceKey}:${fileIndex}`;
314
+ // A torrent that cannot say which pieces it holds makes every range read as
315
+ // "not downloaded", so the walk reads nothing and returns an empty list —
316
+ // which is also what a file with no cues yet returns, and that is how this
317
+ // went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
318
+ // the browser served `WEBVTT` and nothing else). The stand-in the main thread
319
+ // holds is exactly such a torrent; only the thread that owns the object has
320
+ // the bitfield. Nothing here can repair that, so it says so instead.
321
+ if (!torrent?.bitfield || !(Number(torrent?.pieceLength) > 0)) {
322
+ logger.warn(
323
+ `subtitles: asked for cues of "${String(torrent?.name ?? sourceKey).slice(0, 40)}" ` +
324
+ "on a torrent that cannot say which pieces it holds — no cluster can be read here, " +
325
+ "and the answer would be an empty document indistinguishable from a file with no cues"
326
+ );
327
+ return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
328
+ }
314
329
  const plan = await planFor(torrent, fileIndex, key);
315
330
  const state = stateFor(key);
316
331
  const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file WHICH THREAD answers a pull for subtitle cues.
3
+ *
4
+ * Field 2026-09-03, on `[HorribleSubs] Drifters - 04 [1080p].mkv`. The file had
5
+ * been downloaded in an earlier sitting, so the torrent worker's cluster walk
6
+ * found cues 1.5 s after the file was opened and pushed four batches — cursor 1
7
+ * to 31, covering everything up to 81.7 s — before the browser had subscribed.
8
+ * The browser's catch-up pull, which exists for exactly that case, answered a
9
+ * seven-byte `WEBVTT` with `x-subtitle-covered-clusters: 0` against 283
10
+ * indexed. So the viewer watched the first 82 s with no subtitles and the rest
11
+ * of the episode with them.
12
+ *
13
+ * The pull ran on the MAIN thread, where the torrent is a stand-in carrying
14
+ * `infoHash`, `name` and a `files` list — no `bitfield`, no `pieceLength`. The
15
+ * walk decides what it may read from those two, so every range read as "not
16
+ * downloaded", nothing was walked, and an empty document came back. An empty
17
+ * document is also the right answer for a file that holds no cues yet, which is
18
+ * why nothing reported a failure.
19
+ *
20
+ * These checks pin the two halves of the repair: the pull is addressed to the
21
+ * thread that owns the torrent, and a walk asked of a torrent that cannot say
22
+ * what it holds says so rather than answering emptily.
23
+ */
24
+
25
+ import test from "node:test";
26
+ import assert from "node:assert/strict";
27
+
28
+ import { SubtitleOrchestrator } from "../services/orchestrators/SubtitleOrchestrator.js";
29
+ import { cuesHeldFor } from "../services/torrent-worker/subtitle-cues.js";
30
+
31
+ /** The stand-in the main thread holds: a name, a file list, and no pieces. */
32
+ function mainThreadTorrent() {
33
+ return {
34
+ infoHash: "0".repeat(40),
35
+ name: "[HorribleSubs] Drifters - 04 [1080p].mkv",
36
+ sourceKey: "a".repeat(40),
37
+ files: [{ name: "[HorribleSubs] Drifters - 04 [1080p].mkv", length: 567_535_843, createReadStream: () => { throw new Error("not reached"); } }]
38
+ };
39
+ }
40
+
41
+ test("a pull is answered by the thread that owns the torrent, not walked here", async () => {
42
+ const asked = [];
43
+ const pool = {
44
+ async getSubtitleCues(torrent, fileIndex, trackNumber) {
45
+ asked.push({ sourceKey: torrent.sourceKey, fileIndex, trackNumber });
46
+ return {
47
+ cues: [{ startSeconds: 5.4, endSeconds: 9.1, text: "So what if you brought them over?", seq: 1 }],
48
+ coveredClusters: 283,
49
+ indexedClusters: 283,
50
+ codecId: "S_TEXT/ASS",
51
+ codecPrivate: "",
52
+ language: ""
53
+ };
54
+ }
55
+ };
56
+
57
+ const orchestrator = new SubtitleOrchestrator({ forget() {} });
58
+ const held = await orchestrator.getCues(pool, mainThreadTorrent(), 33, "a".repeat(40), 3);
59
+
60
+ assert.deepEqual(asked, [{ sourceKey: "a".repeat(40), fileIndex: 33, trackNumber: 3 }]);
61
+ assert.equal(held.cues.length, 1);
62
+ assert.equal(held.coveredClusters, 283, "the walk's own figure travels back, so the header cannot claim 0 of 283");
63
+ // The worker answers with the track's fields flat — only plain objects cross
64
+ // the boundary — and the caller reads them through `held.track`.
65
+ assert.equal(held.track.codecId, "S_TEXT/ASS");
66
+ });
67
+
68
+ test("the cursor of a pulled cue is the found-order the worker assigned", async () => {
69
+ // The browser mixes cursors from pulls and pushes. Walking a second time on
70
+ // another thread would start a second `seq` counter and the two would not be
71
+ // comparable, which is the deeper reason the pull is not served locally.
72
+ const pool = {
73
+ async getSubtitleCues() {
74
+ return {
75
+ cues: [
76
+ { startSeconds: 5.4, endSeconds: 9.1, text: "one", seq: 1 },
77
+ { startSeconds: 81.7, endSeconds: 84.0, text: "two", seq: 31 }
78
+ ],
79
+ coveredClusters: 12,
80
+ indexedClusters: 283,
81
+ codecId: "S_TEXT/ASS",
82
+ codecPrivate: "",
83
+ language: ""
84
+ };
85
+ }
86
+ };
87
+ const orchestrator = new SubtitleOrchestrator({ forget() {} });
88
+ const held = await orchestrator.getCues(pool, mainThreadTorrent(), 33, "a".repeat(40), 3);
89
+ assert.deepEqual(held.cues.map((cue) => cue.seq), [1, 31]);
90
+ });
91
+
92
+ test("a pool with no channel to the worker is refused, not answered emptily", async () => {
93
+ const orchestrator = new SubtitleOrchestrator({ forget() {} });
94
+ const held = await orchestrator.getCues({}, mainThreadTorrent(), 33, "a".repeat(40), 3);
95
+ assert.deepEqual(held, { cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
96
+ });
97
+
98
+ test("the walk refuses a torrent that cannot say which pieces it holds", async () => {
99
+ // Called directly, as the main thread used to call it. Without this guard the
100
+ // answer is an empty list indistinguishable from a file with no cues, which
101
+ // is what hid the defect for a whole session.
102
+ const held = await cuesHeldFor(mainThreadTorrent(), 33, "b".repeat(40), 3);
103
+ assert.deepEqual(held, { cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
104
+ });