@torrent-tv/proxy 2.97.15 → 2.97.16
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/server.js +15 -2
- package/services/media/MediaReadRequests.js +59 -3
- package/services/server/ViewerRequests.js +40 -0
- package/services/server/wire-outputs.js +1 -1
- package/services/storage/MachineBudget.js +30 -7
- package/services/storage/wire.js +18 -6
- package/services/torrent/worker/held-bytes.js +7 -5
- package/services/viewer/playback-readiness.js +23 -3
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -524,6 +524,14 @@ export async function startProxyServer({
|
|
|
524
524
|
if (!map.isCurrent()) return [];
|
|
525
525
|
const direct = navigated.kind === "result" && navigated.value === true &&
|
|
526
526
|
typeof container?.readSourceRanges === "function" ? container : null;
|
|
527
|
+
// An output whose file-level reading is not there in this pass — the
|
|
528
|
+
// index or Cues that say where a segment is, not yet downloaded, or the
|
|
529
|
+
// table its segments read, refused memory — answers its other segments
|
|
530
|
+
// the same way. One unanswered request is kept to be retried when the
|
|
531
|
+
// bytes or the memory change, and the map is built again when it
|
|
532
|
+
// answers; asking every segment held the main thread for 3.3 s on a cold
|
|
533
|
+
// two-hour AVI (torrent-tv/meta#178).
|
|
534
|
+
const waiting = new Set();
|
|
527
535
|
for (const zone of demands) {
|
|
528
536
|
if (!map.isCurrent()) return [];
|
|
529
537
|
// WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication: its
|
|
@@ -547,6 +555,7 @@ export async function startProxyServer({
|
|
|
547
555
|
// were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
|
|
548
556
|
const known = segmentSourceRanges.get(rangesKey);
|
|
549
557
|
if (known) { convert(known); continue; }
|
|
558
|
+
if (waiting.has(zone.outputKey)) continue;
|
|
550
559
|
if (direct) {
|
|
551
560
|
// Asked per zone too: a soundtrack of an AVI is read from its own
|
|
552
561
|
// packets, which the same index states (torrent-tv/meta#166).
|
|
@@ -572,10 +581,14 @@ export async function startProxyServer({
|
|
|
572
581
|
if (!map.isCurrent()) return [];
|
|
573
582
|
if (!intervalParams) continue;
|
|
574
583
|
const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
|
|
575
|
-
if (navigation.kind !== "result")
|
|
584
|
+
if (navigation.kind !== "result") {
|
|
585
|
+
if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
576
588
|
const originalRanges = navigation.value;
|
|
577
589
|
const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
|
|
578
590
|
if (!map.isCurrent()) return [];
|
|
591
|
+
if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
579
592
|
if (packets.kind !== "result") continue;
|
|
580
593
|
const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
581
594
|
if (input.kind !== "result") continue;
|
|
@@ -599,7 +612,7 @@ export async function startProxyServer({
|
|
|
599
612
|
failed: (request, error) => logger.warn(`media request=${request.key} probe failed: ${error?.message ?? error}`)
|
|
600
613
|
});
|
|
601
614
|
const indexMemory = new IndexMemory({
|
|
602
|
-
reviseBudget: () => { void outputParts.machineBudget.revise().catch(error => logger.warn(`packet index budget: ${error.message}`)); },
|
|
615
|
+
reviseBudget: () => { void outputParts.machineBudget.revise("memory", "media metadata").catch(error => logger.warn(`packet index budget: ${error.message}`)); },
|
|
603
616
|
changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); outputParts.encodeInputs?.memoryChanged(); }
|
|
604
617
|
});
|
|
605
618
|
const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Retry unfinished statements when their missing resource changes.
|
|
3
|
+
*
|
|
4
|
+
* ONE REFUSAL OF MEMORY ANSWERS THE STATEMENT, not one request of it. The
|
|
5
|
+
* table a statement reserves belongs to the file — a soundtrack's packet table
|
|
6
|
+
* is one table for every segment of it — so once a read of a statement has
|
|
7
|
+
* been refused memory, the other requests of the same statement of the same
|
|
8
|
+
* file wait for memory to change instead of being read and refused one by one.
|
|
9
|
+
* Read one by one, a cold two-hour AVI's 1943 soundtrack segments were refused
|
|
10
|
+
* in turn when the index's last piece arrived, which held the main thread for
|
|
11
|
+
* 3.3 s and the budget revision that would have given the memory behind it
|
|
12
|
+
* (Home Assistant 2026-10-12, torrent-tv/meta#178). A retry wave therefore
|
|
13
|
+
* reads one request of each statement first and the others once it has
|
|
14
|
+
* answered.
|
|
15
|
+
*/
|
|
2
16
|
export class MediaReadRequests {
|
|
3
17
|
#requests = new Map();
|
|
18
|
+
/** File and statement → the memory revision at which a read of it was refused memory. */
|
|
19
|
+
#refusedMemory = new Map();
|
|
4
20
|
#read;
|
|
5
21
|
#failed;
|
|
6
22
|
#versions = new Map();
|
|
@@ -39,6 +55,7 @@ export class MediaReadRequests {
|
|
|
39
55
|
this.#notify(params.sourceKey, params.fileIndex, result);
|
|
40
56
|
return this.#requests.delete(key);
|
|
41
57
|
}
|
|
58
|
+
if (result.kind === "needs-memory") this.#refusedMemory.set(statementKey(params, statement), memoryRevision);
|
|
42
59
|
const existing = this.#requests.get(key);
|
|
43
60
|
if (existing) { existing.params = params; existing.kind = result.kind; }
|
|
44
61
|
else this.#requests.set(key, { params, statement, kind: result.kind, running: false, changed: false });
|
|
@@ -54,24 +71,49 @@ export class MediaReadRequests {
|
|
|
54
71
|
|
|
55
72
|
memoryChanged() {
|
|
56
73
|
this.#memoryVersion++;
|
|
74
|
+
const wave = [];
|
|
57
75
|
for (const request of this.#requests.values()) {
|
|
58
76
|
if (request.kind !== "needs-memory") continue;
|
|
59
77
|
// The retried statement announces its result. A memory event is not a
|
|
60
78
|
// source change for another statement still waiting for missing bytes.
|
|
61
79
|
request.changed = true;
|
|
62
|
-
if (!request.running)
|
|
80
|
+
if (!request.running) wave.push(request);
|
|
63
81
|
}
|
|
82
|
+
this.#retryWave(wave);
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
bytesChanged(sourceKey, fileIndex) {
|
|
67
86
|
const fileKey = `${sourceKey}:${fileIndex}`;
|
|
68
87
|
this.#versions.set(fileKey, this.revision(sourceKey, fileIndex) + 1);
|
|
69
88
|
this.#notify(sourceKey, fileIndex, { kind: "bytes-changed" });
|
|
89
|
+
const wave = [];
|
|
70
90
|
for (const request of this.#requests.values()) {
|
|
71
91
|
if (request.params.sourceKey !== sourceKey || request.params.fileIndex !== fileIndex) continue;
|
|
72
92
|
if (request.kind !== "needs-ranges") continue;
|
|
73
93
|
request.changed = true;
|
|
74
|
-
if (!request.running)
|
|
94
|
+
if (!request.running) wave.push(request);
|
|
95
|
+
}
|
|
96
|
+
this.#retryWave(wave);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Retry `requests`: one of each statement of each file first, the others of
|
|
101
|
+
* that statement once it has answered.
|
|
102
|
+
*
|
|
103
|
+
* @param {object[]} requests
|
|
104
|
+
*/
|
|
105
|
+
#retryWave(requests) {
|
|
106
|
+
const groups = new Map();
|
|
107
|
+
for (const request of requests) {
|
|
108
|
+
const key = statementKey(request.params, request.statement);
|
|
109
|
+
const group = groups.get(key);
|
|
110
|
+
if (group) group.push(request);
|
|
111
|
+
else groups.set(key, [request]);
|
|
112
|
+
}
|
|
113
|
+
for (const [head, ...rest] of groups.values()) {
|
|
114
|
+
void this.#retry(head).then(() => {
|
|
115
|
+
for (const request of rest) if (!request.running) void this.#retry(request);
|
|
116
|
+
});
|
|
75
117
|
}
|
|
76
118
|
}
|
|
77
119
|
|
|
@@ -90,6 +132,10 @@ export class MediaReadRequests {
|
|
|
90
132
|
for (const key of this.#versions.keys()) {
|
|
91
133
|
if (fileIndex === undefined ? key.startsWith(`${sourceKey}:`) : key === `${sourceKey}:${fileIndex}`) this.#versions.delete(key);
|
|
92
134
|
}
|
|
135
|
+
for (const key of [...this.#refusedMemory.keys()]) {
|
|
136
|
+
const [refusedSource, refusedFile] = JSON.parse(key);
|
|
137
|
+
if (refusedSource === sourceKey && (fileIndex === undefined || refusedFile === fileIndex)) this.#refusedMemory.delete(key);
|
|
138
|
+
}
|
|
93
139
|
}
|
|
94
140
|
|
|
95
141
|
retain(sourceKey, fileIndex, wanted) {
|
|
@@ -105,6 +151,12 @@ export class MediaReadRequests {
|
|
|
105
151
|
try {
|
|
106
152
|
while (request.changed && this.#requests.get(key) === request) {
|
|
107
153
|
request.changed = false;
|
|
154
|
+
if (this.#refusedMemory.get(statementKey(request.params, request.statement)) === this.#memoryVersion) {
|
|
155
|
+
// The same statement of the same file was refused memory in this
|
|
156
|
+
// very state of memory: so is this one, without reading it.
|
|
157
|
+
request.kind = "needs-memory";
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
108
160
|
await this.#read(request.params, request.statement);
|
|
109
161
|
}
|
|
110
162
|
} catch (error) {
|
|
@@ -118,3 +170,7 @@ export class MediaReadRequests {
|
|
|
118
170
|
function requestKey(params, statement) {
|
|
119
171
|
return JSON.stringify([params.sourceKey, params.fileIndex, statement, params.requestId ?? null]);
|
|
120
172
|
}
|
|
173
|
+
|
|
174
|
+
function statementKey(params, statement) {
|
|
175
|
+
return JSON.stringify([params.sourceKey, params.fileIndex, statement]);
|
|
176
|
+
}
|
|
@@ -738,9 +738,49 @@ export class ViewerRequests {
|
|
|
738
738
|
}))
|
|
739
739
|
})}`);
|
|
740
740
|
}
|
|
741
|
+
this.#sayHolding(session, consumerId, measurement, forecast, input, now);
|
|
741
742
|
return forecast;
|
|
742
743
|
}
|
|
743
744
|
|
|
745
|
+
/**
|
|
746
|
+
* What holds a waiting viewer's start, said when it changes: which piece,
|
|
747
|
+
* its production or its delivery, when it comes against the media before it,
|
|
748
|
+
* and the rates the forecast used. Said again when the delay has moved by a
|
|
749
|
+
* second, at most once a second; a hold that changes nothing says nothing.
|
|
750
|
+
*
|
|
751
|
+
* @param {object} session
|
|
752
|
+
* @param {string} consumerId
|
|
753
|
+
* @param {object} measurement - This viewer's forecast state.
|
|
754
|
+
* @param {object} forecast
|
|
755
|
+
* @param {object} input
|
|
756
|
+
* @param {number} now
|
|
757
|
+
*/
|
|
758
|
+
#sayHolding(session, consumerId, measurement, forecast, input, now) {
|
|
759
|
+
const holding = forecast.holdingBy;
|
|
760
|
+
if (!holding) {
|
|
761
|
+
measurement.held = null;
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const key = `${holding.kind}:${holding.track}:${holding.segment}`;
|
|
765
|
+
const delay = Number.isFinite(forecast.delaySeconds) ? forecast.delaySeconds : null;
|
|
766
|
+
const said = measurement.held;
|
|
767
|
+
if (said && said.key === key && (delay === null || said.delay === null || Math.abs(delay - said.delay) < 1 ||
|
|
768
|
+
now - said.at < 1000)) return;
|
|
769
|
+
measurement.held = { key, delay, at: now };
|
|
770
|
+
const seconds = value => Number.isFinite(value) ? value.toFixed(2) : "?";
|
|
771
|
+
const rates = (forecast.operations ?? []).map(operation =>
|
|
772
|
+
`${operation.track} ${operation.operation} ${Number.isFinite(operation.speed) ? operation.speed.toFixed(2) : "?"}x`);
|
|
773
|
+
const downloads = (input.sources ?? []).map(source => {
|
|
774
|
+
const rate = source.downloadRateReadings?.lastValue;
|
|
775
|
+
return `${Number.isFinite(rate) ? (rate / 1_048_576).toFixed(2) : "?"} MB/s`;
|
|
776
|
+
});
|
|
777
|
+
logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
|
|
778
|
+
(holding.kind === "position-not-held" ? "the position, which nothing buffered covers" :
|
|
779
|
+
`the ${holding.kind} of ${holding.track} #${holding.segment} in ${seconds(holding.atSeconds)}s against ` +
|
|
780
|
+
`${seconds(holding.aheadSeconds)}s of media before it${holding.requiresSource ? ", its input still downloading" : ""}`) +
|
|
781
|
+
`; buffered ${seconds(forecast.bufferedSeconds)}s; rates ${rates.join(", ") || "none"}; download ${downloads.join(", ") || "none"}`);
|
|
782
|
+
}
|
|
783
|
+
|
|
744
784
|
/**
|
|
745
785
|
* The pieces of one output as the start forecast reads them, kept between
|
|
746
786
|
* polls: a piece whose playlist edges, read ranges and torrent inputs have
|
|
@@ -1008,7 +1008,7 @@ export function wireOutputs({
|
|
|
1008
1008
|
resolve: resolveEncodeInput,
|
|
1009
1009
|
readRanges: readEncodeRanges,
|
|
1010
1010
|
heldRanges: readEncodeHeldRanges,
|
|
1011
|
-
reviseBudget: () => parts.machineBudget.revise(),
|
|
1011
|
+
reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
|
|
1012
1012
|
capacity: () => parts.machineBudget.capacityOf("memory"),
|
|
1013
1013
|
// How long this output's input takes to open, from the one owner of start
|
|
1014
1014
|
// readings (torrent-tv/meta#184).
|
|
@@ -77,6 +77,14 @@ import { OtherDemand, divideAllowance } from "./allowance.js";
|
|
|
77
77
|
export class MachineBudget {
|
|
78
78
|
#revisionPromise = null;
|
|
79
79
|
#reviseAgain = false;
|
|
80
|
+
/**
|
|
81
|
+
* The resources the next pass reads: `null` for every one, else their names.
|
|
82
|
+
* A claimant short of memory asks for memory alone; reading the disks first
|
|
83
|
+
* made its answer wait for them (torrent-tv/meta#178).
|
|
84
|
+
*
|
|
85
|
+
* @type {Set<string> | null}
|
|
86
|
+
*/
|
|
87
|
+
#againScope = null;
|
|
80
88
|
/**
|
|
81
89
|
* The revision in hand, for its own line: when it was first asked for, how
|
|
82
90
|
* many asks joined it, and which pass this is. At a cold start on the addon
|
|
@@ -151,32 +159,45 @@ export class MachineBudget {
|
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
/**
|
|
154
|
-
* Read
|
|
162
|
+
* Read the resources, divide each, and tell each claimant its share.
|
|
155
163
|
*
|
|
164
|
+
* @param {string | null} [resource] - One resource to read and divide; every
|
|
165
|
+
* resource when absent. The others keep their last division.
|
|
166
|
+
* @param {string | null} [because] - Who asked, said in the revision's line.
|
|
156
167
|
* @returns {Promise<Map<string, { freeBytes: number, allowanceBytes: number, shares: object[] }>>}
|
|
157
168
|
*/
|
|
158
|
-
revise() {
|
|
169
|
+
revise(resource = null, because = null) {
|
|
170
|
+
const scope = resource === null ? null : new Set([resource]);
|
|
159
171
|
if (this.#revisionPromise) {
|
|
160
172
|
this.#reviseAgain = true;
|
|
173
|
+
this.#againScope = this.#againScope === null || scope === null ? null : new Set([...this.#againScope, ...scope]);
|
|
161
174
|
this.#revision.asks += 1;
|
|
162
175
|
return this.#revisionPromise;
|
|
163
176
|
}
|
|
164
|
-
this.#revision = { askedAt: Date.now(), asks: 1, pass: 0 };
|
|
177
|
+
this.#revision = { askedAt: Date.now(), asks: 1, pass: 0, because };
|
|
178
|
+
this.#againScope = scope;
|
|
165
179
|
this.#revisionPromise = (async () => {
|
|
166
180
|
do {
|
|
181
|
+
const passScope = this.#againScope;
|
|
182
|
+
this.#againScope = new Set();
|
|
167
183
|
this.#reviseAgain = false;
|
|
168
184
|
this.#revision.pass += 1;
|
|
169
|
-
await this.#readRevision();
|
|
185
|
+
await this.#readRevision(passScope);
|
|
170
186
|
} while (this.#reviseAgain);
|
|
171
187
|
return this.#last;
|
|
172
188
|
})().finally(() => { this.#revisionPromise = null; });
|
|
173
189
|
return this.#revisionPromise;
|
|
174
190
|
}
|
|
175
191
|
|
|
176
|
-
|
|
192
|
+
/** @param {Set<string> | null} scope */
|
|
193
|
+
async #readRevision(scope) {
|
|
177
194
|
const passFrom = Date.now();
|
|
178
195
|
let readingMs = 0;
|
|
196
|
+
const readings = [];
|
|
179
197
|
for (const [name, resource] of this.#resources) {
|
|
198
|
+
if (scope !== null && !scope.has(name)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
180
201
|
const claimants = this.#claimants.filter((one) => one.resource === name);
|
|
181
202
|
if (claimants.length === 0) {
|
|
182
203
|
continue;
|
|
@@ -185,6 +206,7 @@ export class MachineBudget {
|
|
|
185
206
|
const readFrom = Date.now();
|
|
186
207
|
const free = await resource.readFree();
|
|
187
208
|
readingMs += Date.now() - readFrom;
|
|
209
|
+
readings.push(`${name} ${Date.now() - readFrom}ms`);
|
|
188
210
|
// WITHOUT A READING, NOTHING IS ALLOWED TO GROW. A resource whose free
|
|
189
211
|
// space cannot be read is not a resource with room; answering "unbounded"
|
|
190
212
|
// there is how the spill file came to have no limit in the first place.
|
|
@@ -213,8 +235,9 @@ export class MachineBudget {
|
|
|
213
235
|
}
|
|
214
236
|
const revision = this.#revision;
|
|
215
237
|
this.#logger?.info?.(`${this.describe()}` + (revision
|
|
216
|
-
? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago
|
|
217
|
-
|
|
238
|
+
? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago` +
|
|
239
|
+
`${revision.because ? `, first by ${revision.because}` : ""}; ` +
|
|
240
|
+
`this pass ${Date.now() - passFrom}ms, ${readingMs}ms of it reading what is free: ${readings.join(", ") || "nothing"})`
|
|
218
241
|
: ""));
|
|
219
242
|
return this.#last;
|
|
220
243
|
}
|
package/services/storage/wire.js
CHANGED
|
@@ -153,8 +153,9 @@ export function wireMachineBudget({
|
|
|
153
153
|
});
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
let evidenceOn = null;
|
|
156
157
|
if (diagnostics) {
|
|
157
|
-
|
|
158
|
+
evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
|
|
158
159
|
defineDisk(evidenceOn, diagnosticsRoot || segmentStore.root);
|
|
159
160
|
budget.register({
|
|
160
161
|
name: "diagnostics",
|
|
@@ -169,11 +170,22 @@ export function wireMachineBudget({
|
|
|
169
170
|
|
|
170
171
|
return {
|
|
171
172
|
capacityOf: name => budget.capacityOf(name),
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
/**
|
|
174
|
+
* @param {string | null} [resource] - One resource to revise; all when absent.
|
|
175
|
+
* @param {string | null} [because] - Who asked, for the revision's line.
|
|
176
|
+
*/
|
|
177
|
+
revise: async (resource = null, because = null) => {
|
|
178
|
+
// Re-read the evidence before dividing its disk: it is the one claimant
|
|
179
|
+
// whose bytes are written by something that never reports them. Only
|
|
180
|
+
// there: a claimant short of memory asks for memory alone, and walking
|
|
181
|
+
// the evidence's directory first made every memory answer wait for it —
|
|
182
|
+
// 3.7 s on a cold AVI behind 1943 other reads (torrent-tv/meta#178).
|
|
183
|
+
if (diagnostics && (resource === null || resource === evidenceOn)) {
|
|
184
|
+
const from = Date.now();
|
|
185
|
+
await diagnostics.measure();
|
|
186
|
+
because = `${because ?? "a full revision"}, after the evidence was measured in ${Date.now() - from}ms`;
|
|
187
|
+
}
|
|
188
|
+
return budget.revise(resource, because);
|
|
177
189
|
},
|
|
178
190
|
segmentBytes: () => segmentBytes,
|
|
179
191
|
diagnostics: diagnostics ?? null,
|
|
@@ -2,9 +2,11 @@ import { pieceStoreOf } from "../piece-store-of.js";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* How many pieces one read for a reader asks of the store at once: the threads
|
|
5
|
-
* libuv runs file operations on (`UV_THREADPOOL_SIZE
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* libuv runs file operations on (`UV_THREADPOOL_SIZE`). The proxy's entry point
|
|
6
|
+
* sets 64 where the deployment states none (`bin/thread-pool.js`), and libuv's
|
|
7
|
+
* own default of four applies only to code run without it, such as the
|
|
8
|
+
* checks. A piece from disk is three of their operations, and more at once
|
|
9
|
+
* would only wait behind them (torrent-tv/meta#178).
|
|
8
10
|
*/
|
|
9
11
|
const FILE_READS_AT_ONCE = Math.max(1, Number.parseInt(process.env.UV_THREADPOOL_SIZE ?? "", 10) || 4);
|
|
10
12
|
|
|
@@ -225,8 +227,8 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
|
|
|
225
227
|
// and the loop runs 0.3-0.7 s late while the swarm delivers 7-14 MB/s: a
|
|
226
228
|
// 210 MB stretch took 11 s here (Home Assistant 2026-10-12,
|
|
227
229
|
// torrent-tv/meta#178). As many are in flight as the runtime has threads
|
|
228
|
-
// for file operations (libuv's pool, `UV_THREADPOOL_SIZE`,
|
|
229
|
-
//
|
|
230
|
+
// for file operations (libuv's pool, `UV_THREADPOOL_SIZE`, 64 as the proxy
|
|
231
|
+
// starts); more would only queue behind them and ahead of everything else
|
|
230
232
|
// this process reads or writes.
|
|
231
233
|
const readOne = async ([index, span]) => {
|
|
232
234
|
const pieceStart = index * pieceLength;
|
|
@@ -396,6 +396,16 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
396
396
|
}
|
|
397
397
|
|
|
398
398
|
const constraints = [];
|
|
399
|
+
// WHICH CONSTRAINT HOLDS THE START, said with the answer: the delay is the
|
|
400
|
+
// largest of thousands, and the log could only say that some term was
|
|
401
|
+
// positive while the picture stood for 17 s (torrent-tv/meta#178).
|
|
402
|
+
let holding = null;
|
|
403
|
+
const constrain = (value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource) => {
|
|
404
|
+
constraints.push(value);
|
|
405
|
+
if (value > 0 && (holding === null || value > holding.value)) {
|
|
406
|
+
holding = { value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource };
|
|
407
|
+
}
|
|
408
|
+
};
|
|
399
409
|
const end = rescale(fromSeconds(duration), unit);
|
|
400
410
|
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
401
411
|
const state = trackState[trackIndex];
|
|
@@ -407,7 +417,8 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
407
417
|
const before = contiguousEnd(delivered, at);
|
|
408
418
|
for (const range of completion.segment.ranges) addMediaRange(delivered, range);
|
|
409
419
|
const after = contiguousEnd(delivered, at);
|
|
410
|
-
|
|
420
|
+
const ahead = secondsAhead(earliest(before, trackEnd), at);
|
|
421
|
+
constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false);
|
|
411
422
|
if (compare(after, before) <= 0 && completion.segment.ranges.some(({ start }) => compare(start, before) > 0)) {
|
|
412
423
|
// A timestamp hole cannot be repaired by waiting longer. Keep it
|
|
413
424
|
// distinct from an unavailable rate or a slow but finite service.
|
|
@@ -425,8 +436,10 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
425
436
|
for (const range of production.segment.ranges) addMediaRange(prepared, range);
|
|
426
437
|
// Supply interruption coverage is stock held by the proxy as well as
|
|
427
438
|
// the browser. It is not a browser buffer-size admission rule.
|
|
428
|
-
|
|
429
|
-
Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource))
|
|
439
|
+
const ahead = Math.max(0, secondsAhead(before, at) -
|
|
440
|
+
Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource));
|
|
441
|
+
constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
|
|
442
|
+
production.requiresSource);
|
|
430
443
|
}
|
|
431
444
|
}
|
|
432
445
|
const delay = Math.max(0, ...constraints);
|
|
@@ -436,6 +449,13 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
436
449
|
Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
|
|
437
450
|
Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
|
|
438
451
|
return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
|
|
452
|
+
// The piece whose `kind` — its production, or its delivery to the page —
|
|
453
|
+
// comes `atSeconds` from now while the media before it lasts
|
|
454
|
+
// `aheadSeconds`: the difference is the delay.
|
|
455
|
+
holdingBy: ready ? null : holding === null ? (originReady ? null : { kind: "position-not-held" })
|
|
456
|
+
: { kind: holding.kind, track: tracks[holding.trackIndex]?.id ?? holding.trackIndex,
|
|
457
|
+
segment: holding.segment, atSeconds: holding.atSeconds, aheadSeconds: holding.aheadSeconds,
|
|
458
|
+
requiresSource: holding.requiresSource },
|
|
439
459
|
preparedUntilSeconds: Math.min(duration, ...trackState.map((state, index) => {
|
|
440
460
|
const ranges = state.clientRanges.map((range) => ({ ...range }));
|
|
441
461
|
for (const item of productions.filter((item) => item.trackIndex === index && item.at <= delay)) {
|