@torrent-tv/proxy 2.80.5 → 2.80.7

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/routes/api/delivery-sink/get.js +8 -4
  4. package/server.js +415 -403
  5. package/services/data-channel-handler.js +87 -25
  6. package/services/delivery-probe.js +38 -5
  7. package/services/encode/CoverageMap.js +77 -4
  8. package/services/encode/EncodePlan.js +1025 -358
  9. package/services/encode/EncodeRun.js +19 -1
  10. package/services/encode/SegmentDemand.js +0 -0
  11. package/services/encode/SegmentStore.js +53 -1
  12. package/services/encode/open-piece.js +22 -1
  13. package/services/encode/run-command.js +12 -2
  14. package/services/hls-session-manager.js +38 -158
  15. package/services/hwaccel.js +182 -54
  16. package/services/orchestrators/EncodeOrchestrator.js +118 -89
  17. package/services/output/LiveOutputs.js +233 -213
  18. package/services/output/Timeline.js +333 -256
  19. package/services/piece-store/shared-piece-store.js +13 -9
  20. package/services/priority/PriorityMap.js +262 -108
  21. package/services/priority/PriorityOrchestrator.js +31 -6
  22. package/services/quality/EncodeCost.js +555 -500
  23. package/services/torrent-pool.js +9 -4
  24. package/test/encode-orchestrator.test.js +195 -65
  25. package/test/encode-plan-viewers.test.js +719 -0
  26. package/test/encode-plan.test.js +174 -81
  27. package/test/open-piece.test.js +40 -0
  28. package/test/output-speed.test.js +86 -0
  29. package/test/piece-store-eviction.test.js +26 -0
  30. package/test/priority-map-download.test.js +25 -7
  31. package/test/priority-map.test.js +134 -83
  32. package/test/seek-landing.test.js +109 -76
  33. package/test/segment-demand.test.js +54 -56
  34. package/test/wedge-certainty.test.js +3 -3
package/server.js CHANGED
@@ -1,403 +1,415 @@
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 { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
20
- import { handleApiSourcesPost } from "./routes/api/sources/post.js";
21
- import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
22
- import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
23
- import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
24
- import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
25
- import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
26
- import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
27
- import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
28
- import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
29
- import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
30
- import { handleApiTranscodeSessionFragmentFarPost } from "./routes/api/transcode-sessions/fragment-far/post.js";
31
- import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
32
- import { handleStreamGet } from "./routes/stream/get.js";
33
- import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
34
- import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
35
- import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
36
- import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
37
- import { handleTranscodeAudioWarmGet } from "./routes/transcode/audio-warm/get.js";
38
- import { createSourceRegistry } from "./store/source-registry.js";
39
- import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
40
- import { HlsSessionManager } from "./services/hls-session-manager.js";
41
- import { createPlaybackPlanner } from "./services/playback-planner.js";
42
- import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, benchmarkContention, detectTonemapSupport } from "./services/hwaccel.js";
43
- import { logger } from "./utils/logger.js";
44
-
45
- const __filename = fileURLToPath(import.meta.url);
46
- const __dirname = path.dirname(__filename);
47
- const require = createRequire(import.meta.url);
48
- const { version } = require("./package.json");
49
- const publicRoot = path.resolve(__dirname, "./public");
50
-
51
- /**
52
- * Build a list of candidate port numbers starting at `startPort`.
53
- *
54
- * @param {number} startPort
55
- * @param {number} [maxAttempts=51]
56
- * @returns {number[]}
57
- */
58
- function buildPortCandidates(startPort, maxAttempts = 51) {
59
- const ports = [];
60
- for (let index = 0; index < maxAttempts; index += 1) {
61
- ports.push(startPort + index);
62
- }
63
- return ports;
64
- }
65
-
66
- /**
67
- * @typedef {Object} ProxyServerOptions
68
- * @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
69
- * @property {number} port - Preferred listen port.
70
- * @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
71
- * @property {string} ffmpegBin - Path to the ffmpeg executable.
72
- * @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
73
- * @property {number} [memoryBytes] - Per-torrent budget for pieces held in memory (undefined = store default).
74
- * @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
75
- * @property {string} [stateDir] - Where to keep what this host has measured about itself.
76
- */
77
-
78
- /**
79
- * Create, configure, and start the proxy HTTP server.
80
- *
81
- * @param {ProxyServerOptions} options
82
- * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
83
- */
84
- export async function startProxyServer({
85
- host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir, onSubtitleCues,
86
- deliverySink = false
87
- }) {
88
- const app = Fastify({
89
- // No practical body-size limit — the proxy server is localhost-only and
90
- // receives torrent source payloads that may be arbitrarily large.
91
- bodyLimit: 256 * 1024 * 1024 // 256 MB
92
- });
93
-
94
- await app.register(fastifyHelmet, {
95
- // Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
96
- crossOriginResourcePolicy: {
97
- policy: "cross-origin"
98
- }
99
- });
100
- await app.register(fastifyCors, {
101
- origin: true,
102
- methods: ["GET", "POST", "OPTIONS"],
103
- allowedHeaders: ["Content-Type", "Range"]
104
- });
105
-
106
- // Allow browser requests from an HTTPS page to this private-network proxy
107
- // without triggering Chromium's Private Network Access permission prompt.
108
- app.addHook("onRequest", async (_req, reply) => {
109
- reply.header("Access-Control-Allow-Private-Network", "true");
110
- });
111
-
112
- const sourceRegistry = createSourceRegistry(200);
113
- // The torrent runs on its own thread. Profiling a live seek (2026-08-02)
114
- // found the main thread ~85% occupied by WebTorrent — buffer concatenation
115
- // ~15%, wire updates ~9%, garbage collection ~5% — while three of four cores
116
- // idled. Serving a segment shared that thread, so reading an already-finished
117
- // 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
118
- // adapter keeps TorrentPool's interface, so nothing downstream changed.
119
- const torrentPool = new WorkerTorrentPool({ maxDiskBytes, memoryBytes, stateDir, onSubtitleCues });
120
- const selectedPort = await getPort({
121
- port: buildPortCandidates(port)
122
- });
123
- // Auto-detect the best available H.264 encoder (hardware-accelerated or
124
- // software) once at startup, with a real test-encode and graceful fallback.
125
- // Only needed when transcoding can occur.
126
- const videoEncoder = transcodeAudio
127
- ? await detectVideoEncoder({ ffmpegBin, logger })
128
- : null;
129
- // For software libx264, benchmark preset throughput once at startup so the
130
- // session manager can pick the highest-quality preset that still encodes each
131
- // stream faster than realtime. Hardware encoders use their own fixed preset.
132
- // The decode model first, and the preset benchmark second — they are
133
- // independent now (the presets are timed on raw frames), but the order costs
134
- // nothing and keeps the two figures side by side in the log.
135
- const decodeCostModel = videoEncoder?.kind === "software"
136
- ? await benchmarkDecodeCost({ ffmpegBin, logger })
137
- : null;
138
- // What a second job costs on this host. Measured because the budget adds
139
- // independent prices and this host says two jobs that each fit alone do not
140
- // fit together — 2.6× on the addon box (2026-08-18).
141
- const contentionPenalties = videoEncoder?.kind === "software"
142
- ? await benchmarkContention({ ffmpegBin, logger })
143
- : null;
144
- const softwarePresetBenchmark = videoEncoder?.kind === "software"
145
- ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
146
- : null;
147
- // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
148
- // Detected once; the session manager applies the tonemap chain only for HDR
149
- // sources on the software path when available.
150
- const tonemapSupported = transcodeAudio
151
- ? await detectTonemapSupport({ ffmpegBin, logger })
152
- : false;
153
- const hlsSessionManager = new HlsSessionManager({
154
- enabled: transcodeAudio,
155
- ffmpegBin,
156
- localBindHost: host,
157
- localPort: selectedPort,
158
- videoEncoder,
159
- softwarePresetBenchmark,
160
- decodeCostModel,
161
- contentionPenalties,
162
- tonemapSupported,
163
- segmentFormatId: segmentFormat,
164
- stateDir,
165
- // Live download stats accessor for the realtime budget: lets it tell a
166
- // CPU-bound transcode from a download-starved input before downscaling.
167
- // What every torrent here has moved, so the proxy can price its own
168
- // downloading, hashing and delivery against the machine (roadmap item 7).
169
- getTorrentTotals: async () => {
170
- if (typeof torrentPool.getTorrentTotals !== "function") {
171
- return null;
172
- }
173
- return torrentPool.getTorrentTotals();
174
- },
175
- // The priority map, on its way to the downloading. It lives in another
176
- // thread, so the map crosses the worker channel; what it does with it —
177
- // seconds into bytes, what to ask the swarm for, what to keep in memory —
178
- // is its own business.
179
- setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
180
- const record = sourceRegistry.get(sourceKey);
181
- if (!record) {
182
- return;
183
- }
184
- try {
185
- await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
186
- } catch {
187
- // Best effort: the map is republished on the next change, and the
188
- // downloading goes on serving reads meanwhile.
189
- }
190
- },
191
- getSourceStats: async (sourceKey, fileIndex) => {
192
- const record = sourceRegistry.get(sourceKey);
193
- if (!record) {
194
- return null;
195
- }
196
- try {
197
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
198
- // Awaited for the same reason as the stats route: this now crosses a
199
- // thread boundary and returns a promise.
200
- return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
201
- } catch {
202
- return null;
203
- }
204
- },
205
- // Reuse the media info the planner already probed for this file (same
206
- // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
207
- // only at session-create time, after playbackPlanner is initialised.
208
- getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
209
- // The file's audio tracks, for the master playlist's rendition group. Already
210
- // probed for the browser's audio menu; read from there rather than probed again.
211
- getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
212
- // What a file declares about itself, read by the container layer from the
213
- // same header its track table comes from. This is how the session learns
214
- // where a soundtrack shipped as its own file begins — it used to spawn an
215
- // ffmpeg over this proxy's own HTTP to ask the same question of the same
216
- // bytes, and that read cost 8.1 s of every cold start (field 2026-09-03).
217
- getContainerMediaInfo: async ({ sourceKey, fileIndex }) => {
218
- const record = sourceRegistry.get(sourceKey);
219
- if (!record || typeof torrentPool.getContainerMediaInfo !== "function") {
220
- return null;
221
- }
222
- try {
223
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
224
- return await torrentPool.getContainerMediaInfo(torrent, fileIndex);
225
- } catch {
226
- return null;
227
- }
228
- },
229
- // Where the file's keyframes are, read by the same container that answered
230
- // the two above. It used to be read by the session itself over this proxy's
231
- // own HTTP, once per session so two viewers opening one film read the
232
- // same table twice, and each of them could get a different answer about
233
- // whether the picture can be copied at all.
234
- getContainerKeyframes: async ({ sourceKey, fileIndex }) => {
235
- const record = sourceRegistry.get(sourceKey);
236
- if (!record || typeof torrentPool.getContainerKeyframes !== "function") {
237
- return null;
238
- }
239
- try {
240
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
241
- return await torrentPool.getContainerKeyframes(torrent, fileIndex);
242
- } catch {
243
- return null;
244
- }
245
- },
246
- // Pull one whole file onto the disk. Used for a soundtrack that ships beside
247
- // the picture, once the encoder is as far ahead of the viewer as it is
248
- // allowed to get the one moment the swarm's capacity is demonstrably
249
- // spare. A bounded read of the file's whole length, NOT `file.select()`:
250
- // selecting a file alongside the readers' own windows is what made a seek
251
- // wait 93 s while the swarm fetched 2.47 GB in file order (see
252
- // `#syncSelections` in `torrent-pool.js`).
253
- fetchWholeFile: async ({ sourceKey, fileIndex }) => {
254
- const record = sourceRegistry.get(sourceKey);
255
- if (!record) {
256
- return;
257
- }
258
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
259
- // The same background fill the warm-up starts when a file is chosen, not a
260
- // second way of doing it. It is guarded against running twice on one file,
261
- // so the two triggers converge instead of putting two readers on the same
262
- // soundtrack and only one of them would have stood aside for the
263
- // picture. This trigger remains for the session that never had a warm-up
264
- // before it.
265
- await torrentPool.fillFileInBackground?.(torrent, fileIndex);
266
- }
267
- });
268
- // What the last life of this process left on the disk. The kernel kills this
269
- // one often enough for that to be an ordinary state rather than an odd one —
270
- // twice in a single viewing on 2026-09-02 — and when it does, no exit handler
271
- // runs and nothing is cleared up. So this is both the cleanup and the only
272
- // record that those encoders ended at all: it says what it found before it
273
- // decides anything, keeps the segments whose closure is proven, and removes
274
- // the one piece per output that was being written when the process died.
275
- hlsSessionManager.adoptSegmentsLeftBehind();
276
- const playbackPlanner = createPlaybackPlanner({
277
- ffmpegBin,
278
- transcodeAudioEnabled: transcodeAudio,
279
- localBaseUrl: hlsSessionManager.localBaseUrl,
280
- sourceRegistry,
281
- torrentPool,
282
- warmKeyframeIndex: (params) => hlsSessionManager.warmKeyframeIndex(params),
283
- expectedFirstSegmentMs: () => hlsSessionManager.expectedFirstSegmentMs(),
284
- expectedSessionCreateMs: () => hlsSessionManager.expectedSessionCreateMs(),
285
- // The quality menu is on screen from the moment a file is opened, so the
286
- // heights this host can actually serve have to be answerable before any
287
- // encoder exists — from the probe and the startup benchmarks alone.
288
- predictOfferedHeights: (mediaInfo) => hlsSessionManager.predictOfferedHeights(mediaInfo)
289
- });
290
-
291
- app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
292
- app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
293
- // Off unless --delivery-sink was given; see the route for why it exists.
294
- app.get("/api/delivery-sink", async (req, reply) =>
295
- handleApiDeliverySinkGet(req, reply, { enabled: deliverySink === true })
296
- );
297
- app.post("/api/sources", async (req, reply) =>
298
- handleApiSourcesPost(req, reply, { sourceRegistry })
299
- );
300
- app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
301
- handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
302
- );
303
- app.get("/api/sources/:sourceKey/files", async (req, reply) =>
304
- handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
305
- );
306
- app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
307
- handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool })
308
- );
309
- app.post("/api/playback-plan", async (req, reply) =>
310
- handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
311
- );
312
- app.get("/api/subtitles", async (req, reply) =>
313
- handleApiSubtitlesGet(req, reply, {
314
- sourceRegistry,
315
- torrentPool,
316
- ffmpegBin,
317
- localBaseUrl: hlsSessionManager.localBaseUrl
318
- })
319
- );
320
- app.get("/stream", async (req, reply) =>
321
- handleStreamGet(req, reply, {
322
- sourceRegistry,
323
- torrentPool,
324
- // So a session that has produced nothing yet can still show it is being
325
- // fed. The route knows only files; the session id rides on the URL the
326
- // session itself built.
327
- noteInputBytes: (sessionId, bytes) => hlsSessionManager.noteInputBytes(sessionId, bytes)
328
- })
329
- );
330
- app.post("/api/transcode-sessions", async (req, reply) =>
331
- handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool })
332
- );
333
- app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
334
- handleApiTranscodeSessionReleasePost(req, reply, { hlsSessionManager })
335
- );
336
- app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
337
- handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager })
338
- );
339
- app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
340
- handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
341
- );
342
- app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
343
- handleApiTranscodeSessionFragmentFarPost(req, reply, { hlsSessionManager })
344
- );
345
- app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
346
- handleApiTranscodeSessionSeekPost(req, reply, { hlsSessionManager })
347
- );
348
- app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
349
- handleTranscodeSessionFileGet(req, reply, { hlsSessionManager })
350
- );
351
- // A quality variant's files. Registered before the static handler for the
352
- // same reason as the line above, and kept a separate route rather than a
353
- // wildcard so the height stays a parsed parameter.
354
- // Registered BEFORE the variant file route: `warm` is not a file name, and
355
- // Fastify matches a static segment ahead of a parameter either way — stated
356
- // here so the order is not "tidied" into a bug.
357
- app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
358
- handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
359
- );
360
- app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
361
- handleTranscodeAudioWarmGet(req, reply, { hlsSessionManager })
362
- );
363
- app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
364
- handleTranscodeAudioFileGet(req, reply, { hlsSessionManager })
365
- );
366
- app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
367
- handleTranscodeVariantFileGet(req, reply, { hlsSessionManager })
368
- );
369
- await app.register(fastifyStatic, {
370
- root: publicRoot,
371
- prefix: "/",
372
- serveDotFiles: true
373
- });
374
-
375
- app.addHook("onClose", async () => {
376
- // Order matters: stop the ffmpeg readers (HLS sessions) before destroying
377
- // the torrents whose files they read from, then remove the torrent data.
378
- await hlsSessionManager.disposeAll();
379
- await torrentPool.destroyAll();
380
- });
381
-
382
- await app.listen({ host, port: selectedPort });
383
- return {
384
- app,
385
- port: selectedPort,
386
- // Asked over the tunnel when the proxy a viewer landed on has refused their
387
- // file: could THIS host sustain it? Answered from the startup benchmarks
388
- // and a description, so it needs no torrent and costs milliseconds.
389
- hlsSessionManager,
390
- // The browser only ever knows a source by its REGISTRY key (a hash of the
391
- // raw request bytes, scoped to one API session) — never the torrent
392
- // pool's own key (the content's infohash, shared across a magnet and a
393
- // `.torrent` naming the same film). The subtitle push subscription is
394
- // recorded from a browser request and published from the pool's side, so
395
- // resolving one into the other is what lets the two ends agree on what
396
- // they are both calling "sourceKey".
397
- sourceRegistry,
398
- // Which films this host holds, for the health poll the browser makes before
399
- // it picks a proxy. The pool is on the worker thread and this is the way to
400
- // it from the process that answers that poll.
401
- torrentPool
402
- };
403
- }
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 { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
20
+ import { handleApiSourcesPost } from "./routes/api/sources/post.js";
21
+ import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
22
+ import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
23
+ import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
24
+ import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
25
+ import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
26
+ import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
27
+ import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
28
+ import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
29
+ import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
30
+ import { handleApiTranscodeSessionFragmentFarPost } from "./routes/api/transcode-sessions/fragment-far/post.js";
31
+ import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
32
+ import { handleStreamGet } from "./routes/stream/get.js";
33
+ import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
34
+ import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
35
+ import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
36
+ import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
37
+ import { handleTranscodeAudioWarmGet } from "./routes/transcode/audio-warm/get.js";
38
+ import { createSourceRegistry } from "./store/source-registry.js";
39
+ import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
40
+ import { HlsSessionManager } from "./services/hls-session-manager.js";
41
+ import { createPlaybackPlanner } from "./services/playback-planner.js";
42
+ import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, benchmarkContention, benchmarkCopySpeed, detectTonemapSupport } from "./services/hwaccel.js";
43
+ import { logger } from "./utils/logger.js";
44
+
45
+ const __filename = fileURLToPath(import.meta.url);
46
+ const __dirname = path.dirname(__filename);
47
+ const require = createRequire(import.meta.url);
48
+ const { version } = require("./package.json");
49
+ const publicRoot = path.resolve(__dirname, "./public");
50
+
51
+ /**
52
+ * Build a list of candidate port numbers starting at `startPort`.
53
+ *
54
+ * @param {number} startPort
55
+ * @param {number} [maxAttempts=51]
56
+ * @returns {number[]}
57
+ */
58
+ function buildPortCandidates(startPort, maxAttempts = 51) {
59
+ const ports = [];
60
+ for (let index = 0; index < maxAttempts; index += 1) {
61
+ ports.push(startPort + index);
62
+ }
63
+ return ports;
64
+ }
65
+
66
+ /**
67
+ * @typedef {Object} ProxyServerOptions
68
+ * @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
69
+ * @property {number} port - Preferred listen port.
70
+ * @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
71
+ * @property {string} ffmpegBin - Path to the ffmpeg executable.
72
+ * @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
73
+ * @property {number} [memoryBytes] - Per-torrent budget for pieces held in memory (undefined = store default).
74
+ * @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
75
+ * @property {string} [stateDir] - Where to keep what this host has measured about itself.
76
+ */
77
+
78
+ /**
79
+ * Create, configure, and start the proxy HTTP server.
80
+ *
81
+ * @param {ProxyServerOptions} options
82
+ * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
83
+ */
84
+ export async function startProxyServer({
85
+ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir, onSubtitleCues,
86
+ deliverySink = false
87
+ }) {
88
+ const app = Fastify({
89
+ // No practical body-size limit — the proxy server is localhost-only and
90
+ // receives torrent source payloads that may be arbitrarily large.
91
+ bodyLimit: 256 * 1024 * 1024 // 256 MB
92
+ });
93
+
94
+ await app.register(fastifyHelmet, {
95
+ // Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
96
+ crossOriginResourcePolicy: {
97
+ policy: "cross-origin"
98
+ }
99
+ });
100
+ await app.register(fastifyCors, {
101
+ origin: true,
102
+ methods: ["GET", "POST", "OPTIONS"],
103
+ allowedHeaders: ["Content-Type", "Range"]
104
+ });
105
+
106
+ // Allow browser requests from an HTTPS page to this private-network proxy
107
+ // without triggering Chromium's Private Network Access permission prompt.
108
+ app.addHook("onRequest", async (_req, reply) => {
109
+ reply.header("Access-Control-Allow-Private-Network", "true");
110
+ });
111
+
112
+ const sourceRegistry = createSourceRegistry(200);
113
+ // The torrent runs on its own thread. Profiling a live seek (2026-08-02)
114
+ // found the main thread ~85% occupied by WebTorrent — buffer concatenation
115
+ // ~15%, wire updates ~9%, garbage collection ~5% — while three of four cores
116
+ // idled. Serving a segment shared that thread, so reading an already-finished
117
+ // 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
118
+ // adapter keeps TorrentPool's interface, so nothing downstream changed.
119
+ const torrentPool = new WorkerTorrentPool({ maxDiskBytes, memoryBytes, stateDir, onSubtitleCues });
120
+ const selectedPort = await getPort({
121
+ port: buildPortCandidates(port)
122
+ });
123
+ // Auto-detect the best available H.264 encoder (hardware-accelerated or
124
+ // software) once at startup, with a real test-encode and graceful fallback.
125
+ // Only needed when transcoding can occur.
126
+ const videoEncoder = transcodeAudio
127
+ ? await detectVideoEncoder({ ffmpegBin, logger })
128
+ : null;
129
+ // For software libx264, benchmark preset throughput once at startup so the
130
+ // session manager can pick the highest-quality preset that still encodes each
131
+ // stream faster than realtime. Hardware encoders use their own fixed preset.
132
+ // The decode model first, and the preset benchmark second — they are
133
+ // independent now (the presets are timed on raw frames), but the order costs
134
+ // nothing and keeps the two figures side by side in the log.
135
+ const decodeCostModel = videoEncoder?.kind === "software"
136
+ ? await benchmarkDecodeCost({ ffmpegBin, logger })
137
+ : null;
138
+ // What a second job costs on this host. Measured because the budget adds
139
+ // independent prices and this host says two jobs that each fit alone do not
140
+ // fit together — 2.6× on the addon box (2026-08-18).
141
+ const contentionPenalties = videoEncoder?.kind === "software"
142
+ ? await benchmarkContention({ ffmpegBin, logger })
143
+ : null;
144
+ const softwarePresetBenchmark = videoEncoder?.kind === "software"
145
+ ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
146
+ : null;
147
+ // What this host does with a picture it does NOT re-encode. Every other
148
+ // startup measurement prices encoding or decoding, and a copied picture does
149
+ // neither it reads packets and writes them out again — so that whole branch
150
+ // had no speed until its own run had been running long enough to report one.
151
+ // The encoding layer decides where encoders go from arrivals, and an arrival
152
+ // cannot be computed without a speed, so the moment it mattered most was the
153
+ // moment nothing was known. Measured whatever the encoder is: copying does not
154
+ // touch it.
155
+ const copySpeedX = transcodeAudio
156
+ ? await benchmarkCopySpeed({ ffmpegBin, logger })
157
+ : null;
158
+ // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
159
+ // Detected once; the session manager applies the tonemap chain only for HDR
160
+ // sources on the software path when available.
161
+ const tonemapSupported = transcodeAudio
162
+ ? await detectTonemapSupport({ ffmpegBin, logger })
163
+ : false;
164
+ const hlsSessionManager = new HlsSessionManager({
165
+ enabled: transcodeAudio,
166
+ ffmpegBin,
167
+ localBindHost: host,
168
+ localPort: selectedPort,
169
+ videoEncoder,
170
+ softwarePresetBenchmark,
171
+ decodeCostModel,
172
+ contentionPenalties,
173
+ copySpeedX,
174
+ tonemapSupported,
175
+ segmentFormatId: segmentFormat,
176
+ stateDir,
177
+ // Live download stats accessor for the realtime budget: lets it tell a
178
+ // CPU-bound transcode from a download-starved input before downscaling.
179
+ // What every torrent here has moved, so the proxy can price its own
180
+ // downloading, hashing and delivery against the machine (roadmap item 7).
181
+ getTorrentTotals: async () => {
182
+ if (typeof torrentPool.getTorrentTotals !== "function") {
183
+ return null;
184
+ }
185
+ return torrentPool.getTorrentTotals();
186
+ },
187
+ // The priority map, on its way to the downloading. It lives in another
188
+ // thread, so the map crosses the worker channel; what it does with it —
189
+ // seconds into bytes, what to ask the swarm for, what to keep in memory —
190
+ // is its own business.
191
+ setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
192
+ const record = sourceRegistry.get(sourceKey);
193
+ if (!record) {
194
+ return;
195
+ }
196
+ try {
197
+ await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
198
+ } catch {
199
+ // Best effort: the map is republished on the next change, and the
200
+ // downloading goes on serving reads meanwhile.
201
+ }
202
+ },
203
+ getSourceStats: async (sourceKey, fileIndex) => {
204
+ const record = sourceRegistry.get(sourceKey);
205
+ if (!record) {
206
+ return null;
207
+ }
208
+ try {
209
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
210
+ // Awaited for the same reason as the stats route: this now crosses a
211
+ // thread boundary and returns a promise.
212
+ return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
213
+ } catch {
214
+ return null;
215
+ }
216
+ },
217
+ // Reuse the media info the planner already probed for this file (same
218
+ // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
219
+ // only at session-create time, after playbackPlanner is initialised.
220
+ getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
221
+ // The file's audio tracks, for the master playlist's rendition group. Already
222
+ // probed for the browser's audio menu; read from there rather than probed again.
223
+ getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
224
+ // What a file declares about itself, read by the container layer from the
225
+ // same header its track table comes from. This is how the session learns
226
+ // where a soundtrack shipped as its own file begins — it used to spawn an
227
+ // ffmpeg over this proxy's own HTTP to ask the same question of the same
228
+ // bytes, and that read cost 8.1 s of every cold start (field 2026-09-03).
229
+ getContainerMediaInfo: async ({ sourceKey, fileIndex }) => {
230
+ const record = sourceRegistry.get(sourceKey);
231
+ if (!record || typeof torrentPool.getContainerMediaInfo !== "function") {
232
+ return null;
233
+ }
234
+ try {
235
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
236
+ return await torrentPool.getContainerMediaInfo(torrent, fileIndex);
237
+ } catch {
238
+ return null;
239
+ }
240
+ },
241
+ // Where the file's keyframes are, read by the same container that answered
242
+ // the two above. It used to be read by the session itself over this proxy's
243
+ // own HTTP, once per session — so two viewers opening one film read the
244
+ // same table twice, and each of them could get a different answer about
245
+ // whether the picture can be copied at all.
246
+ getContainerKeyframes: async ({ sourceKey, fileIndex }) => {
247
+ const record = sourceRegistry.get(sourceKey);
248
+ if (!record || typeof torrentPool.getContainerKeyframes !== "function") {
249
+ return null;
250
+ }
251
+ try {
252
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
253
+ return await torrentPool.getContainerKeyframes(torrent, fileIndex);
254
+ } catch {
255
+ return null;
256
+ }
257
+ },
258
+ // Pull one whole file onto the disk. Used for a soundtrack that ships beside
259
+ // the picture, once the encoder is as far ahead of the viewer as it is
260
+ // allowed to get the one moment the swarm's capacity is demonstrably
261
+ // spare. A bounded read of the file's whole length, NOT `file.select()`:
262
+ // selecting a file alongside the readers' own windows is what made a seek
263
+ // wait 93 s while the swarm fetched 2.47 GB in file order (see
264
+ // `#syncSelections` in `torrent-pool.js`).
265
+ fetchWholeFile: async ({ sourceKey, fileIndex }) => {
266
+ const record = sourceRegistry.get(sourceKey);
267
+ if (!record) {
268
+ return;
269
+ }
270
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
271
+ // The same background fill the warm-up starts when a file is chosen, not a
272
+ // second way of doing it. It is guarded against running twice on one file,
273
+ // so the two triggers converge instead of putting two readers on the same
274
+ // soundtrack and only one of them would have stood aside for the
275
+ // picture. This trigger remains for the session that never had a warm-up
276
+ // before it.
277
+ await torrentPool.fillFileInBackground?.(torrent, fileIndex);
278
+ }
279
+ });
280
+ // What the last life of this process left on the disk. The kernel kills this
281
+ // one often enough for that to be an ordinary state rather than an odd one —
282
+ // twice in a single viewing on 2026-09-02 — and when it does, no exit handler
283
+ // runs and nothing is cleared up. So this is both the cleanup and the only
284
+ // record that those encoders ended at all: it says what it found before it
285
+ // decides anything, keeps the segments whose closure is proven, and removes
286
+ // the one piece per output that was being written when the process died.
287
+ hlsSessionManager.adoptSegmentsLeftBehind();
288
+ const playbackPlanner = createPlaybackPlanner({
289
+ ffmpegBin,
290
+ transcodeAudioEnabled: transcodeAudio,
291
+ localBaseUrl: hlsSessionManager.localBaseUrl,
292
+ sourceRegistry,
293
+ torrentPool,
294
+ warmKeyframeIndex: (params) => hlsSessionManager.warmKeyframeIndex(params),
295
+ expectedFirstSegmentMs: () => hlsSessionManager.expectedFirstSegmentMs(),
296
+ expectedSessionCreateMs: () => hlsSessionManager.expectedSessionCreateMs(),
297
+ // The quality menu is on screen from the moment a file is opened, so the
298
+ // heights this host can actually serve have to be answerable before any
299
+ // encoder exists — from the probe and the startup benchmarks alone.
300
+ predictOfferedHeights: (mediaInfo) => hlsSessionManager.predictOfferedHeights(mediaInfo)
301
+ });
302
+
303
+ app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
304
+ app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
305
+ // Off unless --delivery-sink was given; see the route for why it exists.
306
+ app.get("/api/delivery-sink", async (req, reply) =>
307
+ handleApiDeliverySinkGet(req, reply, { enabled: deliverySink === true })
308
+ );
309
+ app.post("/api/sources", async (req, reply) =>
310
+ handleApiSourcesPost(req, reply, { sourceRegistry })
311
+ );
312
+ app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
313
+ handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
314
+ );
315
+ app.get("/api/sources/:sourceKey/files", async (req, reply) =>
316
+ handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
317
+ );
318
+ app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
319
+ handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool })
320
+ );
321
+ app.post("/api/playback-plan", async (req, reply) =>
322
+ handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
323
+ );
324
+ app.get("/api/subtitles", async (req, reply) =>
325
+ handleApiSubtitlesGet(req, reply, {
326
+ sourceRegistry,
327
+ torrentPool,
328
+ ffmpegBin,
329
+ localBaseUrl: hlsSessionManager.localBaseUrl
330
+ })
331
+ );
332
+ app.get("/stream", async (req, reply) =>
333
+ handleStreamGet(req, reply, {
334
+ sourceRegistry,
335
+ torrentPool,
336
+ // So a session that has produced nothing yet can still show it is being
337
+ // fed. The route knows only files; the session id rides on the URL the
338
+ // session itself built.
339
+ noteInputBytes: (sessionId, bytes) => hlsSessionManager.noteInputBytes(sessionId, bytes)
340
+ })
341
+ );
342
+ app.post("/api/transcode-sessions", async (req, reply) =>
343
+ handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool })
344
+ );
345
+ app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
346
+ handleApiTranscodeSessionReleasePost(req, reply, { hlsSessionManager })
347
+ );
348
+ app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
349
+ handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager })
350
+ );
351
+ app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
352
+ handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
353
+ );
354
+ app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
355
+ handleApiTranscodeSessionFragmentFarPost(req, reply, { hlsSessionManager })
356
+ );
357
+ app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
358
+ handleApiTranscodeSessionSeekPost(req, reply, { hlsSessionManager })
359
+ );
360
+ app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
361
+ handleTranscodeSessionFileGet(req, reply, { hlsSessionManager })
362
+ );
363
+ // A quality variant's files. Registered before the static handler for the
364
+ // same reason as the line above, and kept a separate route rather than a
365
+ // wildcard so the height stays a parsed parameter.
366
+ // Registered BEFORE the variant file route: `warm` is not a file name, and
367
+ // Fastify matches a static segment ahead of a parameter either way — stated
368
+ // here so the order is not "tidied" into a bug.
369
+ app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
370
+ handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
371
+ );
372
+ app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
373
+ handleTranscodeAudioWarmGet(req, reply, { hlsSessionManager })
374
+ );
375
+ app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
376
+ handleTranscodeAudioFileGet(req, reply, { hlsSessionManager })
377
+ );
378
+ app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
379
+ handleTranscodeVariantFileGet(req, reply, { hlsSessionManager })
380
+ );
381
+ await app.register(fastifyStatic, {
382
+ root: publicRoot,
383
+ prefix: "/",
384
+ serveDotFiles: true
385
+ });
386
+
387
+ app.addHook("onClose", async () => {
388
+ // Order matters: stop the ffmpeg readers (HLS sessions) before destroying
389
+ // the torrents whose files they read from, then remove the torrent data.
390
+ await hlsSessionManager.disposeAll();
391
+ await torrentPool.destroyAll();
392
+ });
393
+
394
+ await app.listen({ host, port: selectedPort });
395
+ return {
396
+ app,
397
+ port: selectedPort,
398
+ // Asked over the tunnel when the proxy a viewer landed on has refused their
399
+ // file: could THIS host sustain it? Answered from the startup benchmarks
400
+ // and a description, so it needs no torrent and costs milliseconds.
401
+ hlsSessionManager,
402
+ // The browser only ever knows a source by its REGISTRY key (a hash of the
403
+ // raw request bytes, scoped to one API session) — never the torrent
404
+ // pool's own key (the content's infohash, shared across a magnet and a
405
+ // `.torrent` naming the same film). The subtitle push subscription is
406
+ // recorded from a browser request and published from the pool's side, so
407
+ // resolving one into the other is what lets the two ends agree on what
408
+ // they are both calling "sourceKey".
409
+ sourceRegistry,
410
+ // Which films this host holds, for the health poll the browser makes before
411
+ // it picks a proxy. The pool is on the worker thread and this is the way to
412
+ // it from the process that answers that poll.
413
+ torrentPool
414
+ };
415
+ }