@torrent-tv/proxy 2.97.22 → 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.22",
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": {
@@ -123,6 +123,8 @@ export class EncodeRun {
123
123
  */
124
124
  #pendingAtStop = null;
125
125
  #closedTimings = new Map();
126
+ /** When the encoder named each closed piece on its channel, until it is published. */
127
+ #namedAt = new Map();
126
128
  #publicationError = null;
127
129
  #publicationCode = null;
128
130
  #inputTruncated = false;
@@ -622,6 +624,7 @@ export class EncodeRun {
622
624
  if (name.length === 0) {
623
625
  continue;
624
626
  }
627
+ this.#namedAt.set(name, this.now());
625
628
  if (this.#publicationError) continue;
626
629
  if (this.#stopping) {
627
630
  // Flushed by our stop. Never published; it tells where the piece closed
@@ -681,7 +684,8 @@ export class EncodeRun {
681
684
  // name a request can actually ask for.
682
685
  let served;
683
686
  try {
684
- served = this.onClosed(name, following, this.#closedTimings.get(name) ?? null, { tentative });
687
+ served = this.onClosed(name, following, this.#closedTimings.get(name) ?? null,
688
+ { tentative, namedAt: this.#namedAt.get(name) ?? null });
685
689
  } catch (error) {
686
690
  if (quiet) {
687
691
  // A piece closed before our stop that cannot be proved whole is not
@@ -702,7 +706,10 @@ export class EncodeRun {
702
706
  } finally {
703
707
  // A piece not proved whole on its own keeps its timing for the file
704
708
  // after it.
705
- if (!tentative || served) this.#closedTimings.delete(name);
709
+ if (!tentative || served) {
710
+ this.#closedTimings.delete(name);
711
+ this.#namedAt.delete(name);
712
+ }
706
713
  }
707
714
 
708
715
  if (!served) return false;
@@ -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
@@ -1121,7 +1125,7 @@ export class EncodeRuns {
1121
1125
  // from it — a re-encoded picture, or sound served as its own output. Such
1122
1126
  // a piece is published on its own line (torrent-tv/meta#178).
1123
1127
  publishesAlone: this.publishesAlone(session),
1124
- onClosed: (name, following, timing, { tentative = false } = {}) => {
1128
+ onClosed: (name, following, timing, { tentative = false, namedAt = null } = {}) => {
1125
1129
 
1126
1130
  const index = session.segmentFormat.segmentIndexFromName(
1127
1131
  session.segmentFormat.servedNameOf?.(name) ?? name);
@@ -1131,6 +1135,8 @@ export class EncodeRuns {
1131
1135
  if (Number.isInteger(index) && (index < safeIndex || index > run.to)) return null;
1132
1136
  let bytes = null;
1133
1137
  const address = session.outputKey ?? "";
1138
+ // Read before anything rewrites it: when the encoder finished it.
1139
+ const writtenAt = this.#host.segmentFiles.writtenAtOf?.(address, name) ?? null;
1134
1140
  const fmp4 = admittedInput && session.segmentFormat.id === "fmp4" && session.spec.carries !== "audio-only";
1135
1141
  // The first piece of a run that read the picture from its keyframe:
1136
1142
  // its leading pictures are the ones the run before made, or none.
@@ -1157,6 +1163,14 @@ export class EncodeRuns {
1157
1163
  const published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
1158
1164
  if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
1159
1165
  this.#leadingByOutput.get(address)?.delete(index);
1166
+ // WHERE A LATE PIECE'S TIME WENT: the encoder writing it, this process
1167
+ // getting to its name, or its publication waiting for the file after
1168
+ // it. A piece 5.7 s apart from the one before stopped a viewer inside a
1169
+ // running run, and nothing said which (torrent-tv/meta#178).
1170
+ const since = at => (Number.isFinite(at) ? `+${Math.round(at - run.startedAt)}ms` : "unknown");
1171
+ this.#host.logger.info(`encode-run #${run.from}..#${run.to} on ${address}: #${index} written by the encoder ${since(writtenAt)}, ` +
1172
+ `named ${since(namedAt)}, published ${since(run.now())} ` +
1173
+ (following ? `when ${following} closed` : tentative ? "on its own" : "at the run's end"));
1160
1174
  return published;
1161
1175
 
1162
1176
  },
@@ -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
@@ -594,6 +594,23 @@ export class SegmentStore {
594
594
  return readFileSync(path.join(this.#root, directoryNameFor(key), makingName));
595
595
  }
596
596
 
597
+ /**
598
+ * When the encoder last wrote a file it has closed under its working name,
599
+ * on the kernel's clock and so apart from when this process got to it; null
600
+ * when it is not there.
601
+ *
602
+ * @param {string} key
603
+ * @param {string} makingName
604
+ * @returns {number | null}
605
+ */
606
+ writtenAtOf(key, makingName) {
607
+ try {
608
+ return statSync(path.join(this.#root, directoryNameFor(key), makingName)).mtimeMs;
609
+ } catch {
610
+ return null;
611
+ }
612
+ }
613
+
597
614
  /**
598
615
  * The media intervals a stored piece holds, read once from its bytes and kept
599
616
  * with them, or undefined where its format reads none or it is not here.