@torrent-tv/proxy 2.9.124 → 2.9.125

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,10 @@
1
+ ## 2.9.125
2
+
3
+ - **Fix**: Playback works again. 2.9.124 shipped two names it never declared — `readSelfContainedStartSeconds`, called in `segment-formats/fmp4.js` and imported nowhere, and `SEGMENT_START_DISAGREEMENT_SEC` on the line after it — so preparing any segment cut at keyframes threw a `ReferenceError`. That is every ordinary file. Measured 2026-08-08: the playlist and the init segment were served, twelve finished segments lay in the session directory, and the request for segment #0 was held for 45 281 ms and then answered 404 because the browser had given up and released the session. Peers, download rate and transport were all healthy throughout, so nothing in the logs pointed anywhere near the cause.
4
+ - **Fix**: A fault while preparing a file that EXISTS is now reported instead of being passed off as "not produced yet". One `try` covered both the existence check and everything after it, and its `catch` meant only the first, so the exception above came out as "still warming up" — the request was held, the next poll threw the same exception, and so on until the viewer left. Nothing was logged at any point. The existence check now stands alone; a file that goes away between the check and the read still means "not ready", and anything else is logged with its stack and answered as a failure. The same split applies to the init segment.
5
+ - **Chore**: `npm test` runs the linter before the tests. The linter added in 2.9.103 exists precisely to catch an undeclared name, and it names both of these — it simply was not run before releasing 2.9.124, because nothing ran it.
6
+ - **Chore**: A test asks the session manager for a segment that exists and insists on getting the bytes back. Every unit test of the fMP4 path passed while playback was dead: they import the function straight from `mp4-boxes.js`, so the missing import in its CALLER was invisible. A second test pins that a fault in preparing an existing segment is answered as a failure, not as an endless wait.
7
+
1
8
  ## 2.9.124
2
9
 
3
10
  - **Fix**: Subtitles no longer drift away from the picture. A segment was stamped with the time the PLAYLIST assigned it, and the playlist is built from the container keyframe index — which can be wrong. Measured 2026-08-06 on a Matroska file whose index claimed a keyframe at 157.99 s where the real ones were 153.820 and 164.247: ffmpeg cut at 153.820, the stamp said 157.99, and the player was told that picture belonged 4.17 s later than it did. Subtitles, extracted straight from the source with no offset of any kind, kept the true times, so speech and text sat 4.17 s apart for the whole stretch. The stamp now comes from the piece itself — the muxer records its position as an empty edit at the head of the track edit list, read before the header is stripped — and falls back to the playlist only when the piece does not say. Identical to the old figure whenever the index is honest, so a well-formed file is unaffected. When the two disagree by more than a quarter of a second the log names both, so an index that lies is visible rather than merely felt. Verified against ffmpeg on pieces cut the same way (12/30/36 s read back exactly), and covered by tests including a 64-bit edit list and a non-default movie timescale.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.124",
3
+ "version": "2.9.125",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -19,7 +19,7 @@
19
19
  "major": "npm whoami && npm version major && npm publish && git push --follow-tags",
20
20
  "start": "node ./bin/cli.js",
21
21
  "dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
22
- "test": "node --test",
22
+ "test": "npm run lint && node --test",
23
23
  "lint": "biome lint ."
24
24
  },
25
25
  "dependencies": {
@@ -293,6 +293,12 @@ const PROGRESS_LOG_INTERVAL_MS = 5_000;
293
293
  // hashing starves the event loop in bursts, so fewer read iterations means
294
294
  // far less time lost between chunks while serving the first segments.
295
295
  const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
296
+ // How far a segment's own recorded start may sit from the one the playlist
297
+ // assigned it before the disagreement is worth a log line. The two are built
298
+ // from the same keyframe index and normally match to the sample; a quarter of a
299
+ // second is below any drift a viewer could notice, so anything above it is the
300
+ // index being wrong about where a keyframe is rather than rounding.
301
+ const SEGMENT_START_DISAGREEMENT_SEC = 0.25;
296
302
 
297
303
  /**
298
304
  * Resolve after a given number of milliseconds.
@@ -3338,10 +3344,20 @@ export class HlsSessionManager {
3338
3344
  contentType: session.segmentFormat.initContentType,
3339
3345
  isPlaylist: false
3340
3346
  };
3341
- } catch {
3342
- // Not produced yet — the encode run started at session creation writes
3343
- // it early; the caller long-polls until it appears.
3344
- return { kind: "warming-up" };
3347
+ } catch (error) {
3348
+ if (error?.code === "ENOENT") {
3349
+ // Not produced yet — the encode run started at session creation
3350
+ // writes it early; the caller long-polls until it appears.
3351
+ return { kind: "warming-up" };
3352
+ }
3353
+ logger.error(
3354
+ `transcode ${session.id} could not serve ${initFileName}: ${error?.message ?? error}` +
3355
+ (error?.stack ? `\n${error.stack}` : "")
3356
+ );
3357
+ return {
3358
+ kind: "failed",
3359
+ message: `Could not serve ${initFileName}: ${error?.message ?? String(error)}`
3360
+ };
3345
3361
  }
3346
3362
  }
3347
3363
 
@@ -3360,9 +3376,21 @@ export class HlsSessionManager {
3360
3376
  this.#enforceLookAheadFor(session);
3361
3377
  }
3362
3378
  }
3379
+ // Whether the file is there is asked on its own, and nothing else shares
3380
+ // this catch. Everything below is PREPARATION of a file that exists, and a
3381
+ // failure there means something entirely different from "not produced yet"
3382
+ // — but for one release the two were caught together, so an undeclared name
3383
+ // in the fMP4 path read as "the segment is not ready". Every poll threw the
3384
+ // same ReferenceError, every poll answered "wait", and playback never began
3385
+ // on any file cut at keyframes (2.9.124; measured 2026-08-08: segment #0
3386
+ // held for 45 281 ms with twelve finished segments on disk).
3363
3387
  try {
3364
3388
  await access(filePath);
3365
-
3389
+ } catch {
3390
+ // Not produced yet.
3391
+ return this.#holdForProduction(session, fileName, isPlaylist, options);
3392
+ }
3393
+ try {
3366
3394
  // Existing is not the same as finished. The `hls` muxer wrote each
3367
3395
  // segment to a temporary name and renamed it once complete, so a file
3368
3396
  // appearing WAS a finished segment. The `segment` muxer has no such
@@ -3500,10 +3528,38 @@ export class HlsSessionManager {
3500
3528
  : session.segmentFormat.segmentContentType,
3501
3529
  isPlaylist
3502
3530
  };
3503
- } catch (_error) {
3504
- // File not produced yet.
3531
+ } catch (error) {
3532
+ if (error?.code === "ENOENT") {
3533
+ // The file went away between the check and the read — a leftover being
3534
+ // removed so it can be produced again. Means exactly what never having
3535
+ // existed means.
3536
+ return this.#holdForProduction(session, fileName, isPlaylist, options);
3537
+ }
3538
+ // Anything else is a fault in producing the answer. Name it: a request
3539
+ // answered "wait" for ever tells the viewer nothing and leaves no trace
3540
+ // of what actually happened.
3541
+ logger.error(
3542
+ `transcode ${session.id} could not serve ${fileName}: ${error?.message ?? error}` +
3543
+ (error?.stack ? `\n${error.stack}` : "")
3544
+ );
3545
+ return {
3546
+ kind: "failed",
3547
+ message: `Could not serve ${fileName}: ${error?.message ?? String(error)}`
3548
+ };
3505
3549
  }
3550
+ }
3506
3551
 
3552
+ /**
3553
+ * Answer a request for a file that is not on disk: make sure the encoder is
3554
+ * heading for it, and hold the request.
3555
+ *
3556
+ * @param {HlsSession} session
3557
+ * @param {string} fileName
3558
+ * @param {boolean} isPlaylist
3559
+ * @param {{ requestSeq?: number }} options
3560
+ * @returns {{ kind: "warming-up" }}
3561
+ */
3562
+ #holdForProduction(session, fileName, isPlaylist, options) {
3507
3563
  // A segment was requested that ffmpeg has not produced yet. Decide whether
3508
3564
  // to wait for the current encode run to reach it or to restart the encoder
3509
3565
  // at this position (server-side seeking). The caller long-polls.
@@ -10,7 +10,12 @@
10
10
  * See {@link SegmentFormat} in `./index.js` for the interface contract.
11
11
  */
12
12
 
13
- import { readTrackTimescales, stampSegmentStartTime, walkBoxes } from "./mp4-boxes.js";
13
+ import {
14
+ readSelfContainedStartSeconds,
15
+ readTrackTimescales,
16
+ stampSegmentStartTime,
17
+ walkBoxes
18
+ } from "./mp4-boxes.js";
14
19
 
15
20
  /**
16
21
  * How many distinct tracks have a fragment in this segment.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @file A finished segment on disk must reach the viewer as bytes.
3
+ *
4
+ * The module tests cover each piece of the fMP4 path on its own, and every one
5
+ * of them passed while playback was dead: 2.9.124 called
6
+ * `readSelfContainedStartSeconds` from `fmp4.js` without importing it, and the
7
+ * unit test imports that function straight from `mp4-boxes.js`, so the gap
8
+ * between a module and its CALLER was invisible. This test asks the session
9
+ * manager for a segment that exists and insists on getting it.
10
+ *
11
+ * The second half pins the reason a one-word slip cost a whole release: the
12
+ * failure was reported as "still being produced". Measured 2026-08-08 — segment
13
+ * #0 was held for 45 281 ms with twelve finished segments in the directory, and
14
+ * the log said nothing at all. Anything that goes wrong while preparing a file
15
+ * that EXISTS must be named and answered, never turned into an endless wait.
16
+ */
17
+
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
24
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
25
+
26
+ const MOVIE_TIMESCALE = 1000;
27
+ const VIDEO_TIMESCALE = 90_000;
28
+ const AUDIO_TIMESCALE = 48_000;
29
+ const SEGMENT_START_SECONDS = 12.5;
30
+ const SESSION_ID = "11111111-2222-3333-4444-555555555555";
31
+
32
+ /**
33
+ * @param {string} type
34
+ * @param {Buffer} body
35
+ * @returns {Buffer}
36
+ */
37
+ function box(type, body) {
38
+ const head = Buffer.alloc(8);
39
+ head.writeUInt32BE(8 + body.length, 0);
40
+ head.write(type, 4, "latin1");
41
+ return Buffer.concat([head, body]);
42
+ }
43
+
44
+ /**
45
+ * `elst` holding one empty edit — how the `segment` muxer records where the
46
+ * piece sits on the source timeline.
47
+ *
48
+ * @param {number} offsetSeconds
49
+ * @returns {Buffer}
50
+ */
51
+ function emptyEdit(offsetSeconds) {
52
+ const body = Buffer.alloc(16);
53
+ body.writeUInt32BE(1, 4); // entry count
54
+ body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8); // duration
55
+ body.writeInt32BE(-1, 12); // media_time
56
+ return box("elst", body);
57
+ }
58
+
59
+ /**
60
+ * @param {number} trackId
61
+ * @param {number} timescale
62
+ * @param {number} offsetSeconds
63
+ * @returns {Buffer}
64
+ */
65
+ function trak(trackId, timescale, offsetSeconds) {
66
+ const tkhdBody = Buffer.alloc(84);
67
+ tkhdBody.writeUInt32BE(trackId, 12);
68
+ const mdhdBody = Buffer.alloc(20);
69
+ mdhdBody.writeUInt32BE(timescale, 12);
70
+ return box("trak", Buffer.concat([
71
+ box("tkhd", tkhdBody),
72
+ box("edts", emptyEdit(offsetSeconds)),
73
+ box("mdia", box("mdhd", mdhdBody))
74
+ ]));
75
+ }
76
+
77
+ /**
78
+ * @param {number} trackId
79
+ * @returns {Buffer}
80
+ */
81
+ function traf(trackId) {
82
+ const tfhdBody = Buffer.alloc(8);
83
+ tfhdBody.writeUInt32BE(trackId, 4);
84
+ const tfdtBody = Buffer.alloc(12);
85
+ tfdtBody.writeUInt8(1, 0); // version 1 — 64-bit
86
+ tfdtBody.writeBigUInt64BE(0n, 4); // what ffmpeg writes: zero
87
+ return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
88
+ }
89
+
90
+ /**
91
+ * A piece shaped like the `segment` muxer's output: header, two fragments and a
92
+ * trailing random-access index, all in one file.
93
+ *
94
+ * @param {number} offsetSeconds
95
+ * @returns {Buffer}
96
+ */
97
+ function selfContainedPiece(offsetSeconds) {
98
+ const mvhdBody = Buffer.alloc(100);
99
+ mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
100
+ const moov = box("moov", Buffer.concat([
101
+ box("mvhd", mvhdBody),
102
+ trak(1, VIDEO_TIMESCALE, offsetSeconds),
103
+ trak(2, AUDIO_TIMESCALE, offsetSeconds)
104
+ ]));
105
+ const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
106
+ const mdat = box("mdat", Buffer.alloc(64, 0x5a));
107
+ const mfra = box("mfra", Buffer.alloc(24, 0));
108
+ return Buffer.concat([box("ftyp", Buffer.alloc(16, 0)), moov, moof, mdat, mfra]);
109
+ }
110
+
111
+ /**
112
+ * A manager holding one session whose segments are already on disk, cut at
113
+ * explicit times — the ordinary keyframe-cut path.
114
+ *
115
+ * @param {{ segmentFormat?: object }} [overrides]
116
+ * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
117
+ */
118
+ async function managerWithReadySegment(overrides = {}) {
119
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "segment-serve-"));
120
+ const piece = selfContainedPiece(SEGMENT_START_SECONDS);
121
+ // Two segments, because a piece is only finished once the next one exists.
122
+ await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
123
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
124
+
125
+ const manager = new HlsSessionManager({
126
+ enabled: true,
127
+ ffmpegBin: "ffmpeg",
128
+ localBindHost: "127.0.0.1",
129
+ localPort: 9090
130
+ });
131
+ const session = {
132
+ id: SESSION_ID,
133
+ dirPath,
134
+ state: "ready",
135
+ fileName: "video.mkv",
136
+ startedAt: Date.now(),
137
+ createEntryMs: Date.now(),
138
+ lastAccessedAt: Date.now(),
139
+ ffmpeg: null,
140
+ lastError: "",
141
+ consumers: new Set(),
142
+ segmentFormat: overrides.segmentFormat ?? fmp4Format,
143
+ usesExplicitCuts: true,
144
+ useSyntheticPlaylist: true,
145
+ playlistText: "#EXTM3U\n",
146
+ segmentBoundaries: [0, SEGMENT_START_SECONDS, 25],
147
+ initBytes: fmp4Format.extractInit(piece),
148
+ encodeStartIndex: 0,
149
+ firstSegmentLogged: false,
150
+ waitEpoch: 0
151
+ };
152
+ manager.sessionsById.set(SESSION_ID, session);
153
+ return { manager, session, dirPath };
154
+ }
155
+
156
+ test("a segment that exists is served, not reported as still being produced", async (t) => {
157
+ const { manager, dirPath } = await managerWithReadySegment();
158
+ t.after(async () => {
159
+ await manager.disposeAll();
160
+ await rm(dirPath, { recursive: true, force: true });
161
+ });
162
+
163
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
164
+
165
+ assert.equal(result.kind, "file", "a finished segment on disk must come back as bytes");
166
+ assert.equal(result.contentType, fmp4Format.segmentContentType);
167
+
168
+ const chunks = [];
169
+ for await (const chunk of result.stream) {
170
+ chunks.push(chunk);
171
+ }
172
+ const served = Buffer.concat(chunks);
173
+ assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
174
+
175
+ // The position the PIECE states, carried into the fragment it belongs to.
176
+ // Reading it is the step that threw in 2.9.124.
177
+ assert.equal(
178
+ Number(served.readBigUInt64BE(served.indexOf("tfdt") + 8)),
179
+ Math.round(SEGMENT_START_SECONDS * VIDEO_TIMESCALE),
180
+ "the segment must be stamped with where it really begins"
181
+ );
182
+ });
183
+
184
+ test("a fault while preparing an existing segment is named, not turned into a wait", async (t) => {
185
+ const broken = {
186
+ ...fmp4Format,
187
+ readSegmentStartSeconds() {
188
+ throw new ReferenceError("readSelfContainedStartSeconds is not defined");
189
+ }
190
+ };
191
+ const { manager, dirPath } = await managerWithReadySegment({ segmentFormat: broken });
192
+ t.after(async () => {
193
+ await manager.disposeAll();
194
+ await rm(dirPath, { recursive: true, force: true });
195
+ });
196
+
197
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
198
+
199
+ assert.equal(
200
+ result.kind,
201
+ "failed",
202
+ "answering 'warming-up' hides the fault and holds every request until the viewer gives up"
203
+ );
204
+ assert.match(result.message, /readSelfContainedStartSeconds is not defined/);
205
+ });