@torrent-tv/proxy 2.9.141 → 2.11.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 +28 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +17 -0
- package/routes/transcode/session-file/get.js +18 -0
- package/routes/transcode/variant-file/get.js +47 -0
- package/server.js +7 -0
- package/services/container-index/index.js +10 -6
- package/services/hls-session-manager.js +4962 -4183
- 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 +390 -0
- package/test/segment-serve-wiring.test.js +50 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## 2.11.0
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
- **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.
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
|
|
12
|
+
## 2.10.0
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
- **New**: A variant IS a session — same source, same file, another encode — so nothing parallel was invented for it. It is created on the first request for it and not before, which is what keeps a weak host running one encoder: the player's own bitrate adaptation is off, so no variant is ever asked for unless the viewer picked it. Two viewers on the same rung of the same file share one encode, as sessions already do.
|
|
16
|
+
- **New**: Only one encoder runs. A SEGMENT request for another variant is what says the viewer has moved — a playlist or an init segment is fetched to decide with, and the player fetches both for levels it may never use. On that signal the previous variant's encoder is stopped, every request still held on it is answered at once instead of running out its minute, and the new variant is pointed at where the viewer stands. It has to be told: since 2.9.100 a segment request steers the encoder nowhere, so a variant watched a minute ago is parked wherever it was left.
|
|
17
|
+
- **New**: The session's OWN height is one of the variants, even when the realtime budget settled on something that is not a ladder rung. Leaving it out would mean the player, on loading the master, immediately asks for a height nobody is encoding — a second cold start in place of the run already serving segments. The create response names it (`variantHeight`) so the browser can pin the player to it.
|
|
18
|
+
- **New**: Seek, progress, link reports and release are addressed to the session the browser was given and answered from the variant on screen. The browser holds one id for the whole file and never learns a variant's — which is what keeps the switch out of its state machine.
|
|
19
|
+
- **New**: A variant's playlist is answered from the base session, and no encoder is started for it. Every variant of a file has the same media playlist — same duration, same boundaries, same init name — which is precisely what makes them interchangeable. The player fetches a level's playlist to decide with and may never switch to it, so building a session there would leave a second encoder running on a host with capacity for one. Only the init segment and the segments themselves belong to a variant.
|
|
20
|
+
- **New**: A re-encoded session is no longer shared between viewers. A quality change acts on the session — it stops the encoder of the rung being left and repositions the one being joined — so shared, one viewer's change would kill the stream the other was watching, and that viewer's seek would then be forwarded to a variant they never asked for. The sharing given up was always narrow: two viewers had to open the same file at the same size within the same ten seconds, and a shared seek already dragged both of them. Restoring it needs the active variant tracked per consumer rather than per session.
|
|
21
|
+
- **Fix**: Stopping an encoder clears everything armed to start it again. The input-retry timer fires seconds after a run dies of torrent starvation — routine here — and would have spawned a run for a rung nobody was watching; the seek-settle timer did the same on a quick second switch.
|
|
22
|
+
- **Fix**: A variant made for a session that ended while it was being made is released at once. Making one takes seconds (a probe and a keyframe index) and the viewer can leave inside that window; registered onto a disposed session it would be reachable by nobody, since the browser never learns a variant's id, and would hold an encoder, a directory and a claim on the torrent until its own idle timer noticed half an hour later.
|
|
23
|
+
- **Fix**: Where the viewer is is now taken from the segments they ask for, not only from a reported seek. Playback reports no position at all, so the recorded one was as old as the last scrub — and it is what places the next variant's first encode run. A variant started an hour behind the viewer produces segments nobody will ever request, and since a segment request steers nothing, the ones they DO request would never be made.
|
|
24
|
+
- **Fix**: A variant's first run starts on the ten-second grid that session keys are bucketed to, floored rather than rounded. A position rounded up starts the run past the viewer, so the run just spawned is torn down and restarted before it has produced anything — about half of all switches paid that twice over.
|
|
25
|
+
- **Fix**: A variant that cannot be prepared answers a retryable 503 rather than a bare 500. hls.js treats a 500 on a level playlist as fatal and ends the stream, over a probe or an input that the next attempt may well get past.
|
|
26
|
+
- **New**: A variant's height is its NAME, settled once. The player fetches the master exactly once and addresses the variant by that name for the rest of the session, while the height a session encodes at is not stable — the realtime budget steps it down when the host cannot keep up. Deriving the name afresh would let a downshift silently rename the variant being watched, and the next segment request under the old name would build a SECOND session at the very height the host had just proved it could not manage. A downshift changes the picture inside the variant instead, as it always has.
|
|
27
|
+
- **Chore**: What makes the splice possible is pinned by a test: on the re-encode path the cut times are a uniform grid with keyframes forced onto it, so segment N covers the same span at every height and the source's own keyframes cannot move the cuts. Variants stop being interchangeable the moment that stops holding.
|
|
28
|
+
|
|
1
29
|
## 2.9.141
|
|
2
30
|
|
|
3
31
|
- **New**: A held segment says whether the encoder is actually moving. The line already reported that the run was alive and at the right index and stopped there, which left the two possible causes indistinguishable: an encoder waiting on torrent pieces looks exactly like one that is encoding and has not finished. It now reports how much media the run has produced since it started and at what speed, and says outright when the position has not moved at all — which means the input is what is being waited for. Measured 2026-08-11: segment #675 was held with the run started at #675 and the encoder alive, nothing in the log could say why, and the browser then abandoned the session and built another — which is where the "second session after a seek" came from.
|
package/package.json
CHANGED
|
@@ -109,9 +109,26 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
109
109
|
// watched.
|
|
110
110
|
acquireSource: () => holdSource({ sourceRegistry, torrentPool, sourceKey, fileIndex })
|
|
111
111
|
});
|
|
112
|
+
// The index of quality variants, when this session has more than one to
|
|
113
|
+
// offer. Its presence is what tells the browser it can change quality
|
|
114
|
+
// without a new session: the player switches variants itself, appending the
|
|
115
|
+
// new one after what is already buffered. Absent for a copied video, whose
|
|
116
|
+
// segments are cut at the source's own keyframes and so cannot be spliced
|
|
117
|
+
// with a re-encoded rung.
|
|
118
|
+
const hasVariants = hlsSessionManager.buildMasterPlaylist(session.id) !== null;
|
|
112
119
|
return reply.send({
|
|
113
120
|
sessionId: session.id,
|
|
114
121
|
playlistPath: `/transcode/${session.id}/index.m3u8`,
|
|
122
|
+
...(hasVariants
|
|
123
|
+
? {
|
|
124
|
+
masterPath: `/transcode/${session.id}/master.m3u8`,
|
|
125
|
+
// Which of the master's variants this session IS. The browser pins
|
|
126
|
+
// the player to it, so loading the master costs nothing: an encoder
|
|
127
|
+
// is already producing that height, and any other rung would be a
|
|
128
|
+
// second cold start before the first frame.
|
|
129
|
+
variantHeight: hlsSessionManager.variantHeightOf(session)
|
|
130
|
+
}
|
|
131
|
+
: {}),
|
|
115
132
|
// What this session's output will carry, stated rather than left to be
|
|
116
133
|
// discovered. The browser checks what it actually got against this: a
|
|
117
134
|
// track that never arrives is otherwise noticed only by its absence,
|
|
@@ -40,6 +40,24 @@ const SEGMENT_WAIT_MS = 60_000;
|
|
|
40
40
|
export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
|
|
41
41
|
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
42
42
|
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
43
|
+
return serveSessionFile(req, reply, { hlsSessionManager, sessionId, fileName });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Serve one playlist or segment from a named session.
|
|
48
|
+
*
|
|
49
|
+
* Split from the route above because the variant route
|
|
50
|
+
* (`/transcode/:sessionId/v/:height/:fileName`) serves the same files from
|
|
51
|
+
* another session of the same family, and must hold, log and answer them
|
|
52
|
+
* identically — a switch of quality must not go through a different code path
|
|
53
|
+
* from the stream it switches away from.
|
|
54
|
+
*
|
|
55
|
+
* @param {import("fastify").FastifyRequest} req
|
|
56
|
+
* @param {import("fastify").FastifyReply} reply
|
|
57
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager, sessionId: string, fileName: string }} params
|
|
58
|
+
* @returns {Promise<void>}
|
|
59
|
+
*/
|
|
60
|
+
export async function serveSessionFile(req, reply, { hlsSessionManager, sessionId, fileName }) {
|
|
43
61
|
// Hold the request only briefly, then answer "retry" instead of waiting for
|
|
44
62
|
// the segment. iOS's native HLS player (AVPlayer) enforces a hard ~3.5 s
|
|
45
63
|
// deadline on RESPONSE HEADERS and raises -12889 ("No response for media
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file GET /transcode/:sessionId/v/:height/:fileName — one file of a quality
|
|
3
|
+
* variant.
|
|
4
|
+
*
|
|
5
|
+
* A variant is another encode of the same file at another height, and it is an
|
|
6
|
+
* ordinary session underneath. It lives under the base session's path, one
|
|
7
|
+
* directory level down, so every relative name inside its playlist — its
|
|
8
|
+
* segments and its `#EXT-X-MAP` init — resolves to that variant with nothing in
|
|
9
|
+
* the playlist itself having to change.
|
|
10
|
+
*
|
|
11
|
+
* The variant is created on the first request for it, which is what keeps a
|
|
12
|
+
* weak host running one encoder: the player's own bitrate adaptation is off, so
|
|
13
|
+
* no variant is ever asked for unless the viewer picked it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { serveSessionFile } from "../session-file/get.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {import("fastify").FastifyRequest} req
|
|
20
|
+
* @param {import("fastify").FastifyReply} reply
|
|
21
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
22
|
+
* @returns {Promise<void>}
|
|
23
|
+
*/
|
|
24
|
+
export async function handleTranscodeVariantFileGet(req, reply, { hlsSessionManager }) {
|
|
25
|
+
const baseSessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
26
|
+
const height = Number(req.params.height);
|
|
27
|
+
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
28
|
+
|
|
29
|
+
const resolved = await hlsSessionManager.resolveVariantFile(baseSessionId, height, fileName);
|
|
30
|
+
if (resolved.error) {
|
|
31
|
+
// Preparing the variant failed — a probe, a keyframe index, an input that
|
|
32
|
+
// is not there yet. Retryable, like every other not-ready answer on this
|
|
33
|
+
// path: a 500 for a level playlist is fatal to hls.js, which would end the
|
|
34
|
+
// stream over something the next attempt may well get past.
|
|
35
|
+
reply.header("Retry-After", "1");
|
|
36
|
+
return reply.code(503).send({ error: `Could not prepare the quality variant: ${resolved.error}` });
|
|
37
|
+
}
|
|
38
|
+
if (!resolved.sessionId) {
|
|
39
|
+
return reply.code(404).send({ error: "No such quality variant for this transcode session." });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return serveSessionFile(req, reply, {
|
|
43
|
+
hlsSessionManager,
|
|
44
|
+
sessionId: resolved.sessionId,
|
|
45
|
+
fileName
|
|
46
|
+
});
|
|
47
|
+
}
|
package/server.js
CHANGED
|
@@ -29,6 +29,7 @@ import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-s
|
|
|
29
29
|
import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
|
|
30
30
|
import { handleStreamGet } from "./routes/stream/get.js";
|
|
31
31
|
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
32
|
+
import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
|
|
32
33
|
import { createSourceRegistry } from "./store/source-registry.js";
|
|
33
34
|
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
34
35
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
@@ -215,6 +216,12 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
215
216
|
app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
|
|
216
217
|
handleTranscodeSessionFileGet(req, reply, { hlsSessionManager })
|
|
217
218
|
);
|
|
219
|
+
// A quality variant's files. Registered before the static handler for the
|
|
220
|
+
// same reason as the line above, and kept a separate route rather than a
|
|
221
|
+
// wildcard so the height stays a parsed parameter.
|
|
222
|
+
app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
|
|
223
|
+
handleTranscodeVariantFileGet(req, reply, { hlsSessionManager })
|
|
224
|
+
);
|
|
218
225
|
await app.register(fastifyStatic, {
|
|
219
226
|
root: publicRoot,
|
|
220
227
|
prefix: "/",
|
|
@@ -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
|
}
|