@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.60",
3
+ "version": "2.9.61",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- * How long a request for a not-yet-produced file is held before answering with
3
- * a retryable 503. Must stay comfortably below iOS AVPlayer's ~3.5 s
4
- * response-header deadline (see the call site for the full rationale).
5
- */
6
- const SEGMENT_WAIT_MS = 2_000;
7
-
8
- /**
9
- * Serve HLS playlist and segment files from an active transcode session.
10
- *
11
- * Briefly waits for the requested file to appear, then answers with a
12
- * retryable 503 rather than holding the connection, so clients — in
13
- * particular iOS's native HLS player — never hit their own response
14
- * deadline while a segment is still being produced.
15
- *
16
- * GET /transcode/:sessionId/:fileName
17
- *
18
- * @param {import("fastify").FastifyRequest} req
19
- * @param {import("fastify").FastifyReply} reply
20
- * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
21
- * @returns {Promise<void>}
22
- */
23
- export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
24
- const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
25
- const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
26
- // Hold the request only briefly, then answer "retry" instead of waiting for
27
- // the segment. iOS's native HLS player (AVPlayer) enforces a hard ~3.5 s
28
- // deadline on RESPONSE HEADERS and raises -12889 ("No response for media
29
- // file") when it passes it then cancels in-flight requests, probes
30
- // neighbouring positions and can restart the stream from the beginning. That
31
- // is exactly the post-seek "player thrashing" seen in the field, because a
32
- // seek restarts ffmpeg and the first segment then takes far longer than 3.5 s
33
- // to appear. Holding the connection for 30 s (as this did) guaranteed the
34
- // timeout on every seek. A short hold keeps the fast path intact (a ready or
35
- // nearly-ready segment is still served on the first request) while a slow one
36
- // gets a prompt retryable answer, which resets the player's own deadline.
37
- // hls.js is unaffected: it consumes the 503 through its retry policy, whose
38
- // budget the client widens to match (see hls-player.js fragLoadPolicy).
39
- const result = await waitForSessionFile(hlsSessionManager, sessionId, fileName, SEGMENT_WAIT_MS);
40
-
41
- if (result.kind === "not-found") {
42
- return reply.code(404).send({ error: "Transcode session file was not found." });
43
- }
44
- if (result.kind === "warming-up") {
45
- // The segment is still being produced (e.g. just after a seek-restart).
46
- // Return a retryable 503never 202, which hls.js cannot consume as a
47
- // media segment so the player retries the fetch shortly.
48
- reply.header("Retry-After", "1");
49
- // `Retry-After` tells the player to re-request THIS segment after a short
50
- // pause. Without it a bare 503 reads as "nothing here", and the player goes
51
- // looking elsewhere: because our synthetic VOD playlist lists every segment
52
- // of the file, it believes they all exist and SCANS them (field log: one
53
- // user seek produced probes at #617, #717, #732…). That scan is what used
54
- // to steer the encoder off the real target. Whether iOS's native player
55
- // honours the hint is not guaranteed its behaviour is closed — but this
56
- // is the standard, correct way to say "wait, don't look elsewhere", and
57
- // hls.js already retries the same fragment regardless.
58
- reply.header("Retry-After", "1");
59
- return reply.code(503).send({ error: "Transcode segment is still being produced." });
60
- }
61
- if (result.kind === "failed") {
62
- return reply.code(500).send({ error: result.message });
63
- }
64
-
65
- if (result.isPlaylist) {
66
- reply.header("Cache-Control", "no-store");
67
- } else {
68
- reply.header("Cache-Control", "public, max-age=60");
69
- }
70
- reply.header("Content-Type", result.contentType);
71
- return reply.send(result.stream);
72
- }
73
-
74
- /**
75
- * Poll `hlsSessionManager.getFileStream()` until the file is available,
76
- * the session fails, or the timeout elapses.
77
- *
78
- * @param {import("../../../services/hls-session-manager.js").HlsSessionManager} hlsSessionManager
79
- * @param {string} sessionId
80
- * @param {string} fileName
81
- * @param {number} timeoutMs
82
- * @returns {Promise<Awaited<ReturnType<import("../../../services/hls-session-manager.js").HlsSessionManager["getFileStream"]>>>}
83
- */
84
- async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
85
- const startedAt = Date.now();
86
- // One sequence number for THIS request, reused by every poll below, so the
87
- // session can tell a newly-arrived request apart from an old one polling
88
- // again see HlsSessionManager#ensureEncodingFor for the encoder ping-pong
89
- // this prevents when one seek-bar scrub fires several segment requests.
90
- const requestSeq = hlsSessionManager.nextRequestSeq(sessionId);
91
- while (Date.now() - startedAt < timeoutMs) {
92
- const result = await hlsSessionManager.getFileStream(sessionId, fileName, { requestSeq });
93
- if (result.kind !== "warming-up") {
94
- return result;
95
- }
96
- await delay(300);
97
- }
98
- return { kind: "warming-up" };
99
- }
100
-
101
- /**
102
- * Resolve after a given number of milliseconds.
103
- *
104
- * @param {number} ms
105
- * @returns {Promise<void>}
106
- */
107
- function delay(ms) {
108
- return new Promise((resolve) => {
109
- setTimeout(resolve, ms);
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 passesit 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 { handleStreamGet } from "./routes/stream/get.js";
29
- import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
30
- import { createSourceRegistry } from "./store/source-registry.js";
31
- import { TorrentPool } from "./services/torrent-pool.js";
32
- import { HlsSessionManager } from "./services/hls-session-manager.js";
33
- import { createPlaybackPlanner } from "./services/playback-planner.js";
34
- import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
35
- import { logger } from "./utils/logger.js";
36
-
37
- const __filename = fileURLToPath(import.meta.url);
38
- const __dirname = path.dirname(__filename);
39
- const require = createRequire(import.meta.url);
40
- const { version } = require("./package.json");
41
- const publicRoot = path.resolve(__dirname, "./public");
42
-
43
- /**
44
- * Build a list of candidate port numbers starting at `startPort`.
45
- *
46
- * @param {number} startPort
47
- * @param {number} [maxAttempts=51]
48
- * @returns {number[]}
49
- */
50
- function buildPortCandidates(startPort, maxAttempts = 51) {
51
- const ports = [];
52
- for (let index = 0; index < maxAttempts; index += 1) {
53
- ports.push(startPort + index);
54
- }
55
- return ports;
56
- }
57
-
58
- /**
59
- * @typedef {Object} ProxyServerOptions
60
- * @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
61
- * @property {number} port - Preferred listen port.
62
- * @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
63
- * @property {string} ffmpegBin - Path to the ffmpeg executable.
64
- * @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
65
- * @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
66
- */
67
-
68
- /**
69
- * Create, configure, and start the proxy HTTP server.
70
- *
71
- * @param {ProxyServerOptions} options
72
- * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
73
- */
74
- export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, segmentFormat }) {
75
- const app = Fastify({
76
- // No practical body-size limit — the proxy server is localhost-only and
77
- // receives torrent source payloads that may be arbitrarily large.
78
- bodyLimit: 256 * 1024 * 1024 // 256 MB
79
- });
80
-
81
- await app.register(fastifyHelmet, {
82
- // Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
83
- crossOriginResourcePolicy: {
84
- policy: "cross-origin"
85
- }
86
- });
87
- await app.register(fastifyCors, {
88
- origin: true,
89
- methods: ["GET", "POST", "OPTIONS"],
90
- allowedHeaders: ["Content-Type", "Range"]
91
- });
92
-
93
- // Allow browser requests from an HTTPS page to this private-network proxy
94
- // without triggering Chromium's Private Network Access permission prompt.
95
- app.addHook("onRequest", async (_req, reply) => {
96
- reply.header("Access-Control-Allow-Private-Network", "true");
97
- });
98
-
99
- const sourceRegistry = createSourceRegistry(200);
100
- const torrentPool = new TorrentPool({ maxDiskBytes });
101
- const selectedPort = await getPort({
102
- port: buildPortCandidates(port)
103
- });
104
- // Auto-detect the best available H.264 encoder (hardware-accelerated or
105
- // software) once at startup, with a real test-encode and graceful fallback.
106
- // Only needed when transcoding can occur.
107
- const videoEncoder = transcodeAudio
108
- ? await detectVideoEncoder({ ffmpegBin, logger })
109
- : null;
110
- // For software libx264, benchmark preset throughput once at startup so the
111
- // session manager can pick the highest-quality preset that still encodes each
112
- // stream faster than realtime. Hardware encoders use their own fixed preset.
113
- const softwarePresetBenchmark = videoEncoder?.kind === "software"
114
- ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
115
- : null;
116
- // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
117
- // Detected once; the session manager applies the tonemap chain only for HDR
118
- // sources on the software path when available.
119
- const tonemapSupported = transcodeAudio
120
- ? await detectTonemapSupport({ ffmpegBin, logger })
121
- : false;
122
- const hlsSessionManager = new HlsSessionManager({
123
- enabled: transcodeAudio,
124
- ffmpegBin,
125
- localBindHost: host,
126
- localPort: selectedPort,
127
- videoEncoder,
128
- softwarePresetBenchmark,
129
- tonemapSupported,
130
- segmentFormatId: segmentFormat,
131
- // Live download stats accessor for the realtime budget: lets it tell a
132
- // CPU-bound transcode from a download-starved input before downscaling.
133
- getSourceStats: async (sourceKey, fileIndex) => {
134
- const record = sourceRegistry.get(sourceKey);
135
- if (!record) {
136
- return null;
137
- }
138
- try {
139
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
140
- return torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
141
- } catch {
142
- return null;
143
- }
144
- },
145
- // Reuse the media info the planner already probed for this file (same
146
- // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
147
- // only at session-create time, after playbackPlanner is initialised.
148
- getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params)
149
- });
150
- const playbackPlanner = createPlaybackPlanner({
151
- ffmpegBin,
152
- transcodeAudioEnabled: transcodeAudio,
153
- localBaseUrl: hlsSessionManager.localBaseUrl,
154
- sourceRegistry,
155
- torrentPool
156
- });
157
-
158
- app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
159
- app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
160
- app.post("/api/sources", async (req, reply) =>
161
- handleApiSourcesPost(req, reply, { sourceRegistry })
162
- );
163
- app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
164
- handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
165
- );
166
- app.get("/api/sources/:sourceKey/files", async (req, reply) =>
167
- handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
168
- );
169
- app.post("/api/playback-plan", async (req, reply) =>
170
- handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
171
- );
172
- app.get("/api/subtitles", async (req, reply) =>
173
- handleApiSubtitlesGet(req, reply, {
174
- sourceRegistry,
175
- torrentPool,
176
- ffmpegBin,
177
- localBaseUrl: hlsSessionManager.localBaseUrl
178
- })
179
- );
180
- app.get("/stream", async (req, reply) =>
181
- handleStreamGet(req, reply, { sourceRegistry, torrentPool })
182
- );
183
- app.post("/api/transcode-sessions", async (req, reply) =>
184
- handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager })
185
- );
186
- app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
187
- handleApiTranscodeSessionReleasePost(req, reply, { hlsSessionManager })
188
- );
189
- app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
190
- handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager })
191
- );
192
- app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
193
- handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
194
- );
195
- app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
196
- handleTranscodeSessionFileGet(req, reply, { hlsSessionManager })
197
- );
198
- await app.register(fastifyStatic, {
199
- root: publicRoot,
200
- prefix: "/",
201
- serveDotFiles: true
202
- });
203
-
204
- app.addHook("onClose", async () => {
205
- // Order matters: stop the ffmpeg readers (HLS sessions) before destroying
206
- // the torrents whose files they read from, then remove the torrent data.
207
- await hlsSessionManager.disposeAll();
208
- await torrentPool.destroyAll();
209
- });
210
-
211
- await app.listen({ host, port: selectedPort });
212
- return {
213
- app,
214
- port: selectedPort
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
+ }