@torrent-tv/proxy 2.10.0 → 2.12.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 +20 -0
- package/package.json +1 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-warm/get.js +71 -0
- package/server.js +7 -0
- package/services/container-index/index.js +10 -6
- package/services/hls-session-manager.js +561 -64
- package/services/hwaccel.js +55 -16
- package/test/behind-head-repair.test.js +187 -0
- package/test/keyframe-index-accuracy.test.js +68 -0
- package/test/quality-variants.test.js +111 -20
- package/test/segment-serve-wiring.test.js +50 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## 2.12.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: A rung warmed for a switch the viewer did not make is stopped. Only becoming active stopped the rung being left, so trying two rungs in a row left the first encoding for nobody — three encoders at once on a host sized for one, which is the opposite of what warming is for.
|
|
4
|
+
- **Fix**: The warm-up closes the handle it opened. It answers without sending the bytes, and on formats whose segments are served straight off disk that left a file descriptor behind on every quality pick; enough of them and every read fails, segments included.
|
|
5
|
+
|
|
6
|
+
- **New**: A quality rung is prepared before the player is told to switch to it — `GET /transcode/:id/v/:height/warm?position=<seconds>`. A rung is an encoder that does not exist until it is asked for, so switching first and waiting second put the whole of its cold start on screen as a spinner: measured 2026-08-11, the first segment of a 240p rung producing at 1.2x took 15 988 ms, and the viewer watched all of it. The rung on screen deliberately keeps its own encoder until the player actually moves, so the wait happens behind a picture that is still playing. Both encoders run for the length of the warm-up, which is what the switch costs to be invisible.
|
|
7
|
+
- **Fix**: A viewer who names a resolution keeps the ladder beneath it. Forcing a rung disabled the realtime budget outright, so on 2026-08-11 a viewer picked 480p on a host that encodes it at 0.27-0.78x and the stream simply never caught up — nothing could step in, because the one thing that steps in had been switched off. The encode now STARTS at the size asked for and may still be stepped down under it. The rung's height is its name and does not move with a downshift, so the player goes on addressing it by the height it chose; what changes is the picture, and a smaller picture that plays beats a correct label that freezes.
|
|
8
|
+
- **New**: When a produced segment starts somewhere other than the playlist says, the line now names which boundary it DOES fall on. The two possible faults need opposite fixes and the numbers alone do not separate them: matching boundary #N-1 means this proxy's own numbering is shifted, matching none means the container's index describes times the file does not have. Measured 2026-08-11 on a 1080p Matroska, three samples out by 3.5-4.6 s, all matching #N-1.
|
|
9
|
+
|
|
10
|
+
## 2.11.0
|
|
11
|
+
|
|
12
|
+
- **Fix**: A quality change places the new rung where the PLAYER asked for it, not where the rung being left had read to. After a level switch hls.js discards what it had buffered ahead and fetches from the picture's own position, so its first request for the new rung IS that position; the read head is a whole buffer further on. Measured 2026-08-11 on a switch back up to 400p: a 240p rung encoding at 5-6x had read 56 s past the picture, the run was placed at 3084 s, the player needed 3028 s, and nothing it asked for was ever produced.
|
|
13
|
+
- **Fix**: A segment request BELOW the running encode is repaired instead of being held for ever. The encoder only moves forward from where its run began, so such a request cannot be answered by anything that run does — every other far request is a claim the run may yet reach, this one is a hole. Same session: it was held for two minutes forty-one while the encoder produced 409 s of video nobody had asked for at 2.48x, and the viewer sat on a loading screen until they gave up. It now moves the encoder there, through the same settle a reported seek goes through, and never overrides a seek the viewer has actually stated.
|
|
14
|
+
- **New**: A quality variant can now accompany a COPIED video. The obstacle was never the encoder but the cut points: a copy can only be cut where the source already has a keyframe, while a re-encode was always cut on an even grid, so a rung's segment covered a different span from the copy's and could not stand in its place. A session now carries which grid it is cut on as a fact of its own, separately from whether its video is copied, and a variant of a keyframe-cut session inherits that grid — the same times serve as the muxer's cut list and as the keyframes the encoder is told to force. A copied stream is offered variants only when its own grid is real: with no readable keyframe index it falls back to an even grid that ffmpeg does not cut on, and nothing can be aligned to a fiction.
|
|
15
|
+
- **New**: What a container's keyframe index says about its own file is now counted and reported. The cut times of a copied video ARE that index, and an index can be wrong — measured 2026-08-06, one claimed a keyframe four seconds from where the real ones were. Every produced segment states where it truly begins and is already read whole in order to be stamped, so the comparison costs a subtraction and no scan: nothing is downloaded for it, and only boundaries somebody actually watched are counted. Each session ends with one line naming the container, how many boundaries were examined, how many disagreed and by how much — so silence can be told from nobody having watched, which the per-boundary warning alone could not do.
|
|
16
|
+
- **Fix**: The repair above is bounded, and cannot become the request-steering this proxy removed in 2.9.100. A player that cannot get what it wants scans the playlist — field log 2026-08-02, probes at #178, #681, #725, #807, #74, #245, #387 within half a second — and moving the encoder to the lowest of those would put it at the start of the film with the viewer's own requests unreachable ahead of it. What separates the two: a run placed wrongly is out by at most the buffer the player was holding, fourteen segments in the measured case, while a scan probe is out by anything at all. So only a request within sixty segments behind the head is repaired, only while an encoder is actually running (a rung the viewer switched away from stays parked), and never over a seek the viewer has stated or a target the circuit breaker has already refused.
|
|
17
|
+
- **Fix**: How long a segment has gone unanswered is measured against the run in force. The record was kept for the life of the session, so a timestamp left by an abandoned scan probe minutes earlier said a fresh request had already waited long enough — which would have fired the repair on the first poll, before the browser's own seek report could arrive. It is cleared with each run, which also stops the map growing all session.
|
|
18
|
+
- **Fix**: A copied video whose keyframe index could not be read keeps its explicit cut list. Making the list conditional on the keyframe grid dropped that case onto the `hls` muxer, which takes no cut list and writes no self-contained pieces — so nothing could read where a segment truly begins and each was stamped with a time the file does not have. That is the 4.17 s drift between speech and subtitles, which had already cost one release.
|
|
19
|
+
- **New**: A rung that does not cut where its grid says now says so. The check above runs on re-encoded variants too, where the meaning is different: the encoder was TOLD to put a keyframe there and did not, so a switch to that rung will not join cleanly. Hardware encoders honouring an explicit cut list is unverified — this is what will name it if one does not.
|
|
20
|
+
|
|
1
21
|
## 2.10.0
|
|
2
22
|
|
|
3
23
|
- **New**: Quality can be changed without interrupting playback. A session that re-encodes its video now also publishes a master playlist — `GET /transcode/:id/master.m3u8` — listing every height the file can be served at, each as an ordinary HLS variant under `v/<height>/`. The player then does the switching itself: it fetches the other variant, appends it after what is already buffered, and changes the decoder's type if the codec parameters differ. Until now a change of resolution could only re-open the session, which is a cold start with the picture gone. Rewriting the media playlist underneath the player is not an alternative — ours is VOD and terminated with `#EXT-X-ENDLIST`, and hls.js re-reads only a live playlist, so anything written into it afterwards is never seen.
|
package/package.json
CHANGED
|
@@ -140,7 +140,7 @@ export async function serveSessionFile(req, reply, { hlsSessionManager, sessionI
|
|
|
140
140
|
* @param {number} timeoutMs
|
|
141
141
|
* @returns {Promise<Awaited<ReturnType<import("../../../services/hls-session-manager.js").HlsSessionManager["getFileStream"]>>>}
|
|
142
142
|
*/
|
|
143
|
-
async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
|
|
143
|
+
export async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
|
|
144
144
|
const startedAt = Date.now();
|
|
145
145
|
// One sequence number for THIS request, reused by every poll below, so the
|
|
146
146
|
// session can tell a newly-arrived request apart from an old one polling
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file GET /transcode/:sessionId/v/:height/warm?position=<seconds> — prepare a
|
|
3
|
+
* quality rung before the player is told to switch to it.
|
|
4
|
+
*
|
|
5
|
+
* A rung is an encoder that does not exist until someone asks for it, so a
|
|
6
|
+
* switch made first and waited for second shows the viewer a spinner for as
|
|
7
|
+
* long as the first segment takes to produce — 15 988 ms, measured 2026-08-11
|
|
8
|
+
* on a rung producing at 1.2x. Asking first and switching second moves that
|
|
9
|
+
* wait to where it cannot be seen: the rung on screen goes on playing, and it
|
|
10
|
+
* keeps its own encoder until the player actually moves.
|
|
11
|
+
*
|
|
12
|
+
* Answers when the segment at that position is ready, so the caller can switch
|
|
13
|
+
* knowing there is something to fetch.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { waitForSessionFile } from "../session-file/get.js";
|
|
17
|
+
|
|
18
|
+
/** How long to hold the warm-up request before telling the caller to retry. */
|
|
19
|
+
const WARM_WAIT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {import("fastify").FastifyRequest} req
|
|
23
|
+
* @param {import("fastify").FastifyReply} reply
|
|
24
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
25
|
+
* @returns {Promise<void>}
|
|
26
|
+
*/
|
|
27
|
+
export async function handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager }) {
|
|
28
|
+
const baseSessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
29
|
+
const height = Number(req.params.height);
|
|
30
|
+
const positionSeconds = Number(req.query?.position);
|
|
31
|
+
|
|
32
|
+
if (!Number.isInteger(height) || height <= 0 || !Number.isFinite(positionSeconds) || positionSeconds < 0) {
|
|
33
|
+
return reply.code(400).send({ error: "A height and a non-negative position are required." });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let prepared;
|
|
37
|
+
try {
|
|
38
|
+
prepared = await hlsSessionManager.prepareVariant(baseSessionId, height, positionSeconds);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
reply.header("Retry-After", "1");
|
|
42
|
+
return reply.code(503).send({ error: `Could not prepare the quality variant: ${message}` });
|
|
43
|
+
}
|
|
44
|
+
if (!prepared) {
|
|
45
|
+
return reply.code(404).send({ error: "No such quality variant for this transcode session." });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = await waitForSessionFile(
|
|
49
|
+
hlsSessionManager,
|
|
50
|
+
prepared.sessionId,
|
|
51
|
+
prepared.fileName,
|
|
52
|
+
WARM_WAIT_MS
|
|
53
|
+
);
|
|
54
|
+
if (result.kind === "file") {
|
|
55
|
+
// The bytes are not sent — the player fetches them itself the moment it
|
|
56
|
+
// switches, and by then they are on disk — but the handle opened to reach
|
|
57
|
+
// them is ours to close. Some formats answer with a real file descriptor
|
|
58
|
+
// rather than bytes already in memory, and one left behind per quality pick
|
|
59
|
+
// walks a long-running proxy to EMFILE, where every read fails, segments
|
|
60
|
+
// included.
|
|
61
|
+
result.stream?.destroy?.();
|
|
62
|
+
return reply.code(204).send();
|
|
63
|
+
}
|
|
64
|
+
if (result.kind === "failed") {
|
|
65
|
+
return reply.code(500).send({ error: result.message });
|
|
66
|
+
}
|
|
67
|
+
// Still being produced. The caller may switch anyway — it will simply wait
|
|
68
|
+
// where it would have waited before — or ask again.
|
|
69
|
+
reply.header("Retry-After", "1");
|
|
70
|
+
return reply.code(503).send({ error: "The quality variant is still warming up." });
|
|
71
|
+
}
|
package/server.js
CHANGED
|
@@ -30,6 +30,7 @@ import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessio
|
|
|
30
30
|
import { handleStreamGet } from "./routes/stream/get.js";
|
|
31
31
|
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
32
32
|
import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
|
|
33
|
+
import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
|
|
33
34
|
import { createSourceRegistry } from "./store/source-registry.js";
|
|
34
35
|
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
35
36
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
@@ -219,6 +220,12 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
219
220
|
// A quality variant's files. Registered before the static handler for the
|
|
220
221
|
// same reason as the line above, and kept a separate route rather than a
|
|
221
222
|
// wildcard so the height stays a parsed parameter.
|
|
223
|
+
// Registered BEFORE the variant file route: `warm` is not a file name, and
|
|
224
|
+
// Fastify matches a static segment ahead of a parameter either way — stated
|
|
225
|
+
// here so the order is not "tidied" into a bug.
|
|
226
|
+
app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
|
|
227
|
+
handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
|
|
228
|
+
);
|
|
222
229
|
app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
|
|
223
230
|
handleTranscodeVariantFileGet(req, reply, { hlsSessionManager })
|
|
224
231
|
);
|
|
@@ -62,12 +62,16 @@ const READERS = [
|
|
|
62
62
|
* @param {ReadRange} params.readRange
|
|
63
63
|
* @param {number} params.fileSize
|
|
64
64
|
* @param {string} [params.label] - For logging only.
|
|
65
|
-
* @returns {Promise<number[] | null
|
|
66
|
-
*
|
|
65
|
+
* @returns {Promise<{ times: number[] | null, format: string }>} Ascending
|
|
66
|
+
* seconds, or null times when this file has no readable index — the caller
|
|
67
|
+
* must then not claim to know the grid. The format is which reader matched,
|
|
68
|
+
* reported whether or not it produced anything: how often an index disagrees
|
|
69
|
+
* with its own file is a question about the CONTAINER, and it cannot be
|
|
70
|
+
* answered by a measurement that does not say which one it came from.
|
|
67
71
|
*/
|
|
68
72
|
export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
69
73
|
if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
|
|
70
|
-
return null;
|
|
74
|
+
return { times: null, format: "unknown" };
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
const startedAt = Date.now();
|
|
@@ -76,7 +80,7 @@ export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
|
76
80
|
try {
|
|
77
81
|
const sniff = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
|
|
78
82
|
if (!sniff) {
|
|
79
|
-
return null;
|
|
83
|
+
return { times: null, format: "unread" };
|
|
80
84
|
}
|
|
81
85
|
const reader = READERS.find((candidate) => candidate.matches(sniff));
|
|
82
86
|
if (reader) {
|
|
@@ -87,7 +91,7 @@ export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
|
87
91
|
// A malformed or partially-downloaded index must never take playback down —
|
|
88
92
|
// it only means the grid is unknown, which the caller already handles.
|
|
89
93
|
logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
|
|
90
|
-
return null;
|
|
94
|
+
return { times: null, format };
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
const elapsedMs = Date.now() - startedAt;
|
|
@@ -98,5 +102,5 @@ export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
|
98
102
|
} else {
|
|
99
103
|
logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
|
|
100
104
|
}
|
|
101
|
-
return times;
|
|
105
|
+
return { times, format };
|
|
102
106
|
}
|