@torrent-tv/proxy 2.107.48 → 2.107.50
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/WaitLedger.js → encode/OutputWaitLedger.js} +37 -7
- package/services/encode/output/OutputCatalog.js +46 -2
- package/services/server/OutputLifecycle.js +3 -1
- package/services/server/SegmentServing.js +14 -0
- package/services/server/ViewerRequests.js +5 -3
- package/services/server/transcode-session-files.js +9 -4
- package/services/server/wire-outputs.js +26 -3
- 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
|
@@ -58,6 +58,23 @@ export class WaitLedger {
|
|
|
58
58
|
/** @type {Map<string, Map<string, number>>} */
|
|
59
59
|
#counts = new Map();
|
|
60
60
|
|
|
61
|
+
/** @type {Map<string, object>} */
|
|
62
|
+
#owners = new Map();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Begin a live output-key lifetime. Reopening a key starts a clean history;
|
|
66
|
+
* the returned identity lets a held response prove that it still belongs to
|
|
67
|
+
* this lifetime when it eventually completes.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} key
|
|
70
|
+
* @param {object} identity
|
|
71
|
+
*/
|
|
72
|
+
open(key, identity) {
|
|
73
|
+
if (!key || !identity || this.#owners.get(key) === identity) return;
|
|
74
|
+
this.forget(key);
|
|
75
|
+
this.#owners.set(key, identity);
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
/**
|
|
62
79
|
* Somebody waited this long for something the map ranked this highly.
|
|
63
80
|
*
|
|
@@ -66,11 +83,13 @@ export class WaitLedger {
|
|
|
66
83
|
* @param {number} rank - The map's rank for the thing waited for.
|
|
67
84
|
* @param {number} topRank - The highest rank the map states, so the rank can
|
|
68
85
|
* be read as a position rather than as an absolute number.
|
|
86
|
+
* @param {object} [identity] - The output-key lifetime that started this wait.
|
|
69
87
|
*/
|
|
70
|
-
note(key, waitedMs, rank, topRank) {
|
|
88
|
+
note(key, waitedMs, rank, topRank, identity = undefined) {
|
|
71
89
|
if (!key || !Number.isFinite(waitedMs) || waitedMs < 0) {
|
|
72
90
|
return;
|
|
73
91
|
}
|
|
92
|
+
if (identity !== undefined && this.#owners.get(key) !== identity) return;
|
|
74
93
|
const band = bandOf(rank, topRank);
|
|
75
94
|
let byBand = this.#waits.get(key);
|
|
76
95
|
if (!byBand) {
|
|
@@ -128,15 +147,26 @@ export class WaitLedger {
|
|
|
128
147
|
|
|
129
148
|
/**
|
|
130
149
|
* @param {string} key
|
|
150
|
+
* @param {object} [identity] - Ignore release from an older output lifetime.
|
|
131
151
|
*/
|
|
132
|
-
forget(key) {
|
|
152
|
+
forget(key, identity = undefined) {
|
|
153
|
+
if (identity !== undefined && this.#owners.get(key) !== identity) return false;
|
|
133
154
|
this.#waits.delete(key);
|
|
134
155
|
this.#counts.delete(key);
|
|
156
|
+
if (identity === undefined || this.#owners.get(key) === identity) this.#owners.delete(key);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Release every record owned by this proxy before its shutdown completes. */
|
|
161
|
+
clear() {
|
|
162
|
+
this.#waits.clear();
|
|
163
|
+
this.#counts.clear();
|
|
164
|
+
this.#owners.clear();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Number of output keys with retained measurements. */
|
|
168
|
+
get size() {
|
|
169
|
+
return this.#waits.size;
|
|
135
170
|
}
|
|
136
171
|
}
|
|
137
172
|
|
|
138
|
-
/**
|
|
139
|
-
* One ledger for the process, because the question is asked in two layers and
|
|
140
|
-
* the answer is comparable only if the scale is the same.
|
|
141
|
-
*/
|
|
142
|
-
export const waits = new WaitLedger();
|
|
@@ -26,7 +26,11 @@ export class OutputCatalog {
|
|
|
26
26
|
|
|
27
27
|
#byId = new Map();
|
|
28
28
|
#lifetime = new Map();
|
|
29
|
+
/** @type {Map<string, object>} */
|
|
30
|
+
#waitIdentities = new Map();
|
|
29
31
|
#now;
|
|
32
|
+
#waitIdentityStarted;
|
|
33
|
+
#waitIdentityEnded;
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* @param {object} [params]
|
|
@@ -35,11 +39,15 @@ export class OutputCatalog {
|
|
|
35
39
|
* source file is, which the torrent reports.
|
|
36
40
|
* @param {(address: string) => { index: number, size: number }} [params.largestPieceOf]
|
|
37
41
|
* The biggest piece an output has made, which the disk knows.
|
|
42
|
+
* @param {(address: string, identity: object) => void} [params.waitIdentityStarted]
|
|
43
|
+
* @param {(address: string, identity: object) => void} [params.waitIdentityEnded]
|
|
38
44
|
*/
|
|
39
45
|
constructor({
|
|
40
46
|
now = Date.now,
|
|
41
47
|
fileLengthOf = () => 0,
|
|
42
|
-
largestPieceOf = () => ({ index: -1, size: 0 })
|
|
48
|
+
largestPieceOf = () => ({ index: -1, size: 0 }),
|
|
49
|
+
waitIdentityStarted = () => {},
|
|
50
|
+
waitIdentityEnded = () => {}
|
|
43
51
|
} = {}) {
|
|
44
52
|
this.#now = now;
|
|
45
53
|
// Two facts this catalog needs and does not own, taken as plain functions
|
|
@@ -48,15 +56,22 @@ export class OutputCatalog {
|
|
|
48
56
|
// it is part of the output itself (`OutputSpec`).
|
|
49
57
|
this.fileLengthOf = fileLengthOf;
|
|
50
58
|
this.largestPieceOf = largestPieceOf;
|
|
59
|
+
this.#waitIdentityStarted = waitIdentityStarted;
|
|
60
|
+
this.#waitIdentityEnded = waitIdentityEnded;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
set(id, output) {
|
|
54
64
|
const known = this.#byId.get(id);
|
|
65
|
+
const oldAddress = known?.outputKey;
|
|
55
66
|
this.#byId.set(id, output);
|
|
56
67
|
if (known !== output) {
|
|
57
68
|
const at = this.#now();
|
|
58
69
|
this.#lifetime.set(id, { startedAt: at, lastAccessedAt: at });
|
|
59
70
|
}
|
|
71
|
+
if (known !== output && oldAddress !== output?.outputKey) {
|
|
72
|
+
this.#forgetWaitIdentityIfUnused(oldAddress);
|
|
73
|
+
}
|
|
74
|
+
this.#openWaitIdentity(output?.outputKey);
|
|
60
75
|
return this;
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -69,13 +84,42 @@ export class OutputCatalog {
|
|
|
69
84
|
}
|
|
70
85
|
|
|
71
86
|
delete(id) {
|
|
87
|
+
const output = this.#byId.get(id);
|
|
72
88
|
this.#lifetime.delete(id);
|
|
73
|
-
|
|
89
|
+
const deleted = this.#byId.delete(id);
|
|
90
|
+
if (deleted) this.#forgetWaitIdentityIfUnused(output?.outputKey);
|
|
91
|
+
return deleted;
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
clear() {
|
|
95
|
+
const addresses = new Set([...this.#byId.values()].map(output => output?.outputKey).filter(Boolean));
|
|
77
96
|
this.#lifetime.clear();
|
|
78
97
|
this.#byId.clear();
|
|
98
|
+
for (const address of addresses) this.#forgetWaitIdentityIfUnused(address);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The shared active lifetime for the output addressed by a live session. */
|
|
102
|
+
waitIdentity(id) {
|
|
103
|
+
const output = this.#byId.get(id);
|
|
104
|
+
return output ? this.#waitIdentities.get(output.outputKey) ?? null : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#openWaitIdentity(address) {
|
|
108
|
+
if (typeof address !== "string" || !address || this.#waitIdentities.has(address)) return;
|
|
109
|
+
const identity = {};
|
|
110
|
+
this.#waitIdentities.set(address, identity);
|
|
111
|
+
this.#waitIdentityStarted(address, identity);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#forgetWaitIdentityIfUnused(address) {
|
|
115
|
+
if (typeof address !== "string" || !address) return;
|
|
116
|
+
for (const output of this.#byId.values()) {
|
|
117
|
+
if (output?.outputKey === address) return;
|
|
118
|
+
}
|
|
119
|
+
const identity = this.#waitIdentities.get(address);
|
|
120
|
+
if (!identity) return;
|
|
121
|
+
this.#waitIdentities.delete(address);
|
|
122
|
+
this.#waitIdentityEnded(address, identity);
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
touch(outputOrId, at = this.#now()) {
|
|
@@ -55,7 +55,7 @@ export class OutputLifecycle {
|
|
|
55
55
|
#host;
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* @param {object} host - `retention`, `outputNeeded`, `outputWriting`, `outputReading`, `planEncodersSoon`, `now`, `invalidateWaits`, `segmentFormatOfKey`, `viewerSegmentsOn`, `budgetTimer`, `cleanupTimer`, `encodeRuns`, `keyframeTables`, `machineBudget`, `outputTimes`, `outputs`, `returns`, `segmentStore`, `sessionTtlMs`, `sourceFiles`, `timelines`, `viewers`
|
|
58
|
+
* @param {object} host - `retention`, `outputNeeded`, `outputWriting`, `outputReading`, `planEncodersSoon`, `now`, `invalidateWaits`, `clearWaits`, `segmentFormatOfKey`, `viewerSegmentsOn`, `budgetTimer`, `cleanupTimer`, `encodeRuns`, `keyframeTables`, `machineBudget`, `outputTimes`, `outputs`, `returns`, `segmentStore`, `sessionTtlMs`, `sourceFiles`, `timelines`, `viewers`
|
|
59
59
|
*/
|
|
60
60
|
constructor(host) {
|
|
61
61
|
this.#host = host;
|
|
@@ -267,6 +267,7 @@ export class OutputLifecycle {
|
|
|
267
267
|
for (const sessionId of activeIds) {
|
|
268
268
|
await this.disposeSession(sessionId);
|
|
269
269
|
}
|
|
270
|
+
this.#host.clearWaits?.();
|
|
270
271
|
// Everything this process owns, root included. See SegmentStore.dropAll.
|
|
271
272
|
this.#host.segmentStore.dropAll("the proxy is shutting down");
|
|
272
273
|
}
|
|
@@ -427,6 +428,7 @@ export class OutputLifecycle {
|
|
|
427
428
|
if (!output) {
|
|
428
429
|
return false;
|
|
429
430
|
}
|
|
431
|
+
this.#host.releasePlaybackStorageDemand?.(output.outputKey ?? "", consumerId);
|
|
430
432
|
// Nothing to release in the encoding: it holds one map per output, built
|
|
431
433
|
// from where the viewers are, and the map that arrives next simply does not
|
|
432
434
|
// have this one in it. A name to release was the last place a viewer
|
|
@@ -78,6 +78,20 @@ export class SegmentServing {
|
|
|
78
78
|
state.waitListeners.clear();
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Identity of the live output-key lifetime named by this session. */
|
|
82
|
+
waitIdentity(sessionId) {
|
|
83
|
+
return this.#host.outputs.waitIdentity(sessionId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Record a completed wait through the ledger operation wired by the server. */
|
|
87
|
+
noteWait(ranked, waitedMs, identity) {
|
|
88
|
+
this.#host.noteWait?.(ranked, waitedMs, identity);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
waitBandOf(rank, topRank) {
|
|
92
|
+
return this.#host.waitBandOf?.(rank, topRank) ?? "later";
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
/** Observe storage and lifecycle before a request checks file availability. */
|
|
82
96
|
subscribeFileChange(sessionId) {
|
|
83
97
|
const output = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
|
|
@@ -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 ` +
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
|
-
import { bandOf, waits } from "./WaitLedger.js";
|
|
3
|
-
|
|
4
2
|
/**
|
|
5
3
|
* Which viewer is asking, or an empty string where the transport names nobody.
|
|
6
4
|
*
|
|
@@ -84,6 +82,12 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
|
|
|
84
82
|
// a plain HTTP transport, where no loader of ours builds the URL, and then
|
|
85
83
|
// the single shared position decides as it always did.
|
|
86
84
|
const consumerId = consumerOf(req);
|
|
85
|
+
// Hold the output-key lifetime that this request entered. Disposal followed
|
|
86
|
+
// by reopening the same key must not let its old completion write into the
|
|
87
|
+
// new output's measurements.
|
|
88
|
+
const waitIdentity = typeof serving.waitIdentity === "function"
|
|
89
|
+
? serving.waitIdentity(sessionId)
|
|
90
|
+
: undefined;
|
|
87
91
|
// Readiness or a terminal state ends the wait. A caller leaving cancels it;
|
|
88
92
|
// elapsed time is not evidence that media preparation failed.
|
|
89
93
|
const holdStartedAt = Date.now();
|
|
@@ -132,11 +136,12 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
|
|
|
132
136
|
// like, and without them the table would hold only the failures.
|
|
133
137
|
const ranked = result.ranked ?? null;
|
|
134
138
|
if (ranked) {
|
|
135
|
-
|
|
139
|
+
serving.noteWait?.(ranked, heldMs, waitIdentity);
|
|
136
140
|
}
|
|
137
141
|
logger.info(
|
|
138
142
|
`[hold] ${fileName} ${outcome} after ${heldMs}ms` +
|
|
139
|
-
(ranked ? ` (the map wants it ${
|
|
143
|
+
(ranked ? ` (the map wants it ${serving.waitBandOf?.(ranked.rank, ranked.topRank) ?? "later"}, ` +
|
|
144
|
+
`rank ${ranked.rank} of ${ranked.topRank})` : "")
|
|
140
145
|
);
|
|
141
146
|
}
|
|
142
147
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { logger } from "../../utils/logger.js";
|
|
12
12
|
import { KeyframeTables } from "../media/KeyframeTables.js";
|
|
13
|
-
import {
|
|
13
|
+
import { bandOf, WaitLedger } from "../encode/OutputWaitLedger.js";
|
|
14
14
|
import { readEndedChildrenCpuSeconds, readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "../encode/host-load.js";
|
|
15
15
|
import { minimumBufferFrom } from "../torrent/supply-margin.js";
|
|
16
16
|
import { PriorityOrchestrator } from "../viewer/PriorityOrchestrator.js";
|
|
@@ -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";
|
|
@@ -243,6 +244,9 @@ export function wireOutputs({
|
|
|
243
244
|
// Told when an encoder starts or ends: the room this host has for one more
|
|
244
245
|
// encode changes with it, and the server chooses proxies by that room.
|
|
245
246
|
onEncodersChanged = null}) {
|
|
247
|
+
// Long-lived wait diagnostics belong to this wired output graph: each live
|
|
248
|
+
// output key owns its record, and the graph releases it at the last session.
|
|
249
|
+
const waits = new WaitLedger();
|
|
246
250
|
const parts = {};
|
|
247
251
|
const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
|
|
248
252
|
sourceKey: output.file.sourceKey, fileIndex: output.spec.grid?.fileIndex ?? output.file.fileIndex
|
|
@@ -551,6 +555,9 @@ export function wireOutputs({
|
|
|
551
555
|
get lookaheadSeconds() { return parts.lookaheadSeconds; },
|
|
552
556
|
get outputTimes() { return parts.outputTimes; },
|
|
553
557
|
get outputs() { return parts.outputs; },
|
|
558
|
+
noteWait: (ranked, waitedMs, identity) =>
|
|
559
|
+
waits.note(ranked.address, waitedMs, ranked.rank, ranked.topRank, identity),
|
|
560
|
+
waitBandOf: bandOf,
|
|
554
561
|
get segmentStore() { return parts.segmentStore; },
|
|
555
562
|
get startupWaitMs() { return parts.startupWaitMs; },
|
|
556
563
|
get viewers() { return parts.viewers; },
|
|
@@ -564,6 +571,7 @@ export function wireOutputs({
|
|
|
564
571
|
outputReading: key => parts.segmentStore.isReading(key) || parts.viewers.responsesHold(key),
|
|
565
572
|
planEncodersSoon: () => parts.encodeRuns.planEncodersSoon(),
|
|
566
573
|
invalidateWaits: (output) => parts.serving.invalidateWaits(output),
|
|
574
|
+
clearWaits: () => waits.clear(),
|
|
567
575
|
segmentFormatOfKey,
|
|
568
576
|
// Where the viewers of one output stand, in its segments: what the store
|
|
569
577
|
// the output retention policy reads to give disk back in the right order.
|
|
@@ -579,6 +587,7 @@ export function wireOutputs({
|
|
|
579
587
|
get encodeRuns() { return parts.encodeRuns; },
|
|
580
588
|
get keyframeTables() { return parts.keyframeTables; },
|
|
581
589
|
get machineBudget() { return parts.machineBudget; },
|
|
590
|
+
releasePlaybackStorageDemand: (key, consumerId) => parts.playbackStorageDemand.release(key, consumerId),
|
|
582
591
|
get outputTimes() { return parts.outputTimes; },
|
|
583
592
|
get outputs() { return parts.outputs; },
|
|
584
593
|
get returns() { return parts.returns; },
|
|
@@ -629,7 +638,13 @@ export function wireOutputs({
|
|
|
629
638
|
viewerSecondsOn: (output, consumerId, now) => viewerSecondsOn(parts.viewers, output, consumerId, now),
|
|
630
639
|
minimumBufferSecondsFor: (output) => minimumBufferSecondsOf(output),
|
|
631
640
|
// What this proxy may keep of downloaded pieces on disk.
|
|
632
|
-
sourceStorageBytes:
|
|
641
|
+
sourceStorageBytes: outputKey => {
|
|
642
|
+
const capacity = parts.machineBudget?.storageBytes();
|
|
643
|
+
return Number.isFinite(capacity)
|
|
644
|
+
? Math.max(0, capacity - parts.playbackStorageDemand.bytesExcept(outputKey))
|
|
645
|
+
: null;
|
|
646
|
+
},
|
|
647
|
+
notePlaybackStorageDemand: (key, consumerId, bytes) => parts.playbackStorageDemand.note(key, consumerId, bytes),
|
|
633
648
|
getSourceStats: (...args) => parts.getSourceStats?.(...args) ?? null,
|
|
634
649
|
disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
|
|
635
650
|
planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
|
|
@@ -952,7 +967,9 @@ export function wireOutputs({
|
|
|
952
967
|
// belongs to, the steps, the soundtracks, the height a step is named by.
|
|
953
968
|
parts.outputs = new OutputCatalog({
|
|
954
969
|
fileLengthOf: (session) => parts.hostLoad.fileLengthByKey.get(session.file.key) ?? 0,
|
|
955
|
-
largestPieceOf: (address) => parts.segmentStore.largestPiece(address)
|
|
970
|
+
largestPieceOf: (address) => parts.segmentStore.largestPiece(address),
|
|
971
|
+
waitIdentityStarted: (address, identity) => waits.open(address, identity),
|
|
972
|
+
waitIdentityEnded: (address, identity) => waits.forget(address, identity)
|
|
956
973
|
});
|
|
957
974
|
// Where produced segments live, addressed by WHAT they are rather than by
|
|
958
975
|
// which session's encoder wrote them. Two sessions of one output — two
|
|
@@ -1173,6 +1190,11 @@ export function wireOutputs({
|
|
|
1173
1190
|
// one term of the keeping period that is guessed rather than measured, and
|
|
1174
1191
|
// the only place it can be measured from.
|
|
1175
1192
|
parts.returns = new Returns();
|
|
1193
|
+
parts.playbackStorageDemand = new PlaybackStorageDemand(() => {
|
|
1194
|
+
if (parts.machineBudget) {
|
|
1195
|
+
void parts.machineBudget.revise(parts.machineBudget.storageResource, "playback stock");
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1176
1198
|
parts.encodeInputs = encodeInputs ?? (typeof resolveEncodeInput === "function" ? new EncodeInputs({
|
|
1177
1199
|
resolve: resolveEncodeInput,
|
|
1178
1200
|
readRanges: readEncodeRanges,
|
|
@@ -1221,6 +1243,7 @@ export function wireOutputs({
|
|
|
1221
1243
|
// FFmpeg's cache keeps its file in /tmp, or where FFmpeg runs on a build
|
|
1222
1244
|
// without `mkstemp` — an output directory of the segments.
|
|
1223
1245
|
inputCaches: { held: () => parts.encodeRuns?.inputCacheBytes() ?? 0, root: inputCacheRoot() },
|
|
1246
|
+
playbackStorage: parts.playbackStorageDemand,
|
|
1224
1247
|
policy: parts.budgetPolicy ?? undefined,
|
|
1225
1248
|
readFree: freeBytesFor,
|
|
1226
1249
|
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
|