@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.27",
3
+ "version": "2.95.29",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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
- * Therefore urgency here is expressed by WHETHER a need is stated to the swarm
22
- * at all. A level is stated only while every level above it is satisfied, and
23
- * withdrawn the moment one is not — a withdrawn need is not in the download set
24
- * at all, so a peer with nothing urgent to give cannot fall through to it and
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: everything urgent
111
- * shares one non-zero priority and rotates fairly among itself; the
112
- * speculative levels take zero, which the library always places last and never
113
- * rotates into the group above.
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 honours one distinction: non-zero
21
- * before zero. A wire with nothing left to take from the non-zero selections
22
- * goes on to the zero ones, and within a selection it takes the earliest piece
23
- * it holds.
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
- * **So everything wanted is stated, and only the first class is non-zero.**
26
- * The first class is the most urgent level that is still missing anything, at
27
- * its highest priority, whatever its deadlines. Everything else that is not
28
- * speculative is stated at zero, so it is fetched only by a wire that would
29
- * otherwise stand idle. Stating only the earliest deadline, as this did after
30
- * #95, left every peer that held none of that segment's pieces without a
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
- // Speculative levels are never the first class: the library keeps
100
- // zero-priority selections last and never rotates them above.
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. Each selection states it non-zero and the rest zero.
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: the rest is
146
- // fetched only by a wire with nothing first-class left to take.
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 two things that tell the
1187
- * candidates apart: whether the swarm was ASKED for anything (pieces selected
1188
- * and still missing, blocks in flight) or was asked and did not answer (peers
1189
- * holding what we want, how many are choking us).
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) return { ...result(false, null, buffered, reserve, null, "download-schedule-unavailable", preparedSegments),
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
- sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(interval, sourceArrivals.get(sourceId)));
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