@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.
|
|
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
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
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 (
|
|
3504
|
-
|
|
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 {
|
|
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
|
+
});
|