@torrent-tv/proxy 2.12.2 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ # Calibration clips — attribution
2
+
3
+ The three clips in this folder are excerpts from **"Meridian"**, part of
4
+ [Netflix Open Content](https://opencontent.netflix.com/), licensed under the
5
+ **Creative Commons Attribution 4.0 International (CC BY 4.0)** licence:
6
+ <https://creativecommons.org/licenses/by/4.0/>.
7
+
8
+ They were cut from `Meridian/Meridian_UHD4k5994_HDR_P3PQ.mp4` and re-encoded to
9
+ H.264 High, 24 fps, 5.04 s (121 frames), no audio:
10
+
11
+ | file | resolution | bitrate |
12
+ |---|---|---|
13
+ | `cal-1080-hi.mp4` | 1920×1080 | 11.38 Mbit/s |
14
+ | `cal-1080-lo.mp4` | 1920×1080 | 0.97 Mbit/s |
15
+ | `cal-720.mp4` | 1280×720 | 2.25 Mbit/s |
16
+
17
+ ## Why these three, and why real footage
18
+
19
+ `services/hwaccel.js` decodes them at startup and solves the host's decode cost,
20
+ `a × Mpixel/s + b × Mbit/s + c`, from the three measurements. The first two
21
+ clips share a pixel count and differ 11.7× in bitrate; the third changes the
22
+ pixel count. Three points, three unknowns.
23
+
24
+ Real, grainy live action rather than a generated pattern: measured 2026-08-14 on
25
+ the addon host, this material decodes 11 % away from the film being served,
26
+ where a generated `testsrc2` clip is 158 % away.
27
+
28
+ Replacing a clip is allowed — the benchmark reads each clip's dimensions, frame
29
+ rate and bitrate from ffmpeg's own output rather than from this table — but the
30
+ three must keep spanning the two axes, or the fit has nothing to separate.
Binary file
package/bin/cli.js CHANGED
@@ -76,6 +76,10 @@ program
76
76
  .option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
77
77
  .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
78
78
  .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
79
+ .option(
80
+ "--state-dir <path>",
81
+ "Where to keep what this host has measured about itself (default: beside the installed proxy)"
82
+ )
79
83
  .option(
80
84
  "--segment-format <format>",
81
85
  `HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
@@ -273,7 +277,8 @@ try {
273
277
  ffmpegBin,
274
278
  maxDiskBytes,
275
279
  memoryBytes,
276
- segmentFormat: options.segmentFormat
280
+ segmentFormat: options.segmentFormat,
281
+ stateDir: options.stateDir
277
282
  });
278
283
  app = started.app;
279
284
  actualPort = started.port;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.12.2",
3
+ "version": "2.14.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -73,6 +73,11 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
73
73
  // (capped to source), with the realtime budget's auto-downscale + runtime
74
74
  // downswitch disabled for the session.
75
75
  const manualQuality = payload.manualQuality === true;
76
+ // Whether this browser will take its audio from a separate rendition group in
77
+ // the master playlist rather than muxed into the picture. It has to say so:
78
+ // publishing renditions AND muxing the same audio would play it twice, while
79
+ // a browser that does not know about them would get no sound at all.
80
+ const audioRenditions = payload.audioRenditions === true;
76
81
  const startPositionSeconds = Number(payload.startPositionSeconds);
77
82
  const audioTrackIndex = Number(payload.audioTrackIndex);
78
83
  // Which container to produce. The browser knows what its media stack will
@@ -96,6 +101,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
96
101
  targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
97
102
  targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
98
103
  manualQuality,
104
+ audioRenditions,
99
105
  startPositionSeconds:
100
106
  Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
101
107
  ? startPositionSeconds
@@ -129,6 +135,13 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
129
135
  variantHeight: hlsSessionManager.variantHeightOf(session)
130
136
  }
131
137
  : {}),
138
+ // The heights this host will actually serve this file at, largest first
139
+ // — the ladder minus every rung it cannot produce faster than it is
140
+ // watched. The browser needs it whether or not a master exists: without
141
+ // it, it fell back to a ladder of its own invention and offered rungs the
142
+ // proxy had just refused, and picking one re-opened the session at a
143
+ // height measured at a third of realtime.
144
+ offeredHeights: hlsSessionManager.offeredHeights(session),
132
145
  // What this session's output will carry, stated rather than left to be
133
146
  // discovered. The browser checks what it actually got against this: a
134
147
  // track that never arrives is otherwise noticed only by its absence,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file GET /transcode/:sessionId/a/:trackIndex/:fileName — one file of an audio
3
+ * rendition.
4
+ *
5
+ * A rendition is one audio track of the file, encoded once and shared by every
6
+ * quality rung, published in the master playlist as `#EXT-X-MEDIA`. It is an
7
+ * ordinary session underneath, living one directory level below the base
8
+ * session so every relative name inside its playlist resolves to it unchanged —
9
+ * the same arrangement quality variants use.
10
+ *
11
+ * Unlike a variant, a rendition is fetched ALONGSIDE the picture rather than
12
+ * instead of it: both encoders run, one for the rung and one for the audio.
13
+ */
14
+
15
+ import { serveSessionFile } from "../session-file/get.js";
16
+
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 handleTranscodeAudioFileGet(req, reply, { hlsSessionManager }) {
24
+ const baseSessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
25
+ const trackIndex = Number(req.params.trackIndex);
26
+ const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
27
+
28
+ const resolved = await hlsSessionManager.resolveAudioRenditionFile(baseSessionId, trackIndex, fileName);
29
+ if (resolved.error) {
30
+ // Retryable, like every other not-ready answer on this path: a 500 for a
31
+ // rendition playlist would end the stream over something the next attempt
32
+ // may well get past.
33
+ reply.header("Retry-After", "1");
34
+ return reply.code(503).send({ error: `Could not prepare the audio rendition: ${resolved.error}` });
35
+ }
36
+ if (!resolved.sessionId) {
37
+ return reply.code(404).send({ error: "No such audio rendition for this transcode session." });
38
+ }
39
+
40
+ return serveSessionFile(req, reply, {
41
+ hlsSessionManager,
42
+ sessionId: resolved.sessionId,
43
+ fileName
44
+ });
45
+ }
package/server.js CHANGED
@@ -30,12 +30,13 @@ import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessio
30
30
  import { handleStreamGet } from "./routes/stream/get.js";
31
31
  import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
32
32
  import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
33
+ import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
33
34
  import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
34
35
  import { createSourceRegistry } from "./store/source-registry.js";
35
36
  import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
36
37
  import { HlsSessionManager } from "./services/hls-session-manager.js";
37
38
  import { createPlaybackPlanner } from "./services/playback-planner.js";
38
- import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
39
+ import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, detectTonemapSupport } from "./services/hwaccel.js";
39
40
  import { logger } from "./utils/logger.js";
40
41
 
41
42
  const __filename = fileURLToPath(import.meta.url);
@@ -68,6 +69,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
68
69
  * @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
69
70
  * @property {number} [memoryBytes] - Per-torrent budget for pieces held in memory (undefined = store default).
70
71
  * @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
72
+ * @property {string} [stateDir] - Where to keep what this host has measured about itself.
71
73
  */
72
74
 
73
75
  /**
@@ -76,7 +78,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
76
78
  * @param {ProxyServerOptions} options
77
79
  * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
78
80
  */
79
- export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat }) {
81
+ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir }) {
80
82
  const app = Fastify({
81
83
  // No practical body-size limit — the proxy server is localhost-only and
82
84
  // receives torrent source payloads that may be arbitrarily large.
@@ -124,6 +126,18 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
124
126
  const softwarePresetBenchmark = videoEncoder?.kind === "software"
125
127
  ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
126
128
  : null;
129
+ // A re-encode pays for decoding as well, and the preset benchmark measures
130
+ // only the encoder — which is how a rung this host runs at 0.39x came to be
131
+ // offered as if it cleared realtime 2.5× over (measured 2026-08-14). Solve
132
+ // the decode cost from the bundled calibration clips once at startup; every
133
+ // source is then priced from figures the probe already has.
134
+ // Only the software path can read it: the budget and the ladder both bail
135
+ // on a missing preset benchmark, and that is only produced for libx264. A
136
+ // host with a hardware encoder would pay three decodes at every start for a
137
+ // figure nothing would ever ask for.
138
+ const decodeCostModel = videoEncoder?.kind === "software"
139
+ ? await benchmarkDecodeCost({ ffmpegBin, logger })
140
+ : null;
127
141
  // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
128
142
  // Detected once; the session manager applies the tonemap chain only for HDR
129
143
  // sources on the software path when available.
@@ -137,8 +151,10 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
137
151
  localPort: selectedPort,
138
152
  videoEncoder,
139
153
  softwarePresetBenchmark,
154
+ decodeCostModel,
140
155
  tonemapSupported,
141
156
  segmentFormatId: segmentFormat,
157
+ stateDir,
142
158
  // Live download stats accessor for the realtime budget: lets it tell a
143
159
  // CPU-bound transcode from a download-starved input before downscaling.
144
160
  getSourceStats: async (sourceKey, fileIndex) => {
@@ -158,7 +174,10 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
158
174
  // Reuse the media info the planner already probed for this file (same
159
175
  // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
160
176
  // only at session-create time, after playbackPlanner is initialised.
161
- getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params)
177
+ getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
178
+ // The file's audio tracks, for the master playlist's rendition group. Already
179
+ // probed for the browser's audio menu; read from there rather than probed again.
180
+ getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params)
162
181
  });
163
182
  const playbackPlanner = createPlaybackPlanner({
164
183
  ffmpegBin,
@@ -168,7 +187,11 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
168
187
  torrentPool,
169
188
  warmKeyframeIndex: (params) => hlsSessionManager.warmKeyframeIndex(params),
170
189
  expectedFirstSegmentMs: () => hlsSessionManager.expectedFirstSegmentMs(),
171
- expectedSessionCreateMs: () => hlsSessionManager.expectedSessionCreateMs()
190
+ expectedSessionCreateMs: () => hlsSessionManager.expectedSessionCreateMs(),
191
+ // The quality menu is on screen from the moment a file is opened, so the
192
+ // heights this host can actually serve have to be answerable before any
193
+ // encoder exists — from the probe and the startup benchmarks alone.
194
+ predictOfferedHeights: (mediaInfo) => hlsSessionManager.predictOfferedHeights(mediaInfo)
172
195
  });
173
196
 
174
197
  app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
@@ -226,6 +249,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
226
249
  app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
227
250
  handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
228
251
  );
252
+ app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
253
+ handleTranscodeAudioFileGet(req, reply, { hlsSessionManager })
254
+ );
229
255
  app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
230
256
  handleTranscodeVariantFileGet(req, reply, { hlsSessionManager })
231
257
  );
@@ -54,6 +54,48 @@ export function parseFfmpegStartTimeSeconds(stderrText) {
54
54
  return Number.isFinite(value) ? value : 0;
55
55
  }
56
56
 
57
+ /**
58
+ * Parse the bitrate (kbit/s) that the decode cost is priced from: the VIDEO
59
+ * stream's own, falling back to the container's when the stream does not state
60
+ * one. Returns null when neither is present.
61
+ *
62
+ * The distinction is not cosmetic. The calibration clips carry video alone and
63
+ * are decoded with `-an`, so the fitted bitrate term describes VIDEO bits; the
64
+ * container figure adds every audio and subtitle track. A Russian release with
65
+ * two or three AC-3/DTS tracks carries 1-2 Mbit/s of audio, and on this host's
66
+ * own fit that inflates the predicted decode cost by 10-25 % — refusing rungs
67
+ * on the strength of audio the benchmark never decoded. And the term is not the
68
+ * weak one it was once described as: on the shipped clips an 11.7× bitrate
69
+ * change moved the cost 2.47×, and it accounts for about two thirds of the
70
+ * predicted cost of a high-bitrate 1080p source.
71
+ *
72
+ * @param {string} stderrText
73
+ * @returns {number | null}
74
+ */
75
+ export function parseFfmpegBitrateKbps(stderrText) {
76
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
77
+ return null;
78
+ }
79
+ // `Stream #0:0 … Video: h264 … 11375 kb/s, 24 fps` — the stream's own rate,
80
+ // stated per stream and therefore free of the other tracks. Only the INPUT
81
+ // section is read: everything from "Stream mapping:" onwards describes what
82
+ // ffmpeg is about to produce, and that line carries a bitrate of its own.
83
+ const inputSection = stderrText.split(/^Stream mapping:/m)[0];
84
+ const perStream = inputSection.match(/Stream\s+#[^\n]*?Video:[^\n]*?,\s*(\d+)\s*kb\/s/i);
85
+ if (perStream) {
86
+ const streamValue = Number(perStream[1]);
87
+ if (Number.isFinite(streamValue) && streamValue > 0) {
88
+ return streamValue;
89
+ }
90
+ }
91
+ const match = stderrText.match(/Duration:[^\n]*?bitrate:\s*(\d+)\s*kb\/s/i);
92
+ if (!match) {
93
+ return null;
94
+ }
95
+ const value = Number(match[1]);
96
+ return Number.isFinite(value) && value > 0 ? value : null;
97
+ }
98
+
57
99
  /**
58
100
  * Parse the source video resolution from ffmpeg's stderr (the "Stream … Video:
59
101
  * … WxH" line). Returns `{ width: null, height: null }` when absent.