@torrent-tv/proxy 2.96.5 → 2.96.6

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.6",
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);
@@ -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
  }