@torrent-tv/proxy 2.80.8 → 2.80.10

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +1675 -1658
  2. package/bin/cli.js +606 -596
  3. package/package.json +51 -50
  4. package/services/data-channel-handler.js +2 -4
  5. package/services/encode/CoverageMap.js +428 -401
  6. package/services/encode/EncodePlan.js +0 -3
  7. package/services/encode/EncodeRun.js +14 -0
  8. package/services/encode/SegmentStore.js +718 -669
  9. package/services/hls-session-manager.js +44 -162
  10. package/services/hwaccel.js +2 -2
  11. package/services/memory-report.js +596 -592
  12. package/services/orchestrators/EncodeOrchestrator.js +143 -11
  13. package/services/quality/EncodeCost.js +555 -555
  14. package/test/auto-quality-step.test.js +514 -507
  15. package/test/contention.test.js +1 -1
  16. package/test/coverage-follows-the-disk.test.js +187 -0
  17. package/test/coverage-map.test.js +195 -178
  18. package/test/encode-orchestrator.test.js +1 -2
  19. package/test/encode-plan-viewers.test.js +18 -19
  20. package/test/encode-plan.test.js +3 -4
  21. package/test/held-request-width.test.js +155 -154
  22. package/test/helpers/encode-run.js +136 -128
  23. package/test/orchestrator-wired.test.js +16 -11
  24. package/test/produced-copy-choice.test.js +358 -327
  25. package/test/run-intervals.test.js +100 -100
  26. package/test/segment-serve-wiring.test.js +76 -20
  27. package/test/segment-store.test.js +216 -187
  28. package/test/stale-request-after-seek.test.js +51 -77
  29. package/test/tracks-begin-together.test.js +176 -153
  30. package/test/viewer-outputs.test.js +278 -273
  31. package/test/behind-head-repair.test.js +0 -261
  32. /package/services/{contention.js → encode/contention.js} +0 -0
  33. /package/{test/decode-cost.test.js → test-measured/decode-cost.measured.js} +0 -0
  34. /package/{test/decode-measurement.test.js → test-measured/decode-measurement.measured.js} +0 -0
@@ -29,7 +29,7 @@ import { endOfRun } from "../encode/EncodeRun.js";
29
29
  import { ENCODE_EXIT } from "../encode/encode-exit.js";
30
30
  import { affordableRuns } from "../encode/run-budget.js";
31
31
  import { RunCosts } from "../encode/run-costs.js";
32
- import { contentionPenalty } from "../contention.js";
32
+ import { contentionPenalty } from "../encode/contention.js";
33
33
  import { SegmentDemand } from "../encode/SegmentDemand.js";
34
34
 
35
35
  export class EncodeOrchestrator {
@@ -55,6 +55,9 @@ export class EncodeOrchestrator {
55
55
  /** The last reason a budget was cut, so the same one is not said twice. */
56
56
  #lastBudgetReason = new Map();
57
57
 
58
+ /** The last unmet want said out loud, so a stuck one is said once. */
59
+ #lastUnmet = new Map();
60
+
58
61
  /**
59
62
  * @param {object} params
60
63
  * @param {(address: string) => number} params.maxRunsFor - How many encoders
@@ -65,7 +68,7 @@ export class EncodeOrchestrator {
65
68
  * for a stretch. What to read, what to map and how to cut belong to whoever
66
69
  * knows the source.
67
70
  * @param {number} params.segmentSeconds
68
- * @param {import("../contention.js").ContentionPenalties | null}
71
+ * @param {import("../encode/contention.js").ContentionPenalties | null}
69
72
  * [params.contentionPenalties] - How much slower one encoder runs beside
70
73
  * others, MEASURED on this host at startup and keyed by how many others
71
74
  * there are. Null until something has measured it, and then the penalty is
@@ -126,6 +129,73 @@ export class EncodeOrchestrator {
126
129
  return map;
127
130
  }
128
131
 
132
+ /**
133
+ * Put the map's picture of what is ready back in step with the disk.
134
+ *
135
+ * ONE AUTHORITY ON WHAT EXISTS, AND IT IS THE STORE. The map holds no memory
136
+ * of readiness between calls: it is handed the whole answer, replacing
137
+ * whatever it had, immediately before anything is decided from it. So a
138
+ * segment whose file was discarded with the run that had it open, dropped to
139
+ * make room, or reopened by a run restarting on it stops being ready in the
140
+ * same breath — without anything having to notice and say so.
141
+ *
142
+ * The map used to be filled from outside, by the session manager, with a
143
+ * method that only ever added. Nothing anywhere took a number back. Field
144
+ * 2026-09-07: the map claimed all 482 segments of a film while the directory
145
+ * held nothing, so every arrangement scored perfect, the only encoder was
146
+ * stopped as unnecessary and none was placed again — two sessions in a row
147
+ * with no picture at all.
148
+ *
149
+ * A store is optional here only in the sense that an authority which was not
150
+ * supplied cannot be consulted: without one the map keeps what it was told
151
+ * directly, which is how this class is exercised with plain numbers.
152
+ *
153
+ * @param {string} address
154
+ * @returns {CoverageMap}
155
+ */
156
+ #upToDateCoverage(address) {
157
+ const coverage = this.coverageOf(address);
158
+ if (this.segmentStore) {
159
+ coverage.setReady(this.segmentStore.provenNumbers(address));
160
+ }
161
+ return coverage;
162
+ }
163
+
164
+ /**
165
+ * Where a run started here must stop: the free stretch in front of it.
166
+ *
167
+ * Asked of the one map, brought up to date first. It used to be worked out by
168
+ * the session manager, which reached into this layer for the map and into the
169
+ * store for what was on the disk and put the two together itself — one fact
170
+ * with two owners and a third party carrying it between them, which is how the
171
+ * two came to disagree.
172
+ *
173
+ * @param {object} params
174
+ * @param {string} params.address
175
+ * @param {number} params.from - Where the run will start.
176
+ * @param {object | null} [params.exceptRun] - The run being replaced, whose
177
+ * own claim is not somebody else's material.
178
+ * @param {number} [params.segmentCount] - The output's length, when known.
179
+ * @returns {number} The last number to work through, or `-1` for the end of
180
+ * the film.
181
+ */
182
+ freeStretchEnd({ address, from, exceptRun = null, segmentCount = 0 }) {
183
+ if (!address) {
184
+ return -1;
185
+ }
186
+ if (segmentCount > 0) {
187
+ this.coverageOf(address).setSegmentCount(segmentCount);
188
+ }
189
+ const coverage = this.#upToDateCoverage(address);
190
+ const start = Math.max(0, from);
191
+ const free = coverage.freeRunFrom(start, exceptRun);
192
+ if (!Number.isFinite(free)) {
193
+ return -1;
194
+ }
195
+ const end = start + Math.max(1, free) - 1;
196
+ return segmentCount > 0 && end >= segmentCount - 1 ? -1 : end;
197
+ }
198
+
129
199
  /**
130
200
  * @param {string} address
131
201
  * @returns {import("../encode/EncodeRun.js").EncodeRun[]}
@@ -153,7 +223,9 @@ export class EncodeOrchestrator {
153
223
  * @param {Iterable<number>} indexes
154
224
  */
155
225
  noteAlreadyMade(address, indexes) {
156
- this.coverageOf(address).markReadyAll(indexes);
226
+ for (const index of indexes) {
227
+ this.noteProduced(address, index);
228
+ }
157
229
  }
158
230
 
159
231
  /**
@@ -200,6 +272,11 @@ export class EncodeOrchestrator {
200
272
  * @param {number} index
201
273
  */
202
274
  noteProduced(address, index) {
275
+ // TOLD TO THE AUTHORITY, not only to the map. A piece being closed is a fact
276
+ // about the disk, and the store is what holds those; told to the map alone
277
+ // it would survive exactly until the next time the map is brought back into
278
+ // step, and then be gone with no file to show for it.
279
+ this.segmentStore?.markClosed(address, index);
203
280
  this.coverageOf(address).markReady(index);
204
281
  for (const run of this.runsOn(address)) {
205
282
  run.noteProduced(index);
@@ -258,7 +335,10 @@ export class EncodeOrchestrator {
258
335
  * @param {string} address
259
336
  */
260
337
  #reconcileOne(address) {
261
- const coverage = this.coverageOf(address);
338
+ // WHAT EXISTS IS ASKED OF THE DISK, HERE, EVERY TIME. The plan is arithmetic
339
+ // over what is made, what is being made and what is wanted, and the first of
340
+ // those is not this layer's to remember.
341
+ const coverage = this.#upToDateCoverage(address);
262
342
  // A run that has ended and said nothing. One built here reports its own
263
343
  // ending and is released by `noteEnded`; one ADOPTED from elsewhere — a
264
344
  // session whose encoder stopped — has no such promise, and its claim would
@@ -290,6 +370,10 @@ export class EncodeOrchestrator {
290
370
  // lives where it belongs to nobody.
291
371
  const windows = this.demand.mapOn(address);
292
372
  const live = this.runsOn(address).filter((run) => run.isAlive);
373
+ // Asked ONCE. It is arithmetic over measurements, but it also says out loud
374
+ // when the reason it cuts the budget changes, so asking it three times in
375
+ // one pass is three chances to say a thing that happened once.
376
+ const maxRuns = this.#affordableOn(address, live);
293
377
  const actions = planEncoders({
294
378
  coverage,
295
379
  windows,
@@ -297,7 +381,7 @@ export class EncodeOrchestrator {
297
381
  // each; what it hands back names the run by BEING it, so nothing has to
298
382
  // invent a token to refer to one by.
299
383
  runs: live,
300
- maxRuns: this.#affordableOn(address, live),
384
+ maxRuns,
301
385
  segmentSeconds: this.segmentSeconds,
302
386
  // What a start and a kill cost, measured from this host's own runs rather
303
387
  // than written into the code from one machine's reading. Zero until
@@ -335,7 +419,7 @@ export class EncodeOrchestrator {
335
419
  if (actions.some((action) => action.type === "move")) {
336
420
  this.logger.info(
337
421
  `encode-plan move on ${address}: windows=${JSON.stringify(windows)} ` +
338
- `maxRuns=${this.#affordableOn(address, live)} ` +
422
+ `maxRuns=${maxRuns} ` +
339
423
  `live=${JSON.stringify(live.map((run) => ({ from: run.from, to: run.to, head: run.head, speedX: run.speedX })))}`
340
424
  );
341
425
  }
@@ -376,6 +460,35 @@ export class EncodeOrchestrator {
376
460
  // `-to` and simply stops when its head meets somebody else's claim.
377
461
  coverage.claim(action.run, action.from, endOfRun({ from: action.from, to: action.to }));
378
462
  }
463
+
464
+ // NOBODY IS MAKING WHAT SOMEBODY IS WAITING FOR. Said here, with the numbers
465
+ // the decision was taken from, because it is the one state in which a viewer
466
+ // waits for ever and every line above it reads as a healthy proxy.
467
+ //
468
+ // Field 2026-09-07, twice in one evening: the last word about an output was
469
+ // "the film is no worse off without it", and after it nothing — no run, no
470
+ // refusal, no answer to the browser's request for the header. The wait ended
471
+ // at the browser's own timeout with a message naming no cause, and the
472
+ // proxy's log named none either.
473
+ //
474
+ // SAID ONCE PER STATE, not once per pass. A stuck output is reconciled on
475
+ // every event that touches it, and a line repeated for as long as the state
476
+ // lasts is what buried the last one: 49 295 copies of `send queue stuck` in
477
+ // a 159 090-line file, 31 % of the log, all of one wedge.
478
+ const wanting = firstUnmetWant(coverage, windows);
479
+ const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
480
+ if (wanting === null || stillRunning.length > 0) {
481
+ this.#lastUnmet.delete(address);
482
+ } else if (this.#lastUnmet.get(address) !== wanting) {
483
+ this.#lastUnmet.set(address, wanting);
484
+ const held = this.segmentStore ? this.segmentStore.filesHeld(address) : -1;
485
+ this.logger.warn(
486
+ `encode: #${wanting} of ${address} is wanted and NO ENCODER IS MAKING IT — ` +
487
+ `ready=${coverage.stats().ready} of ${coverage.segmentCount} ` +
488
+ `files=${held < 0 ? "?" : held} maxRuns=${maxRuns} ` +
489
+ `windows=${JSON.stringify(windows)}`
490
+ );
491
+ }
379
492
  }
380
493
 
381
494
  /**
@@ -417,11 +530,17 @@ export class EncodeOrchestrator {
417
530
  const onThisOutput = this.#runs.get(address) ?? [];
418
531
  onThisOutput.push(run);
419
532
  this.#runs.set(address, onThisOutput);
420
- // This run rewrites everything from here on, so what was closed from here
421
- // on is no longer closed. Without this a number closed by an earlier run
533
+ // This run rewrites the stretch it was given, so what was closed inside that
534
+ // stretch is no longer closed. Without this a number closed by an earlier run
422
535
  // stays servable while a later one is halfway through writing it again.
423
- this.segmentStore?.forgetClosedFrom(address, from);
424
- this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
536
+ //
537
+ // Bounded by the run's own end, which is the same number the claim below
538
+ // carries. Unbounded it unproved the whole film beyond the start of any run,
539
+ // and readiness is now a projection of what is proven — so a one-segment run
540
+ // at the beginning would have declared the rest of the output unmade.
541
+ const runsTo = endOfRun({ from, to });
542
+ this.segmentStore?.forgetClosed(address, from, runsTo);
543
+ this.coverageOf(address).claim(run, from, runsTo);
425
544
  run.start(because);
426
545
  }
427
546
 
@@ -579,8 +698,21 @@ export class EncodeOrchestrator {
579
698
  .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from)
580
699
  .map((w) => `p${w.priority ?? 0}:#${w.from}..#${w.to}`)
581
700
  .join(" ");
701
+ // THE WHOLE ADDRESS. Cut to sixty characters, every output of one film
702
+ // printed the same string — the picture, its quality steps and each
703
+ // soundtrack are told apart only by the tail — so three lines of this
704
+ // could not be matched to the three things they describe. Read on
705
+ // 2026-09-07 while accounting for a session that produced nothing, and
706
+ // the accounting had to be done by which line carried a run.
707
+ //
708
+ // And WHAT THE DISK HOLDS beside what is proven closed. They are two
709
+ // different statements: files with nothing proving them closed reads as a
710
+ // reporting fault, no files at all reads as an output yet to be made, and
711
+ // the difference decides where to look.
712
+ const held = this.segmentStore ? this.segmentStore.filesHeld(address) : -1;
582
713
  parts.push(
583
- `${address.slice(0, 60)} ready=${coverage.stats().ready} ` +
714
+ `${address} ready=${coverage.stats().ready}` +
715
+ `${held < 0 ? "" : ` of ${held} file(s) on disk`} ` +
584
716
  `zones=[${zones}] runs=[${runs}] ` +
585
717
  `waiting=${waiting === null ? "nobody" : `#${waiting}`}`
586
718
  );