@torrent-tv/proxy 2.44.0 → 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,9 @@
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
+
1
7
  ## 2.44.0
2
8
 
3
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.44.0",
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": {
@@ -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