@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 +1 -1
- package/services/encode/EncodeRun.js +9 -2
- package/services/encode/EncodeRuns.js +17 -3
- package/services/encode/input-cache.js +63 -0
- package/services/encode/source-command.js +4 -1
- package/services/server/wire-outputs.js +15 -4
- package/services/storage/segment-store/SegmentStore.js +17 -0
package/package.json
CHANGED
|
@@ -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,
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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
|
|
1049
|
-
//
|
|
1050
|
-
inputCaches: { held: () => parts.encodeRuns?.inputCacheBytes() ?? 0, root:
|
|
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.
|