@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.43.2",
3
+ "version": "2.45.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- // Where the browser's copy of this track ends. It already holds every cue
104
- // before this second, so sending them again is bytes for nothing: measured
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 fresh = Number.isFinite(after)
294
- ? held.cues.filter((cue) => cue.startSeconds > after)
295
- : held.cues;
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 || !TEXT_CODECS.has(codecId)) {
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
- const boundaries = Array.isArray(session.segmentBoundaries) ? session.segmentBoundaries : [];
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.segmentBoundaries, safeIndex)
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 playlist says ${declaredStart.toFixed(3)}s — ` +
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