@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.107.48",
3
+ "version": "2.107.49",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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
- // 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 ` +
@@ -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: () => parts.machineBudget?.allowedFor("spilled pieces") ?? null,
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
+ }
@@ -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