@torrent-tv/proxy 2.97.23 → 2.97.24

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.97.23",
3
+ "version": "2.97.24",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -291,7 +291,7 @@ export class EncodeRuns {
291
291
  inputCacheBytes() {
292
292
  let bytes = 0;
293
293
  for (const run of this.#runsByInputToken.values()) {
294
- if (run.isAlive && run.originalInput) bytes += Math.max(0, Number(run.originalInput.bytes) || 0);
294
+ if (run.isAlive && run.originalInput?.readThroughCache) bytes += Math.max(0, Number(run.originalInput.bytes) || 0);
295
295
  }
296
296
  return bytes;
297
297
  }
@@ -1024,6 +1024,9 @@ export class EncodeRuns {
1024
1024
  this.#lastInputToken += 1;
1025
1025
  const inputToken = this.#lastInputToken;
1026
1026
  if (admittedInput) admittedInput.runTag = `${startIndex}r${inputToken}`;
1027
+ // Whether this run's FFmpeg keeps what it reads in its cache's file, which
1028
+ // is what the budget counts on that file's disk.
1029
+ if (admittedInput?.original) admittedInput.readThroughCache = this.#host.readsThroughCache?.() === true;
1027
1030
  const buildCommand = admittedInput.original ? buildOriginalCommand : buildAdmittedCommand;
1028
1031
  const built = buildCommand({
1029
1032
  admittedInput,
@@ -1044,7 +1047,8 @@ export class EncodeRuns {
1044
1047
  endIndex: admittedInput.original ? inputEnd
1045
1048
  : Number.isInteger(ordered?.to) ? ordered.to : runEnd >= startIndex ? runEnd : session.timeline.segmentCount - 1,
1046
1049
  videoEncoder: this.#host.videoEncoder,
1047
- segmentDurationSec: this.#host.segmentDurationSec
1050
+ segmentDurationSec: this.#host.segmentDurationSec,
1051
+ readThroughCache: admittedInput.readThroughCache === true
1048
1052
  });
1049
1053
  const { args, cutTimes } = built;
1050
1054
  // The piece before the start, made only to have its group in hand, is
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @file Whether this machine's FFmpeg can read an encoder's input through its
3
+ * `cache:` protocol, and on which directory's disk that cache lies.
4
+ *
5
+ * FFmpeg's cache (libavformat/cache.c) keeps what it has read in a temporary
6
+ * file opened by `avpriv_tempfile`, and a Linux build can only open that file
7
+ * in /tmp (libavutil/file_open.c n8.1, `mkstemp`); a Windows or Android build
8
+ * opens it where FFmpeg runs. Where no /tmp can be written, every run reading
9
+ * through the cache fails at its open — checked on Home Assistant with a
10
+ * read-only root: "ff_tempfile: Cannot open temporary file
11
+ * /tmp/ffcacheXXXXXX", while the same file read directly opened. So one small
12
+ * read through the cache is tried at startup with this proxy's own FFmpeg, and
13
+ * until it has succeeded an input is read directly (torrent-tv/meta#178).
14
+ */
15
+
16
+ import { spawn as nodeSpawn } from "node:child_process";
17
+ import { randomBytes } from "node:crypto";
18
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+
22
+ /**
23
+ * Where FFmpeg keeps its cache's file: /tmp on a build that uses `mkstemp`,
24
+ * else where FFmpeg runs, which for a run is a directory of the segments.
25
+ *
26
+ * @param {string} [platform]
27
+ * @returns {string | null} null for the segments' own directory.
28
+ */
29
+ export function inputCacheRoot(platform = process.platform) {
30
+ return platform === "win32" || platform === "android" ? null : "/tmp";
31
+ }
32
+
33
+ /**
34
+ * Read a small file through FFmpeg's cache once.
35
+ *
36
+ * @param {object} params
37
+ * @param {string} params.ffmpegBin
38
+ * @param {typeof nodeSpawn} [params.spawn]
39
+ * @param {string} [params.scratch] - Where the file read is written.
40
+ * @returns {Promise<{ available: boolean, reason: string }>}
41
+ */
42
+ export async function probeInputCache({ ffmpegBin, spawn = nodeSpawn, scratch = os.tmpdir() }) {
43
+ let directory = null;
44
+ try {
45
+ directory = await mkdtemp(path.join(scratch, "ttv-input-cache-"));
46
+ const file = path.join(directory, "input.bin");
47
+ await writeFile(file, randomBytes(64 * 1024));
48
+ const { code, error } = await new Promise((resolve) => {
49
+ let stderr = "";
50
+ const child = spawn(ffmpegBin, ["-v", "error", "-nostdin", "-f", "data", "-i", `cache:file:${file}`,
51
+ "-map", "0", "-c", "copy", "-f", "null", "-"], { stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
52
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
53
+ child.once("error", (failure) => resolve({ code: null, error: failure.message }));
54
+ child.once("close", (exitCode) => resolve({ code: exitCode, error: stderr.trim().split(/\r?\n/).pop() ?? "" }));
55
+ });
56
+ return code === 0 ? { available: true, reason: "a read through it completed" }
57
+ : { available: false, reason: `FFmpeg ${code === null ? "did not start" : `exited ${code}`}: ${error || "no message"}` };
58
+ } catch (error) {
59
+ return { available: false, reason: `the file to read could not be prepared: ${error instanceof Error ? error.message : String(error)}` };
60
+ } finally {
61
+ if (directory) await rm(directory, { recursive: true, force: true }).catch(() => undefined);
62
+ }
63
+ }
@@ -278,7 +278,10 @@ export function buildOriginalCommand(params) {
278
278
  // the pieces were the same to the byte, on FFmpeg 6.1 and 8.1.2 (Home
279
279
  // Assistant 2026-10-13, torrent-tv/meta#178). The cache's file holds what
280
280
  // FFmpeg read and is counted by the budget (`EncodeRuns.inputCacheBytes`).
281
- const url = source => `cache:${new URL(`/encode-input/${inputToken}/${source.fileIndex}`, baseUrl).href}`;
281
+ // Only where a read through it has worked on this machine
282
+ // (`encode/input-cache.js`); elsewhere the input is read directly.
283
+ const readThroughCache = params.readThroughCache !== false;
284
+ const url = source => `${readThroughCache ? "cache:" : ""}${new URL(`/encode-input/${inputToken}/${source.fileIndex}`, baseUrl).href}`;
282
285
  // The container named the bytes FFmpeg's stream search reads for this much
283
286
  // media time; the search is told the same, whatever its own default.
284
287
  const openArgs = source => Number.isFinite(source.input.streamSearchSeconds)
@@ -93,7 +93,7 @@ import { audioReadingFor } from "../encode/audio-work-rate.js";
93
93
  import { wireMachineBudget } from "../storage/wire.js";
94
94
  import { Returns } from "../storage/returns.js";
95
95
  import { freeBytesFor } from "../storage/free.js";
96
- import { existsSync } from "node:fs";
96
+ import { inputCacheRoot, probeInputCache } from "../encode/input-cache.js";
97
97
  import path from "node:path";
98
98
  import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
99
99
  import { configurationKeyOf } from "../encode/fingerprint.js";
@@ -341,6 +341,7 @@ export function wireOutputs({
341
341
  get outputs() { return parts.outputs; },
342
342
  get priority() { return parts.priority; },
343
343
  get segmentDurationSec() { return parts.segmentDurationSec; },
344
+ readsThroughCache: () => parts.inputCacheAvailable === true,
344
345
  segmentFiles,
345
346
  get videoEncoder() { return parts.encoders.current; },
346
347
  get encoders() { return parts.encoders; },
@@ -627,6 +628,16 @@ export function wireOutputs({
627
628
  parts.coldStarts = new ColdStarts();
628
629
  parts.enabled = Boolean(enabled);
629
630
  parts.ffmpegBin = ffmpegBin;
631
+ // Whether a run may read its input through FFmpeg's cache: once a read
632
+ // through it has worked with this FFmpeg, and directly until then
633
+ // (`encode/input-cache.js`).
634
+ parts.inputCacheAvailable = false;
635
+ if (parts.enabled && ffmpegBin) {
636
+ void probeInputCache({ ffmpegBin }).then(({ available, reason }) => {
637
+ parts.inputCacheAvailable = available;
638
+ logger.info(`encoder input: ${available ? "read through FFmpeg's cache" : "read directly"} — ${reason}`);
639
+ });
640
+ }
630
641
  parts.sourceInputsFor = typeof sourceInputsFor === "function" ? sourceInputsFor : null;
631
642
  parts.keyframeTables = keyframeTables;
632
643
  // Where measurements about this host are kept between runs. Empty means
@@ -1045,9 +1056,9 @@ export function wireOutputs({
1045
1056
  memory: parts.memoryClaimant,
1046
1057
  encodeInputs: parts.encodeInputs,
1047
1058
  indexMemory,
1048
- // FFmpeg puts its temporary files in /tmp, or where it runs when there is
1049
- // none — an output directory of the segments.
1050
- inputCaches: { held: () => parts.encodeRuns?.inputCacheBytes() ?? 0, root: existsSync("/tmp") ? "/tmp" : null },
1059
+ // FFmpeg's cache keeps its file in /tmp, or where FFmpeg runs on a build
1060
+ // without `mkstemp` — an output directory of the segments.
1061
+ inputCaches: { held: () => parts.encodeRuns?.inputCacheBytes() ?? 0, root: inputCacheRoot() },
1051
1062
  policy: parts.budgetPolicy ?? undefined,
1052
1063
  readFree: freeBytesFor,
1053
1064
  logger