@torrent-tv/proxy 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.8.0",
3
+ "version": "2.9.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": {
package/server.js CHANGED
@@ -27,6 +27,8 @@ import { createSourceRegistry } from "./store/source-registry.js";
27
27
  import { TorrentPool } from "./services/torrent-pool.js";
28
28
  import { HlsSessionManager } from "./services/hls-session-manager.js";
29
29
  import { createPlaybackPlanner } from "./services/playback-planner.js";
30
+ import { detectVideoEncoder } from "./services/hwaccel.js";
31
+ import { logger } from "./utils/logger.js";
30
32
 
31
33
  const __filename = fileURLToPath(import.meta.url);
32
34
  const __dirname = path.dirname(__filename);
@@ -91,11 +93,18 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
91
93
  const selectedPort = await getPort({
92
94
  port: buildPortCandidates(port)
93
95
  });
96
+ // Auto-detect the best available H.264 encoder (hardware-accelerated or
97
+ // software) once at startup, with a real test-encode and graceful fallback.
98
+ // Only needed when transcoding can occur.
99
+ const videoEncoder = transcodeAudio
100
+ ? await detectVideoEncoder({ ffmpegBin, logger })
101
+ : null;
94
102
  const hlsSessionManager = new HlsSessionManager({
95
103
  enabled: transcodeAudio,
96
104
  ffmpegBin,
97
105
  localBindHost: host,
98
- localPort: selectedPort
106
+ localPort: selectedPort,
107
+ videoEncoder
99
108
  });
100
109
  const playbackPlanner = createPlaybackPlanner({
101
110
  ffmpegBin,
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import { randomUUID } from "node:crypto";
16
16
  import { spawn } from "node:child_process";
17
17
  import { logger } from "../utils/logger.js";
18
+ import { softwareDescriptor } from "./hwaccel.js";
18
19
 
19
20
  const PLAYLIST_FILE_NAME = "index.m3u8";
20
21
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -32,9 +33,6 @@ const DEFAULT_SESSION_TTL_MS = 120 * 1000;
32
33
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
33
34
  const MICROSECONDS_PER_SECOND = 1_000_000;
34
35
  const PROGRESS_LOG_INTERVAL_MS = 5_000;
35
- const VIDEO_TRANSCODE_PRESET = "superfast";
36
- const VIDEO_TRANSCODE_CRF = "24";
37
- const VIDEO_TRANSCODE_FPS = 24;
38
36
 
39
37
  /**
40
38
  * Resolve after a given number of milliseconds.
@@ -349,10 +347,15 @@ export class HlsSessionManager {
349
347
  localPort,
350
348
  segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
351
349
  sessionTtlMs = DEFAULT_SESSION_TTL_MS,
352
- startupWaitMs = DEFAULT_STARTUP_WAIT_MS
350
+ startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
351
+ videoEncoder = null
353
352
  }) {
354
353
  this.enabled = Boolean(enabled);
355
354
  this.ffmpegBin = ffmpegBin;
355
+ // Detected H.264 encoder descriptor (hardware or software). Defaults to
356
+ // software libx264 when no detection result is supplied. May be downgraded
357
+ // to software at runtime if a hardware encode fails.
358
+ this.videoEncoder = videoEncoder ?? softwareDescriptor();
356
359
  this.segmentDurationSec = segmentDurationSec;
357
360
  this.sessionTtlMs = sessionTtlMs;
358
361
  this.startupWaitMs = startupWaitMs;
@@ -588,30 +591,26 @@ export class HlsSessionManager {
588
591
  }
589
592
  }
590
593
 
594
+ // Video: re-encode only when required, using the detected encoder
595
+ // (hardware-accelerated or software). The descriptor builds the filter +
596
+ // codec args (including keyframe alignment on segment boundaries).
591
597
  const videoCodecArgs = session.transcodeVideo
592
- ? [
593
- "-vf",
594
- this.#buildVideoFilter(session.targetWidth, session.targetHeight),
595
- "-c:v",
596
- "libx264",
597
- "-preset",
598
- VIDEO_TRANSCODE_PRESET,
599
- "-crf",
600
- VIDEO_TRANSCODE_CRF,
601
- "-pix_fmt",
602
- "yuv420p",
603
- // Force keyframes on segment boundaries so each segment is
604
- // independently decodable and exactly segmentDuration long — this
605
- // keeps the synthetic playlist's timing accurate.
606
- "-force_key_frames",
607
- `expr:gte(t,n_forced*${this.segmentDurationSec})`
608
- ]
598
+ ? this.videoEncoder.buildVideoArgs({
599
+ targetWidth: session.targetWidth,
600
+ targetHeight: session.targetHeight,
601
+ segmentDurationSec: this.segmentDurationSec
602
+ })
609
603
  : ["-c:v", "copy"];
610
604
  const audioCodecArgs = session.transcodeAudio
611
605
  ? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
612
606
  : ["-c:a", "copy"];
613
607
 
614
608
  const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
609
+ // Hardware decode/encode setup (e.g. VAAPI device) must precede -i, and
610
+ // only applies when we actually re-encode the video track.
611
+ if (session.transcodeVideo && Array.isArray(this.videoEncoder.inputArgs)) {
612
+ args.push(...this.videoEncoder.inputArgs);
613
+ }
615
614
  if (startSeconds > 0) {
616
615
  // Fast keyframe-level seek before -i (skips decoding earlier frames).
617
616
  args.push("-ss", String(startSeconds));
@@ -762,12 +761,25 @@ export class HlsSessionManager {
762
761
  logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
763
762
  return;
764
763
  }
765
- session.state = "failed";
766
- session.progress.state = "failed";
767
- session.progress.updatedAt = Date.now();
768
764
  if (!session.lastError) {
769
765
  session.lastError = `ffmpeg exited with code ${code ?? -1}${signal ? ` (signal ${signal})` : ""}`;
770
766
  }
767
+ // Runtime safety net: if a hardware encode fails, downgrade this proxy to
768
+ // software encoding for all sessions and restart this one, so playback is
769
+ // never permanently broken by a hardware/driver issue.
770
+ if (session.transcodeVideo && this.videoEncoder.kind !== "software") {
771
+ const failedEncoder = this.videoEncoder.name;
772
+ this.videoEncoder = softwareDescriptor();
773
+ logger.warn(
774
+ `transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
775
+ `(${session.lastError}); falling back to software libx264 and restarting`
776
+ );
777
+ this.#startEncodeRun(session, session.encodeStartIndex);
778
+ return;
779
+ }
780
+ session.state = "failed";
781
+ session.progress.state = "failed";
782
+ session.progress.updatedAt = Date.now();
771
783
  logger.error(`transcode ${session.id} encode-run failed: ${session.lastError}`);
772
784
  });
773
785
  }
@@ -800,12 +812,6 @@ export class HlsSessionManager {
800
812
  this.#startEncodeRun(session, index);
801
813
  }
802
814
 
803
- #buildVideoFilter(targetWidth, targetHeight) {
804
- const safeWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
805
- const safeHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
806
- return `scale=${safeWidth}:${safeHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${VIDEO_TRANSCODE_FPS}`;
807
- }
808
-
809
815
  /**
810
816
  * Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
811
817
  * header, or until the session fails, or until the startup timeout elapses.
@@ -0,0 +1,345 @@
1
+ /**
2
+ * @file Hardware-accelerated H.264 encoder auto-detection.
3
+ *
4
+ * Probes the ffmpeg build and the host for a usable hardware H.264 encoder
5
+ * (NVENC / QSV / VAAPI / V4L2 M2M), verifying each candidate with a real
6
+ * test-encode before selecting it. Falls back to software libx264 when no
7
+ * hardware encoder is present or working.
8
+ *
9
+ * Deployment-agnostic: relies only on ffmpeg, the filesystem and
10
+ * `process.platform`; makes no assumptions about Home Assistant or any
11
+ * specific host. A garbled or unsupported hardware path simply fails its
12
+ * test-encode and is skipped, so the worst case is software encoding.
13
+ *
14
+ * A descriptor exposes:
15
+ * - `name` human-readable encoder id (e.g. "h264_vaapi")
16
+ * - `kind` "software" | "vaapi" | "qsv" | "nvenc" | "v4l2m2m"
17
+ * - `device` device node path or null
18
+ * - `inputArgs` ffmpeg args inserted before `-i` (decode/hwaccel setup)
19
+ * - `buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec })`
20
+ * ffmpeg video filter + encoder args inserted after `-map`s
21
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { readdirSync } from "node:fs";
25
+
26
+ const SOFTWARE_PRESET = "superfast";
27
+ const SOFTWARE_CRF = "24";
28
+ const TRANSCODE_FPS = 24;
29
+
30
+ /**
31
+ * @param {number} targetWidth
32
+ * @param {number} targetHeight
33
+ * @returns {{ w: number, h: number }}
34
+ */
35
+ function safeDimensions(targetWidth, targetHeight) {
36
+ const w = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
37
+ const h = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
38
+ return { w, h };
39
+ }
40
+
41
+ /**
42
+ * Force a keyframe on every segment boundary so each HLS segment is
43
+ * independently decodable and exactly `segmentDurationSec` long.
44
+ *
45
+ * @param {number} segmentDurationSec
46
+ * @returns {string[]}
47
+ */
48
+ function keyFrameArgs(segmentDurationSec) {
49
+ return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
50
+ }
51
+
52
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
53
+ export function softwareDescriptor() {
54
+ return {
55
+ name: "libx264",
56
+ kind: "software",
57
+ device: null,
58
+ inputArgs: [],
59
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
60
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
61
+ return [
62
+ "-vf",
63
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
64
+ "-c:v", "libx264",
65
+ "-preset", SOFTWARE_PRESET,
66
+ "-crf", SOFTWARE_CRF,
67
+ "-pix_fmt", "yuv420p",
68
+ ...keyFrameArgs(segmentDurationSec)
69
+ ];
70
+ }
71
+ };
72
+ }
73
+
74
+ /**
75
+ * @param {string} device
76
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
77
+ */
78
+ function vaapiDescriptor(device) {
79
+ return {
80
+ name: "h264_vaapi",
81
+ kind: "vaapi",
82
+ device,
83
+ // Decode on the GPU into VAAPI surfaces; scale and encode stay on-GPU.
84
+ inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
85
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
86
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
87
+ return [
88
+ "-vf",
89
+ `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
90
+ "-c:v", "h264_vaapi",
91
+ "-qp", "24",
92
+ ...keyFrameArgs(segmentDurationSec)
93
+ ];
94
+ }
95
+ };
96
+ }
97
+
98
+ /**
99
+ * @param {string} device
100
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
101
+ */
102
+ function qsvDescriptor(device) {
103
+ return {
104
+ name: "h264_qsv",
105
+ kind: "qsv",
106
+ device,
107
+ inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
108
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
109
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
110
+ return [
111
+ "-vf", `scale_qsv=w=${w}:h=${h}`,
112
+ "-c:v", "h264_qsv",
113
+ "-global_quality", "24",
114
+ ...keyFrameArgs(segmentDurationSec)
115
+ ];
116
+ }
117
+ };
118
+ }
119
+
120
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
121
+ function nvencDescriptor() {
122
+ return {
123
+ name: "h264_nvenc",
124
+ kind: "nvenc",
125
+ device: null,
126
+ inputArgs: [],
127
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
128
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
129
+ return [
130
+ "-vf",
131
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
132
+ "-c:v", "h264_nvenc",
133
+ "-preset", "p4",
134
+ "-cq", "24",
135
+ "-pix_fmt", "yuv420p",
136
+ ...keyFrameArgs(segmentDurationSec)
137
+ ];
138
+ }
139
+ };
140
+ }
141
+
142
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
143
+ function v4l2m2mDescriptor() {
144
+ // ARM SoC (e.g. Raspberry Pi) stateful M2M encoder. No GPU scaler — scale in
145
+ // software, then hand YUV420 frames to the hardware encoder.
146
+ return {
147
+ name: "h264_v4l2m2m",
148
+ kind: "v4l2m2m",
149
+ device: null,
150
+ inputArgs: [],
151
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
152
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
153
+ return [
154
+ "-vf",
155
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS},format=yuv420p`,
156
+ "-c:v", "h264_v4l2m2m",
157
+ "-b:v", "3M",
158
+ ...keyFrameArgs(segmentDurationSec)
159
+ ];
160
+ }
161
+ };
162
+ }
163
+
164
+ /**
165
+ * @typedef {Object} VideoEncoderDescriptor
166
+ * @property {string} name
167
+ * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
168
+ * @property {string|null} device
169
+ * @property {string[]} inputArgs
170
+ * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number }) => string[]} buildVideoArgs
171
+ */
172
+
173
+ /**
174
+ * Run ffmpeg and resolve with its exit code and captured output.
175
+ *
176
+ * @param {string} ffmpegBin
177
+ * @param {string[]} args
178
+ * @param {number} [timeoutMs=12000]
179
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
180
+ */
181
+ function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
182
+ return new Promise((resolve) => {
183
+ let stdout = "";
184
+ let stderr = "";
185
+ let settled = false;
186
+ let child;
187
+ const finish = (code) => {
188
+ if (settled) {
189
+ return;
190
+ }
191
+ settled = true;
192
+ resolve({ code, stdout, stderr });
193
+ };
194
+ try {
195
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
196
+ } catch {
197
+ finish(-1);
198
+ return;
199
+ }
200
+ const timer = setTimeout(() => {
201
+ try {
202
+ child.kill("SIGKILL");
203
+ } catch {
204
+ // ignore
205
+ }
206
+ finish(-1);
207
+ }, timeoutMs);
208
+ child.stdout.on("data", (d) => {
209
+ stdout += String(d);
210
+ });
211
+ child.stderr.on("data", (d) => {
212
+ stderr += String(d);
213
+ });
214
+ child.on("error", () => {
215
+ clearTimeout(timer);
216
+ finish(-1);
217
+ });
218
+ child.on("exit", (code) => {
219
+ clearTimeout(timer);
220
+ finish(code ?? -1);
221
+ });
222
+ });
223
+ }
224
+
225
+ /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
226
+ function listRenderNodes() {
227
+ try {
228
+ return readdirSync("/dev/dri")
229
+ .filter((n) => n.startsWith("renderD"))
230
+ .map((n) => `/dev/dri/${n}`)
231
+ .sort();
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
236
+
237
+ /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
238
+ function hasNvidiaDevice() {
239
+ try {
240
+ return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
246
+ /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
247
+ function hasV4l2Device() {
248
+ try {
249
+ return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Kind-specific test-encode args that verify the encoder initialises and
257
+ * encodes a few frames from a synthetic source.
258
+ *
259
+ * @param {VideoEncoderDescriptor} descriptor
260
+ * @returns {string[]}
261
+ */
262
+ function testEncodeArgs(descriptor) {
263
+ const src = ["-f", "lavfi", "-i", "color=c=black:s=320x240:r=15:d=0.4"];
264
+ switch (descriptor.kind) {
265
+ case "vaapi":
266
+ return [
267
+ "-hide_banner", "-loglevel", "error",
268
+ "-vaapi_device", String(descriptor.device),
269
+ ...src,
270
+ "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi",
271
+ "-f", "null", "-"
272
+ ];
273
+ case "qsv":
274
+ return [
275
+ "-hide_banner", "-loglevel", "error",
276
+ "-qsv_device", String(descriptor.device),
277
+ ...src,
278
+ "-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv",
279
+ "-f", "null", "-"
280
+ ];
281
+ case "nvenc":
282
+ return ["-hide_banner", "-loglevel", "error", ...src, "-c:v", "h264_nvenc", "-f", "null", "-"];
283
+ case "v4l2m2m":
284
+ return [
285
+ "-hide_banner", "-loglevel", "error",
286
+ ...src, "-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-f", "null", "-"
287
+ ];
288
+ default:
289
+ return [
290
+ "-hide_banner", "-loglevel", "error",
291
+ ...src, "-c:v", "libx264", "-preset", "ultrafast", "-f", "null", "-"
292
+ ];
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Detect the best usable H.264 encoder. Always resolves (falls back to
298
+ * software libx264). Each hardware candidate is verified with a real
299
+ * test-encode before being selected.
300
+ *
301
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
302
+ * @returns {Promise<VideoEncoderDescriptor>}
303
+ */
304
+ export async function detectVideoEncoder({ ffmpegBin, logger }) {
305
+ const log = logger ?? { info: () => {}, warn: () => {} };
306
+ const software = softwareDescriptor();
307
+
308
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
309
+ if (code !== 0) {
310
+ log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
311
+ return software;
312
+ }
313
+ const has = (name) => stdout.includes(name);
314
+
315
+ /** @type {VideoEncoderDescriptor[]} */
316
+ const candidates = [];
317
+ const renderNodes = listRenderNodes();
318
+ if (has("h264_nvenc") && hasNvidiaDevice()) {
319
+ candidates.push(nvencDescriptor());
320
+ }
321
+ if (has("h264_qsv") && renderNodes.length > 0) {
322
+ candidates.push(qsvDescriptor(renderNodes[0]));
323
+ }
324
+ if (has("h264_vaapi") && renderNodes.length > 0) {
325
+ candidates.push(vaapiDescriptor(renderNodes[0]));
326
+ }
327
+ if (has("h264_v4l2m2m") && hasV4l2Device()) {
328
+ candidates.push(v4l2m2mDescriptor());
329
+ }
330
+
331
+ for (const candidate of candidates) {
332
+ const result = await runFfmpeg(ffmpegBin, testEncodeArgs(candidate), 12000);
333
+ if (result.code === 0) {
334
+ log.info(
335
+ `hwaccel: using hardware encoder ${candidate.name}` +
336
+ `${candidate.device ? ` (${candidate.device})` : ""}`
337
+ );
338
+ return candidate;
339
+ }
340
+ log.warn(`hwaccel: ${candidate.name} present but test-encode failed; skipping`);
341
+ }
342
+
343
+ log.info("hwaccel: no working hardware encoder; using software libx264");
344
+ return software;
345
+ }