@torrent-tv/proxy 2.95.27 → 2.95.29
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/ViewerRequests.js +20 -3
- package/services/torrent/demand/Urgency.js +8 -10
- package/services/torrent/download/SwarmSelection.js +14 -18
- package/services/torrent/download/registry.js +3 -3
- package/services/torrent/torrent-pool.js +31 -5
- package/services/viewer/playback-readiness.js +51 -3
package/package.json
CHANGED
|
@@ -614,6 +614,10 @@ export class ViewerRequests {
|
|
|
614
614
|
id: sourceId,
|
|
615
615
|
complete: stats?.fileAvailable === true,
|
|
616
616
|
residence: stats?.residence,
|
|
617
|
+
fileOffset: stats?.fileOffset,
|
|
618
|
+
fileLength: stats?.fileLength,
|
|
619
|
+
pieceLength: stats?.pieceLength,
|
|
620
|
+
downloadRateReadings: this.#sourceRateReadings(measurement, sourceId, stats, now),
|
|
617
621
|
downloadForecast,
|
|
618
622
|
});
|
|
619
623
|
}
|
|
@@ -718,10 +722,11 @@ export class ViewerRequests {
|
|
|
718
722
|
bufferLimitSeconds: input.bufferLimitSeconds,
|
|
719
723
|
lookaheadSeconds: input.lookaheadSeconds,
|
|
720
724
|
link: input.linkReadings,
|
|
721
|
-
sources: input.sources.map(({ id, complete, residence, downloadForecast }) => ({
|
|
725
|
+
sources: input.sources.map(({ id, complete, residence, downloadForecast, downloadRateReadings }) => ({
|
|
722
726
|
id, complete, residenceRanges: residence?.length ?? 0,
|
|
723
727
|
forecastAt: downloadForecast?.measuredAt ?? null, plannedPieces: downloadForecast?.ranges.length ?? 0,
|
|
724
|
-
unknownPieces: downloadForecast?.ranges.filter(range => range.availableAt === null).length ?? 0
|
|
728
|
+
unknownPieces: downloadForecast?.ranges.filter(range => range.availableAt === null).length ?? 0,
|
|
729
|
+
downloadRateBytesPerSecond: downloadRateReadings?.lastValue ?? null
|
|
725
730
|
})),
|
|
726
731
|
tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
|
|
727
732
|
id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
|
|
@@ -740,7 +745,7 @@ export class ViewerRequests {
|
|
|
740
745
|
const id = typeof consumerId === "string" ? consumerId : "";
|
|
741
746
|
let state = byViewer.get(id);
|
|
742
747
|
if (!state) {
|
|
743
|
-
state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), tracks: new Map() };
|
|
748
|
+
state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), tracks: new Map(), sources: new Map() };
|
|
744
749
|
byViewer.set(id, state);
|
|
745
750
|
}
|
|
746
751
|
return state;
|
|
@@ -764,4 +769,16 @@ export class ViewerRequests {
|
|
|
764
769
|
}
|
|
765
770
|
return trend.snapshot();
|
|
766
771
|
}
|
|
772
|
+
|
|
773
|
+
#sourceRateReadings(state, id, stats, now) {
|
|
774
|
+
let trend = state.sources.get(id);
|
|
775
|
+
if (!trend) {
|
|
776
|
+
trend = new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds);
|
|
777
|
+
state.sources.set(id, trend);
|
|
778
|
+
}
|
|
779
|
+
if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
|
|
780
|
+
trend.add(now, stats.downloadSpeed);
|
|
781
|
+
}
|
|
782
|
+
return trend.snapshot();
|
|
783
|
+
}
|
|
767
784
|
}
|
|
@@ -18,12 +18,10 @@
|
|
|
18
18
|
* So distinct non-zero numbers give an order once and a round robin thereafter.
|
|
19
19
|
* Only two things hold: non-zero rotates fairly, zero is always last.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* spend the shared link on it. A permanently low priority does exactly that,
|
|
26
|
-
* which is why it is not what this does.
|
|
21
|
+
* WebTorrent checks selections per wire and falls through to zero-priority
|
|
22
|
+
* selections when a wire has no piece from a non-zero selection. Therefore the
|
|
23
|
+
* global first class is the only urgent level stated while any urgent piece is
|
|
24
|
+
* missing. Conditional levels are stated only when no urgent piece is missing.
|
|
27
25
|
*
|
|
28
26
|
* The rotation is not merely tolerated, it is wanted: with two viewers of one
|
|
29
27
|
* film both stopped, both their needs sit at {@link Urgency.BLOCKED} and the
|
|
@@ -107,10 +105,10 @@ export function isConditional(urgency) {
|
|
|
107
105
|
/**
|
|
108
106
|
* The number handed to `torrent.select`.
|
|
109
107
|
*
|
|
110
|
-
* Two values only, because two is all the library keeps:
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
108
|
+
* Two values only, because two is all the library keeps: selected first-class
|
|
109
|
+
* windows share one non-zero priority and rotate fairly among themselves;
|
|
110
|
+
* conditional levels take zero. Less urgent windows are withheld while an
|
|
111
|
+
* urgent piece is missing, rather than relying on zero to keep them back.
|
|
114
112
|
*
|
|
115
113
|
* @param {number} urgency
|
|
116
114
|
* @returns {number}
|
|
@@ -17,23 +17,17 @@
|
|
|
17
17
|
* vendored 2.8.5: selections are sorted by priority only when one is inserted,
|
|
18
18
|
* and `shufflePriority` then moves the selection just served to the back of the
|
|
19
19
|
* whole non-zero group. Distinct numbers therefore order the list once and
|
|
20
|
-
* round-robin it afterwards. The library
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* round-robin it afterwards. The library checks selections separately for each
|
|
21
|
+
* wire. If that wire holds no missing piece from a non-zero selection, it falls
|
|
22
|
+
* through to a zero selection. A zero priority therefore does not withhold a
|
|
23
|
+
* less urgent piece from a peer that lacks the first class.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* request: on Home Assistant, with three peers each holding every third piece
|
|
32
|
-
* and one complete slow peer, twelve segments took 184 s at 0.61 MiB/s against
|
|
33
|
-
* 18.3 s at 6.1 MiB/s when the whole band was stated
|
|
34
|
-
* (`research/download-translation-2026-10-07.md`, torrent-tv/meta#95).
|
|
35
|
-
* Speculative levels stay withheld while anything urgent is missing anywhere,
|
|
36
|
-
* because the link is shared.
|
|
25
|
+
* While an urgent piece is missing, only the global first class is stated.
|
|
26
|
+
* This keeps a wire that lacks those pieces from spending its requests on a
|
|
27
|
+
* lower class. The first class is the most urgent missing level at its highest
|
|
28
|
+
* map priority, regardless of deadlines. Every window in that class is stated
|
|
29
|
+
* so peers holding different first-class pieces can all contribute. Once no
|
|
30
|
+
* urgent piece is missing, conditional levels may be stated at zero.
|
|
37
31
|
*/
|
|
38
32
|
|
|
39
33
|
import {
|
|
@@ -90,16 +84,18 @@ export class SwarmSelection {
|
|
|
90
84
|
reconcile({ speculativeAllowed = !this.hasUrgentMissing(), firstClass = null } = {}) {
|
|
91
85
|
const missing = this.#register.windows().filter(window => !this.#isSatisfied(window));
|
|
92
86
|
const first = firstClass ?? this.missingBand();
|
|
87
|
+
const strictFirstClass = first !== null && !isConditional(first.urgency);
|
|
93
88
|
/** @type {Map<string, { from: number, to: number, priority: number }>} */
|
|
94
89
|
const wanted = new Map();
|
|
95
90
|
for (const priority of [1, 0]) {
|
|
96
91
|
const ranges = [];
|
|
97
92
|
for (const window of missing) {
|
|
98
93
|
if (!speculativeAllowed && isConditional(window.urgency)) continue;
|
|
99
|
-
//
|
|
100
|
-
//
|
|
94
|
+
// The first class is the only eligible urgent level; zero priority
|
|
95
|
+
// still falls through on wires that have none of its pieces.
|
|
101
96
|
const inFirstClass = first !== null && !isConditional(window.urgency)
|
|
102
97
|
&& window.urgency === first.urgency && window.priority >= first.priority;
|
|
98
|
+
if (strictFirstClass && !inFirstClass) continue;
|
|
103
99
|
if ((inFirstClass ? 1 : 0) !== priority) continue;
|
|
104
100
|
const range = this.#piecesFor(window);
|
|
105
101
|
if (range) ranges.push(range);
|
|
@@ -125,7 +125,7 @@ export function reconcileAll() {
|
|
|
125
125
|
const entries = [...live];
|
|
126
126
|
const speculativeAllowed = !entries.some((entry) => entry.selection.hasUrgentMissing());
|
|
127
127
|
// The first class across every torrent: the most urgent missing level and
|
|
128
|
-
// its highest priority.
|
|
128
|
+
// its highest priority. Lower classes stay out of every torrent's selection.
|
|
129
129
|
const bands = entries.map(entry => entry.selection.missingBand()).filter(Boolean);
|
|
130
130
|
const urgency = bands.length ? Math.min(...bands.map(band => band.urgency)) : null;
|
|
131
131
|
const firstClass = urgency === null ? null
|
|
@@ -142,8 +142,8 @@ export function reconcileAll() {
|
|
|
142
142
|
} finally {
|
|
143
143
|
publishingSelections = false;
|
|
144
144
|
}
|
|
145
|
-
// What the swarm is told to fetch FIRST, said when it changes
|
|
146
|
-
//
|
|
145
|
+
// What the swarm is told to fetch FIRST, said when it changes. Lower classes
|
|
146
|
+
// are withheld because a wire with no first-class piece would fall through.
|
|
147
147
|
const said = entries.map(entry => `${String(entry.torrent.infoHash ?? "").slice(0, 8)}: ` +
|
|
148
148
|
`${(entry.selection.firstClassPieces ?? []).slice(0, 8).join(",") || "nothing"}` +
|
|
149
149
|
`${(entry.selection.firstClassPieces?.length ?? 0) > 8 ? ` +${entry.selection.firstClassPieces.length - 8} more` : ""}`).join("; ");
|
|
@@ -283,11 +283,36 @@ function describeSwarmDemand(torrent) {
|
|
|
283
283
|
? torrent._critical.reduce((count, flag) => (flag ? count + 1 : count), 0)
|
|
284
284
|
: 0;
|
|
285
285
|
|
|
286
|
+
const firstClassPieces = [];
|
|
287
|
+
const seenFirstClassPieces = new Set();
|
|
288
|
+
let omittedFirstClassPieces = 0;
|
|
289
|
+
for (const item of items) {
|
|
290
|
+
if (!(Number(item?.priority) > 0)) continue;
|
|
291
|
+
const from = Number(item?.from);
|
|
292
|
+
const to = Number(item?.to);
|
|
293
|
+
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) continue;
|
|
294
|
+
for (let index = from; index <= to; index += 1) {
|
|
295
|
+
if (seenFirstClassPieces.has(index) || torrent.bitfield?.get(index)) continue;
|
|
296
|
+
seenFirstClassPieces.add(index);
|
|
297
|
+
if (firstClassPieces.length >= 32) {
|
|
298
|
+
omittedFirstClassPieces += 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const holders = wires.filter(wire => !wire?.destroyed && wire?.peerPieces?.get?.(index) === true);
|
|
302
|
+
const ready = holders.filter(wire => wire.peerChoking !== true).length;
|
|
303
|
+
const requests = wires.reduce((count, wire) => count + (Array.isArray(wire?.requests)
|
|
304
|
+
? wire.requests.filter(request => request?.piece === index).length : 0), 0);
|
|
305
|
+
firstClassPieces.push(`${index}(holders=${holders.length},unchoked=${ready},requests=${requests})`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
286
309
|
return (
|
|
287
310
|
`${items.length} selection(s) covering ${selectedPieces} piece(s), ` +
|
|
288
311
|
`${missingSelected} of them missing, ${critical} marked critical; ` +
|
|
289
312
|
`${wires.length} peers, ${interested} we want data from, ${choking} choking us, ` +
|
|
290
|
-
`${asking} being asked, ${inFlight} blocks in flight`
|
|
313
|
+
`${asking} being asked, ${inFlight} blocks in flight; first-class missing ` +
|
|
314
|
+
`${firstClassPieces.join(", ") || "none"}` +
|
|
315
|
+
(omittedFirstClassPieces > 0 ? `; ${omittedFirstClassPieces} more first-class piece(s)` : "")
|
|
291
316
|
);
|
|
292
317
|
}
|
|
293
318
|
|
|
@@ -1183,10 +1208,10 @@ export class TorrentPool {
|
|
|
1183
1208
|
* line of the log said anything was wrong — the collapse had to be recovered
|
|
1184
1209
|
* afterwards by hand from three unrelated counters.
|
|
1185
1210
|
*
|
|
1186
|
-
* So the stall reports itself, and it reports the
|
|
1187
|
-
*
|
|
1188
|
-
* and
|
|
1189
|
-
*
|
|
1211
|
+
* So the stall reports itself, and it reports whether the swarm was asked for
|
|
1212
|
+
* anything (pieces selected and still missing, blocks in flight) or was asked
|
|
1213
|
+
* and did not answer (peer holders and outstanding requests for each missing
|
|
1214
|
+
* first-class piece, plus how many peers are choking us).
|
|
1190
1215
|
*
|
|
1191
1216
|
* @returns {void}
|
|
1192
1217
|
*/
|
|
@@ -2093,6 +2118,7 @@ export class TorrentPool {
|
|
|
2093
2118
|
fileDownloaded,
|
|
2094
2119
|
fileLength,
|
|
2095
2120
|
fileOffset: file.offset,
|
|
2121
|
+
pieceLength: torrent.pieceLength,
|
|
2096
2122
|
residence,
|
|
2097
2123
|
fileAvailable: fileLength > 0 && residence.length > 0 && residence.every(range => range.location !== "missing"),
|
|
2098
2124
|
// Compatibility fields report the same current urgent map demand.
|
|
@@ -193,7 +193,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
193
193
|
}
|
|
194
194
|
const sourceState = new Map();
|
|
195
195
|
for (const [id, source] of sources) {
|
|
196
|
-
sourceState.set(id, { source });
|
|
196
|
+
sourceState.set(id, { source, rate: rateCurve(source.downloadRateReadings, now), serviceFinish: 0 });
|
|
197
197
|
}
|
|
198
198
|
const link = rateCurve(input.linkReadings, now);
|
|
199
199
|
let unknownReason = null;
|
|
@@ -318,11 +318,43 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
318
318
|
const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals } = job;
|
|
319
319
|
let sourceArrivalAt = 0;
|
|
320
320
|
for (const [sourceId, ranges] of sourceByteIntervals) {
|
|
321
|
+
const source = sourceState.get(sourceId);
|
|
322
|
+
if (!source) {
|
|
323
|
+
return result(false, null, buffered, reserve, null, "source-measurement-unavailable", preparedSegments);
|
|
324
|
+
}
|
|
321
325
|
for (const interval of ranges) {
|
|
322
326
|
const missing = uncoveredIntervals(interval, sourceByteCoverage.get(sourceId) ?? []);
|
|
323
|
-
if (missing.length)
|
|
327
|
+
if (missing.length === 0) {
|
|
328
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(interval, sourceArrivals.get(sourceId)));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Explicit piece arrivals remain authoritative. For still-unmapped
|
|
333
|
+
// pieces, project only the media-order demand from the torrent's latest
|
|
334
|
+
// measured download service; no supplier or positive rate means no
|
|
335
|
+
// estimated arrival.
|
|
336
|
+
const { source: facts, rate } = source;
|
|
337
|
+
if (!rate || !(rate.rateAt(0) > 0) || !Number.isSafeInteger(facts.pieceLength) ||
|
|
338
|
+
facts.pieceLength <= 0 || !Number.isSafeInteger(facts.fileOffset) ||
|
|
339
|
+
!Number.isSafeInteger(facts.fileLength) || facts.fileLength <= 0) {
|
|
340
|
+
return { ...result(false, null, buffered, reserve, null,
|
|
341
|
+
rate ? "service-not-advancing" : "download-rate-unavailable", preparedSegments),
|
|
324
342
|
unavailableSource: { sourceId, segmentIndex: segment.index, range: interval, missing } };
|
|
325
|
-
|
|
343
|
+
}
|
|
344
|
+
const estimatedPieces = sourcePiecesFor(missing, facts);
|
|
345
|
+
let finish = Math.max(source.serviceFinish, sourceArrivalAt);
|
|
346
|
+
const service = sourceByteService.get(sourceId) ?? [];
|
|
347
|
+
for (const pieceRange of estimatedPieces) {
|
|
348
|
+
const stillMissing = uncoveredIntervals(pieceRange, sourceByteCoverage.get(sourceId) ?? []);
|
|
349
|
+
for (const part of stillMissing) {
|
|
350
|
+
finish = rate.finish(part.end - part.start, finish);
|
|
351
|
+
service.push({ start: part.start, end: part.end, begin: finish, finish });
|
|
352
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, finish);
|
|
353
|
+
}
|
|
354
|
+
sourceByteCoverage.set(sourceId, numericCoverage(service));
|
|
355
|
+
sourceArrivals.set(sourceId, indexedArrivals(service));
|
|
356
|
+
}
|
|
357
|
+
source.serviceFinish = finish;
|
|
326
358
|
}
|
|
327
359
|
}
|
|
328
360
|
// Production accepts a complete held segment input. It cannot overlap its
|
|
@@ -536,6 +568,22 @@ function uncoveredIntervals(interval, served) {
|
|
|
536
568
|
return missing;
|
|
537
569
|
}
|
|
538
570
|
|
|
571
|
+
/** Expand missing file bytes to the whole torrent pieces the reader must hold. */
|
|
572
|
+
function sourcePiecesFor(ranges, source) {
|
|
573
|
+
const pieces = [];
|
|
574
|
+
for (const range of ranges) {
|
|
575
|
+
if (!(range.end > range.start)) continue;
|
|
576
|
+
const first = Math.floor((source.fileOffset + range.start) / source.pieceLength);
|
|
577
|
+
const last = Math.ceil((source.fileOffset + range.end) / source.pieceLength) - 1;
|
|
578
|
+
for (let index = first; index <= last; index += 1) {
|
|
579
|
+
const start = Math.max(0, index * source.pieceLength - source.fileOffset);
|
|
580
|
+
const end = Math.min(source.fileLength, (index + 1) * source.pieceLength - source.fileOffset);
|
|
581
|
+
if (end > start) pieces.push({ start, end });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return numericCoverage(pieces);
|
|
585
|
+
}
|
|
586
|
+
|
|
539
587
|
/**
|
|
540
588
|
* Ranges for pieces not read yet, placed in the clock of the pieces that were.
|
|
541
589
|
* A cut time of the playlist is mapped linearly between the nearest measured
|