@torrent-tv/proxy 2.97.29 → 2.97.30

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.97.29",
3
+ "version": "2.97.30",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -18,6 +18,7 @@ import { IndexMemory } from "./services/storage/IndexMemory.js";
18
18
  import { probePackets } from "./services/media/probe-packets.js";
19
19
  import { segmentDemands, sourceDeadline } from "./services/viewer/segment-demands.js";
20
20
  import { expandToTargetInputs } from "./services/viewer/target-input-expansion.js";
21
+ import { SegmentInputTable } from "./services/viewer/segment-input-table.js";
21
22
  import { SourcePreparation } from "./services/viewer/SourcePreparation.js";
22
23
  import { handleHealthzGet } from "./routes/healthz/get.js";
23
24
  import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
@@ -185,6 +186,7 @@ export async function startProxyServer({
185
186
  for (const key of segmentSourceRanges.keys()) if (key.startsWith(`${sourceKey}:`)) segmentSourceRanges.delete(key);
186
187
  for (const key of segmentReadings.keys()) if (key.startsWith(`${sourceKey}:`)) segmentReadings.delete(key);
187
188
  for (const key of resolvedPasses.keys()) if (key.startsWith(`${sourceKey}:`)) resolvedPasses.delete(key);
189
+ segmentInputs.forget(sourceKey);
188
190
  outputParts.encodeInputs?.bytesChanged();
189
191
  },
190
192
  // Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
@@ -476,6 +478,33 @@ export async function startProxyServer({
476
478
  * from and what it resolved (`playback-pass.js`); forgotten with the source.
477
479
  */
478
480
  const resolvedPasses = new Map();
481
+ /** Which source bytes each segment of an output reads, for the forecast (`segment-input-table.js`). */
482
+ const segmentInputs = new SegmentInputTable();
483
+ // WHAT THE PASSES COST, said once a minute: how many were taken whole from
484
+ // the last one and how many worked out again, how many segments and ranges
485
+ // those went through, how many zones they built, and the time from a pass's
486
+ // start to its result — which includes waits for reads, so it bounds the
487
+ // main thread's share rather than measuring it. A pass worked out again
488
+ // whose zones state the same bytes as the last is the repeated work worth
489
+ // removing; how often that happens is what the map's own "unchanged not
490
+ // sent" count says (torrent-tv/meta#178 review).
491
+ const passCosts = { since: Date.now(), reused: 0, worked: 0, incomplete: 0, segments: 0, ranges: 0, zones: 0, built: 0, buildMs: 0, ms: 0, maxMs: 0 };
492
+ const notePass = ({ reused = false, complete = true, segments = 0, ranges = 0, zones = 0, ms = 0 }) => {
493
+ if (reused) passCosts.reused += 1;
494
+ else {
495
+ passCosts.worked += 1;
496
+ if (!complete) passCosts.incomplete += 1;
497
+ passCosts.segments += segments; passCosts.ranges += ranges; passCosts.zones += zones;
498
+ passCosts.ms += ms; passCosts.maxMs = Math.max(passCosts.maxMs, ms);
499
+ }
500
+ const now = Date.now();
501
+ if (now - passCosts.since < 60_000) return;
502
+ logger.info(`download map passes in ${((now - passCosts.since) / 1000).toFixed(1)}s: ${passCosts.reused} taken whole from the last, ` +
503
+ `${passCosts.worked} worked out (${passCosts.incomplete} incomplete) over ${passCosts.segments} segment(s) and ${passCosts.ranges} range(s), ` +
504
+ `giving ${passCosts.zones} zone(s); ${Math.round(passCosts.ms)}ms from start to result in all, longest ${Math.round(passCosts.maxMs)}ms; ` +
505
+ `${passCosts.built} zone list(s) built for the map in ${Math.round(passCosts.buildMs)}ms`);
506
+ Object.assign(passCosts, { since: now, reused: 0, worked: 0, incomplete: 0, segments: 0, ranges: 0, zones: 0, built: 0, buildMs: 0, ms: 0, maxMs: 0 });
507
+ };
479
508
  /** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
480
509
  const joinedRanges = new WeakMap();
481
510
  const joinedRangesOf = (ranges, pieceLength) => {
@@ -513,12 +542,20 @@ export async function startProxyServer({
513
542
  // A zone's ranges are stated to the torrent at the precision it fetches:
514
543
  // ranges closer than a piece hold the same pieces as one.
515
544
  const pieceLength = Number(torrentPool.knownTorrent(map.sourceKey)?.pieceLength);
545
+ const passStartedAt = performance.now();
546
+ let segments = 0;
516
547
  const converted = [];
517
548
  const demands = [];
518
549
  // What each output's next run would copy, asked of the swarm as one group
519
550
  // with its first segment (`target-input-expansion.js`).
520
551
  const targets = new Map();
521
- const expanded = () => expandToTargetInputs(converted, outputKey => targets.get(outputKey) ?? null);
552
+ const expanded = () => {
553
+ const startedAt = performance.now();
554
+ const zones = expandToTargetInputs(converted, outputKey => targets.get(outputKey) ?? null);
555
+ passCosts.built += 1;
556
+ passCosts.buildMs += performance.now() - startedAt;
557
+ return zones;
558
+ };
522
559
  for (const output of outputParts.outputs.values()) {
523
560
  if (output.file.sourceKey !== map.sourceKey) continue;
524
561
  const consumers = [...outputParts.viewers.forOutput(output).values()].filter(viewer =>
@@ -552,7 +589,10 @@ export async function startProxyServer({
552
589
  const fileKey = `${map.sourceKey}:${map.fileIndex}`;
553
590
  const pass = { tracks: tracks.value, ...passInputs({ pieceLength, shift, targets, demands }) };
554
591
  const last = resolvedPasses.get(fileKey);
555
- if (samePass(last, pass)) return last.result;
592
+ if (samePass(last, pass)) {
593
+ notePass({ reused: true });
594
+ return last.result;
595
+ }
556
596
  // Kept only from a pass that worked every segment out; one that met a
557
597
  // reading not there yet is worked out again until it is.
558
598
  let complete = true;
@@ -588,7 +628,12 @@ export async function startProxyServer({
588
628
  const { wanted, modes, interval, rangesKey } = reading;
589
629
  const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
590
630
  const convert = ranges => {
591
- for (const [byteStart, byteEnd] of joinedRangesOf(ranges, pieceLength)) converted.push({ ...demand,
631
+ const joined = joinedRangesOf(ranges, pieceLength);
632
+ segments += 1;
633
+ // What the segment reads is kept for the forecast in a table of its
634
+ // own, whatever form the torrent is told it in.
635
+ segmentInputs.set(map.sourceKey, map.fileIndex, zone.outputKey, zone.index, joined);
636
+ for (const [byteStart, byteEnd] of joined) converted.push({ ...demand,
592
637
  downloadInterval: { from: zone.owner.from, to: zone.owner.to }, byteStart, byteEnd });
593
638
  };
594
639
  // The bytes one segment needs are a fact of the file and the output, so
@@ -649,6 +694,7 @@ export async function startProxyServer({
649
694
  convert(input.ranges);
650
695
  }
651
696
  const result = expanded();
697
+ notePass({ complete, segments, ranges: converted.length, zones: result.length, ms: performance.now() - passStartedAt });
652
698
  if (complete) resolvedPasses.set(fileKey, { ...pass, result });
653
699
  else resolvedPasses.delete(fileKey);
654
700
  return result;
@@ -751,7 +797,7 @@ export async function startProxyServer({
751
797
  readMetadataActivity: () => containerOrchestrator.activity(),
752
798
  onViewerChanged: () => { void sourcePreparation?.refresh(); onStateChanged(); },
753
799
  onEncodersChanged: () => onStateChanged(),
754
- sourceInputsFor: (output, index) => downloadMaps.inputsForOutput(output.file.sourceKey, output.outputKey, index),
800
+ sourceInputsFor: (output, index) => segmentInputs.inputsFor(output.file.sourceKey, output.outputKey, index),
755
801
  readSourceMedia: async (params) => {
756
802
  await playbackPlanner.getPlan(params);
757
803
  const media = playbackPlanner.getCachedMediaInfo(params);
@@ -20,8 +20,6 @@ export class DownloadMaps {
20
20
  #clock;
21
21
  /** What the torrent is told of the playback zones: as many windows as say the same thing. */
22
22
  #pack;
23
- /** What a list of zones says each segment reads, by the list. @type {WeakMap<object[], Map>} */
24
- #inputsOf = new WeakMap();
25
23
 
26
24
  constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
27
25
  clock = () => performance.timeOrigin + performance.now(), pack = zones => zones }) {
@@ -152,19 +150,6 @@ export class DownloadMaps {
152
150
  return this.#files.get(`${sourceKey}:${fileIndex}`)?.sourceZones.some(zone => zone.from === from && zone.to === to) ?? false;
153
151
  }
154
152
 
155
- /** Exact source ranges already resolved by the download map, without new reads. */
156
- inputsForOutput(sourceKey, outputKey, index) {
157
- const sources = [];
158
- for (const file of this.#files.values()) {
159
- if (file.sourceKey !== sourceKey) continue;
160
- const ranges = file.inputs.get(outputKey)?.get(index);
161
- if (!ranges?.length) continue;
162
- sources.push({ sourceId: `${sourceKey}:${file.fileIndex}`,
163
- ranges });
164
- }
165
- return sources.length ? sources : null;
166
- }
167
-
168
153
  metadata({ sourceKey, fileIndex, statement, result, priority = 100, urgent = true, deadlineAt = 0, interval, leadSeconds = 0, scope = "playback", order = 0 }) {
169
154
  if (!Number.isFinite(leadSeconds) || leadSeconds < 0) throw new TypeError("Metadata preparation time must be finite and nonnegative.");
170
155
  const file = this.#file(sourceKey, fileIndex);
@@ -255,7 +240,7 @@ export class DownloadMaps {
255
240
  const key = `${sourceKey}:${fileIndex}`;
256
241
  let file = this.#files.get(key);
257
242
  if (!file) {
258
- file = { sourceKey, fileIndex, durationSeconds: 0, zones: [], inputs: new Map(), nativeZones: [], sourceZones: [], revision: 0, epoch: ++this.#nextEpoch, metadata: new Map(), pending: Promise.resolve(), dirty: true };
243
+ file = { sourceKey, fileIndex, durationSeconds: 0, zones: [], nativeZones: [], sourceZones: [], revision: 0, epoch: ++this.#nextEpoch, metadata: new Map(), pending: Promise.resolve(), dirty: true };
259
244
  this.#files.set(key, file);
260
245
  }
261
246
  return file;
@@ -265,24 +250,6 @@ export class DownloadMaps {
265
250
  file.zones = zones;
266
251
  // What `#emit` reads has changed, whether or not it is published now.
267
252
  file.dirty = true;
268
- // A pass that resolves what the pass before resolved hands back the same
269
- // zones (`resolvedPasses` in server.js), and what they say of each segment
270
- // is the same too.
271
- const known = this.#inputsOf.get(zones);
272
- if (known) {
273
- file.inputs = known;
274
- return;
275
- }
276
- file.inputs = new Map();
277
- this.#inputsOf.set(zones, file.inputs);
278
- for (const zone of zones) {
279
- if (typeof zone.outputKey !== "string" || !Number.isSafeInteger(zone.index)) continue;
280
- let output = file.inputs.get(zone.outputKey);
281
- if (!output) file.inputs.set(zone.outputKey, output = new Map());
282
- let ranges = output.get(zone.index);
283
- if (!ranges) output.set(zone.index, ranges = []);
284
- ranges.push({ start: zone.byteStart, end: zone.byteEnd + 1 });
285
- }
286
253
  }
287
254
 
288
255
  #emit(file) {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file Which source bytes each segment of an output reads, as the download
3
+ * map's pass worked them out (torrent-tv/meta#178).
4
+ *
5
+ * A fact of the file and the output, kept apart from what the torrent is told:
6
+ * the far part of a film is stated to the torrent as windows of a band
7
+ * (`download-bands.js`), and the forecast still has to find every segment's own
8
+ * bytes (`ViewerRequests` `#segmentsOf`). It used to read them off the zones
9
+ * sent, which named every segment.
10
+ */
11
+
12
+ export class SegmentInputTable {
13
+ /** `sourceKey` → output → segment → file → its ranges. @type {Map<string, Map<string, Map<number, Map<number, { joined: Array<[number, number]>, ranges: Array<{ start: number, end: number }> }>>>>} */
14
+ #bySource = new Map();
15
+
16
+ /**
17
+ * @param {string} sourceKey
18
+ * @param {number} fileIndex
19
+ * @param {string} outputKey
20
+ * @param {number} index
21
+ * @param {Array<[number, number]>} joined - Inclusive ranges.
22
+ */
23
+ set(sourceKey, fileIndex, outputKey, index, joined) {
24
+ let outputs = this.#bySource.get(sourceKey);
25
+ if (!outputs) this.#bySource.set(sourceKey, outputs = new Map());
26
+ let segments = outputs.get(outputKey);
27
+ if (!segments) outputs.set(outputKey, segments = new Map());
28
+ let files = segments.get(index);
29
+ if (!files) segments.set(index, files = new Map());
30
+ // The same reading keeps its list: whoever compares inputs compares them by it.
31
+ if (files.get(fileIndex)?.joined === joined) return;
32
+ files.set(fileIndex, { joined, ranges: joined.map(([start, end]) => ({ start, end: end + 1 })) });
33
+ }
34
+
35
+ /**
36
+ * The bytes one segment reads, by file, or null where none are known.
37
+ *
38
+ * @param {string} sourceKey
39
+ * @param {string} outputKey
40
+ * @param {number} index
41
+ * @returns {Array<{ sourceId: string, ranges: Array<{ start: number, end: number }> }> | null}
42
+ */
43
+ inputsFor(sourceKey, outputKey, index) {
44
+ const files = this.#bySource.get(sourceKey)?.get(outputKey)?.get(index);
45
+ if (!files?.size) return null;
46
+ return [...files].map(([fileIndex, { ranges }]) => ({ sourceId: `${sourceKey}:${fileIndex}`, ranges }));
47
+ }
48
+
49
+ /** @param {string} sourceKey */
50
+ forget(sourceKey) {
51
+ this.#bySource.delete(sourceKey);
52
+ }
53
+ }