@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
|
@@ -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
|
-
|
|
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
|
|
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
|
+
});
|