@torrent-tv/proxy 2.97.3 → 2.97.4

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.3",
3
+ "version": "2.97.4",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -28,12 +28,12 @@ export async function admitInput({ sources, reserve, readRanges }) {
28
28
  !Buffer.isBuffer(buffer) || buffer.length !== source.input.ranges[index][1] - source.input.ranges[index][0] + 1)) {
29
29
  throw new Error("Available-only input reader returned incomplete ranges.");
30
30
  }
31
- hash.update(JSON.stringify(source.input.ranges));
32
- for (const buffer of buffers) {
33
- hash.update(String(buffer.length));
34
- hash.update(":");
35
- hash.update(buffer);
36
- }
31
+ // Named by the source and what is stated about its packets below, not by
32
+ // the bytes: verified pieces and strict reads make the same ranges of the
33
+ // same source the same bytes (torrent-tv/meta#178). The check that each
34
+ // packet's address holds its expected payload (`expectedHash`) stays: a
35
+ // verified piece does not prove an address inside it was worked out right.
36
+ hash.update(JSON.stringify([source.sourceKey, source.fileIndex, source.input.ranges]));
37
37
  for (const input of source.input.tracks) {
38
38
  const shifted = { ...input,
39
39
  ...(Number.isFinite(input.sourceEndSeconds) ? { sourceEndSeconds: input.sourceEndSeconds - (source.timeShiftSeconds ?? 0) } : {}),
@@ -1039,7 +1039,6 @@ export class EncodeRuns {
1039
1039
  });
1040
1040
  if (admittedInput.original) {
1041
1041
  run.originalInput = admittedInput;
1042
- run.inputFingerprint = admittedInput.fingerprint;
1043
1042
  // Every number of the stretch answers to its one key, so a failure
1044
1043
  // anywhere inside it is recorded against the stretch that was commanded.
1045
1044
  run.admittedInputKeys = new Map(Array.from({ length: inputEnd - safeIndex + 1 }, (_, offset) => [safeIndex + offset, inputKey]));
@@ -1052,7 +1051,6 @@ export class EncodeRuns {
1052
1051
  // this run works and is ready when the plan places the next run there.
1053
1052
  if (inputEnd < mayReach) this.#host.encodeInputs?.take(session, inputEnd + 1, mayReach);
1054
1053
  } else if (admittedInput) {
1055
- run.inputFingerprint = admittedInput.fingerprint;
1056
1054
  run.admittedInputKeys = new Map([[safeIndex, inputKey]]);
1057
1055
  let nextIndex = startIndex + 1;
1058
1056
  const canAppend = index => this.isLive(session) && run.isAlive &&
@@ -12,7 +12,7 @@ export function failedAdmittedInput(ended, hardwareEncode = false) {
12
12
  return ended.ending === ENCODE_EXIT.SHORT || ended.ending === ENCODE_EXIT.FAILED;
13
13
  }
14
14
 
15
- /** A failed input is retried only after its bytes or output parameters change. */
15
+ /** A failed input is retried only after its identity — source, file, ranges and what is stated about them — or the output parameters change. */
16
16
  export class InputFailures {
17
17
  #failures = new Map();
18
18
 
@@ -25,8 +25,13 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
25
25
  !Buffer.isBuffer(buffer) || buffer.length !== source.input.ranges[index][1] - source.input.ranges[index][0] + 1)) {
26
26
  throw new Error("Original input reader returned incomplete ranges.");
27
27
  }
28
+ // THE INPUT IS NAMED BY WHAT IT IS, NOT BY ITS BYTES. Every source is a
29
+ // torrent whose pieces are verified before they are stored, and a read
30
+ // gives every byte or none, so the same source, file, ranges and
31
+ // selections are the same bytes. Hashing the bytes as well took 13.45 s
32
+ // of 180 s of the main thread on a Matroska film whose picture and sound
33
+ // copy the same ~30 MB a piece (torrent-tv/meta#178).
28
34
  hash.update(JSON.stringify([source.sourceKey, source.fileIndex, source.input.ranges, source.input.selections]));
29
- for (const buffer of buffers) hash.update(buffer);
30
35
  held.set(source.fileIndex, { source, buffers });
31
36
  }
32
37
  let released = false;
@@ -13,6 +13,7 @@ import { VideoTrack } from "../tracks/VideoTrack.js";
13
13
  import { AudioTrack } from "../tracks/AudioTrack.js";
14
14
  import { ContainerTrack } from "../tracks/ContainerTrack.js";
15
15
  import { PacketIndex } from "./PacketIndex.js";
16
+ import { IndexMemoryUnavailable } from "./memory-unavailable.js";
16
17
  import { readAviIndex } from "./avi-index.js";
17
18
  import { isUnavailable } from "./unavailable.js";
18
19
  import { Mpeg4PictureTiming } from "./mpeg4-picture-timing.js";
@@ -34,6 +35,8 @@ export class AviContainer extends Container {
34
35
  #packets = null;
35
36
  /** The sound packets of an indexed file while they are being stated, so two asks build them once. */
36
37
  #soundPacketsReading = null;
38
+ /** The bytes the sound packet table was refused, while memory still could not hold them. */
39
+ #soundPacketsRefused = null;
37
40
  #scan = null;
38
41
  #declarations = null;
39
42
  #workTags = null;
@@ -330,9 +333,25 @@ export class AviContainer extends Container {
330
333
  // copied 2.3 GB for 0.19 GB of its own (torrent-tv/meta#166).
331
334
  const indexed = await this.#readIndex();
332
335
  if (indexed) {
336
+ // A refusal of memory for the table is kept for as long as the memory's
337
+ // owner says the same reservation still would not fit. Not kept, the
338
+ // next reader walked the index, counted the packets and was refused
339
+ // again — once per soundtrack segment, 1943 times in 8.3 s for one
340
+ // two-hour film (torrent-tv/meta#178). Asked at each use, the refusal
341
+ // cannot outlive the state of memory it was given in.
342
+ if (this.#soundPacketsRefused !== null) {
343
+ if (this.packetMemory?.fits?.(this.#soundPacketsRefused) === false) {
344
+ throw new IndexMemoryUnavailable(this.#soundPacketsRefused);
345
+ }
346
+ this.#soundPacketsRefused = null;
347
+ }
333
348
  this.#soundPacketsReading ??= this.#soundPacketsOf(indexed).then(
334
349
  packets => (this.#packets = packets),
335
- error => { this.#soundPacketsReading = null; throw error; });
350
+ error => {
351
+ this.#soundPacketsReading = null;
352
+ if (error instanceof IndexMemoryUnavailable) this.#soundPacketsRefused = error.bytes;
353
+ throw error;
354
+ });
336
355
  return this.#soundPacketsReading;
337
356
  }
338
357
  const tracks = await this.readTracks();
@@ -41,32 +41,39 @@ export class IndexMemory {
41
41
  let file = this.#files.get(key);
42
42
  if (file) return file.memory;
43
43
  file = { sourceKey, held: 0, needed: 0, records: new Set(), closed: false };
44
- file.memory = { forRecord: (record, kind = "packets") => {
45
- file.records.add(record);
46
- let disposed = false;
47
- return {
48
- reserve: bytes => {
49
- if (file.closed || disposed) return false;
50
- if (this.#held + bytes > this.#allowed) {
51
- const needed = file.held + bytes;
52
- if (needed > file.needed) { file.needed = needed; this.#requestRevision(); }
53
- return false;
54
- }
55
- file.held += bytes;
56
- this.#held += bytes;
57
- if (kind === "packets") this.#packetBytes += bytes;
58
- if (file.held >= file.needed) file.needed = 0;
59
- return true;
60
- },
61
- release: bytes => {
62
- file.held -= bytes;
63
- this.#held -= bytes;
64
- if (kind === "packets") this.#packetBytes -= bytes;
65
- this.#requestRevision();
66
- },
67
- dispose: () => { disposed = true; file.records.delete(record); }
68
- };
69
- } };
44
+ file.memory = {
45
+ // Whether a reservation of this many bytes would succeed now. Asked by a
46
+ // reader that keeps a refusal, so the refusal never outlives the state
47
+ // of memory it was given in — an event comes only when the allowance
48
+ // changes, and bytes released under the same allowance raise none.
49
+ fits: bytes => !file.closed && this.#held + bytes <= this.#allowed,
50
+ forRecord: (record, kind = "packets") => {
51
+ file.records.add(record);
52
+ let disposed = false;
53
+ return {
54
+ reserve: bytes => {
55
+ if (file.closed || disposed) return false;
56
+ if (this.#held + bytes > this.#allowed) {
57
+ const needed = file.held + bytes;
58
+ if (needed > file.needed) { file.needed = needed; this.#requestRevision(); }
59
+ return false;
60
+ }
61
+ file.held += bytes;
62
+ this.#held += bytes;
63
+ if (kind === "packets") this.#packetBytes += bytes;
64
+ if (file.held >= file.needed) file.needed = 0;
65
+ return true;
66
+ },
67
+ release: bytes => {
68
+ file.held -= bytes;
69
+ this.#held -= bytes;
70
+ if (kind === "packets") this.#packetBytes -= bytes;
71
+ this.#requestRevision();
72
+ },
73
+ dispose: () => { disposed = true; file.records.delete(record); }
74
+ };
75
+ }
76
+ };
70
77
  this.#files.set(key, file);
71
78
  return file.memory;
72
79
  }
@@ -136,15 +136,42 @@ export class DownloadMaps {
136
136
  urgent = current.urgent;
137
137
  deadlineAt = Number.isFinite(current.deadlineAt) ? current.deadlineAt - leadSeconds * 1000 : current.deadlineAt;
138
138
  }
139
- file.metadata.set(statement, { ranges: result.ranges, requestId: result.requestId, priority, urgent, deadlineAt, interval, leadSeconds, scope, order });
140
- } else if (result.kind !== "needs-memory") {
139
+ const entry = { ranges: result.ranges, requestId: result.requestId, priority, urgent, deadlineAt, interval, leadSeconds, scope, order };
140
+ const previous = file.metadata.get(statement);
141
+ if (previous && sameMetadata(previous, entry) && this.#standing(file)) return this.#unchanged(file);
142
+ file.metadata.set(statement, entry);
143
+ } else if (result.kind === "needs-memory") {
141
144
  // Allocation refusal says nothing about whether previously missing
142
- // source bytes have arrived. Keep their demand until the read resolves.
143
- file.metadata.delete(statement);
145
+ // source bytes have arrived. Keep their demand until the read resolves —
146
+ // and since it changes nothing in the map, nothing is built for it: the
147
+ // sound packet table of a two-hour AVI was refused once per soundtrack
148
+ // segment, 1943 times in 8.3 s, each a build and sort of every zone of
149
+ // the file (torrent-tv/meta#178).
150
+ if (this.#standing(file)) return this.#unchanged(file);
151
+ } else if (!file.metadata.delete(statement) && this.#standing(file)) {
152
+ return this.#unchanged(file);
144
153
  }
145
154
  return this.#emit(file);
146
155
  }
147
156
 
157
+ /**
158
+ * Whether the map last built for this file still stands, so a statement that
159
+ * changes nothing needs no new one: nothing `#emit` reads has changed since
160
+ * it ran (`dirty`), and the publication it made was not refused — `#emit`
161
+ * forgets the shape when the torrent thread did not apply the map or the
162
+ * publication failed, and that is what makes the next statement, changed or
163
+ * not, send it again.
164
+ */
165
+ #standing(file) {
166
+ return !file.dirty && Boolean(file.shape);
167
+ }
168
+
169
+ #unchanged(file) {
170
+ this.#statsOf(file).skipped += 1;
171
+ this.#report(file);
172
+ return Promise.resolve();
173
+ }
174
+
148
175
  withdrawMetadata(sourceKey, fileIndex, statement) {
149
176
  const file = this.#files.get(`${sourceKey}:${fileIndex}`);
150
177
  return file?.metadata.delete(statement) ? this.#emit(file) : Promise.resolve();
@@ -188,7 +215,7 @@ export class DownloadMaps {
188
215
  const key = `${sourceKey}:${fileIndex}`;
189
216
  let file = this.#files.get(key);
190
217
  if (!file) {
191
- file = { sourceKey, fileIndex, durationSeconds: 0, zones: [], inputs: new Map(), nativeZones: [], sourceZones: [], revision: 0, epoch: ++this.#nextEpoch, metadata: new Map(), pending: Promise.resolve() };
218
+ file = { sourceKey, fileIndex, durationSeconds: 0, zones: [], inputs: new Map(), nativeZones: [], sourceZones: [], revision: 0, epoch: ++this.#nextEpoch, metadata: new Map(), pending: Promise.resolve(), dirty: true };
192
219
  this.#files.set(key, file);
193
220
  }
194
221
  return file;
@@ -196,6 +223,8 @@ export class DownloadMaps {
196
223
 
197
224
  #setZones(file, zones) {
198
225
  file.zones = zones;
226
+ // What `#emit` reads has changed, whether or not it is published now.
227
+ file.dirty = true;
199
228
  file.inputs = new Map();
200
229
  for (const zone of zones) {
201
230
  if (typeof zone.outputKey !== "string" || !Number.isSafeInteger(zone.index)) continue;
@@ -208,6 +237,7 @@ export class DownloadMaps {
208
237
  }
209
238
 
210
239
  #emit(file) {
240
+ file.dirty = false;
211
241
  const builtFrom = this.#clock();
212
242
  const zones = [...file.zones, ...file.nativeZones];
213
243
  for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
@@ -297,7 +327,8 @@ export class DownloadMaps {
297
327
  `(${total(stats.appliedMs)}ms in all); ` +
298
328
  `stages of a sent map — built ${stage(stats.sentBuildMs)}, waited behind the previous ${stage(stats.waitedMs)}, ` +
299
329
  `posted ${stage(stats.postMs)}, post to the torrent thread's handler ${stage(stats.toHandlerMs)}, ` +
300
- `reply back ${stage(stats.replyMs)}; maps built and not sent took ${total([stats.unsentBuildMs])}ms here`);
330
+ `reply back ${stage(stats.replyMs)}; maps built and not sent took ${total([stats.unsentBuildMs])}ms here; ` +
331
+ `${stats.skipped} statement(s) changed nothing and built no map`);
301
332
  file.stats = emptyStats(now);
302
333
  }
303
334
  }
@@ -305,7 +336,7 @@ export class DownloadMaps {
305
336
  /** @param {number} since */
306
337
  function emptyStats(since) {
307
338
  return { since, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [],
308
- sentBuildMs: [], waitedMs: [], postMs: [], toHandlerMs: [], replyMs: [], unsentBuildMs: 0 };
339
+ sentBuildMs: [], waitedMs: [], postMs: [], toHandlerMs: [], replyMs: [], unsentBuildMs: 0, skipped: 0 };
309
340
  }
310
341
 
311
342
  /** One stage's median, largest and sum, in milliseconds with tenths. @param {number[]} values */
@@ -316,6 +347,22 @@ function stage(values) {
316
347
  return `median ${sorted[Math.floor(sorted.length / 2)].toFixed(1)}ms max ${sorted.at(-1).toFixed(1)}ms sum ${Math.round(sum)}ms`;
317
348
  }
318
349
 
350
+ /**
351
+ * Whether two metadata demands are the same demand: every field, the byte
352
+ * ranges and the interval by value. The fields that do not reach the
353
+ * publication — the interval, the preparation time, the scope — decide how the
354
+ * demand is repriced and withdrawn later, so a change of them is a change.
355
+ */
356
+ function sameMetadata(left, right) {
357
+ return left.requestId === right.requestId && Object.is(left.priority, right.priority) &&
358
+ left.urgent === right.urgent && Object.is(left.deadlineAt, right.deadlineAt) &&
359
+ Object.is(left.leadSeconds, right.leadSeconds) && left.scope === right.scope && left.order === right.order &&
360
+ (left.interval?.from ?? null) === (right.interval?.from ?? null) &&
361
+ (left.interval?.to ?? null) === (right.interval?.to ?? null) &&
362
+ left.ranges.length === right.ranges.length &&
363
+ left.ranges.every(([start, end], index) => start === right.ranges[index][0] && end === right.ranges[index][1]);
364
+ }
365
+
319
366
  /** How many numbers describe one zone's part in what the torrent does. */
320
367
  const ZONE_FIELDS = 7;
321
368