@torrent-tv/proxy 2.97.19 → 2.97.21
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
package/server.js
CHANGED
|
@@ -79,6 +79,7 @@ import { readHostFingerprint } from "./services/encode/fingerprint.js";
|
|
|
79
79
|
import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
|
|
80
80
|
import { benchmarkAudio } from "./services/encode/audio-calibration.js";
|
|
81
81
|
import { logger } from "./utils/logger.js";
|
|
82
|
+
import { takeTurns } from "./utils/take-turns.js";
|
|
82
83
|
import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
|
|
83
84
|
import { startupCpuProfile } from "./services/storage/cpu-profile.js";
|
|
84
85
|
import { startProcessorReport } from "./services/storage/processor-report.js";
|
|
@@ -532,6 +533,7 @@ export async function startProxyServer({
|
|
|
532
533
|
// answers; asking every segment held the main thread for 3.3 s on a cold
|
|
533
534
|
// two-hour AVI (torrent-tv/meta#178).
|
|
534
535
|
const waiting = new Set();
|
|
536
|
+
const turn = takeTurns();
|
|
535
537
|
for (const zone of demands) {
|
|
536
538
|
if (!map.isCurrent()) return [];
|
|
537
539
|
// WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication: its
|
|
@@ -571,14 +573,14 @@ export async function startProxyServer({
|
|
|
571
573
|
value = new SegmentInputs({ index, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
572
574
|
}
|
|
573
575
|
} catch { value = null; }
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
576
|
+
// The pass shares the thread in equal turns (`takeTurns`): in one
|
|
577
|
+
// stretch the first pass over a film's 4000 zones held the main thread
|
|
578
|
+
// for all of its work, and handed back after every zone it took
|
|
579
|
+
// 14.7-22 s while the start forecast waited for it. What was worked
|
|
580
|
+
// out is kept, so a pass a newer map ends is not lost
|
|
578
581
|
// (torrent-tv/meta#178).
|
|
579
582
|
if (value?.kind === "result") segmentSourceRanges.set(rangesKey, value.ranges);
|
|
580
|
-
await
|
|
581
|
-
if (!map.isCurrent()) return [];
|
|
583
|
+
if (await turn() && !map.isCurrent()) return [];
|
|
582
584
|
if (value?.kind === "result") {
|
|
583
585
|
convert(value.ranges);
|
|
584
586
|
continue;
|
|
@@ -281,6 +281,21 @@ export class EncodeRuns {
|
|
|
281
281
|
return this.#runsByInputToken.get(runToken)?.originalInput ?? null;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* What FFmpeg's caches may hold for the live runs that read an original
|
|
286
|
+
* file: at most what each was admitted, since a cache keeps what its run
|
|
287
|
+
* read of it (`source-command.js`).
|
|
288
|
+
*
|
|
289
|
+
* @returns {number}
|
|
290
|
+
*/
|
|
291
|
+
inputCacheBytes() {
|
|
292
|
+
let bytes = 0;
|
|
293
|
+
for (const run of this.#runsByInputToken.values()) {
|
|
294
|
+
if (run.isAlive && run.originalInput) bytes += Math.max(0, Number(run.originalInput.bytes) || 0);
|
|
295
|
+
}
|
|
296
|
+
return bytes;
|
|
297
|
+
}
|
|
298
|
+
|
|
284
299
|
/**
|
|
285
300
|
* @param {object} output
|
|
286
301
|
* @returns {number}
|
|
@@ -270,7 +270,15 @@ export function buildOriginalCommand(params) {
|
|
|
270
270
|
const primary = sourceFor(audioOnly ? "audio" : "video");
|
|
271
271
|
const audio = sourceFor("audio") ?? primary;
|
|
272
272
|
if (!primary) throw new Error("The selected source track is absent from original input.");
|
|
273
|
-
|
|
273
|
+
// READ THROUGH FFMPEG'S CACHE, so a return to bytes already read is not a
|
|
274
|
+
// new request. An OpenDML AVI's open seeks from its super index in the header
|
|
275
|
+
// to each standard index and back: on the field's two-hour AVI, 195 indexes
|
|
276
|
+
// made 390 requests and 636-1243 MB of answers, and the first piece came 4.8 s
|
|
277
|
+
// after the start against 2.8 s through the cache, which made 3 requests;
|
|
278
|
+
// the pieces were the same to the byte, on FFmpeg 6.1 and 8.1.2 (Home
|
|
279
|
+
// Assistant 2026-10-13, torrent-tv/meta#178). The cache's file holds what
|
|
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}`;
|
|
274
282
|
// The container named the bytes FFmpeg's stream search reads for this much
|
|
275
283
|
// media time; the search is told the same, whatever its own default.
|
|
276
284
|
const openArgs = source => Number.isFinite(source.input.streamSearchSeconds)
|
|
@@ -93,6 +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
97
|
import path from "node:path";
|
|
97
98
|
import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
|
|
98
99
|
import { configurationKeyOf } from "../encode/fingerprint.js";
|
|
@@ -345,7 +346,7 @@ export function wireOutputs({
|
|
|
345
346
|
get encoders() { return parts.encoders; },
|
|
346
347
|
invalidateWaits: (output) => parts.serving.invalidateWaits(output),
|
|
347
348
|
productionFailed: (output) => parts.renditions.noteProductionFailed(output),
|
|
348
|
-
encodersChanged: () => { parts.applyDownloadShare?.(); onEncodersChanged?.(); },
|
|
349
|
+
encodersChanged: () => { parts.applyDownloadShare?.(); reviseInputCaches(); onEncodersChanged?.(); },
|
|
349
350
|
});
|
|
350
351
|
// How much film is ready in front of the viewers, said; and the spare soundtracks fetched once it is full.
|
|
351
352
|
parts.cushion = new CushionReport({
|
|
@@ -1044,10 +1045,25 @@ export function wireOutputs({
|
|
|
1044
1045
|
memory: parts.memoryClaimant,
|
|
1045
1046
|
encodeInputs: parts.encodeInputs,
|
|
1046
1047
|
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 },
|
|
1047
1051
|
policy: parts.budgetPolicy ?? undefined,
|
|
1048
1052
|
readFree: freeBytesFor,
|
|
1049
1053
|
logger
|
|
1050
1054
|
});
|
|
1055
|
+
/**
|
|
1056
|
+
* A run reading an original file started or ended, so the disk its cache is
|
|
1057
|
+
* on is divided again — only when what the caches may hold has changed.
|
|
1058
|
+
*/
|
|
1059
|
+
let inputCachesDivided = 0;
|
|
1060
|
+
function reviseInputCaches() {
|
|
1061
|
+
const bytes = parts.encodeRuns?.inputCacheBytes() ?? 0;
|
|
1062
|
+
const on = parts.machineBudget?.inputCachesOn;
|
|
1063
|
+
if (!on || bytes === inputCachesDivided) return;
|
|
1064
|
+
inputCachesDivided = bytes;
|
|
1065
|
+
void parts.machineBudget.revise(on, "encoder input caches").catch(error => logger.warn(`input cache budget: ${error.message}`));
|
|
1066
|
+
}
|
|
1051
1067
|
/**
|
|
1052
1068
|
* What starting a run costs this output before its first piece is encoded:
|
|
1053
1069
|
* its own measured open where it has one (`RunCosts.openOf`), else the
|
package/services/storage/wire.js
CHANGED
|
@@ -44,6 +44,9 @@ import { availableMemoryBytes } from "./machine-memory.js";
|
|
|
44
44
|
* not fit in memory is spilled, so this share decides how much disk is needed.
|
|
45
45
|
* @param {{ held: () => number, allow: (bytes: number) => unknown, root?: string }} [params.wholeFiles] -
|
|
46
46
|
* Files downloaded whole and kept as files, on the torrent thread as well.
|
|
47
|
+
* @param {{ held: () => number, root?: string | null }} [params.inputCaches] -
|
|
48
|
+
* What FFmpeg's caches of original input hold, and the directory FFmpeg puts
|
|
49
|
+
* them in (the segments' disk when none).
|
|
47
50
|
* @param {import("./Diagnostics.js").Diagnostics} [params.diagnostics] - The
|
|
48
51
|
* evidence. Built where the collectors are, so one object is both registered
|
|
49
52
|
* here and consulted by them.
|
|
@@ -64,6 +67,7 @@ export function wireMachineBudget({
|
|
|
64
67
|
encodeInputs,
|
|
65
68
|
indexMemory,
|
|
66
69
|
wholeFiles,
|
|
70
|
+
inputCaches,
|
|
67
71
|
diagnostics,
|
|
68
72
|
diagnosticsRoot = "",
|
|
69
73
|
readFree,
|
|
@@ -153,6 +157,25 @@ export function wireMachineBudget({
|
|
|
153
157
|
});
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
// FFMPEG'S CACHE OF WHAT A RUN READ from an original file (`cache:`,
|
|
161
|
+
// `encode/source-command.js`): a temporary file where FFmpeg puts its own,
|
|
162
|
+
// as large as what the run has read and at most what it was admitted. Held
|
|
163
|
+
// while the run lives and given back by nobody else, so it is required in
|
|
164
|
+
// full and the rest of its disk is divided after it.
|
|
165
|
+
let inputCachesOn = null;
|
|
166
|
+
if (typeof inputCaches?.held === "function") {
|
|
167
|
+
inputCachesOn = inputCaches.root ? deviceOf(inputCaches.root) : segmentsOn;
|
|
168
|
+
defineDisk(inputCachesOn, inputCaches.root || segmentStore.root);
|
|
169
|
+
budget.register({
|
|
170
|
+
name: "encoder input caches",
|
|
171
|
+
resource: inputCachesOn,
|
|
172
|
+
held: () => inputCaches.held(),
|
|
173
|
+
wanted: () => inputCaches.held(),
|
|
174
|
+
required: () => inputCaches.held(),
|
|
175
|
+
allow: () => {}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
156
179
|
let evidenceOn = null;
|
|
157
180
|
if (diagnostics) {
|
|
158
181
|
evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
|
|
@@ -188,6 +211,8 @@ export function wireMachineBudget({
|
|
|
188
211
|
return budget.revise(resource, because);
|
|
189
212
|
},
|
|
190
213
|
segmentBytes: () => segmentBytes,
|
|
214
|
+
/** The disk FFmpeg's input caches are counted on, or null when none are. */
|
|
215
|
+
inputCachesOn,
|
|
191
216
|
diagnostics: diagnostics ?? null,
|
|
192
217
|
describe: () => budget.describe()
|
|
193
218
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long work on one thread, shared with the rest of that thread in equal turns.
|
|
3
|
+
*
|
|
4
|
+
* The returned function is called between steps of the work. It hands the
|
|
5
|
+
* thread back once the work has run, since it last did, as long as the rest
|
|
6
|
+
* of the thread took in its own turn then: the work takes at most about twice
|
|
7
|
+
* its own time, and nothing else waits longer than it took itself. Handed back
|
|
8
|
+
* after every step, the download map's first pass over a two-hour AVI took
|
|
9
|
+
* 14.7-22 s of wall time for about 4 s of its own work, and the start forecast,
|
|
10
|
+
* which needs the whole pass, could not answer meanwhile; held in one stretch
|
|
11
|
+
* it held the thread for that work (Home Assistant 2026-10-12,
|
|
12
|
+
* torrent-tv/meta#178).
|
|
13
|
+
*
|
|
14
|
+
* @param {{ now?: () => number, nextTurn?: () => Promise<void> }} [options]
|
|
15
|
+
* @returns {() => Promise<boolean>} Resolves true when the thread was handed back.
|
|
16
|
+
*/
|
|
17
|
+
export function takeTurns({ now = () => performance.now(), nextTurn = () => new Promise(resolve => setImmediate(resolve)) } = {}) {
|
|
18
|
+
let workingSince = now();
|
|
19
|
+
let othersTook = 0;
|
|
20
|
+
return async () => {
|
|
21
|
+
if (now() - workingSince < othersTook) return false;
|
|
22
|
+
const handedBackAt = now();
|
|
23
|
+
await nextTurn();
|
|
24
|
+
workingSince = now();
|
|
25
|
+
othersTook = workingSince - handedBackAt;
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
}
|