@torrent-tv/proxy 2.37.0 → 2.38.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,11 @@
1
+ ## 2.38.0
2
+
3
+ - **Fix**: An MP4's keyframe times are read as composition times, on the track the handler names. Two faults, both measured on real releases over the swarm (`research/mp4-composition-times-2026-08-19.md`). (1) The reader took sample times from `stts`, which is DECODE order, and used neither `ctts` nor `elst`: ISO/IEC 14496-12 says `CT(n) = DT(n) + CTTS(n)` (§8.6.1.3) and the edit list then shifts that (§8.6.6.3). Every LostFilm MP4 measured carries a composition offset AND an edit list cancelling it exactly, which is why decode times had been right on them; `Firefly.S01E03.720p.mp4` carries the same 2002-tick offset with NO edit list, and its times were **62.1 ms early on all 34 keyframes** compared against ffmpeg's own `pts_time` — a constant that closes to four decimals as offset (0.08342 s) minus the container start (0.02133 s). After the fix that file matches ffmpeg to the container start, which `computeSegmentBoundaries` already subtracts, and `Superman.720p` — where the terms cancel — is unchanged and exact to 0.0000 s. Version 1 offsets are read as SIGNED, which is what that version exists for; an empty edit (`media_time = -1`) is skipped rather than treated as a shift. (2) The video track was "the first one carrying sync samples", and the handler was never read. That worked only because all seven measured files put video first; the standard identifies a track by `hdlr`, and a file whose audio track carries sync samples, or one leading with a cover-art video track, would have been read from the wrong place — the same defect fixed in the Matroska reader the day before, arrived at from the other side.
4
+
5
+ ## 2.37.1
6
+
7
+ - **Fix**: The cost of a seek no longer counts against the quality offer. `requiredSpeed` — the speed a step must sustain to survive a swarm — is built from the reader's interruptions, and the wait on the first piece after a JUMP is not one of them: those pieces have not been asked for yet and the encoder is restarting, so it measures the move, not the supply. Measured 2026-08-18: `proxy now offers 720p` landed 131 ms after a seek, collapsing a five-rung menu to one while the player was already hunting for a fragment, and another session churned `640p` → `640p 540p` → `640p 240p`. The wait is still reported, saying plainly that it belongs to the jump and is not counted, so a gap in the history cannot be mistaken for a swarm that never made the reader wait.
8
+
1
9
  ## 2.37.0
2
10
 
3
11
  - **Fix**: The segment the viewer seeks TO is no longer refused as stale. A seek bumps the wait epoch so requests made for the position being LEFT stop being held, and the epoch alone cannot tell those apart from the request for the position just arrived at — hls.js asks for it within milliseconds of the seek, and it raced the bump. Measured 2026-08-18: a seek to 1061.0 s, `segment-00101` answered 503 twice within 80 ms, the player never asked for it again, and instead re-fetched `a/0/segment-00103` and `a/0/segment-00104` **737 and 736 times over 149 seconds** — about half a gigabyte of the same two segments — while the picture stood at `t=1061.0s readyState=1` until the session ended. A held request is now released only when its segment lies behind where the viewer now is, or so far ahead that the running encode will not reach it; anything between is what the viewer is waiting for and is held.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.37.0",
3
+ "version": "2.38.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": {
@@ -149,7 +149,7 @@ function findAllBoxes(buffer, start, end, type) {
149
149
  * @param {Set<number>} wanted - Sample numbers (1-based).
150
150
  * @returns {number[]} Seconds, ascending.
151
151
  */
152
- function resolveSampleTimes(buffer, stts, timescale, wanted) {
152
+ function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
153
153
  const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
154
154
  const times = [];
155
155
  let sampleNumber = 1;
@@ -160,7 +160,10 @@ function resolveSampleTimes(buffer, stts, timescale, wanted) {
160
160
  const delta = buffer.readUInt32BE(cursor + 4);
161
161
  for (let index = 0; index < count; index += 1) {
162
162
  if (wanted.has(sampleNumber)) {
163
- times.push(ticks / timescale);
163
+ // `CT(n) = DT(n) + CTTS(n)` — ISO/IEC 14496-12 §8.6.1.3. The offset is
164
+ // what turns decode order into the order frames are shown in, and it is
165
+ // the timeline ffmpeg cuts on.
166
+ times.push((ticks + (offsets?.get(sampleNumber) ?? 0)) / timescale);
164
167
  }
165
168
  ticks += delta;
166
169
  sampleNumber += 1;
@@ -170,6 +173,100 @@ function resolveSampleTimes(buffer, stts, timescale, wanted) {
170
173
  return times;
171
174
  }
172
175
 
176
+ /**
177
+ * Composition offsets for the sample numbers asked for.
178
+ *
179
+ * `ctts` is run-length encoded like `stts`, and version 1 carries SIGNED
180
+ * offsets — which is what the version exists for: a frame may be shown before
181
+ * it is decoded. Reading them as unsigned turns a small negative offset into
182
+ * roughly four billion ticks.
183
+ *
184
+ * @param {Buffer} buffer
185
+ * @param {{ dataOffset: number, end: number }} ctts
186
+ * @param {Set<number>} wanted - Sample numbers (1-based).
187
+ * @returns {Map<number, number>} Sample number to offset in media ticks.
188
+ */
189
+ function readCompositionOffsets(buffer, ctts, wanted) {
190
+ const version = buffer[ctts.dataOffset];
191
+ const entryCount = buffer.readUInt32BE(ctts.dataOffset + 4);
192
+ const offsets = new Map();
193
+ let sampleNumber = 1;
194
+ let cursor = ctts.dataOffset + 8;
195
+ for (let entry = 0; entry < entryCount && cursor + 8 <= ctts.end; entry += 1) {
196
+ const count = buffer.readUInt32BE(cursor);
197
+ const offset = version === 1 ? buffer.readInt32BE(cursor + 4) : buffer.readUInt32BE(cursor + 4);
198
+ for (let index = 0; index < count; index += 1) {
199
+ if (wanted.has(sampleNumber)) {
200
+ offsets.set(sampleNumber, offset);
201
+ }
202
+ sampleNumber += 1;
203
+ }
204
+ cursor += 8;
205
+ }
206
+ return offsets;
207
+ }
208
+
209
+ /**
210
+ * How far the edit list shifts this track's composition timeline, in media
211
+ * ticks.
212
+ *
213
+ * ISO/IEC 14496-12 §8.6.6.3: `media_time` is the start of the edit within the
214
+ * media, in the MEDIA timescale and in composition time, while
215
+ * `segment_duration` is in the MOVIE timescale — two different units in one
216
+ * structure, which is why only the first is read here. `media_time = -1` is an
217
+ * empty edit: it inserts blank presentation time and starts no media, so the
218
+ * first real edit is the one that matters.
219
+ *
220
+ * Measured 2026-08-19: every LostFilm MP4 that carries a composition offset
221
+ * also carries an edit list cancelling it exactly, which is why decode times
222
+ * have been right on those files. `Firefly.S01E03` has the offset and NO edit
223
+ * list, and its times were 62.1 ms early on all 34 keyframes checked.
224
+ *
225
+ * @param {Buffer} buffer
226
+ * @param {{ dataOffset: number, end: number }} elst
227
+ * @returns {number} Ticks to subtract; zero when nothing is shifted.
228
+ */
229
+ function readEditShift(buffer, elst) {
230
+ const version = buffer[elst.dataOffset];
231
+ const entryCount = buffer.readUInt32BE(elst.dataOffset + 4);
232
+ const wide = version === 1;
233
+ const entryBytes = wide ? 20 : 12;
234
+ let cursor = elst.dataOffset + 8;
235
+ for (let entry = 0; entry < entryCount && cursor + entryBytes <= elst.end; entry += 1) {
236
+ const mediaTime = wide
237
+ ? Number(buffer.readBigInt64BE(cursor + 8))
238
+ : buffer.readInt32BE(cursor + 4);
239
+ if (mediaTime >= 0) {
240
+ return mediaTime;
241
+ }
242
+ cursor += entryBytes;
243
+ }
244
+ return 0;
245
+ }
246
+
247
+ /**
248
+ * Whether this track's handler says it carries video.
249
+ *
250
+ * The standard identifies a track by its `hdlr`, and nothing else does. Picking
251
+ * "the first track that happens to carry sync samples" worked only because the
252
+ * seven releases measured all put video first; a file whose audio track carries
253
+ * them, or one that leads with a cover-art video track, would be read from the
254
+ * wrong place. That is the same defect that was fixed in the Matroska reader on
255
+ * 2026-08-18, arrived at from the other side.
256
+ *
257
+ * @param {Buffer} buffer
258
+ * @param {{ dataOffset: number, end: number }} mdia
259
+ * @returns {boolean}
260
+ */
261
+ function isVideoTrack(buffer, mdia) {
262
+ const hdlr = findBox(buffer, mdia.dataOffset, mdia.end, "hdlr");
263
+ if (!hdlr || hdlr.dataOffset + 12 > hdlr.end) {
264
+ return false;
265
+ }
266
+ // FullBox header (4) then a reserved pre_defined (4), then the handler type.
267
+ return buffer.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) === "vide";
268
+ }
269
+
173
270
  /**
174
271
  * Read the keyframe times of an MP4/MOV file.
175
272
  *
@@ -189,12 +286,12 @@ export async function readMp4KeyframeTimes(readRange, fileSize) {
189
286
  return null;
190
287
  }
191
288
 
192
- // Examine every track; the video one is whichever carries sync samples. A
289
+ // Examine every track, and take the one whose HANDLER says it is video. A
193
290
  // track with no `stss` has every sample a keyframe, so it constrains nothing
194
- // and is skipped.
291
+ // and is skipped even when it is the video one.
195
292
  for (const trak of findAllBoxes(moov, moovBox.headerBytes, moov.length, "trak")) {
196
293
  const mdia = findBox(moov, trak.dataOffset, trak.end, "mdia");
197
- if (!mdia) {
294
+ if (!mdia || !isVideoTrack(moov, mdia)) {
198
295
  continue;
199
296
  }
200
297
  const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
@@ -237,9 +334,24 @@ export async function readMp4KeyframeTimes(readRange, fileSize) {
237
334
  continue;
238
335
  }
239
336
 
240
- const times = resolveSampleTimes(moov, stts, timescale, wanted);
337
+ // The two terms that turn decode times into the timeline ffmpeg cuts on.
338
+ // Both are optional: a file without them is one whose decode and
339
+ // composition orders already agree, and then nothing is added or taken.
340
+ const ctts = findBox(moov, stbl.dataOffset, stbl.end, "ctts");
341
+ const offsets = ctts ? readCompositionOffsets(moov, ctts, wanted) : null;
342
+ const edts = findBox(moov, trak.dataOffset, trak.end, "edts");
343
+ const elst = edts && findBox(moov, edts.dataOffset, edts.end, "elst");
344
+ const editShift = elst ? readEditShift(moov, elst) : 0;
345
+
346
+ const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets);
241
347
  if (times.length > 0) {
242
- return times;
348
+ // A shift applied after the division would be in the wrong units: the
349
+ // edit's `media_time` is in MEDIA ticks, like everything else here.
350
+ const shifted = editShift === 0 ? times : times.map((time) => time - editShift / timescale);
351
+ // A negative time is not a position in the file. It happens when an edit
352
+ // starts later than a keyframe the table lists, and those frames are not
353
+ // presented at all.
354
+ return shifted.filter((time) => time >= 0);
243
355
  }
244
356
  }
245
357
  return null;
@@ -484,6 +484,20 @@ export async function* readFragments({
484
484
  // process.
485
485
  const readerId = `read-${(readerSequence += 1)}`;
486
486
 
487
+ /**
488
+ * Set when the window JUMPS, cleared by the first wait after it.
489
+ *
490
+ * The wait that follows a jump is the cost of the jump: the pieces at the new
491
+ * position have not been asked for yet, and the encoder is restarting. It is
492
+ * not evidence about how well this swarm SUSTAINS a read, which is the only
493
+ * thing `requiredSpeed` is about — and letting it in is what collapsed the
494
+ * quality offer 131 ms after the seek measured on 2026-08-18, refusing every
495
+ * re-encoded rung on the strength of one jump.
496
+ *
497
+ * @type {boolean}
498
+ */
499
+ let waitBelongsToJump = false;
500
+
487
501
  const moveWindowTo = (pieceIndex) => {
488
502
  const next = readWindowFor({ pieceIndex, lastPiece, windowPieces });
489
503
  if (window && window.from === next.from && window.to === next.to) {
@@ -501,6 +515,7 @@ export async function* readFragments({
501
515
  // fills the store while the encoder runs ahead of the viewer.
502
516
  store.protectRange?.(readerId, next.from, next.to);
503
517
  if (isJump) {
518
+ waitBelongsToJump = true;
504
519
  // A jump — a seek, not the window sliding along — can land on pieces that
505
520
  // are already downloaded but have been spilled to disk. Bring the whole
506
521
  // window back at once instead of one disk round trip per piece as the
@@ -603,7 +618,18 @@ export async function* readFragments({
603
618
  // short, an immediate hit means it is longer than it needs to be. Applied
604
619
  // before the logging below so the line reports the window the next piece
605
620
  // will actually use.
606
- noteSupplyWait(`${torrent?.infoHash ?? "?"}/${file?.name ?? "?"}`, file?.name ?? "", waitedMs);
621
+ if (waitBelongsToJump) {
622
+ // Recorded nowhere: see `waitBelongsToJump`. Said out loud, because a
623
+ // gap in the supply history is otherwise indistinguishable from a swarm
624
+ // that never made the reader wait.
625
+ logger.info(
626
+ `piece-reader: ${waitedMs}ms on the first piece after a jump — the cost of moving, ` +
627
+ `not of this swarm's supply, so it is not counted against the quality offer`
628
+ );
629
+ waitBelongsToJump = false;
630
+ } else {
631
+ noteSupplyWait(`${torrent?.infoHash ?? "?"}/${file?.name ?? "?"}`, file?.name ?? "", waitedMs);
632
+ }
607
633
  const widened = nextWindowPieces({
608
634
  current: windowPieces,
609
635
  base: basePieces,