@torrent-tv/proxy 2.107.34 → 2.107.35

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.34",
3
+ "version": "2.107.35",
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
@@ -649,6 +649,7 @@ export async function startProxyServer({
649
649
  return downloadMaps.refresh(sourceKey, fileIndex);
650
650
  }).catch(error => logger.warn(`download map after a packet table: ${error?.message ?? error}`));
651
651
  };
652
+ let outputPartsReady = false;
652
653
  const downloadMaps = new DownloadMaps({
653
654
  // The ranges a picture reads at open are named again by every segment;
654
655
  // only the zone the swarm would rank first is sent (torrent-tv/meta#166).
@@ -665,6 +666,12 @@ export async function startProxyServer({
665
666
  pack: (zones) => coalescedZones(zones),
666
667
  log: (line) => logger.info(line),
667
668
  observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
669
+ // A file's own reads are wanted while a present viewer has the file chosen
670
+ // or on screen. The viewers are assembled below; until they exist no read
671
+ // can have been asked for by one.
672
+ fileWanted: (sourceKey, fileIndex) => (outputPartsReady ? outputParts.viewers.forSource(sourceKey) : [])
673
+ .some(viewer => viewer.source?.selectedFileIndex === fileIndex ||
674
+ viewer.source?.visibleFileIndices?.includes(fileIndex)),
668
675
  resolvePlayback: async (map) => {
669
676
  const fileKey = `${map.sourceKey}:${map.fileIndex}`;
670
677
  if (map.zones.length === 0) {
@@ -1000,7 +1007,10 @@ export async function startProxyServer({
1000
1007
  ...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}),
1001
1008
  ...(readPriority?.requestId ? { priority: readPriority.rank, urgent: readPriority.urgent === true,
1002
1009
  deadlineAt: Number.isFinite(readPriority.deadlineAt) ? readPriority.deadlineAt : Number.POSITIVE_INFINITY,
1003
- scope: "preparation" } : {}) });
1010
+ scope: "preparation" } : {}),
1011
+ // Neither a viewer's interval nor an encode input asked for it: the
1012
+ // file read itself, and that read is wanted while the file is.
1013
+ ...(!demand && !readPriority?.requestId ? { owner: "file" } : {}) });
1004
1014
  }
1005
1015
  if (finished && result.kind === "result") {
1006
1016
  void downloadMaps.refresh(sourceKey, fileIndex).catch(error =>
@@ -1039,7 +1049,11 @@ export async function startProxyServer({
1039
1049
  pinnedPreset,
1040
1050
  indexMemory,
1041
1051
  readMetadataActivity: () => containerOrchestrator.activity(),
1042
- onViewerChanged: () => { void sourcePreparation?.refresh(); onStateChanged(); },
1052
+ onViewerChanged: () => {
1053
+ void sourcePreparation?.refresh();
1054
+ void downloadMaps.viewersChanged().catch(error => logger.warn(`download maps after a viewer change: ${error.message}`));
1055
+ onStateChanged();
1056
+ },
1043
1057
  onEncodersChanged: () => onStateChanged(),
1044
1058
  sourceInputsFor: (output, index) => segmentInputs.inputsFor(output.file.sourceKey, output.outputKey, index),
1045
1059
  readSourceMedia: async (params) => {
@@ -1333,6 +1347,7 @@ export async function startProxyServer({
1333
1347
  await torrentPool.fillFileInBackground?.(torrent, fileIndex);
1334
1348
  }
1335
1349
  });
1350
+ outputPartsReady = true;
1336
1351
  // Where the processor goes, and at a cold start what the main thread does
1337
1352
  // with it, when the operator asks (torrent-tv/meta#177).
1338
1353
  const startupProfile = startupCpuProfile({
@@ -89,6 +89,9 @@ export class SwarmSelection {
89
89
  const strictFirstClass = first !== null && !isConditional(first.urgency);
90
90
  /** @type {Map<string, { from: number, to: number, priority: number }>} */
91
91
  const wanted = new Map();
92
+ // Who asked for the first class here, for the line that says what the
93
+ // swarm is told first: which file and which request holds it.
94
+ const holders = new Set();
92
95
  for (const priority of [1, 0]) {
93
96
  const ranges = [];
94
97
  for (const window of missing) {
@@ -99,6 +102,7 @@ export class SwarmSelection {
99
102
  && window.urgency === first.urgency && window.priority >= first.priority;
100
103
  if (strictFirstClass && !inFirstClass) continue;
101
104
  if ((inFirstClass ? 1 : 0) !== priority) continue;
105
+ if (priority === 1) holders.add(`${window.fileIndex}:${window.requestId}`);
102
106
  const range = this.#piecesFor(window);
103
107
  if (range) ranges.push(range);
104
108
  }
@@ -114,6 +118,7 @@ export class SwarmSelection {
114
118
  for (const range of union) wanted.set(`${range.from}-${range.to}-${priority}`, range);
115
119
  if (priority === 1) {
116
120
  this.firstClassPieces = union.map(range => range.from === range.to ? `${range.from}` : `${range.from}-${range.to}`);
121
+ this.firstClassHolders = [...holders].sort();
117
122
  this.#required = strictFirstClass ? union.map(({ from, to }) => ({ from, to })) : [];
118
123
  }
119
124
  }
@@ -285,9 +285,16 @@ export function reconcileAll() {
285
285
  for (const entry of entries) entry.limit.sample();
286
286
  // What the swarm is told to fetch FIRST, said when it changes. Lower classes
287
287
  // are withheld because a wire with no first-class piece would fall through.
288
+ // And who asked for it, as file:request: a class one torrent holds is what
289
+ // every other torrent waits behind, so the line names the request holding it.
290
+ const holdersOf = entry => {
291
+ const holders = entry.selection.firstClassPieces?.length ? entry.selection.firstClassHolders ?? [] : [];
292
+ return holders.length ? ` (held by ${holders.slice(0, 4).join(", ")}${holders.length > 4 ? ` +${holders.length - 4} more` : ""})` : "";
293
+ };
288
294
  const said = entries.map(entry => `${String(entry.torrent.infoHash ?? "").slice(0, 8)}: ` +
289
295
  `${(entry.selection.firstClassPieces ?? []).slice(0, 8).join(",") || "nothing"}` +
290
- `${(entry.selection.firstClassPieces?.length ?? 0) > 8 ? ` +${entry.selection.firstClassPieces.length - 8} more` : ""}`).join("; ");
296
+ `${(entry.selection.firstClassPieces?.length ?? 0) > 8 ? ` +${entry.selection.firstClassPieces.length - 8} more` : ""}` +
297
+ holdersOf(entry)).join("; ");
291
298
  const head = firstClass ? `${urgencyName(firstClass.urgency)} priority ${firstClass.priority}` : "none";
292
299
  firstClassStated = `${head} — pieces ${said}`;
293
300
  return { torrents: entries.length, speculativeAllowed, stated, withdrawn };
@@ -21,13 +21,21 @@ export class DownloadMaps {
21
21
  /** What the torrent is told of the playback zones: as many windows as say the same thing. */
22
22
  #pack;
23
23
  #observeWait;
24
+ /**
25
+ * Whether a present viewer holds this file, chosen or listed. A read the
26
+ * file itself made — its track table, media info, keyframes, cover — has no
27
+ * owner of its own, so it is wanted exactly while the file is.
28
+ */
29
+ #fileWanted;
24
30
 
25
31
  constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
26
- clock = () => performance.timeOrigin + performance.now(), pack = zones => zones, observeWait = null }) {
32
+ clock = () => performance.timeOrigin + performance.now(), pack = zones => zones, observeWait = null,
33
+ fileWanted = () => true }) {
27
34
  if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
28
35
  this.#publish = publish;
29
36
  this.#resolvePlayback = resolvePlayback;
30
37
  this.#pack = pack;
38
+ this.#fileWanted = fileWanted;
31
39
  this.#log = typeof log === "function" ? log : null;
32
40
  this.#now = now;
33
41
  this.#clock = clock;
@@ -178,7 +186,14 @@ export class DownloadMaps {
178
186
  return Boolean(file && zoneIndexOf(file).exactly({ from, to }));
179
187
  }
180
188
 
181
- metadata({ sourceKey, fileIndex, statement, result, priority = 100, urgent = true, deadlineAt = 0, interval, leadSeconds = 0, scope = "playback", order = 0 }) {
189
+ /**
190
+ * @param {object} params
191
+ * @param {"file"} [params.owner] - `"file"` for a read the file made for
192
+ * itself with no viewer's demand behind it. Such a statement is published
193
+ * only while {@link DownloadMaps#viewersChanged}'s question says the file
194
+ * is held by somebody.
195
+ */
196
+ metadata({ sourceKey, fileIndex, statement, result, priority = 100, urgent = true, deadlineAt = 0, interval, leadSeconds = 0, scope = "playback", order = 0, owner }) {
182
197
  if (!Number.isFinite(leadSeconds) || leadSeconds < 0) throw new TypeError("Metadata preparation time must be finite and nonnegative.");
183
198
  const file = this.#file(sourceKey, fileIndex);
184
199
  if (result.kind === "needs-ranges") {
@@ -189,7 +204,8 @@ export class DownloadMaps {
189
204
  urgent = current.urgent;
190
205
  deadlineAt = Number.isFinite(current.deadlineAt) ? current.deadlineAt - leadSeconds * 1000 : current.deadlineAt;
191
206
  }
192
- const entry = { ranges: result.ranges, requestId: result.requestId, priority, urgent, deadlineAt, interval, leadSeconds, scope, order };
207
+ const entry = { ranges: result.ranges, requestId: result.requestId, priority, urgent, deadlineAt, interval, leadSeconds, scope, order,
208
+ ...(owner === "file" ? { owner } : {}) };
193
209
  const previous = file.metadata.get(statement);
194
210
  if (previous && sameMetadata(previous, entry) && this.#standing(file)) return this.#unchanged(file);
195
211
  file.metadata.set(statement, entry);
@@ -254,6 +270,35 @@ export class DownloadMaps {
254
270
  return Promise.all(changed);
255
271
  }
256
272
 
273
+ /**
274
+ * The viewers changed: a file's own reads are published again where whether
275
+ * anybody holds the file has changed.
276
+ *
277
+ * A read the file made for itself states its bytes at the urgency of a
278
+ * stopped viewer, and the swarm fetches only the most urgent class across
279
+ * every torrent. Kept after its viewer had gone, the head of a film nobody
280
+ * was opening any more held that class for the whole proxy: its swarm gave
281
+ * nothing, and every other film was told to fetch only the piece its viewer
282
+ * stood on (Home Assistant 2026-10-26, PPPD-452, torrent-tv/meta#239).
283
+ *
284
+ * @returns {Promise<unknown[]>}
285
+ */
286
+ viewersChanged() {
287
+ const sent = [];
288
+ for (const file of this.#files.values()) {
289
+ const own = [...file.metadata].filter(([, metadata]) => metadata.owner === "file");
290
+ if (own.length === 0) continue;
291
+ const wanted = this.#fileWanted(file.sourceKey, file.fileIndex) === true;
292
+ if (file.ownReadsWanted === wanted) continue;
293
+ file.ownReadsWanted = wanted;
294
+ this.#log?.(`download map ${file.sourceKey}:${file.fileIndex}: ${own.length} read(s) of the file itself ` +
295
+ `${wanted ? "stated again — a viewer holds the file" : "withheld — no present viewer holds the file"} ` +
296
+ `[${own.map(([statement]) => statement).join(", ")}]`);
297
+ sent.push(this.#emit(file));
298
+ }
299
+ return Promise.all(sent);
300
+ }
301
+
257
302
  forget(sourceKey, fileIndex, { keepPreparation = false } = {}) {
258
303
  const file = this.#file(sourceKey, fileIndex);
259
304
  this.#setZones(file, []);
@@ -302,7 +347,10 @@ export class DownloadMaps {
302
347
  // Compared and sent as the torrent is to act on it: the playback zones
303
348
  // packed into as few windows as say the same (`coalescedZones`).
304
349
  const zones = [...this.#packedPlayback(file)];
305
- for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
350
+ const ownReadsWanted = this.#fileWanted(file.sourceKey, file.fileIndex) === true;
351
+ file.ownReadsWanted = ownReadsWanted;
352
+ for (const { ranges, requestId, priority, urgent, deadlineAt, order, owner } of file.metadata.values()) {
353
+ if (owner === "file" && !ownReadsWanted) continue;
306
354
  for (const [byteStart, byteEnd] of ranges) {
307
355
  zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });
308
356
  }
@@ -520,6 +568,7 @@ function sameMetadata(left, right) {
520
568
  return left.requestId === right.requestId && Object.is(left.priority, right.priority) &&
521
569
  left.urgent === right.urgent && Object.is(left.deadlineAt, right.deadlineAt) &&
522
570
  Object.is(left.leadSeconds, right.leadSeconds) && left.scope === right.scope && left.order === right.order &&
571
+ left.owner === right.owner &&
523
572
  (left.interval?.from ?? null) === (right.interval?.from ?? null) &&
524
573
  (left.interval?.to ?? null) === (right.interval?.to ?? null) &&
525
574
  left.ranges.length === right.ranges.length &&