@torrent-tv/proxy 2.43.2 → 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.
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## 2.44.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: The cues a browser is missing are found by the order they were READ, not by where they sit in the film. Cues come out of whichever clusters are downloaded, and a torrent does not arrive in film order, so the set of known cues grows in the MIDDLE as well as at the end — and the cursor shipped in 2.43.1 was a time. Measured 2026-08-20: a viewer at 272 s was answered with cues out to 1176 s, and from that moment every cue between the two was filtered away for the rest of the session, with 59 of 276 clusters read. The subtitles the viewer was about to need had become unreachable, while cues fifteen minutes ahead kept arriving. Each cue now carries the order it was found in, `?since=<n>` selects by that, and the answer states the next cursor in `X-Subtitle-Cursor`. `?after=<seconds>` still works, for a browser that has not been reloaded.
|
|
4
|
+
- **Fix**: One walk over the container fills EVERY subtitle track, instead of one walk per track. A Matroska cluster carries the blocks of every track that has anything to say over its span, so the bytes that answer one track answer them all — but the set of clusters already read was kept per track, so the same bytes were fetched and parsed as many times as the film has subtitle tracks. On the field film that was five reads of everything, each costing 0.2-5.2 s, for cues that together weigh a few kilobytes. The union of the tracks' cluster lists is walked once and every track is filled from it, which is also why offering all of them costs no more than offering one.
|
|
5
|
+
|
|
1
6
|
## 2.43.2
|
|
2
7
|
|
|
3
8
|
- **Fix**: The proxy no longer dies without a word in the middle of a film. It was a segmentation fault in the uTP native library — `on_uv_read` parsed a sender address that a FAILED read never produced, and libuv passes null there — so a read error dereferenced a null pointer on the thread that owns the torrent client. Three core dumps in two days, each about three and a half hours into an otherwise healthy run, each with the same top frame; the last one on a swarm of 63-75 peers delivering 13 MB/s, one segment after a successful 158 Mbit/s send. Fixed in our build (`@torrent-tv/utp-native@2.5.3-ttv.3`, which this now depends on) and absent from upstream master. Detail: `research/utp-native-null-addr-2026-08-20.md`.
|
package/package.json
CHANGED
|
@@ -100,18 +100,30 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
100
100
|
// read that way. Costs no network at all and answers with the part of the
|
|
101
101
|
// film they are watching; the rest arrives as they watch it. Only when the
|
|
102
102
|
// container cannot be read this way does the old extraction run.
|
|
103
|
-
//
|
|
104
|
-
//
|
|
103
|
+
// How many cues this browser has already been sent, counted in the order
|
|
104
|
+
// they were FOUND. Sending them again is bytes for nothing: measured
|
|
105
105
|
// 2026-08-19, one track is 76 KB and the browser asked for it every few
|
|
106
106
|
// seconds while the film downloaded. Absent or unparsable means "send
|
|
107
107
|
// everything", which is what a browser asking for the first time wants.
|
|
108
|
+
//
|
|
109
|
+
// Found-order, not time. A cue's time cannot serve as a cursor here: the
|
|
110
|
+
// cues are read out of whichever clusters are downloaded, and those are not
|
|
111
|
+
// contiguous, so the set grows in the middle as well as at the end. The old
|
|
112
|
+
// `?after=<seconds>` therefore threw away every cue that turned up BEHIND
|
|
113
|
+
// the furthest one already sent — which is the stretch the viewer is about
|
|
114
|
+
// to watch. Measured 2026-08-20: a viewer at 272 s was sent cues out to
|
|
115
|
+
// 1176 s, and from that moment nothing between the two could ever reach
|
|
116
|
+
// them, with 59 of 276 clusters read. `after` is still honoured so an older
|
|
117
|
+
// browser keeps working.
|
|
118
|
+
const since = Number.parseInt(String(req.query?.since ?? ""), 10);
|
|
108
119
|
const after = Number.parseFloat(String(req.query?.after ?? ""));
|
|
109
120
|
const held = await cuesFromDownloadedClusters(
|
|
110
121
|
torrentPool,
|
|
111
122
|
torrent,
|
|
112
123
|
fileIndex,
|
|
113
124
|
trackIndex,
|
|
114
|
-
Number.isFinite(after) ? after : null
|
|
125
|
+
Number.isFinite(after) ? after : null,
|
|
126
|
+
Number.isInteger(since) ? since : null
|
|
115
127
|
);
|
|
116
128
|
if (held !== null) {
|
|
117
129
|
setLanguageHeaders(reply, held.language);
|
|
@@ -122,9 +134,11 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
122
134
|
// as playback moves into clusters that were not downloaded yet.
|
|
123
135
|
reply.header("X-Subtitle-Covered-Clusters", String(held.coveredClusters));
|
|
124
136
|
reply.header("X-Subtitle-Indexed-Clusters", String(held.indexedClusters));
|
|
137
|
+
// What to send back as `?since=` next time.
|
|
138
|
+
reply.header("X-Subtitle-Cursor", String(held.cursor));
|
|
125
139
|
reply.raw.setHeader(
|
|
126
140
|
"Access-Control-Expose-Headers",
|
|
127
|
-
"X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters"
|
|
141
|
+
"X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor"
|
|
128
142
|
);
|
|
129
143
|
return reply.send(held.vtt);
|
|
130
144
|
}
|
|
@@ -264,7 +278,7 @@ function readFileFully(file, maxBytes) {
|
|
|
264
278
|
* @returns {Promise<{ vtt: string, language: object | null, coveredClusters: number, indexedClusters: number } | null>}
|
|
265
279
|
* Null when this file cannot be read this way, and then the caller falls back.
|
|
266
280
|
*/
|
|
267
|
-
async function cuesFromDownloadedClusters(torrentPool, torrent, fileIndex, trackIndex, after = null) {
|
|
281
|
+
async function cuesFromDownloadedClusters(torrentPool, torrent, fileIndex, trackIndex, after = null, since = null) {
|
|
268
282
|
if (typeof torrentPool?.getSubtitleTracks !== "function") {
|
|
269
283
|
return null;
|
|
270
284
|
}
|
|
@@ -290,9 +304,12 @@ async function cuesFromDownloadedClusters(torrentPool, torrent, fileIndex, track
|
|
|
290
304
|
// Only what the browser does not have. The language is still detected from
|
|
291
305
|
// EVERYTHING held, because three new lines say much less about a language
|
|
292
306
|
// than the whole track does.
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
307
|
+
const cursor = held.cues.reduce((highest, cue) => Math.max(highest, Number(cue.seq) || 0), 0);
|
|
308
|
+
const fresh = Number.isInteger(since)
|
|
309
|
+
? held.cues.filter((cue) => (Number(cue.seq) || 0) > since)
|
|
310
|
+
: Number.isFinite(after)
|
|
311
|
+
? held.cues.filter((cue) => cue.startSeconds > after)
|
|
312
|
+
: held.cues;
|
|
296
313
|
const vtt = cuesToVtt(fresh, held.codecId);
|
|
297
314
|
const language = held.cues.length > 0
|
|
298
315
|
? detectLanguage(held.cues.map((cue) => cue.text).join("\n"))
|
|
@@ -300,6 +317,7 @@ async function cuesFromDownloadedClusters(torrentPool, torrent, fileIndex, track
|
|
|
300
317
|
return {
|
|
301
318
|
vtt,
|
|
302
319
|
language,
|
|
320
|
+
cursor,
|
|
303
321
|
coveredClusters: held.coveredClusters ?? 0,
|
|
304
322
|
indexedClusters: held.indexedClusters ?? 0
|
|
305
323
|
};
|
|
@@ -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({
|
|
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
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
for (const
|
|
241
|
-
cues.
|
|
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
|
-
|
|
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
|
+
});
|