@torrent-tv/proxy 2.96.5 → 2.96.7

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.96.5",
3
+ "version": "2.96.7",
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
@@ -422,6 +422,7 @@ export async function startProxyServer({
422
422
  const segmentSourceRanges = new Map();
423
423
  const downloadMaps = new DownloadMaps({
424
424
  publish: (map) => torrentPool.setPriorityMap(map),
425
+ log: (line) => logger.info(line),
425
426
  resolvePlayback: async (map) => {
426
427
  if (map.zones.length === 0) return [];
427
428
  const params = await containerOver(map);
@@ -494,9 +495,17 @@ export async function startProxyServer({
494
495
  const known = segmentSourceRanges.get(rangesKey);
495
496
  if (known) { convert(known); continue; }
496
497
  if (direct) {
498
+ // Asked per zone too: a soundtrack of an AVI is read from its own
499
+ // packets, which the same index states (torrent-tv/meta#166).
497
500
  let value = null;
498
- try { value = await direct.readSourceRanges(interval); }
499
- catch { value = null; }
501
+ try {
502
+ if (await direct.supportsOriginalSourceRanges(interval) === true) value = await direct.readSourceRanges(interval);
503
+ else {
504
+ const index = await direct.readPacketIndex(interval);
505
+ await index.prepareAudioDependencies?.(interval, direct.readRange);
506
+ value = new SegmentInputs({ index, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
507
+ }
508
+ } catch { value = null; }
500
509
  if (!map.isCurrent()) return [];
501
510
  if (value?.kind === "result") {
502
511
  segmentSourceRanges.set(rangesKey, value.ranges);
@@ -198,7 +198,7 @@ export class ContainerOrchestrator {
198
198
  value = await (statement === "cover" ? container.readCover(edgesOf(params)) : container.readWorkTags(edgesOf(params)));
199
199
  if (value === null) return { kind: "terminal", reason: statement === "cover" ? "no-cover" : "format-states-nothing", requestId };
200
200
  } else if (statement === "source-navigation") {
201
- value = await container.supportsOriginalSourceRanges?.() === true;
201
+ value = await container.supportsOriginalSourceRanges?.(params.packetInterval ?? null) === true;
202
202
  } else if (statement === "source-ranges") {
203
203
  if (typeof container.readSourceRanges !== "function") return { kind: "terminal", reason: "source-range-index-not-supported", requestId };
204
204
  value = await container.readSourceRanges(params.packetInterval);
@@ -32,6 +32,8 @@ export class AviContainer extends Container {
32
32
  #headers = null;
33
33
  #tracks = null;
34
34
  #packets = null;
35
+ /** The sound packets of an indexed file while they are being stated, so two asks build them once. */
36
+ #soundPacketsReading = null;
35
37
  #scan = null;
36
38
  #declarations = null;
37
39
  #workTags = null;
@@ -150,10 +152,18 @@ export class AviContainer extends Container {
150
152
  return index;
151
153
  }
152
154
 
153
- async supportsOriginalSourceRanges() {
155
+ /**
156
+ * Whether `interval` is read from the original file. Only a picture needs
157
+ * it: an AVI states no presentation time, so FFmpeg's own reading is what
158
+ * keeps its frames in order. Sound alone is read from its own packets, whose
159
+ * time the index states (torrent-tv/meta#166); asked of the file as a whole,
160
+ * the answer is the picture's.
161
+ */
162
+ async supportsOriginalSourceRanges(interval = null) {
154
163
  const index = await this.#readIndex();
155
164
  if (!index) return false;
156
165
  const picture = ContainerTrack.firstUsable(await this.readTracks(), "video");
166
+ if (picture && interval?.trackIds?.length && !interval.trackIds.includes(picture.trackNumber)) return false;
157
167
  return !picture || index.keyframeTimes(picture.trackNumber).length > 0;
158
168
  }
159
169
 
@@ -312,9 +322,19 @@ export class AviContainer extends Container {
312
322
  async readPacketIndex(interval) {
313
323
  if (this.#packets) return this.#packets;
314
324
  if (this.#scan) return this.#readUnindexed(interval);
315
- // A file with an index is read by FFmpeg from the original bytes; packets
316
- // are reassembled only where no index states them (torrent-tv/meta#151).
317
- if (await this.#readIndex()) throw new Error("AVI with an index is read from the original file, not from reassembled packets.");
325
+ // A picture of a file with an index is read by FFmpeg from the original
326
+ // bytes (torrent-tv/meta#151). Its SOUND is not: the index states every
327
+ // sound packet's place and FFmpeg's time for it, and a soundtrack read from
328
+ // the original file has to carry the picture's bytes too, because sound and
329
+ // picture are interleaved — field 2026-10-10, a two-hour film's soundtrack
330
+ // copied 2.3 GB for 0.19 GB of its own (torrent-tv/meta#166).
331
+ const indexed = await this.#readIndex();
332
+ if (indexed) {
333
+ this.#soundPacketsReading ??= this.#soundPacketsOf(indexed).then(
334
+ packets => (this.#packets = packets),
335
+ error => { this.#soundPacketsReading = null; throw error; });
336
+ return this.#soundPacketsReading;
337
+ }
318
338
  const tracks = await this.readTracks();
319
339
  const { streams } = await this.#readHeaders();
320
340
  const index = new PacketIndex({ packetMemory: this.packetMemory, deferMemory: true });
@@ -333,6 +353,45 @@ export class AviContainer extends Container {
333
353
  return this.#readUnindexed(interval);
334
354
  }
335
355
 
356
+ /**
357
+ * The sound packets an AVI index states, on FFmpeg's clock: the time the
358
+ * index gives each packet is the one FFmpeg's demuxer gives it, and a packet
359
+ * lasts until the next one of its stream, the last until the stream's clock
360
+ * after it. Only sound is declared; a picture of an indexed AVI states no
361
+ * presentation time and is read from the original file.
362
+ *
363
+ * @param {{ packets: Array<{ count: number, starts: Float64Array, lengths: Uint32Array, times: Float64Array, end: number | null }> }} index
364
+ * @returns {Promise<PacketIndex>}
365
+ */
366
+ async #soundPacketsOf(index) {
367
+ const tracks = await this.readTracks();
368
+ const { streams } = await this.#readHeaders();
369
+ const packets = new PacketIndex({ packetMemory: this.packetMemory });
370
+ try {
371
+ for (const track of tracks.filter(one => one.type === "audio")) {
372
+ const stream = index.packets[track.trackNumber];
373
+ packets.declareTrack(track.trackNumber, { type: "audio", codecId: track.codecId,
374
+ codecRanges: streams[track.trackNumber].codecRanges });
375
+ let previous = -1;
376
+ const append = (end) => {
377
+ packets.append(track.trackNumber, { pts: stream.times[previous], duration: Math.max(0, end - stream.times[previous]),
378
+ keyframe: true, ranges: [[stream.starts[previous], stream.starts[previous] + stream.lengths[previous] - 1]] });
379
+ };
380
+ for (let at = 0; at < stream.count; at++) {
381
+ if (!stream.lengths[at]) continue;
382
+ if (previous >= 0) append(stream.times[at]);
383
+ previous = at;
384
+ }
385
+ if (previous >= 0) append(stream.end ?? stream.times[previous]);
386
+ packets.complete(track.trackNumber);
387
+ }
388
+ } catch (error) {
389
+ packets.dispose();
390
+ throw error;
391
+ }
392
+ return packets;
393
+ }
394
+
336
395
  async #readUnindexed(interval) {
337
396
  const state = this.#scan;
338
397
  state.index.flushPending();
@@ -44,6 +44,8 @@ class StreamPackets {
44
44
  this.lengths = new Uint32Array(count);
45
45
  this.times = new Float64Array(count);
46
46
  this.keyframes = new Uint8Array(count);
47
+ /** Where the stream's clock stands after its last packet: the last packet's end. */
48
+ this.end = null;
47
49
  }
48
50
 
49
51
  push(start, length, time, keyframe) {
@@ -186,6 +188,7 @@ async function readIdx1({ read, fileSize, movi, idx1, streams, allocation }) {
186
188
  clocks[id].advance(length);
187
189
  }
188
190
  }
191
+ packets.forEach((stream, id) => { stream.end = clocks[id].seconds(); });
189
192
  return new AviIndex({ packets, indexRanges: [[idx1.start - 8, fileSize - 1]], held, allocation });
190
193
  } catch (error) {
191
194
  allocation?.release?.(held);
@@ -247,6 +250,7 @@ async function readOpenDml({ read, fileSize, streams, allocation }) {
247
250
  }
248
251
  }
249
252
  }
253
+ packets.forEach((stream, id) => { stream.end = clocks[id].seconds(); });
250
254
  return new AviIndex({ packets, indexRanges: parsed.map(standard => [standard.start, standard.end - 1]), held, allocation });
251
255
  } catch (error) {
252
256
  allocation?.release?.(held);
@@ -476,7 +476,7 @@ export class TorrentWorkerClient {
476
476
  * @returns {Promise<void>}
477
477
  */
478
478
  async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds }) {
479
- await this.#caller.call(Command.PRIORITY_MAP, {
479
+ return this.#caller.call(Command.PRIORITY_MAP, {
480
480
  sourceKey,
481
481
  fileIndex,
482
482
  zones,
@@ -168,7 +168,7 @@ export class WorkerTorrentPool {
168
168
  if (!sourceKey) {
169
169
  return;
170
170
  }
171
- await this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
171
+ return this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
172
172
  }
173
173
 
174
174
  /**
@@ -459,12 +459,17 @@ async function runCommand(command, params, id) {
459
459
  const zones = Array.isArray(params.zones) ? params.zones : [];
460
460
  // Nor may one for a file held whole, which has nothing left to fetch.
461
461
  const torrent = await knownTorrent(params.sourceKey);
462
- if (torrent) {
463
- ensureArrivalsWired(params.sourceKey, torrent);
464
- pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
465
- torrent.emit("priority-map-changed", params.fileIndex);
462
+ if (!torrent) {
463
+ return { appliedMs: null };
466
464
  }
467
- return true;
465
+ // What applying a map costs this thread, told to whoever sent it: every
466
+ // zone becomes a window of the register and the swarm is told again
467
+ // (torrent-tv/meta#166).
468
+ const startedAt = performance.now();
469
+ ensureArrivalsWired(params.sourceKey, torrent);
470
+ pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
471
+ torrent.emit("priority-map-changed", params.fileIndex);
472
+ return { appliedMs: performance.now() - startedAt };
468
473
  }
469
474
 
470
475
  case Command.READ_RANGE: {
@@ -1,14 +1,24 @@
1
+ /**
2
+ * How often what the publications of one file cost is said, at most: the
3
+ * cadence of the proxy's other once-a-minute readings.
4
+ */
5
+ const REPORT_EVERY_MS = 60_000;
6
+
1
7
  /** One published download map per file, including download-only metadata reads. */
2
8
  export class DownloadMaps {
3
9
  #files = new Map();
4
10
  #publish;
5
11
  #resolvePlayback;
6
12
  #nextEpoch = 0;
13
+ #log;
14
+ #now;
7
15
 
8
- constructor({ publish, resolvePlayback = async map => map.zones }) {
16
+ constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now }) {
9
17
  if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
10
18
  this.#publish = publish;
11
19
  this.#resolvePlayback = resolvePlayback;
20
+ this.#log = typeof log === "function" ? log : null;
21
+ this.#now = now;
12
22
  }
13
23
 
14
24
  async playback({ sourceKey, fileIndex, durationSeconds, zones }) {
@@ -197,11 +207,130 @@ export class DownloadMaps {
197
207
  }
198
208
  }
199
209
  const publication = { sourceKey: file.sourceKey, fileIndex: file.fileIndex, durationSeconds: file.durationSeconds, zones };
210
+ // SENT ONLY WHEN THE TORRENT WOULD ACT ON IT DIFFERENTLY: other bytes,
211
+ // another level or order of claim, or deadlines in another order. A map was
212
+ // sent after every read of the file's statements, changed or not, and the
213
+ // read waited for it: field 2026-10-10, 48-64 such reads per stretch of a
214
+ // two-hour AVI, 28-54 s before its copy began, with 5-12 thousand zones in
215
+ // each map (torrent-tv/meta#166). A deadline worked out again on a later
216
+ // clock moves every zone of a viewer playing on by the same amount and
217
+ // orders nothing differently, so the deadlines' order is compared and not
218
+ // their values.
219
+ const shape = shapeOf(publication);
220
+ if (file.shape && sameShape(file.shape, shape)) {
221
+ this.#statsOf(file).unchanged += 1;
222
+ this.#report(file);
223
+ return Promise.resolve();
224
+ }
225
+ file.shape = shape;
200
226
  const pending = file.pending.then(() => {
201
- if (this.#files.get(`${file.sourceKey}:${file.fileIndex}`) === file) return this.#publish(publication);
227
+ if (this.#files.get(`${file.sourceKey}:${file.fileIndex}`) !== file) return undefined;
228
+ const sentAt = this.#now();
229
+ return Promise.resolve(this.#publish(publication)).then(reply => {
230
+ // A torrent not yet in the thread applies nothing, so this map was not
231
+ // delivered and the next one, however alike, has to be.
232
+ if (reply?.appliedMs === null && file.shape === shape) file.shape = null;
233
+ // The window open now, which a report since this map was asked for may have started.
234
+ const stats = this.#statsOf(file);
235
+ stats.sent += 1;
236
+ stats.zones.push(zones.length);
237
+ stats.deliveredMs.push(this.#now() - sentAt);
238
+ if (Number.isFinite(reply?.appliedMs)) stats.appliedMs.push(reply.appliedMs);
239
+ this.#report(file);
240
+ return reply;
241
+ });
242
+ });
243
+ // A failed publication must not prevent the next state change from being
244
+ // sent, and leaves nothing to compare the next one against.
245
+ file.pending = pending.catch(() => {
246
+ if (file.shape === shape) file.shape = null;
202
247
  });
203
- // A failed publication must not prevent the next state change from being sent.
204
- file.pending = pending.catch(() => undefined);
205
248
  return pending;
206
249
  }
250
+
251
+ #statsOf(file) {
252
+ file.stats ??= { since: this.#now(), sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [] };
253
+ return file.stats;
254
+ }
255
+
256
+ /** What this file's publications cost, said once a minute at most and only when one was asked for. */
257
+ #report(file) {
258
+ const stats = file.stats;
259
+ const now = this.#now();
260
+ if (!this.#log || !stats || now - stats.since < REPORT_EVERY_MS) return;
261
+ const total = values => Math.round(values.reduce((sum, value) => sum + value, 0));
262
+ this.#log(`download map ${file.sourceKey.slice(0, 48)}:${file.fileIndex}: ${stats.sent} sent and ` +
263
+ `${stats.unchanged} unchanged not sent in ${((now - stats.since) / 1000).toFixed(1)}s; ` +
264
+ `zones median ${middle(stats.zones)} max ${largest(stats.zones)}; ` +
265
+ `delivered median ${middle(stats.deliveredMs)}ms max ${largest(stats.deliveredMs)}ms (${total(stats.deliveredMs)}ms in all), ` +
266
+ `applied in the torrent thread median ${middle(stats.appliedMs)}ms max ${largest(stats.appliedMs)}ms ` +
267
+ `(${total(stats.appliedMs)}ms in all)`);
268
+ file.stats = { since: now, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [] };
269
+ }
270
+ }
271
+
272
+ /** How many numbers describe one zone's part in what the torrent does. */
273
+ const ZONE_FIELDS = 7;
274
+
275
+ /** @param {boolean | undefined} value */
276
+ function flag(value) {
277
+ return value === true ? 2 : value === false ? 1 : 0;
278
+ }
279
+
280
+ /** @param {{ deadlineAt?: number }} zone */
281
+ function deadlineOf(zone) {
282
+ return Number.isFinite(zone.deadlineAt) ? zone.deadlineAt : Number.POSITIVE_INFINITY;
283
+ }
284
+
285
+ /**
286
+ * What the torrent acts on in a publication, packed for comparison: each
287
+ * zone's bytes, priority, words of level, order of claim, and the RANK of its
288
+ * deadline among the map's zones rather than the deadline itself. A zone with
289
+ * no deadline carries the time it is wanted within, which the torrent turns
290
+ * into a deadline on its own clock when the map is applied.
291
+ *
292
+ * @param {{ durationSeconds: number, zones: object[] }} publication
293
+ * @returns {{ durationSeconds: number, fields: Float64Array }}
294
+ */
295
+ function shapeOf({ durationSeconds, zones }) {
296
+ const byDeadline = zones.map((_zone, index) => index).sort((left, right) => {
297
+ const first = deadlineOf(zones[left]);
298
+ const second = deadlineOf(zones[right]);
299
+ return first === second ? left - right : first < second ? -1 : 1;
300
+ });
301
+ const fields = new Float64Array(zones.length * ZONE_FIELDS);
302
+ byDeadline.forEach((index, rank) => {
303
+ fields[index * ZONE_FIELDS + 5] = rank;
304
+ });
305
+ zones.forEach((zone, index) => {
306
+ const at = index * ZONE_FIELDS;
307
+ fields[at] = Number.isFinite(zone.byteStart) ? zone.byteStart : -1;
308
+ fields[at + 1] = Number.isFinite(zone.byteEnd) ? zone.byteEnd : -1;
309
+ fields[at + 2] = Number.isFinite(zone.priority) ? zone.priority : -1;
310
+ fields[at + 3] = flag(zone.urgent) * 27 + flag(zone.deferred) * 9 + flag(zone.behind) * 3 + flag(zone.downloadOnly);
311
+ fields[at + 4] = Number.isSafeInteger(zone.order) ? zone.order : 0;
312
+ fields[at + 6] = Number.isFinite(zone.deadlineAt) ? -2 : Number.isFinite(zone.withinSeconds) ? zone.withinSeconds : -1;
313
+ });
314
+ return { durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : -1, fields };
315
+ }
316
+
317
+ /** @param {{ durationSeconds: number, fields: Float64Array }} left @param {{ durationSeconds: number, fields: Float64Array }} right */
318
+ function sameShape(left, right) {
319
+ if (left.durationSeconds !== right.durationSeconds || left.fields.length !== right.fields.length) return false;
320
+ for (let index = 0; index < left.fields.length; index++) {
321
+ if (left.fields[index] !== right.fields[index]) return false;
322
+ }
323
+ return true;
324
+ }
325
+
326
+ /** @param {number[]} values */
327
+ function middle(values) {
328
+ if (values.length === 0) return "n/a";
329
+ const sorted = [...values].sort((left, right) => left - right);
330
+ return Math.round(sorted[Math.floor(sorted.length / 2)]);
331
+ }
332
+
333
+ /** @param {number[]} values */
334
+ function largest(values) {
335
+ return values.length === 0 ? "n/a" : Math.round(Math.max(...values));
207
336
  }