@torrent-tv/proxy 2.107.48 → 2.107.49
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/server/OutputLifecycle.js +1 -0
- package/services/server/ViewerRequests.js +5 -3
- package/services/server/wire-outputs.js +15 -1
- package/services/storage/PlaybackStorageDemand.js +64 -0
- package/services/storage/wire.js +18 -2
- package/services/viewer/playback-readiness.js +8 -4
package/package.json
CHANGED
|
@@ -427,6 +427,7 @@ export class OutputLifecycle {
|
|
|
427
427
|
if (!output) {
|
|
428
428
|
return false;
|
|
429
429
|
}
|
|
430
|
+
this.#host.releasePlaybackStorageDemand?.(output.outputKey ?? "", consumerId);
|
|
430
431
|
// Nothing to release in the encoding: it holds one map per output, built
|
|
431
432
|
// from where the viewers are, and the map that arrives next simply does not
|
|
432
433
|
// have this one in it. A name to release was the last place a viewer
|
|
@@ -1013,9 +1013,8 @@ export class ViewerRequests {
|
|
|
1013
1013
|
bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
|
|
1014
1014
|
reserveSeconds,
|
|
1015
1015
|
lookaheadSeconds: this.#host.lookaheadSeconds,
|
|
1016
|
-
//
|
|
1017
|
-
|
|
1018
|
-
sourceStorageBytes: this.#host.sourceStorageBytes?.() ?? null,
|
|
1016
|
+
// Disk room remaining after stock reserved for other active outputs.
|
|
1017
|
+
sourceStorageBytes: this.#host.sourceStorageBytes?.(session.outputKey ?? session.id) ?? null,
|
|
1019
1018
|
requiredAudio: requiresSeparateAudio,
|
|
1020
1019
|
sources: [...sourceMeasurements.values()],
|
|
1021
1020
|
tracks,
|
|
@@ -1028,6 +1027,9 @@ export class ViewerRequests {
|
|
|
1028
1027
|
viewer.subtitle.fileIndex === session.file.fileIndex ? viewer.subtitle : null;
|
|
1029
1028
|
const startedAt = performance.now();
|
|
1030
1029
|
const predicted = this.#host.playbackReadiness.predict(input);
|
|
1030
|
+
if (consumerId && viewer && this.#host.viewers.get(consumerId) === viewer && Number.isFinite(predicted.cushionBytes)) {
|
|
1031
|
+
this.#host.notePlaybackStorageDemand?.(session.outputKey ?? session.id, consumerId, predicted.cushionBytes);
|
|
1032
|
+
}
|
|
1031
1033
|
const predictMs = performance.now() - startedAt;
|
|
1032
1034
|
this.#noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
|
|
1033
1035
|
logger.info(`readiness parts ${session.id} consumer=${consumerId}: source stats ${Math.round(parts.statsMs)}ms ` +
|
|
@@ -95,6 +95,7 @@ import { audioReadingFor } from "../encode/audio-work-rate.js";
|
|
|
95
95
|
import { wireMachineBudget } from "../storage/wire.js";
|
|
96
96
|
import { Returns } from "../storage/returns.js";
|
|
97
97
|
import { freeBytesFor } from "../storage/free.js";
|
|
98
|
+
import { PlaybackStorageDemand } from "../storage/PlaybackStorageDemand.js";
|
|
98
99
|
import { inputCacheRoot, probeInputCache } from "../encode/input-cache.js";
|
|
99
100
|
import path from "node:path";
|
|
100
101
|
import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
|
|
@@ -579,6 +580,7 @@ export function wireOutputs({
|
|
|
579
580
|
get encodeRuns() { return parts.encodeRuns; },
|
|
580
581
|
get keyframeTables() { return parts.keyframeTables; },
|
|
581
582
|
get machineBudget() { return parts.machineBudget; },
|
|
583
|
+
releasePlaybackStorageDemand: (key, consumerId) => parts.playbackStorageDemand.release(key, consumerId),
|
|
582
584
|
get outputTimes() { return parts.outputTimes; },
|
|
583
585
|
get outputs() { return parts.outputs; },
|
|
584
586
|
get returns() { return parts.returns; },
|
|
@@ -629,7 +631,13 @@ export function wireOutputs({
|
|
|
629
631
|
viewerSecondsOn: (output, consumerId, now) => viewerSecondsOn(parts.viewers, output, consumerId, now),
|
|
630
632
|
minimumBufferSecondsFor: (output) => minimumBufferSecondsOf(output),
|
|
631
633
|
// What this proxy may keep of downloaded pieces on disk.
|
|
632
|
-
sourceStorageBytes:
|
|
634
|
+
sourceStorageBytes: outputKey => {
|
|
635
|
+
const capacity = parts.machineBudget?.storageBytes();
|
|
636
|
+
return Number.isFinite(capacity)
|
|
637
|
+
? Math.max(0, capacity - parts.playbackStorageDemand.bytesExcept(outputKey))
|
|
638
|
+
: null;
|
|
639
|
+
},
|
|
640
|
+
notePlaybackStorageDemand: (key, consumerId, bytes) => parts.playbackStorageDemand.note(key, consumerId, bytes),
|
|
633
641
|
getSourceStats: (...args) => parts.getSourceStats?.(...args) ?? null,
|
|
634
642
|
disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
|
|
635
643
|
planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
|
|
@@ -1173,6 +1181,11 @@ export function wireOutputs({
|
|
|
1173
1181
|
// one term of the keeping period that is guessed rather than measured, and
|
|
1174
1182
|
// the only place it can be measured from.
|
|
1175
1183
|
parts.returns = new Returns();
|
|
1184
|
+
parts.playbackStorageDemand = new PlaybackStorageDemand(() => {
|
|
1185
|
+
if (parts.machineBudget) {
|
|
1186
|
+
void parts.machineBudget.revise(parts.machineBudget.storageResource, "playback stock");
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1176
1189
|
parts.encodeInputs = encodeInputs ?? (typeof resolveEncodeInput === "function" ? new EncodeInputs({
|
|
1177
1190
|
resolve: resolveEncodeInput,
|
|
1178
1191
|
readRanges: readEncodeRanges,
|
|
@@ -1221,6 +1234,7 @@ export function wireOutputs({
|
|
|
1221
1234
|
// FFmpeg's cache keeps its file in /tmp, or where FFmpeg runs on a build
|
|
1222
1235
|
// without `mkstemp` — an output directory of the segments.
|
|
1223
1236
|
inputCaches: { held: () => parts.encodeRuns?.inputCacheBytes() ?? 0, root: inputCacheRoot() },
|
|
1237
|
+
playbackStorage: parts.playbackStorageDemand,
|
|
1224
1238
|
policy: parts.budgetPolicy ?? undefined,
|
|
1225
1239
|
readFree: freeBytesFor,
|
|
1226
1240
|
logger
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bytes an active playback forecast says must remain available on disk.
|
|
3
|
+
* Viewers of one output share its stored bytes, so its demand is the largest
|
|
4
|
+
* live viewer demand; distinct outputs add their requirements.
|
|
5
|
+
*/
|
|
6
|
+
export class PlaybackStorageDemand {
|
|
7
|
+
#byOutput = new Map();
|
|
8
|
+
#changed;
|
|
9
|
+
|
|
10
|
+
constructor(changed = () => {}) {
|
|
11
|
+
this.#changed = changed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @returns {number} */
|
|
15
|
+
bytes() {
|
|
16
|
+
let total = 0;
|
|
17
|
+
for (const viewers of this.#byOutput.values()) {
|
|
18
|
+
let shared = 0;
|
|
19
|
+
for (const bytes of viewers.values()) shared = Math.max(shared, bytes);
|
|
20
|
+
total += shared;
|
|
21
|
+
}
|
|
22
|
+
return total;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} outputKey @returns {number} */
|
|
26
|
+
bytesExcept(outputKey) {
|
|
27
|
+
let total = 0;
|
|
28
|
+
for (const [key, viewers] of this.#byOutput) {
|
|
29
|
+
if (key === outputKey) continue;
|
|
30
|
+
let shared = 0;
|
|
31
|
+
for (const bytes of viewers.values()) shared = Math.max(shared, bytes);
|
|
32
|
+
total += shared;
|
|
33
|
+
}
|
|
34
|
+
return total;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {string} outputKey @param {string} consumerId @param {number} bytes */
|
|
38
|
+
note(outputKey, consumerId, bytes) {
|
|
39
|
+
if (!outputKey || !consumerId || !Number.isFinite(bytes) || bytes < 0) return;
|
|
40
|
+
const before = this.bytes();
|
|
41
|
+
let viewers = this.#byOutput.get(outputKey);
|
|
42
|
+
if (!viewers) {
|
|
43
|
+
viewers = new Map();
|
|
44
|
+
this.#byOutput.set(outputKey, viewers);
|
|
45
|
+
}
|
|
46
|
+
viewers.set(consumerId, bytes);
|
|
47
|
+
this.#notifyIfChanged(before);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string} outputKey @param {string} consumerId */
|
|
51
|
+
release(outputKey, consumerId) {
|
|
52
|
+
const viewers = this.#byOutput.get(outputKey);
|
|
53
|
+
if (!viewers?.has(consumerId)) return;
|
|
54
|
+
const before = this.bytes();
|
|
55
|
+
viewers.delete(consumerId);
|
|
56
|
+
if (viewers.size === 0) this.#byOutput.delete(outputKey);
|
|
57
|
+
this.#notifyIfChanged(before);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#notifyIfChanged(before) {
|
|
61
|
+
const after = this.bytes();
|
|
62
|
+
if (after !== before) this.#changed(after);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/services/storage/wire.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* segments an encoder produced, the files downloaded whole — and the evidence,
|
|
11
11
|
* which is a claimant with a rule of its own (`Diagnostics.js`). Three of the
|
|
12
12
|
* five live on the torrent thread, so they arrive as pairs of closures over its
|
|
13
|
-
* channel rather than as the objects themselves.
|
|
13
|
+
* channel rather than as the objects themselves. Active playback also
|
|
14
|
+
* reserves its forecasted source stock here; that is a requirement, not a
|
|
15
|
+
* store holding bytes yet.
|
|
14
16
|
*
|
|
15
17
|
* **Which RESOURCE each takes is read, not assumed.** Memory is one. Disk is
|
|
16
18
|
* one per device, because two filesystems cannot pay for each other: measured
|
|
@@ -56,7 +58,7 @@ import { availableMemoryBytes } from "./machine-memory.js";
|
|
|
56
58
|
* @param {import("./MachineBudget.js").BudgetPolicy} [params.policy] - What the
|
|
57
59
|
* operator has said this proxy may take. Absent, the measured default.
|
|
58
60
|
* @param {{ info: Function, warn?: Function }} [params.logger]
|
|
59
|
-
* @returns {{ revise: () => Promise<unknown>, segmentBytes: () => number | null, diagnostics: object | null, describe: () => string }}
|
|
61
|
+
* @returns {{ revise: (resource?: string | null, because?: string | null) => Promise<unknown>, segmentBytes: () => number | null, storageBytes: () => number | null, storageResource: string, diagnostics: object | null, describe: () => string }}
|
|
60
62
|
* What the segments may hold is asked for rather than pushed: null until the
|
|
61
63
|
* first revision, then the measured allowance, including a measured zero.
|
|
62
64
|
*/
|
|
@@ -68,6 +70,7 @@ export function wireMachineBudget({
|
|
|
68
70
|
indexMemory,
|
|
69
71
|
wholeFiles,
|
|
70
72
|
inputCaches,
|
|
73
|
+
playbackStorage,
|
|
71
74
|
diagnostics,
|
|
72
75
|
diagnosticsRoot = "",
|
|
73
76
|
readFree,
|
|
@@ -177,6 +180,17 @@ export function wireMachineBudget({
|
|
|
177
180
|
});
|
|
178
181
|
}
|
|
179
182
|
|
|
183
|
+
if (typeof playbackStorage?.bytes === "function") {
|
|
184
|
+
budget.register({
|
|
185
|
+
name: "playback stock",
|
|
186
|
+
resource: segmentsOn,
|
|
187
|
+
held: () => 0,
|
|
188
|
+
wanted: () => playbackStorage.bytes(),
|
|
189
|
+
required: () => playbackStorage.bytes(),
|
|
190
|
+
allow: () => {}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
180
194
|
let evidenceOn = null;
|
|
181
195
|
if (diagnostics) {
|
|
182
196
|
evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
|
|
@@ -194,6 +208,8 @@ export function wireMachineBudget({
|
|
|
194
208
|
|
|
195
209
|
return {
|
|
196
210
|
capacityOf: name => budget.capacityOf(name),
|
|
211
|
+
storageBytes: () => budget.capacityOf(segmentsOn),
|
|
212
|
+
storageResource: segmentsOn,
|
|
197
213
|
/** What one claimant was last allowed, or null before a measured division. */
|
|
198
214
|
allowedFor: name => budget.allowedFor(name),
|
|
199
215
|
/**
|
|
@@ -841,16 +841,19 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
841
841
|
// THE STOCK THE START ASKS FOR HAS TO FIT. Starting without a stop while the
|
|
842
842
|
// source comes slower than the film plays means holding, at the start, what
|
|
843
843
|
// is already here and everything that arrives before it. Where that is more
|
|
844
|
-
// than
|
|
845
|
-
// given up before the viewer reached it,
|
|
844
|
+
// than the disk room left after other active playback stock is reserved,
|
|
845
|
+
// some of it would be given up before the viewer reached it, so no wait
|
|
846
|
+
// makes the start safe:
|
|
846
847
|
// Despicable Me 3 BDRemux at 1.4 MB/s asked for about 36 GB against a 29.5 GB
|
|
847
848
|
// share (Home Assistant 2026-10-26, torrent-tv/meta#239). Said as such, and
|
|
848
849
|
// the viewer keeps waiting rather than starting into stops.
|
|
849
850
|
const storageBytes = Number(input.sourceStorageBytes);
|
|
850
851
|
const tailParts = tailStock.filter(part => !part.skipped);
|
|
852
|
+
const cushionBytes = Number.isFinite(delay) && tailParts.length > 0
|
|
853
|
+
? tailParts.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
|
|
854
|
+
Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0)
|
|
855
|
+
: null;
|
|
851
856
|
if (Number.isFinite(delay) && Number.isFinite(storageBytes) && storageBytes >= 0 && tailParts.length > 0) {
|
|
852
|
-
const cushionBytes = tailParts.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
|
|
853
|
-
Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0);
|
|
854
857
|
if (cushionBytes > storageBytes) {
|
|
855
858
|
return { ...result(false, null, buffered, reserve, null, "cushion-exceeds-storage", preparedSegments),
|
|
856
859
|
cushionBytes, storageBytes, tail: tailStock };
|
|
@@ -862,6 +865,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
862
865
|
Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
|
|
863
866
|
Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
|
|
864
867
|
return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
|
|
868
|
+
...(cushionBytes === null ? {} : { cushionBytes }),
|
|
865
869
|
firstRequiredSegmentsSeconds,
|
|
866
870
|
firstRequiredStages,
|
|
867
871
|
// What the rest of the film weighs per source, at what rate it comes and
|