@torrent-tv/proxy 2.9.141 → 2.10.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 +17 -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/hls-session-manager.js +4610 -4183
- package/test/quality-variants.test.js +326 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## 2.10.0
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
- **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.
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
|
|
1
18
|
## 2.9.141
|
|
2
19
|
|
|
3
20
|
- **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: "/",
|