@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.107.48",
3
+ "version": "2.107.50",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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
- return this.#byId.delete(id);
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
- // What this proxy may keep of downloaded pieces on disk: the most a
1017
- // start can hold of the film ahead of the viewer.
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
- waits.note(ranked.address, heldMs, ranked.rank, ranked.topRank);
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 ${bandOf(ranked.rank, ranked.topRank)}, rank ${ranked.rank} of ${ranked.topRank})` : "")
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 { waits } from "./WaitLedger.js";
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: () => parts.machineBudget?.allowedFor("spilled pieces") ?? null,
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
+ }
@@ -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 this proxy may keep of downloaded pieces, the oldest of it would be
845
- // given up before the viewer reached it, and no wait makes the start safe:
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