@torrent-tv/proxy 2.9.120 → 2.9.122

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,12 @@
1
+ ## 2.9.122
2
+
3
+ - **Fix**: A session created with a start position begins encoding there, instead of at the top of the file. The position was honoured everywhere except the one place that mattered: it went into the session key and into the log line, and then the first run started at index 0 regardless. Measured 2026-08-06 on a Retry after the proxy had restarted — the session was created with `start=1580s`, the encoder began at #0, the player asked for #152, and 45 s later the browser gave up with "no data arrived from the proxy" while the transcode ran happily at 9.9x through the opening credits.
4
+
5
+ ## 2.9.121
6
+
7
+ - **Fix**: A segment that is short of a track is no longer served, which is what left a seek hanging with the proxy answering every request in 98 ms. A run that is terminated closes its current output file properly — trailing index and all — but the file holds only what had been muxed by then, and after a seek-restart that is routinely one track of two. Nothing about such a file looks unfinished: it exists, the next one exists, so the readiness rule called it done. Measured 2026-08-06 on a stuck session: segment #133 carried one `tfdt` where #131 and #134 carried two, and #132 was zero bytes. The fragments are already walked to stamp their timestamps, so counting the tracks in them costs nothing; a segment missing one is deleted and produced again.
8
+ - **New**: The session-start line says where the browser asked the encoder to begin. A resume that reaches hls.js but not this call makes the player request a segment nobody was told to produce — measured the same day, the session began at #0 while the player asked for #127 and gave up 45.6 s later — and neither side said what it meant.
9
+
1
10
  ## 2.9.120
2
11
 
3
12
  - **Fix**: The rest of the file is fetched while the viewer needs nothing, instead of the link sitting idle. The background fill was re-evaluated only when a reader window MOVED, so during exactly the state it exists for — the encoder held back by the look-ahead cap, the viewer comfortably ahead, the link free — nothing was fetched at all. It is now owned by the pool's own timer rather than by the reader, because a parked reader cannot act, and parked is the whole point. Priority 0 against the window's 1, and withdrawn the moment any reader window wants something, so it can never take capacity from the picture.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.120",
3
+ "version": "2.9.122",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { createReadStream, readdirSync } from "node:fs";
11
- import { access, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
11
+ import { access, mkdir, readdir, readFile, rm, stat, unlink } from "node:fs/promises";
12
12
  import { Readable } from "node:stream";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
@@ -1430,6 +1430,12 @@ export class HlsSessionManager {
1430
1430
  // one of these, so "is the host actually running the build I published?"
1431
1431
  // is answered by the log itself instead of a round trip to the machine.
1432
1432
  `transcode ${sessionId} start (proxy ${PROXY_VERSION}) "${logName}" ` +
1433
+ // Where the browser asked the encoder to begin. A resume that reaches
1434
+ // hls.js but not this call makes the player request a segment nobody
1435
+ // was told to produce: measured 2026-08-06, the session began at #0
1436
+ // while the player asked for #127 and gave up 45.6 s later. Neither
1437
+ // side saying what it meant is why that took three attempts to place.
1438
+ `start=${Math.round(normalizedStartPosition)}s ` +
1433
1439
  `video=${transcodeVideo ? `${this.videoEncoder.name}${softwarePreset ? `/${softwarePreset}` : ""}` : "copy"} ` +
1434
1440
  `audio=${transcodeAudio ? "aac" : "copy"} ` +
1435
1441
  // Branch tag for log correlation: A = video re-encode (fixed GOP, grid
@@ -1448,7 +1454,18 @@ export class HlsSessionManager {
1448
1454
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
1449
1455
  );
1450
1456
 
1451
- await this.#startEncodeRun(session, 0);
1457
+ // Begin where the viewer asked, not at the top of the file. The position
1458
+ // was already honoured everywhere EXCEPT here: it went into the session key
1459
+ // and into the log line, and then the first run started at index 0 anyway.
1460
+ // Measured 2026-08-06 on a Retry after the proxy restarted — the session
1461
+ // was created with `start=1580s`, the encoder began at #0, the player
1462
+ // asked for #152, and 45 s later the browser gave up with "no data arrived
1463
+ // from the proxy" while the transcode ran happily at 9.9x through the
1464
+ // opening credits.
1465
+ const firstIndex = normalizedStartPosition > 0
1466
+ ? this.#segmentIndexForTime(session, normalizedStartPosition)
1467
+ : 0;
1468
+ await this.#startEncodeRun(session, firstIndex);
1452
1469
 
1453
1470
  try {
1454
1471
  await this.waitUntilReady(session);
@@ -3388,6 +3405,30 @@ export class HlsSessionManager {
3388
3405
  const bytes = session.usesExplicitCuts && session.segmentFormat.stripInit
3389
3406
  ? session.segmentFormat.stripInit(raw)
3390
3407
  : raw;
3408
+ // A segment that is short of a track is not servable, whatever the
3409
+ // directory says about it. A terminated run closes its current output
3410
+ // file properly — trailing index and all — but with only what had been
3411
+ // muxed by then, and after a seek-restart that is routinely one track
3412
+ // of two. The file exists and so does the next one, so the readiness
3413
+ // rule calls it done. Measured 2026-08-06: segment #133 held one track
3414
+ // where its neighbours held two, the proxy answered every request for
3415
+ // it in 98 ms, and the viewer's seek never completed. Treated as not
3416
+ // ready, so the encoder makes it again.
3417
+ if (
3418
+ typeof session.segmentFormat.hasEveryTrack === "function" &&
3419
+ !session.segmentFormat.hasEveryTrack(bytes, session.initBytes ?? null)
3420
+ ) {
3421
+ logger.warn(
3422
+ `transcode ${session.id} segment #${index} is short of a track — ` +
3423
+ "left behind by a run that was terminated; producing it again"
3424
+ );
3425
+ try {
3426
+ await unlink(filePath);
3427
+ } catch {
3428
+ // Already gone, or being rewritten: either way nothing to do.
3429
+ }
3430
+ return { kind: "warming-up" };
3431
+ }
3391
3432
  const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
3392
3433
  startSeconds: this.#segmentStartTime(session, index),
3393
3434
  initBytes: session.initBytes ?? null
@@ -10,7 +10,23 @@
10
10
  * See {@link SegmentFormat} in `./index.js` for the interface contract.
11
11
  */
12
12
 
13
- import { readTrackTimescales, stampSegmentStartTime } from "./mp4-boxes.js";
13
+ import { readTrackTimescales, stampSegmentStartTime, walkBoxes } from "./mp4-boxes.js";
14
+
15
+ /**
16
+ * How many distinct tracks have a fragment in this segment.
17
+ *
18
+ * @param {Buffer} bytes
19
+ * @returns {number}
20
+ */
21
+ function countFragmentTracks(bytes) {
22
+ const tracks = new Set();
23
+ walkBoxes(bytes, (type, bodyStart) => {
24
+ if (type === "tfhd" && bodyStart + 8 <= bytes.length) {
25
+ tracks.add(bytes.readUInt32BE(bodyStart + 4));
26
+ }
27
+ });
28
+ return tracks.size;
29
+ }
14
30
 
15
31
  /**
16
32
  * Where the fragments begin and where the trailing index starts, as offsets of
@@ -185,6 +201,35 @@ export const fmp4Format = {
185
201
  */
186
202
  needsSegmentRewrite: true,
187
203
 
204
+ /**
205
+ * Whether a segment carries every track the init promises.
206
+ *
207
+ * A run that is TERMINATED closes its current output file properly — trailing
208
+ * index and all — but the file holds only what had been muxed by then, which
209
+ * after a seek-restart is routinely one track of two. Nothing about it looks
210
+ * unfinished: the file exists, the next one exists, so the readiness rule
211
+ * calls it done and it is served. The player then cannot use it and the seek
212
+ * never completes. Measured 2026-08-06: segment #133 carried one `tfdt`
213
+ * where its neighbours carried two, and a viewer sat on a spinner while the
214
+ * proxy answered every request in 98 ms.
215
+ *
216
+ * Cheap to check: the fragments are already walked to stamp them.
217
+ *
218
+ * @param {Buffer} bytes - The media segment, init header already removed.
219
+ * @param {Buffer | null} initBytes
220
+ * @returns {boolean} False only when a track is provably missing.
221
+ */
222
+ hasEveryTrack(bytes, initBytes) {
223
+ if (!initBytes || initBytes.length === 0) {
224
+ return true;
225
+ }
226
+ const expected = readTrackTimescales(initBytes);
227
+ if (expected.size === 0) {
228
+ return true;
229
+ }
230
+ return countFragmentTracks(bytes) >= expected.size;
231
+ },
232
+
188
233
  prepareSegmentBytes(bytes, { startSeconds, initBytes }) {
189
234
  if (!initBytes || initBytes.length === 0) {
190
235
  // No init cached yet — nothing to read timescales from. The player always
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @file A segment that is short of a track must not be served.
3
+ *
4
+ * A run that is terminated closes its current output file properly — trailing
5
+ * index and all — but the file holds only what had been muxed by then, and
6
+ * after a seek-restart that is routinely one track of two. Nothing about it
7
+ * looks unfinished, so the readiness rule called it done and served it, and the
8
+ * player could not complete the seek. Measured 2026-08-06: segment #133 carried
9
+ * one `tfdt` where its neighbours carried two, every request for it was
10
+ * answered in 98 ms, and the viewer sat on a spinner.
11
+ */
12
+
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
16
+ // Build a minimal init declaring two tracks, and fragments declaring one/two.
17
+ function box(type, body) {
18
+ const b = Buffer.alloc(8 + body.length);
19
+ b.writeUInt32BE(8 + body.length, 0); b.write(type, 4, "latin1"); body.copy(b, 8);
20
+ return b;
21
+ }
22
+ function mdhd(timescale) { const b = Buffer.alloc(20); b.writeUInt32BE(timescale, 12); return box("mdhd", b); }
23
+ function tkhd(id) { const b = Buffer.alloc(84); b.writeUInt32BE(id, 12); return box("tkhd", b); }
24
+ function trak(id, ts) { return box("trak", Buffer.concat([tkhd(id), box("mdia", mdhd(ts))])); }
25
+ const init = box("moov", Buffer.concat([trak(1, 16000), trak(2, 48000)]));
26
+ function tfhd(id) { const b = Buffer.alloc(8); b.writeUInt32BE(id, 4); return box("tfhd", b); }
27
+ function moof(ids) { return box("moof", Buffer.concat(ids.map((id) => box("traf", tfhd(id))))); }
28
+
29
+ test("a segment with every track is accepted", () => {
30
+ assert.equal(fmp4Format.hasEveryTrack(moof([1, 2]), init), true);
31
+ });
32
+ test("a segment left short of a track by a killed run is refused", () => {
33
+ assert.equal(fmp4Format.hasEveryTrack(moof([1]), init), false,
34
+ "one track of two is what a terminated run leaves behind, and it looks complete");
35
+ });
36
+ test("an empty file is refused", () => {
37
+ assert.equal(fmp4Format.hasEveryTrack(Buffer.alloc(0), init), false);
38
+ });
39
+ test("without an init there is nothing to compare against, so it is allowed", () => {
40
+ assert.equal(fmp4Format.hasEveryTrack(moof([1]), null), true);
41
+ });