@torrent-tv/proxy 2.9.60 → 2.9.61
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 +4 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/seek/post.js +50 -0
- package/routes/transcode/session-file/get.js +145 -111
- package/server.js +220 -216
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2.9.61
|
|
2
|
+
|
|
3
|
+
- **Chore**: Measurement build for the iOS player question. A request for a not-yet-produced segment is now held up to 60 s (was 2 s) and each hold logs `[hold] <file> <outcome> after <ms>` — where the outcome distinguishes the segment arriving, our own limit expiring, and **the client aborting the connection**. The 2 s refusal was introduced (2.9.57) to dodge a reported iOS AVPlayer ~3.5 s response-header deadline, but that error code never appears in our own logs, and all five reference projects hold instead of refusing (Jellyfin and hls-vod-too unbounded, hls-media-server 10 s — see research/hls-seek-prior-art-2026-08-02.md). This build measures the player's real patience on our own hardware so the final value comes from observation rather than from a number read elsewhere. Not a permanent setting.
|
|
4
|
+
|
|
1
5
|
## 2.9.60
|
|
2
6
|
|
|
3
7
|
- **Fix**: Seeking restarted the encoder at the position it was **already encoding**, destroying the very work being waited for — visible in the field log 2026-08-02 as `restart at #865` twice within ten seconds, each killing a run that was encoding #865. While the target segment is being produced the player keeps re-requesting it, and every such request looks "far" from where the encoder USED to be, so each one re-triggered a restart at the position we had only just moved to; playback data kept appearing and vanishing, and a seek only completed when a segment happened to reach the player before the next restart. A settled seek whose target equals the current runs start index is now ignored outright. This is distinct from the 2.9.58 guard, which only decides whether to let the current run finish its first segment — not whether a new run is needed at all; that guard behaved correctly here (it logged `run produced 4.5s (first segment done)`) and still let the pointless restart through.
|
package/package.json
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file POST /api/transcode-sessions/:sessionId/seek — the viewer's seek target.
|
|
3
|
+
*
|
|
4
|
+
* The browser reports WHERE THE VIEWER ACTUALLY SEEKED, explicitly, the moment
|
|
5
|
+
* the scrub ends. This is the only authoritative source of that intent: the
|
|
6
|
+
* position lives in the browser (`video.currentTime`) and nowhere else.
|
|
7
|
+
*
|
|
8
|
+
* Why this route exists at all: our synthetic VOD playlist advertises every
|
|
9
|
+
* segment of the file, but segments only exist once ffmpeg has produced them.
|
|
10
|
+
* A player is entitled by the HLS contract to fetch any advertised segment and
|
|
11
|
+
* get it immediately, so when one 503s it legitimately probes elsewhere —
|
|
12
|
+
* field log 2026-08-02 shows a correct target burst (#973..#985, the real seek)
|
|
13
|
+
* followed by scattered probing across the whole file (#125, #173, #251, #326,
|
|
14
|
+
* #478...). Inferring the target from that request stream made the encoder
|
|
15
|
+
* restart at #519 — a probe, not the seek — and the viewer waited ten seconds
|
|
16
|
+
* for a segment nobody wanted. Segment requests are data fetches, not commands;
|
|
17
|
+
* the seek target now arrives here instead of being guessed from them.
|
|
18
|
+
*
|
|
19
|
+
* Mirrors how Jellyfin/Plex handle server-side seeking (an explicit start
|
|
20
|
+
* position from the client), rather than heuristics over request patterns.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import("fastify").FastifyRequest} req
|
|
25
|
+
* @param {import("fastify").FastifyReply} reply
|
|
26
|
+
* @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
export async function handleApiTranscodeSessionSeekPost(req, reply, { hlsSessionManager }) {
|
|
30
|
+
const sessionId = req.params?.sessionId;
|
|
31
|
+
const body = req.body && typeof req.body === "object" ? req.body : {};
|
|
32
|
+
const positionSeconds = Number(body.positionSeconds);
|
|
33
|
+
|
|
34
|
+
if (!Number.isFinite(positionSeconds) || positionSeconds < 0) {
|
|
35
|
+
reply.code(400);
|
|
36
|
+
return reply.send({ error: "positionSeconds must be a non-negative number." });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const applied = hlsSessionManager.requestSeek(sessionId, positionSeconds);
|
|
40
|
+
if (!applied) {
|
|
41
|
+
// Unknown or disposed session — nothing to steer. Not an error worth
|
|
42
|
+
// surfacing to the viewer: the seek will be handled by whatever session
|
|
43
|
+
// replaces it.
|
|
44
|
+
reply.code(404);
|
|
45
|
+
return reply.send({ error: "No such transcode session." });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
reply.code(204);
|
|
49
|
+
return reply.send();
|
|
50
|
+
}
|
|
@@ -1,111 +1,145 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
1
|
+
import { logger } from "../../../utils/logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How long a request for a not-yet-produced file is held before answering with
|
|
5
|
+
* a retryable 503.
|
|
6
|
+
*
|
|
7
|
+
* MEASUREMENT MODE (2026-08-02): deliberately far above any plausible player
|
|
8
|
+
* deadline, so OUR limit never fires first. Whatever ends the wait is then the
|
|
9
|
+
* player's own behaviour — which is exactly what we need to observe. The
|
|
10
|
+
* `[hold]` log line at the call site records, per request, whether the segment
|
|
11
|
+
* arrived, whether we gave up, or whether the CLIENT aborted, and after how
|
|
12
|
+
* long.
|
|
13
|
+
*
|
|
14
|
+
* The previous value (2 s) was chosen to dodge a reported iOS AVPlayer ~3.5 s
|
|
15
|
+
* response-header deadline. That deadline never appears in our own logs (no
|
|
16
|
+
* -12889 across six hours of production logs), and every reference project
|
|
17
|
+
* holds rather than refusing: Jellyfin and hls-vod-too hold unbounded,
|
|
18
|
+
* hls-media-server holds 10 s. The early refusal is what made the player probe
|
|
19
|
+
* scattered positions, which then steered the encoder off target. Choose the
|
|
20
|
+
* final value from what this measurement shows, not from a number read
|
|
21
|
+
* elsewhere.
|
|
22
|
+
*/
|
|
23
|
+
const SEGMENT_WAIT_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serve HLS playlist and segment files from an active transcode session.
|
|
27
|
+
*
|
|
28
|
+
* Briefly waits for the requested file to appear, then answers with a
|
|
29
|
+
* retryable 503 rather than holding the connection, so clients — in
|
|
30
|
+
* particular iOS's native HLS player — never hit their own response
|
|
31
|
+
* deadline while a segment is still being produced.
|
|
32
|
+
*
|
|
33
|
+
* GET /transcode/:sessionId/:fileName
|
|
34
|
+
*
|
|
35
|
+
* @param {import("fastify").FastifyRequest} req
|
|
36
|
+
* @param {import("fastify").FastifyReply} reply
|
|
37
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
|
|
41
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
42
|
+
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
43
|
+
// Hold the request only briefly, then answer "retry" instead of waiting for
|
|
44
|
+
// the segment. iOS's native HLS player (AVPlayer) enforces a hard ~3.5 s
|
|
45
|
+
// deadline on RESPONSE HEADERS and raises -12889 ("No response for media
|
|
46
|
+
// file") when it passes — it then cancels in-flight requests, probes
|
|
47
|
+
// neighbouring positions and can restart the stream from the beginning. That
|
|
48
|
+
// is exactly the post-seek "player thrashing" seen in the field, because a
|
|
49
|
+
// seek restarts ffmpeg and the first segment then takes far longer than 3.5 s
|
|
50
|
+
// to appear. Holding the connection for 30 s (as this did) guaranteed the
|
|
51
|
+
// timeout on every seek. A short hold keeps the fast path intact (a ready or
|
|
52
|
+
// nearly-ready segment is still served on the first request) while a slow one
|
|
53
|
+
// gets a prompt retryable answer, which resets the player's own deadline.
|
|
54
|
+
// hls.js is unaffected: it consumes the 503 through its retry policy, whose
|
|
55
|
+
// budget the client widens to match (see hls-player.js fragLoadPolicy).
|
|
56
|
+
// Instrumented wait. `clientAborted` flips when the player drops the
|
|
57
|
+
// connection while we are still holding it — the single most informative
|
|
58
|
+
// signal about its real patience, and observable only from this side.
|
|
59
|
+
const holdStartedAt = Date.now();
|
|
60
|
+
let clientAborted = false;
|
|
61
|
+
const onClientAbort = () => { clientAborted = true; };
|
|
62
|
+
req.raw.on("close", onClientAbort);
|
|
63
|
+
|
|
64
|
+
const result = await waitForSessionFile(hlsSessionManager, sessionId, fileName, SEGMENT_WAIT_MS);
|
|
65
|
+
|
|
66
|
+
req.raw.off("close", onClientAbort);
|
|
67
|
+
const heldMs = Date.now() - holdStartedAt;
|
|
68
|
+
if (result.isPlaylist !== true) {
|
|
69
|
+
const outcome = clientAborted
|
|
70
|
+
? "client-aborted"
|
|
71
|
+
: result.kind === "ok" ? "served" : result.kind;
|
|
72
|
+
logger.info(`[hold] ${fileName} ${outcome} after ${heldMs}ms`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (result.kind === "not-found") {
|
|
76
|
+
return reply.code(404).send({ error: "Transcode session file was not found." });
|
|
77
|
+
}
|
|
78
|
+
if (result.kind === "warming-up") {
|
|
79
|
+
// The segment is still being produced (e.g. just after a seek-restart).
|
|
80
|
+
// Return a retryable 503 — never 202, which hls.js cannot consume as a
|
|
81
|
+
// media segment — so the player retries the fetch shortly.
|
|
82
|
+
reply.header("Retry-After", "1");
|
|
83
|
+
// `Retry-After` tells the player to re-request THIS segment after a short
|
|
84
|
+
// pause. Without it a bare 503 reads as "nothing here", and the player goes
|
|
85
|
+
// looking elsewhere: because our synthetic VOD playlist lists every segment
|
|
86
|
+
// of the file, it believes they all exist and SCANS them (field log: one
|
|
87
|
+
// user seek produced probes at #617, #717, #732…). That scan is what used
|
|
88
|
+
// to steer the encoder off the real target. Whether iOS's native player
|
|
89
|
+
// honours the hint is not guaranteed — its behaviour is closed — but this
|
|
90
|
+
// is the standard, correct way to say "wait, don't look elsewhere", and
|
|
91
|
+
// hls.js already retries the same fragment regardless.
|
|
92
|
+
reply.header("Retry-After", "1");
|
|
93
|
+
return reply.code(503).send({ error: "Transcode segment is still being produced." });
|
|
94
|
+
}
|
|
95
|
+
if (result.kind === "failed") {
|
|
96
|
+
return reply.code(500).send({ error: result.message });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (result.isPlaylist) {
|
|
100
|
+
reply.header("Cache-Control", "no-store");
|
|
101
|
+
} else {
|
|
102
|
+
reply.header("Cache-Control", "public, max-age=60");
|
|
103
|
+
}
|
|
104
|
+
reply.header("Content-Type", result.contentType);
|
|
105
|
+
return reply.send(result.stream);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Poll `hlsSessionManager.getFileStream()` until the file is available,
|
|
110
|
+
* the session fails, or the timeout elapses.
|
|
111
|
+
*
|
|
112
|
+
* @param {import("../../../services/hls-session-manager.js").HlsSessionManager} hlsSessionManager
|
|
113
|
+
* @param {string} sessionId
|
|
114
|
+
* @param {string} fileName
|
|
115
|
+
* @param {number} timeoutMs
|
|
116
|
+
* @returns {Promise<Awaited<ReturnType<import("../../../services/hls-session-manager.js").HlsSessionManager["getFileStream"]>>>}
|
|
117
|
+
*/
|
|
118
|
+
async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
|
|
119
|
+
const startedAt = Date.now();
|
|
120
|
+
// One sequence number for THIS request, reused by every poll below, so the
|
|
121
|
+
// session can tell a newly-arrived request apart from an old one polling
|
|
122
|
+
// again — see HlsSessionManager#ensureEncodingFor for the encoder ping-pong
|
|
123
|
+
// this prevents when one seek-bar scrub fires several segment requests.
|
|
124
|
+
const requestSeq = hlsSessionManager.nextRequestSeq(sessionId);
|
|
125
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
126
|
+
const result = await hlsSessionManager.getFileStream(sessionId, fileName, { requestSeq });
|
|
127
|
+
if (result.kind !== "warming-up") {
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
await delay(300);
|
|
131
|
+
}
|
|
132
|
+
return { kind: "warming-up" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve after a given number of milliseconds.
|
|
137
|
+
*
|
|
138
|
+
* @param {number} ms
|
|
139
|
+
* @returns {Promise<void>}
|
|
140
|
+
*/
|
|
141
|
+
function delay(ms) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
setTimeout(resolve, ms);
|
|
144
|
+
});
|
|
145
|
+
}
|
package/server.js
CHANGED
|
@@ -1,216 +1,220 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Proxy HTTP server bootstrap.
|
|
3
|
-
*
|
|
4
|
-
* Creates and configures the Fastify application, registers all routes and
|
|
5
|
-
* plugins, then starts listening on the first available port at or above the
|
|
6
|
-
* requested one.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import Fastify from "fastify";
|
|
10
|
-
import fastifyCors from "@fastify/cors";
|
|
11
|
-
import fastifyHelmet from "@fastify/helmet";
|
|
12
|
-
import fastifyStatic from "@fastify/static";
|
|
13
|
-
import getPort from "get-port";
|
|
14
|
-
import path from "node:path";
|
|
15
|
-
import { createRequire } from "node:module";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { handleHealthGet } from "./routes/health/get.js";
|
|
18
|
-
import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
19
|
-
import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
20
|
-
import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
21
|
-
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
22
|
-
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
23
|
-
import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
|
|
24
|
-
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
25
|
-
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
26
|
-
import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
|
|
27
|
-
import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* @param {number}
|
|
48
|
-
* @
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
* @
|
|
61
|
-
* @property {
|
|
62
|
-
* @property {
|
|
63
|
-
* @property {
|
|
64
|
-
* @property {
|
|
65
|
-
* @property {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* @
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
app.get("/
|
|
160
|
-
app.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
};
|
|
216
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file Proxy HTTP server bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Creates and configures the Fastify application, registers all routes and
|
|
5
|
+
* plugins, then starts listening on the first available port at or above the
|
|
6
|
+
* requested one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Fastify from "fastify";
|
|
10
|
+
import fastifyCors from "@fastify/cors";
|
|
11
|
+
import fastifyHelmet from "@fastify/helmet";
|
|
12
|
+
import fastifyStatic from "@fastify/static";
|
|
13
|
+
import getPort from "get-port";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { handleHealthGet } from "./routes/health/get.js";
|
|
18
|
+
import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
19
|
+
import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
20
|
+
import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
21
|
+
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
22
|
+
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
23
|
+
import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
|
|
24
|
+
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
25
|
+
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
26
|
+
import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
|
|
27
|
+
import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
|
|
28
|
+
import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
|
|
29
|
+
import { handleStreamGet } from "./routes/stream/get.js";
|
|
30
|
+
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
31
|
+
import { createSourceRegistry } from "./store/source-registry.js";
|
|
32
|
+
import { TorrentPool } from "./services/torrent-pool.js";
|
|
33
|
+
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
34
|
+
import { createPlaybackPlanner } from "./services/playback-planner.js";
|
|
35
|
+
import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
|
|
36
|
+
import { logger } from "./utils/logger.js";
|
|
37
|
+
|
|
38
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
39
|
+
const __dirname = path.dirname(__filename);
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const { version } = require("./package.json");
|
|
42
|
+
const publicRoot = path.resolve(__dirname, "./public");
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a list of candidate port numbers starting at `startPort`.
|
|
46
|
+
*
|
|
47
|
+
* @param {number} startPort
|
|
48
|
+
* @param {number} [maxAttempts=51]
|
|
49
|
+
* @returns {number[]}
|
|
50
|
+
*/
|
|
51
|
+
function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
52
|
+
const ports = [];
|
|
53
|
+
for (let index = 0; index < maxAttempts; index += 1) {
|
|
54
|
+
ports.push(startPort + index);
|
|
55
|
+
}
|
|
56
|
+
return ports;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {Object} ProxyServerOptions
|
|
61
|
+
* @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
|
|
62
|
+
* @property {number} port - Preferred listen port.
|
|
63
|
+
* @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
|
|
64
|
+
* @property {string} ffmpegBin - Path to the ffmpeg executable.
|
|
65
|
+
* @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
|
|
66
|
+
* @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create, configure, and start the proxy HTTP server.
|
|
71
|
+
*
|
|
72
|
+
* @param {ProxyServerOptions} options
|
|
73
|
+
* @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
|
|
74
|
+
*/
|
|
75
|
+
export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, segmentFormat }) {
|
|
76
|
+
const app = Fastify({
|
|
77
|
+
// No practical body-size limit — the proxy server is localhost-only and
|
|
78
|
+
// receives torrent source payloads that may be arbitrarily large.
|
|
79
|
+
bodyLimit: 256 * 1024 * 1024 // 256 MB
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await app.register(fastifyHelmet, {
|
|
83
|
+
// Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
|
|
84
|
+
crossOriginResourcePolicy: {
|
|
85
|
+
policy: "cross-origin"
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
await app.register(fastifyCors, {
|
|
89
|
+
origin: true,
|
|
90
|
+
methods: ["GET", "POST", "OPTIONS"],
|
|
91
|
+
allowedHeaders: ["Content-Type", "Range"]
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Allow browser requests from an HTTPS page to this private-network proxy
|
|
95
|
+
// without triggering Chromium's Private Network Access permission prompt.
|
|
96
|
+
app.addHook("onRequest", async (_req, reply) => {
|
|
97
|
+
reply.header("Access-Control-Allow-Private-Network", "true");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const sourceRegistry = createSourceRegistry(200);
|
|
101
|
+
const torrentPool = new TorrentPool({ maxDiskBytes });
|
|
102
|
+
const selectedPort = await getPort({
|
|
103
|
+
port: buildPortCandidates(port)
|
|
104
|
+
});
|
|
105
|
+
// Auto-detect the best available H.264 encoder (hardware-accelerated or
|
|
106
|
+
// software) once at startup, with a real test-encode and graceful fallback.
|
|
107
|
+
// Only needed when transcoding can occur.
|
|
108
|
+
const videoEncoder = transcodeAudio
|
|
109
|
+
? await detectVideoEncoder({ ffmpegBin, logger })
|
|
110
|
+
: null;
|
|
111
|
+
// For software libx264, benchmark preset throughput once at startup so the
|
|
112
|
+
// session manager can pick the highest-quality preset that still encodes each
|
|
113
|
+
// stream faster than realtime. Hardware encoders use their own fixed preset.
|
|
114
|
+
const softwarePresetBenchmark = videoEncoder?.kind === "software"
|
|
115
|
+
? await benchmarkSoftwarePresets({ ffmpegBin, logger })
|
|
116
|
+
: null;
|
|
117
|
+
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
118
|
+
// Detected once; the session manager applies the tonemap chain only for HDR
|
|
119
|
+
// sources on the software path when available.
|
|
120
|
+
const tonemapSupported = transcodeAudio
|
|
121
|
+
? await detectTonemapSupport({ ffmpegBin, logger })
|
|
122
|
+
: false;
|
|
123
|
+
const hlsSessionManager = new HlsSessionManager({
|
|
124
|
+
enabled: transcodeAudio,
|
|
125
|
+
ffmpegBin,
|
|
126
|
+
localBindHost: host,
|
|
127
|
+
localPort: selectedPort,
|
|
128
|
+
videoEncoder,
|
|
129
|
+
softwarePresetBenchmark,
|
|
130
|
+
tonemapSupported,
|
|
131
|
+
segmentFormatId: segmentFormat,
|
|
132
|
+
// Live download stats accessor for the realtime budget: lets it tell a
|
|
133
|
+
// CPU-bound transcode from a download-starved input before downscaling.
|
|
134
|
+
getSourceStats: async (sourceKey, fileIndex) => {
|
|
135
|
+
const record = sourceRegistry.get(sourceKey);
|
|
136
|
+
if (!record) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
141
|
+
return torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
// Reuse the media info the planner already probed for this file (same
|
|
147
|
+
// ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
|
|
148
|
+
// only at session-create time, after playbackPlanner is initialised.
|
|
149
|
+
getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params)
|
|
150
|
+
});
|
|
151
|
+
const playbackPlanner = createPlaybackPlanner({
|
|
152
|
+
ffmpegBin,
|
|
153
|
+
transcodeAudioEnabled: transcodeAudio,
|
|
154
|
+
localBaseUrl: hlsSessionManager.localBaseUrl,
|
|
155
|
+
sourceRegistry,
|
|
156
|
+
torrentPool
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
|
|
160
|
+
app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
|
|
161
|
+
app.post("/api/sources", async (req, reply) =>
|
|
162
|
+
handleApiSourcesPost(req, reply, { sourceRegistry })
|
|
163
|
+
);
|
|
164
|
+
app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
|
|
165
|
+
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
|
|
166
|
+
);
|
|
167
|
+
app.get("/api/sources/:sourceKey/files", async (req, reply) =>
|
|
168
|
+
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
|
|
169
|
+
);
|
|
170
|
+
app.post("/api/playback-plan", async (req, reply) =>
|
|
171
|
+
handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
172
|
+
);
|
|
173
|
+
app.get("/api/subtitles", async (req, reply) =>
|
|
174
|
+
handleApiSubtitlesGet(req, reply, {
|
|
175
|
+
sourceRegistry,
|
|
176
|
+
torrentPool,
|
|
177
|
+
ffmpegBin,
|
|
178
|
+
localBaseUrl: hlsSessionManager.localBaseUrl
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
app.get("/stream", async (req, reply) =>
|
|
182
|
+
handleStreamGet(req, reply, { sourceRegistry, torrentPool })
|
|
183
|
+
);
|
|
184
|
+
app.post("/api/transcode-sessions", async (req, reply) =>
|
|
185
|
+
handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager })
|
|
186
|
+
);
|
|
187
|
+
app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
|
|
188
|
+
handleApiTranscodeSessionReleasePost(req, reply, { hlsSessionManager })
|
|
189
|
+
);
|
|
190
|
+
app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
|
|
191
|
+
handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager })
|
|
192
|
+
);
|
|
193
|
+
app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
|
|
194
|
+
handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
|
|
195
|
+
);
|
|
196
|
+
app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
|
|
197
|
+
handleApiTranscodeSessionSeekPost(req, reply, { hlsSessionManager })
|
|
198
|
+
);
|
|
199
|
+
app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
|
|
200
|
+
handleTranscodeSessionFileGet(req, reply, { hlsSessionManager })
|
|
201
|
+
);
|
|
202
|
+
await app.register(fastifyStatic, {
|
|
203
|
+
root: publicRoot,
|
|
204
|
+
prefix: "/",
|
|
205
|
+
serveDotFiles: true
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
app.addHook("onClose", async () => {
|
|
209
|
+
// Order matters: stop the ffmpeg readers (HLS sessions) before destroying
|
|
210
|
+
// the torrents whose files they read from, then remove the torrent data.
|
|
211
|
+
await hlsSessionManager.disposeAll();
|
|
212
|
+
await torrentPool.destroyAll();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await app.listen({ host, port: selectedPort });
|
|
216
|
+
return {
|
|
217
|
+
app,
|
|
218
|
+
port: selectedPort
|
|
219
|
+
};
|
|
220
|
+
}
|