@torrent-tv/proxy 2.97.15 → 2.97.16

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.15",
3
+ "version": "2.97.16",
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
@@ -524,6 +524,14 @@ export async function startProxyServer({
524
524
  if (!map.isCurrent()) return [];
525
525
  const direct = navigated.kind === "result" && navigated.value === true &&
526
526
  typeof container?.readSourceRanges === "function" ? container : null;
527
+ // An output whose file-level reading is not there in this pass — the
528
+ // index or Cues that say where a segment is, not yet downloaded, or the
529
+ // table its segments read, refused memory — answers its other segments
530
+ // the same way. One unanswered request is kept to be retried when the
531
+ // bytes or the memory change, and the map is built again when it
532
+ // answers; asking every segment held the main thread for 3.3 s on a cold
533
+ // two-hour AVI (torrent-tv/meta#178).
534
+ const waiting = new Set();
527
535
  for (const zone of demands) {
528
536
  if (!map.isCurrent()) return [];
529
537
  // WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication: its
@@ -547,6 +555,7 @@ export async function startProxyServer({
547
555
  // were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
548
556
  const known = segmentSourceRanges.get(rangesKey);
549
557
  if (known) { convert(known); continue; }
558
+ if (waiting.has(zone.outputKey)) continue;
550
559
  if (direct) {
551
560
  // Asked per zone too: a soundtrack of an AVI is read from its own
552
561
  // packets, which the same index states (torrent-tv/meta#166).
@@ -572,10 +581,14 @@ export async function startProxyServer({
572
581
  if (!map.isCurrent()) return [];
573
582
  if (!intervalParams) continue;
574
583
  const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
575
- if (navigation.kind !== "result") continue;
584
+ if (navigation.kind !== "result") {
585
+ if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
586
+ continue;
587
+ }
576
588
  const originalRanges = navigation.value;
577
589
  const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
578
590
  if (!map.isCurrent()) return [];
591
+ if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
579
592
  if (packets.kind !== "result") continue;
580
593
  const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
581
594
  if (input.kind !== "result") continue;
@@ -599,7 +612,7 @@ export async function startProxyServer({
599
612
  failed: (request, error) => logger.warn(`media request=${request.key} probe failed: ${error?.message ?? error}`)
600
613
  });
601
614
  const indexMemory = new IndexMemory({
602
- reviseBudget: () => { void outputParts.machineBudget.revise().catch(error => logger.warn(`packet index budget: ${error.message}`)); },
615
+ reviseBudget: () => { void outputParts.machineBudget.revise("memory", "media metadata").catch(error => logger.warn(`packet index budget: ${error.message}`)); },
603
616
  changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); outputParts.encodeInputs?.memoryChanged(); }
604
617
  });
605
618
  const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
@@ -1,6 +1,22 @@
1
- /** Retry unfinished statements when their missing resource changes. */
1
+ /**
2
+ * Retry unfinished statements when their missing resource changes.
3
+ *
4
+ * ONE REFUSAL OF MEMORY ANSWERS THE STATEMENT, not one request of it. The
5
+ * table a statement reserves belongs to the file — a soundtrack's packet table
6
+ * is one table for every segment of it — so once a read of a statement has
7
+ * been refused memory, the other requests of the same statement of the same
8
+ * file wait for memory to change instead of being read and refused one by one.
9
+ * Read one by one, a cold two-hour AVI's 1943 soundtrack segments were refused
10
+ * in turn when the index's last piece arrived, which held the main thread for
11
+ * 3.3 s and the budget revision that would have given the memory behind it
12
+ * (Home Assistant 2026-10-12, torrent-tv/meta#178). A retry wave therefore
13
+ * reads one request of each statement first and the others once it has
14
+ * answered.
15
+ */
2
16
  export class MediaReadRequests {
3
17
  #requests = new Map();
18
+ /** File and statement → the memory revision at which a read of it was refused memory. */
19
+ #refusedMemory = new Map();
4
20
  #read;
5
21
  #failed;
6
22
  #versions = new Map();
@@ -39,6 +55,7 @@ export class MediaReadRequests {
39
55
  this.#notify(params.sourceKey, params.fileIndex, result);
40
56
  return this.#requests.delete(key);
41
57
  }
58
+ if (result.kind === "needs-memory") this.#refusedMemory.set(statementKey(params, statement), memoryRevision);
42
59
  const existing = this.#requests.get(key);
43
60
  if (existing) { existing.params = params; existing.kind = result.kind; }
44
61
  else this.#requests.set(key, { params, statement, kind: result.kind, running: false, changed: false });
@@ -54,24 +71,49 @@ export class MediaReadRequests {
54
71
 
55
72
  memoryChanged() {
56
73
  this.#memoryVersion++;
74
+ const wave = [];
57
75
  for (const request of this.#requests.values()) {
58
76
  if (request.kind !== "needs-memory") continue;
59
77
  // The retried statement announces its result. A memory event is not a
60
78
  // source change for another statement still waiting for missing bytes.
61
79
  request.changed = true;
62
- if (!request.running) void this.#retry(request);
80
+ if (!request.running) wave.push(request);
63
81
  }
82
+ this.#retryWave(wave);
64
83
  }
65
84
 
66
85
  bytesChanged(sourceKey, fileIndex) {
67
86
  const fileKey = `${sourceKey}:${fileIndex}`;
68
87
  this.#versions.set(fileKey, this.revision(sourceKey, fileIndex) + 1);
69
88
  this.#notify(sourceKey, fileIndex, { kind: "bytes-changed" });
89
+ const wave = [];
70
90
  for (const request of this.#requests.values()) {
71
91
  if (request.params.sourceKey !== sourceKey || request.params.fileIndex !== fileIndex) continue;
72
92
  if (request.kind !== "needs-ranges") continue;
73
93
  request.changed = true;
74
- if (!request.running) void this.#retry(request);
94
+ if (!request.running) wave.push(request);
95
+ }
96
+ this.#retryWave(wave);
97
+ }
98
+
99
+ /**
100
+ * Retry `requests`: one of each statement of each file first, the others of
101
+ * that statement once it has answered.
102
+ *
103
+ * @param {object[]} requests
104
+ */
105
+ #retryWave(requests) {
106
+ const groups = new Map();
107
+ for (const request of requests) {
108
+ const key = statementKey(request.params, request.statement);
109
+ const group = groups.get(key);
110
+ if (group) group.push(request);
111
+ else groups.set(key, [request]);
112
+ }
113
+ for (const [head, ...rest] of groups.values()) {
114
+ void this.#retry(head).then(() => {
115
+ for (const request of rest) if (!request.running) void this.#retry(request);
116
+ });
75
117
  }
76
118
  }
77
119
 
@@ -90,6 +132,10 @@ export class MediaReadRequests {
90
132
  for (const key of this.#versions.keys()) {
91
133
  if (fileIndex === undefined ? key.startsWith(`${sourceKey}:`) : key === `${sourceKey}:${fileIndex}`) this.#versions.delete(key);
92
134
  }
135
+ for (const key of [...this.#refusedMemory.keys()]) {
136
+ const [refusedSource, refusedFile] = JSON.parse(key);
137
+ if (refusedSource === sourceKey && (fileIndex === undefined || refusedFile === fileIndex)) this.#refusedMemory.delete(key);
138
+ }
93
139
  }
94
140
 
95
141
  retain(sourceKey, fileIndex, wanted) {
@@ -105,6 +151,12 @@ export class MediaReadRequests {
105
151
  try {
106
152
  while (request.changed && this.#requests.get(key) === request) {
107
153
  request.changed = false;
154
+ if (this.#refusedMemory.get(statementKey(request.params, request.statement)) === this.#memoryVersion) {
155
+ // The same statement of the same file was refused memory in this
156
+ // very state of memory: so is this one, without reading it.
157
+ request.kind = "needs-memory";
158
+ break;
159
+ }
108
160
  await this.#read(request.params, request.statement);
109
161
  }
110
162
  } catch (error) {
@@ -118,3 +170,7 @@ export class MediaReadRequests {
118
170
  function requestKey(params, statement) {
119
171
  return JSON.stringify([params.sourceKey, params.fileIndex, statement, params.requestId ?? null]);
120
172
  }
173
+
174
+ function statementKey(params, statement) {
175
+ return JSON.stringify([params.sourceKey, params.fileIndex, statement]);
176
+ }
@@ -738,9 +738,49 @@ export class ViewerRequests {
738
738
  }))
739
739
  })}`);
740
740
  }
741
+ this.#sayHolding(session, consumerId, measurement, forecast, input, now);
741
742
  return forecast;
742
743
  }
743
744
 
745
+ /**
746
+ * What holds a waiting viewer's start, said when it changes: which piece,
747
+ * its production or its delivery, when it comes against the media before it,
748
+ * and the rates the forecast used. Said again when the delay has moved by a
749
+ * second, at most once a second; a hold that changes nothing says nothing.
750
+ *
751
+ * @param {object} session
752
+ * @param {string} consumerId
753
+ * @param {object} measurement - This viewer's forecast state.
754
+ * @param {object} forecast
755
+ * @param {object} input
756
+ * @param {number} now
757
+ */
758
+ #sayHolding(session, consumerId, measurement, forecast, input, now) {
759
+ const holding = forecast.holdingBy;
760
+ if (!holding) {
761
+ measurement.held = null;
762
+ return;
763
+ }
764
+ const key = `${holding.kind}:${holding.track}:${holding.segment}`;
765
+ const delay = Number.isFinite(forecast.delaySeconds) ? forecast.delaySeconds : null;
766
+ const said = measurement.held;
767
+ if (said && said.key === key && (delay === null || said.delay === null || Math.abs(delay - said.delay) < 1 ||
768
+ now - said.at < 1000)) return;
769
+ measurement.held = { key, delay, at: now };
770
+ const seconds = value => Number.isFinite(value) ? value.toFixed(2) : "?";
771
+ const rates = (forecast.operations ?? []).map(operation =>
772
+ `${operation.track} ${operation.operation} ${Number.isFinite(operation.speed) ? operation.speed.toFixed(2) : "?"}x`);
773
+ const downloads = (input.sources ?? []).map(source => {
774
+ const rate = source.downloadRateReadings?.lastValue;
775
+ return `${Number.isFinite(rate) ? (rate / 1_048_576).toFixed(2) : "?"} MB/s`;
776
+ });
777
+ logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
778
+ (holding.kind === "position-not-held" ? "the position, which nothing buffered covers" :
779
+ `the ${holding.kind} of ${holding.track} #${holding.segment} in ${seconds(holding.atSeconds)}s against ` +
780
+ `${seconds(holding.aheadSeconds)}s of media before it${holding.requiresSource ? ", its input still downloading" : ""}`) +
781
+ `; buffered ${seconds(forecast.bufferedSeconds)}s; rates ${rates.join(", ") || "none"}; download ${downloads.join(", ") || "none"}`);
782
+ }
783
+
744
784
  /**
745
785
  * The pieces of one output as the start forecast reads them, kept between
746
786
  * polls: a piece whose playlist edges, read ranges and torrent inputs have
@@ -1008,7 +1008,7 @@ export function wireOutputs({
1008
1008
  resolve: resolveEncodeInput,
1009
1009
  readRanges: readEncodeRanges,
1010
1010
  heldRanges: readEncodeHeldRanges,
1011
- reviseBudget: () => parts.machineBudget.revise(),
1011
+ reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
1012
1012
  capacity: () => parts.machineBudget.capacityOf("memory"),
1013
1013
  // How long this output's input takes to open, from the one owner of start
1014
1014
  // readings (torrent-tv/meta#184).
@@ -77,6 +77,14 @@ import { OtherDemand, divideAllowance } from "./allowance.js";
77
77
  export class MachineBudget {
78
78
  #revisionPromise = null;
79
79
  #reviseAgain = false;
80
+ /**
81
+ * The resources the next pass reads: `null` for every one, else their names.
82
+ * A claimant short of memory asks for memory alone; reading the disks first
83
+ * made its answer wait for them (torrent-tv/meta#178).
84
+ *
85
+ * @type {Set<string> | null}
86
+ */
87
+ #againScope = null;
80
88
  /**
81
89
  * The revision in hand, for its own line: when it was first asked for, how
82
90
  * many asks joined it, and which pass this is. At a cold start on the addon
@@ -151,32 +159,45 @@ export class MachineBudget {
151
159
  }
152
160
 
153
161
  /**
154
- * Read every resource, divide each, and tell each claimant its share.
162
+ * Read the resources, divide each, and tell each claimant its share.
155
163
  *
164
+ * @param {string | null} [resource] - One resource to read and divide; every
165
+ * resource when absent. The others keep their last division.
166
+ * @param {string | null} [because] - Who asked, said in the revision's line.
156
167
  * @returns {Promise<Map<string, { freeBytes: number, allowanceBytes: number, shares: object[] }>>}
157
168
  */
158
- revise() {
169
+ revise(resource = null, because = null) {
170
+ const scope = resource === null ? null : new Set([resource]);
159
171
  if (this.#revisionPromise) {
160
172
  this.#reviseAgain = true;
173
+ this.#againScope = this.#againScope === null || scope === null ? null : new Set([...this.#againScope, ...scope]);
161
174
  this.#revision.asks += 1;
162
175
  return this.#revisionPromise;
163
176
  }
164
- this.#revision = { askedAt: Date.now(), asks: 1, pass: 0 };
177
+ this.#revision = { askedAt: Date.now(), asks: 1, pass: 0, because };
178
+ this.#againScope = scope;
165
179
  this.#revisionPromise = (async () => {
166
180
  do {
181
+ const passScope = this.#againScope;
182
+ this.#againScope = new Set();
167
183
  this.#reviseAgain = false;
168
184
  this.#revision.pass += 1;
169
- await this.#readRevision();
185
+ await this.#readRevision(passScope);
170
186
  } while (this.#reviseAgain);
171
187
  return this.#last;
172
188
  })().finally(() => { this.#revisionPromise = null; });
173
189
  return this.#revisionPromise;
174
190
  }
175
191
 
176
- async #readRevision() {
192
+ /** @param {Set<string> | null} scope */
193
+ async #readRevision(scope) {
177
194
  const passFrom = Date.now();
178
195
  let readingMs = 0;
196
+ const readings = [];
179
197
  for (const [name, resource] of this.#resources) {
198
+ if (scope !== null && !scope.has(name)) {
199
+ continue;
200
+ }
180
201
  const claimants = this.#claimants.filter((one) => one.resource === name);
181
202
  if (claimants.length === 0) {
182
203
  continue;
@@ -185,6 +206,7 @@ export class MachineBudget {
185
206
  const readFrom = Date.now();
186
207
  const free = await resource.readFree();
187
208
  readingMs += Date.now() - readFrom;
209
+ readings.push(`${name} ${Date.now() - readFrom}ms`);
188
210
  // WITHOUT A READING, NOTHING IS ALLOWED TO GROW. A resource whose free
189
211
  // space cannot be read is not a resource with room; answering "unbounded"
190
212
  // there is how the spill file came to have no limit in the first place.
@@ -213,8 +235,9 @@ export class MachineBudget {
213
235
  }
214
236
  const revision = this.#revision;
215
237
  this.#logger?.info?.(`${this.describe()}` + (revision
216
- ? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago; ` +
217
- `this pass ${Date.now() - passFrom}ms, ${readingMs}ms of it reading what is free)`
238
+ ? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago` +
239
+ `${revision.because ? `, first by ${revision.because}` : ""}; ` +
240
+ `this pass ${Date.now() - passFrom}ms, ${readingMs}ms of it reading what is free: ${readings.join(", ") || "nothing"})`
218
241
  : ""));
219
242
  return this.#last;
220
243
  }
@@ -153,8 +153,9 @@ export function wireMachineBudget({
153
153
  });
154
154
  }
155
155
 
156
+ let evidenceOn = null;
156
157
  if (diagnostics) {
157
- const evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
158
+ evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
158
159
  defineDisk(evidenceOn, diagnosticsRoot || segmentStore.root);
159
160
  budget.register({
160
161
  name: "diagnostics",
@@ -169,11 +170,22 @@ export function wireMachineBudget({
169
170
 
170
171
  return {
171
172
  capacityOf: name => budget.capacityOf(name),
172
- revise: async () => {
173
- // Re-read the evidence before dividing: it is the one claimant whose
174
- // bytes are written by something that never reports them.
175
- await diagnostics?.measure();
176
- return budget.revise();
173
+ /**
174
+ * @param {string | null} [resource] - One resource to revise; all when absent.
175
+ * @param {string | null} [because] - Who asked, for the revision's line.
176
+ */
177
+ revise: async (resource = null, because = null) => {
178
+ // Re-read the evidence before dividing its disk: it is the one claimant
179
+ // whose bytes are written by something that never reports them. Only
180
+ // there: a claimant short of memory asks for memory alone, and walking
181
+ // the evidence's directory first made every memory answer wait for it —
182
+ // 3.7 s on a cold AVI behind 1943 other reads (torrent-tv/meta#178).
183
+ if (diagnostics && (resource === null || resource === evidenceOn)) {
184
+ const from = Date.now();
185
+ await diagnostics.measure();
186
+ because = `${because ?? "a full revision"}, after the evidence was measured in ${Date.now() - from}ms`;
187
+ }
188
+ return budget.revise(resource, because);
177
189
  },
178
190
  segmentBytes: () => segmentBytes,
179
191
  diagnostics: diagnostics ?? null,
@@ -2,9 +2,11 @@ import { pieceStoreOf } from "../piece-store-of.js";
2
2
 
3
3
  /**
4
4
  * How many pieces one read for a reader asks of the store at once: the threads
5
- * libuv runs file operations on (`UV_THREADPOOL_SIZE`, four unless set). A
6
- * piece from disk is three of their operations, and more at once would only
7
- * wait behind them (torrent-tv/meta#178).
5
+ * libuv runs file operations on (`UV_THREADPOOL_SIZE`). The proxy's entry point
6
+ * sets 64 where the deployment states none (`bin/thread-pool.js`), and libuv's
7
+ * own default of four applies only to code run without it, such as the
8
+ * checks. A piece from disk is three of their operations, and more at once
9
+ * would only wait behind them (torrent-tv/meta#178).
8
10
  */
9
11
  const FILE_READS_AT_ONCE = Math.max(1, Number.parseInt(process.env.UV_THREADPOOL_SIZE ?? "", 10) || 4);
10
12
 
@@ -225,8 +227,8 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
225
227
  // and the loop runs 0.3-0.7 s late while the swarm delivers 7-14 MB/s: a
226
228
  // 210 MB stretch took 11 s here (Home Assistant 2026-10-12,
227
229
  // torrent-tv/meta#178). As many are in flight as the runtime has threads
228
- // for file operations (libuv's pool, `UV_THREADPOOL_SIZE`, four by
229
- // default); more would only queue behind them and ahead of everything else
230
+ // for file operations (libuv's pool, `UV_THREADPOOL_SIZE`, 64 as the proxy
231
+ // starts); more would only queue behind them and ahead of everything else
230
232
  // this process reads or writes.
231
233
  const readOne = async ([index, span]) => {
232
234
  const pieceStart = index * pieceLength;
@@ -396,6 +396,16 @@ export function predictPlaybackReadiness(input = {}) {
396
396
  }
397
397
 
398
398
  const constraints = [];
399
+ // WHICH CONSTRAINT HOLDS THE START, said with the answer: the delay is the
400
+ // largest of thousands, and the log could only say that some term was
401
+ // positive while the picture stood for 17 s (torrent-tv/meta#178).
402
+ let holding = null;
403
+ const constrain = (value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource) => {
404
+ constraints.push(value);
405
+ if (value > 0 && (holding === null || value > holding.value)) {
406
+ holding = { value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource };
407
+ }
408
+ };
399
409
  const end = rescale(fromSeconds(duration), unit);
400
410
  for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
401
411
  const state = trackState[trackIndex];
@@ -407,7 +417,8 @@ export function predictPlaybackReadiness(input = {}) {
407
417
  const before = contiguousEnd(delivered, at);
408
418
  for (const range of completion.segment.ranges) addMediaRange(delivered, range);
409
419
  const after = contiguousEnd(delivered, at);
410
- constraints.push(completion.at - secondsAhead(earliest(before, trackEnd), at));
420
+ const ahead = secondsAhead(earliest(before, trackEnd), at);
421
+ constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false);
411
422
  if (compare(after, before) <= 0 && completion.segment.ranges.some(({ start }) => compare(start, before) > 0)) {
412
423
  // A timestamp hole cannot be repaired by waiting longer. Keep it
413
424
  // distinct from an unavailable rate or a slow but finite service.
@@ -425,8 +436,10 @@ export function predictPlaybackReadiness(input = {}) {
425
436
  for (const range of production.segment.ranges) addMediaRange(prepared, range);
426
437
  // Supply interruption coverage is stock held by the proxy as well as
427
438
  // the browser. It is not a browser buffer-size admission rule.
428
- constraints.push(production.at - Math.max(0, secondsAhead(before, at) -
429
- Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource)));
439
+ const ahead = Math.max(0, secondsAhead(before, at) -
440
+ Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource));
441
+ constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
442
+ production.requiresSource);
430
443
  }
431
444
  }
432
445
  const delay = Math.max(0, ...constraints);
@@ -436,6 +449,13 @@ export function predictPlaybackReadiness(input = {}) {
436
449
  Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
437
450
  Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
438
451
  return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
452
+ // The piece whose `kind` — its production, or its delivery to the page —
453
+ // comes `atSeconds` from now while the media before it lasts
454
+ // `aheadSeconds`: the difference is the delay.
455
+ holdingBy: ready ? null : holding === null ? (originReady ? null : { kind: "position-not-held" })
456
+ : { kind: holding.kind, track: tracks[holding.trackIndex]?.id ?? holding.trackIndex,
457
+ segment: holding.segment, atSeconds: holding.atSeconds, aheadSeconds: holding.aheadSeconds,
458
+ requiresSource: holding.requiresSource },
439
459
  preparedUntilSeconds: Math.min(duration, ...trackState.map((state, index) => {
440
460
  const ranges = state.clientRanges.map((range) => ({ ...range }));
441
461
  for (const item of productions.filter((item) => item.trackIndex === index && item.at <= delay)) {