@torrent-tv/proxy 2.43.2 → 2.45.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 +11 -0
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +26 -8
- package/services/container-index/matroska-subtitles.js +24 -4
- package/services/hls-session-manager.js +35 -3
- package/services/playback-planner.js +574 -538
- package/services/subtitle-defaults.js +131 -0
- package/services/torrent-worker/pool-adapter.js +18 -0
- package/services/torrent-worker/subtitle-cues.js +97 -12
- package/services/torrent-worker/worker.js +5 -2
- package/test/cuts-follow-published-grid.test.js +73 -0
- package/test/subtitle-cursor.test.js +63 -0
- package/test/subtitle-defaults.test.js +97 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.45.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: A run is cut where the PLAYER was told the cuts are. There are two boundary tables — the live one, corrected as produced segments reveal where the file's cuts truly are, and the one the playlist text was written from — and a player places every fragment by the text it holds, which never changes. The cut list handed to ffmpeg came from the live table, so every correction moved the run away from the timeline the player is reading: measured 2026-08-20 on `Minions.and.Monsters.1080p.mkv`, 119 of 125 produced segments arrived a uniform 2.002 s before the times their playlist named, against the 0.5 s hls.js bridges, and a fragment that does not land is fetched again — on 2026-08-17 two of them 1908 times each. A seek is resolved on the same table for the same reason: the time being resolved came out of that playlist. The corrections keep their purpose, which is to describe the FILE — a variant created later inherits the corrected table and publishes it, so its own playlist and its own cuts agree from the start — but they may no longer move the cuts of a session already being read.
|
|
4
|
+
- **New**: `FlagDefault` is read from the container itself rather than from ffmpeg's description of it. Matroska's flag DEFAULTS TO 1 and ffmpeg has applied that default by the time it prints `(default)`, so a file whose muxer marked no subtitle track is indistinguishable in the banner from one that marked every track — and the difference is the whole question, since one means "show this" and the other means "the file has no opinion". The EBML reader already walks the Tracks element for subtitle extraction and now also records whether the element was WRITTEN. Lining the two readings up is the awkward part and it is checked rather than assumed: ffmpeg numbers `0:s:N` over every subtitle stream in container order, so position is the correspondence, but each pair must agree on language or on title — one that agrees on neither, or a differing count, and the container reading is not used at all, leaving the probe's own flags with nothing claimed for them. `mergeContainerSubtitleFlags` is pure and tested.
|
|
5
|
+
- **Chore**: The line reporting a segment that began away from its grid said "the playlist says", while the figure it prints comes from the live table. Reading that log on 2026-08-20 cost a wrong diagnosis; it now says "the grid says".
|
|
6
|
+
|
|
7
|
+
## 2.44.0
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
|
|
1
12
|
## 2.43.2
|
|
2
13
|
|
|
3
14
|
- **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
|
};
|
|
@@ -92,7 +92,7 @@ function readString(buffer, element) {
|
|
|
92
92
|
*
|
|
93
93
|
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
94
94
|
* @param {number} fileSize
|
|
95
|
-
* @returns {Promise<{ tracks: SubtitleTrackPlan[], secondsPerTick: number, segmentDataOffset: number } | null>}
|
|
95
|
+
* @returns {Promise<{ tracks: SubtitleTrackPlan[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
|
|
96
96
|
*/
|
|
97
97
|
export async function readSubtitlePlan(readRange, fileSize) {
|
|
98
98
|
const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
|
|
@@ -124,6 +124,15 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
124
124
|
const tracksEnd = Math.min(head.length, tracksElement.dataOffset + tracksElement.size);
|
|
125
125
|
/** @type {SubtitleTrackPlan[]} */
|
|
126
126
|
const tracks = [];
|
|
127
|
+
/**
|
|
128
|
+
* Every subtitle track the file declares, in the order the Tracks element
|
|
129
|
+
* names them, text or picture. This is not for extraction — `tracks` is —
|
|
130
|
+
* but for lining ffmpeg's `0:s:N` numbering up against the container, which
|
|
131
|
+
* only holds while nothing is missing from the middle of the list.
|
|
132
|
+
*
|
|
133
|
+
* @type {Array<{ trackNumber: number, codecId: string, language: string, name: string, isDefault: boolean, declaresDefault: boolean }>}
|
|
134
|
+
*/
|
|
135
|
+
const declared = [];
|
|
127
136
|
for (const entry of iterateElements(head, tracksElement.dataOffset, tracksEnd)) {
|
|
128
137
|
if (entry.id !== ID_TRACK_ENTRY) {
|
|
129
138
|
continue;
|
|
@@ -135,7 +144,13 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
135
144
|
let language = "";
|
|
136
145
|
let name = "";
|
|
137
146
|
let codecPrivate = "";
|
|
147
|
+
// Matroska's `FlagDefault` DEFAULTS TO 1, so a file whose muxer wrote it on
|
|
148
|
+
// no track is indistinguishable, once the default has been applied, from
|
|
149
|
+
// one that wrote it on every track — which is how ffmpeg's banner prints it
|
|
150
|
+
// and why the banner cannot answer this. Both are kept: what the flag
|
|
151
|
+
// amounts to, and whether the file said anything at all.
|
|
138
152
|
let isDefault = true;
|
|
153
|
+
let declaresDefault = false;
|
|
139
154
|
for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
|
|
140
155
|
if (field.id === ID_TRACK_NUMBER) {
|
|
141
156
|
trackNumber = readUint(head, field.dataOffset, field.size);
|
|
@@ -149,11 +164,16 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
149
164
|
name = readString(head, field);
|
|
150
165
|
} else if (field.id === ID_FLAG_DEFAULT) {
|
|
151
166
|
isDefault = readUint(head, field.dataOffset, field.size) === 1;
|
|
167
|
+
declaresDefault = true;
|
|
152
168
|
} else if (field.id === ID_CODEC_PRIVATE) {
|
|
153
169
|
codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
|
|
154
170
|
}
|
|
155
171
|
}
|
|
156
|
-
if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null
|
|
172
|
+
if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
declared.push({ trackNumber, codecId, language, name, isDefault, declaresDefault });
|
|
176
|
+
if (!TEXT_CODECS.has(codecId)) {
|
|
157
177
|
continue;
|
|
158
178
|
}
|
|
159
179
|
tracks.push({
|
|
@@ -167,7 +187,7 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
167
187
|
});
|
|
168
188
|
}
|
|
169
189
|
if (tracks.length === 0) {
|
|
170
|
-
return { tracks, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
190
|
+
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
171
191
|
}
|
|
172
192
|
|
|
173
193
|
// Where the clusters holding those tracks are. A file that indexes only its
|
|
@@ -232,7 +252,7 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
232
252
|
}
|
|
233
253
|
}
|
|
234
254
|
}
|
|
235
|
-
return { tracks, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
255
|
+
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
236
256
|
}
|
|
237
257
|
|
|
238
258
|
/**
|
|
@@ -2434,6 +2434,21 @@ export class HlsSessionManager {
|
|
|
2434
2434
|
* @param {number} index
|
|
2435
2435
|
* @returns {number}
|
|
2436
2436
|
*/
|
|
2437
|
+
/**
|
|
2438
|
+
* The boundary table the player is working from: the one its playlist was
|
|
2439
|
+
* written from, falling back to the live table when no playlist was built
|
|
2440
|
+
* from a table at all (no duration, so no synthetic playlist — and then
|
|
2441
|
+
* nothing the player holds contradicts it).
|
|
2442
|
+
*
|
|
2443
|
+
* @param {HlsSession} session
|
|
2444
|
+
* @returns {number[]}
|
|
2445
|
+
*/
|
|
2446
|
+
publishedGridFor(session) {
|
|
2447
|
+
return Array.isArray(session.publishedBoundaries) && session.publishedBoundaries.length > 0
|
|
2448
|
+
? session.publishedBoundaries
|
|
2449
|
+
: (session.segmentBoundaries ?? []);
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2437
2452
|
#publishedStartTime(session, index) {
|
|
2438
2453
|
const boundaries = Array.isArray(session.publishedBoundaries) && session.publishedBoundaries.length > 0
|
|
2439
2454
|
? session.publishedBoundaries
|
|
@@ -2486,7 +2501,10 @@ export class HlsSessionManager {
|
|
|
2486
2501
|
* @returns {number}
|
|
2487
2502
|
*/
|
|
2488
2503
|
#segmentIndexForTime(session, t) {
|
|
2489
|
-
|
|
2504
|
+
// The player's grid, for the same reason the cut list uses it: the time
|
|
2505
|
+
// being resolved came from the playlist the player holds, so the index it
|
|
2506
|
+
// means is the index that playlist gives it.
|
|
2507
|
+
const boundaries = this.publishedGridFor(session);
|
|
2490
2508
|
if (boundaries.length < 2) {
|
|
2491
2509
|
return Math.max(0, Math.floor(t / this.segmentDurationSec));
|
|
2492
2510
|
}
|
|
@@ -3797,8 +3815,22 @@ export class HlsSessionManager {
|
|
|
3797
3815
|
// writes no self-contained pieces, so nothing could read a true start and
|
|
3798
3816
|
// segments were stamped with times the file does not have — the 4.17 s
|
|
3799
3817
|
// speech-against-subtitles drift, back again.
|
|
3818
|
+
//
|
|
3819
|
+
// Cut on the grid the PLAYER WAS GIVEN, not on the corrected one. A player
|
|
3820
|
+
// places a fragment by the playlist it holds, and that text was written
|
|
3821
|
+
// once and never changes; the live table keeps moving as produced segments
|
|
3822
|
+
// reveal where the file's cuts really are. Cutting on the moved table makes
|
|
3823
|
+
// every run faithful to a timeline nobody sent the player — measured
|
|
3824
|
+
// 2026-08-20, the picture's segments arriving a uniform 2.002 s before the
|
|
3825
|
+
// times the playlist named for them, which is four times what hls.js will
|
|
3826
|
+
// bridge, so the fragment does not land and is asked for again.
|
|
3827
|
+
//
|
|
3828
|
+
// The corrections keep their purpose: they describe the file, and a variant
|
|
3829
|
+
// created later inherits the corrected table and PUBLISHES it, so its own
|
|
3830
|
+
// playlist and its own cuts agree from the start. What they may not do is
|
|
3831
|
+
// move the cuts of a session whose playlist is already being read.
|
|
3800
3832
|
const gridCutTimes = explicitTimes && (!session.transcodeVideo || session.cutGrid === "keyframe")
|
|
3801
|
-
? segmentCutTimesFrom(session
|
|
3833
|
+
? segmentCutTimesFrom(this.publishedGridFor(session), safeIndex)
|
|
3802
3834
|
: null;
|
|
3803
3835
|
// Cut times are stated on the grid, for both branches.
|
|
3804
3836
|
//
|
|
@@ -5117,7 +5149,7 @@ export class HlsSessionManager {
|
|
|
5117
5149
|
logger.warn(
|
|
5118
5150
|
`transcode ${session.id} segment #${index} really starts at ` +
|
|
5119
5151
|
`${trueStart.toFixed(3)}s (boundary ${at === null ? "none" : `#${at}`}), ` +
|
|
5120
|
-
`the
|
|
5152
|
+
`the grid says ${declaredStart.toFixed(3)}s — ` +
|
|
5121
5153
|
(session.audioOnly === true
|
|
5122
5154
|
// A soundtrack is cut exactly where it was asked to be, so a
|
|
5123
5155
|
// disagreement here is not a reading about the file at all: it is the
|