@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
@@ -21,7 +21,7 @@ import { ContainerFactory } from "./container/ContainerFactory.js";
21
21
  import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
22
22
  import { speedFromReadings } from "./encoder-readings.js";
23
23
  import { availableShareFrom } from "./available-share.js";
24
- import { contentionPenalty } from "./contention.js";
24
+ import { contentionPenalty } from "./encode/contention.js";
25
25
  import { minimumBufferFrom } from "./supply-margin.js";
26
26
  import { PriorityOrchestrator } from "./priority/PriorityOrchestrator.js";
27
27
  import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
@@ -468,21 +468,16 @@ const SEEK_SETTLE_MS = 300;
468
468
  // has waited: repetition is the player saying it still needs this exact
469
469
  // segment, while a delay only says time has passed. The floor below stays as a
470
470
  // last guard against acting on a single stray poll.
471
- const BEHIND_HEAD_REPAIR_MIN_ASKS = 2;
472
471
  // More distinct indices than this behind the head at once is the player
473
472
  // scanning the playlist rather than waiting for a frame.
474
- const BEHIND_HEAD_SCAN_INDICES = 3;
475
473
  // The window the count above is taken over. A player's scan lands inside half a
476
474
  // second (field log 2026-08-02); a viewer waiting asks every few seconds.
477
- const BEHIND_HEAD_SCAN_WINDOW_MS = 2_000;
478
- const BEHIND_HEAD_REPAIR_MS = 400;
479
475
  // How far behind the run a request may be and still be treated as the encoder
480
476
  // standing in the wrong place rather than as a player scanning the playlist. A
481
477
  // misplaced run is out by at most the buffer the player was holding — measured
482
478
  // 2026-08-11 at 14 segments — while a scan probe is out by anything at all.
483
479
  // Generous against that measurement, and far short of the hundreds of segments
484
480
  // a scan reaches.
485
- const BEHIND_HEAD_REPAIR_MAX_SEGMENTS = 60;
486
481
  // How far the accounting of a backward restart looks for work about to be done
487
482
  // twice. It runs on the restart path and a session an hour in has thousands of
488
483
  // segments; the figure is for a comparison, not an inventory.
@@ -3798,10 +3793,7 @@ export class HlsSessionManager {
3798
3793
  }
3799
3794
  byOutput.set(address, [...(byOutput.get(address) ?? []), session]);
3800
3795
  }
3801
- const staleAfterMs = this.presenceStaleAfterMs();
3802
- const now = Date.now();
3803
3796
  for (const [address, sessions] of byOutput) {
3804
- const coverage = this.encodeOrchestrator.coverageOf(address);
3805
3797
  // From the TIMELINE, which is where how a file is cut has lived since
3806
3798
  // 2.76.0. Read off the session it left, this was `undefined` on every
3807
3799
  // session ever made: the map then held no length, and the walk that
@@ -3810,9 +3802,8 @@ export class HlsSessionManager {
3810
3802
  // 2026-09-05 with the stack read out of the live process.
3811
3803
  const segmentCount = Number(sessions[0].timeline?.segmentCount) || 0;
3812
3804
  if (segmentCount > 0) {
3813
- coverage.setSegmentCount(segmentCount);
3805
+ this.encodeOrchestrator.setSegmentCount(address, segmentCount);
3814
3806
  }
3815
- coverage.markReadyAll(this.segmentStore.provenNumbers(address));
3816
3807
  for (const session of sessions) {
3817
3808
  for (const run of liveRunsOf(session)) {
3818
3809
  this.encodeOrchestrator.adopt(address, run);
@@ -5418,22 +5409,12 @@ export class HlsSessionManager {
5418
5409
  * @returns {number} The last number to work through, or `-1` for the end.
5419
5410
  */
5420
5411
  #runEndFrom(session, startIndex, exceptRun = null) {
5421
- const key = session.outputKey ?? "";
5422
- if (!key) {
5423
- return -1;
5424
- }
5425
- const coverage = this.encodeOrchestrator.coverageOf(key);
5426
- const segmentCount = Number(session.timeline?.segmentCount) || 0;
5427
- if (segmentCount > 0) {
5428
- coverage.setSegmentCount(segmentCount);
5429
- }
5430
- coverage.markReadyAll(this.segmentStore.provenNumbers(key));
5431
- const free = coverage.freeRunFrom(Math.max(0, startIndex), exceptRun);
5432
- if (!Number.isFinite(free)) {
5433
- return -1;
5434
- }
5435
- const end = Math.max(0, startIndex) + Math.max(1, free) - 1;
5436
- return segmentCount > 0 && end >= segmentCount - 1 ? -1 : end;
5412
+ return this.encodeOrchestrator.freeStretchEnd({
5413
+ address: session.outputKey ?? "",
5414
+ from: startIndex,
5415
+ exceptRun,
5416
+ segmentCount: Number(session.timeline?.segmentCount) || 0
5417
+ });
5437
5418
  }
5438
5419
 
5439
5420
  // Returns the encoder it built, or nothing when there was nothing to build.
@@ -5497,7 +5478,6 @@ export class HlsSessionManager {
5497
5478
  // for the seek that should move the encoder. It also stops the map growing
5498
5479
  // for the life of a session.
5499
5480
  session.firstWantedAt = new Map();
5500
- session.behindHeadAsks = new Map();
5501
5481
  // This attempt, so that a newer one can tell it has been overtaken. It used
5502
5482
  // to be a counter compared against a copy of itself; the attempt is a thing,
5503
5483
  // and comparing the thing says the same without a number to keep in step.
@@ -5960,16 +5940,6 @@ export class HlsSessionManager {
5960
5940
  if (!session.firstWantedAt.has(index)) {
5961
5941
  session.firstWantedAt.set(index, Date.now());
5962
5942
  }
5963
- // How often each index behind the run has been asked for, and how many
5964
- // distinct ones there are. The repair reads both: one index asked twice is
5965
- // a viewer waiting, a dozen asked once each is the player scanning. Kept
5966
- // only for what is behind the head — everything ahead is ordinary
5967
- // read-ahead — and cleared with each run, like the record above.
5968
- if (index < (earliestRunStart(session) ?? 0)) {
5969
- session.behindHeadAsks ??= new Map();
5970
- const asked = session.behindHeadAsks.get(index);
5971
- session.behindHeadAsks.set(index, { count: (asked?.count ?? 0) + 1, at: Date.now() });
5972
- }
5973
5943
  if (!session || session.state === "disposed" || index < 0) {
5974
5944
  return;
5975
5945
  }
@@ -6015,7 +5985,17 @@ export class HlsSessionManager {
6015
5985
  // should move the encoder. Only a request still unanswerable after that is
6016
5986
  // repaired here.
6017
5987
  if (index < head) {
6018
- this.#repairBehindHead(session, index, head);
5988
+ // SAID, NOT ACTED ON. What is missing in front of a viewer is stated by
5989
+ // the priority map, and putting encoders on it is the plan's work. This
5990
+ // used to move the encoder itself, from a segment REQUEST — a second
5991
+ // authority over where encoders go, with six chosen constants of its own,
5992
+ // and it survived the pass that removed the other two because it lives in
5993
+ // the path that answers a file rather than in the plan.
5994
+ this.#explainHold(
5995
+ session,
5996
+ session.segmentFormat.segmentFileName(index),
5997
+ `it is behind the run (#${head}); where the viewers are is what places encoders`
5998
+ );
6019
5999
  return;
6020
6000
  }
6021
6001
  // Circuit breaker: this exact target has already failed MAX_FAILED_STARTS
@@ -6049,120 +6029,6 @@ export class HlsSessionManager {
6049
6029
  // See research/hls-seek-prior-art-2026-08-02.md.
6050
6030
  }
6051
6031
 
6052
- /**
6053
- * Move the encoder back to a segment it can no longer produce.
6054
- *
6055
- * A run only ever goes forward from where it began, so a request below that
6056
- * point is not a claim the run may yet reach — it is a hole, and holding it
6057
- * holds it for ever. Measured 2026-08-11: a run placed at #770 while the
6058
- * player needed #757 held that request for two minutes forty-one, producing
6059
- * 409 s of video nobody had asked for.
6060
- *
6061
- * Deliberately narrow, because moving the encoder from a segment REQUEST is
6062
- * exactly what this codebase removed once already: a player that cannot get
6063
- * what it wants scans the playlist, and those probes are scattered across the
6064
- * whole file (field log: #178, #681, #725, #807, #74, #245, #387 within half
6065
- * a second). Steering on the lowest of them put the encoder at the start of
6066
- * the film and left the viewer's own requests unreachable ahead of it.
6067
- *
6068
- * What separates the two: a run placed wrongly is out by at most the buffer
6069
- * the player had — 14 segments in the measured case — while a scan probe is
6070
- * out by anything at all. So only a request within reach behind the head is
6071
- * repaired; the rest are what they always were, claims that answer 503.
6072
- *
6073
- * @param {HlsSession} session
6074
- * @param {number} index - The segment being held.
6075
- * @param {number} head - Where the current run begins.
6076
- * @returns {void}
6077
- */
6078
- #repairBehindHead(session, index, head) {
6079
- if (head - index > BEHIND_HEAD_REPAIR_MAX_SEGMENTS) {
6080
- return;
6081
- }
6082
- // Nothing is encoding: a rung the viewer has switched away from is left
6083
- // exactly so, and its held requests must not bring its encoder back.
6084
- if (!processCanBeSignalled(runStateOf(session))) {
6085
- return;
6086
- }
6087
- // A seek already settling is about to move the encoder to where the VIEWER
6088
- // said they are. That statement outranks anything inferred here.
6089
- if (session.seekSettleTimer != null) {
6090
- return;
6091
- }
6092
- // And it outranks it AFTERWARDS too, which is what was missing. The guard
6093
- // above only holds while the settle timer is armed — a second later it is
6094
- // gone, and a request the browser issued BEFORE the seek is then treated as
6095
- // fresh evidence. Field 2026-08-17: a seek to 2083.4 s put both runs at
6096
- // #373, a request for #371 from before it arrived a second afterwards, and
6097
- // this repair moved the encoder to #370 — three segments behind the viewer,
6098
- // who waited for it to come back. A request BEHIND what the viewer
6099
- // themselves reported cannot be describing where they are.
6100
- const reportedSeconds = Number(session.viewerReportedSeconds);
6101
- if (Number.isFinite(reportedSeconds)) {
6102
- const reportedIndex = this.#segmentIndexForTime(session, reportedSeconds);
6103
- if (index < reportedIndex) {
6104
- this.#explainHold(
6105
- session,
6106
- session.segmentFormat.segmentFileName(index),
6107
- `it is behind #${reportedIndex}, where the viewer said they are — answered, not obeyed`
6108
- );
6109
- return;
6110
- }
6111
- }
6112
- // What separates a request the viewer is waiting for from the player
6113
- // scanning the playlist is not TIME but what else it is asking for. On a
6114
- // seek hls.js fires dozens of DIFFERENT indices within half a second (field
6115
- // log: #178, #681, #725, #807, #74, #245, #387) and abandons them all; a
6116
- // viewer waiting for audio asks for the SAME one, over and over, because it
6117
- // is the only thing that will let playback continue.
6118
- //
6119
- // So: this index has been asked for at least twice, and it is the only
6120
- // thing behind the head being asked for. Both are facts about the traffic,
6121
- // available at once, where a delay is a guess about it — and it was three
6122
- // seconds of the twenty a track change cost on 2026-08-15.
6123
- const asked = session.behindHeadAsks?.get(index)?.count ?? 0;
6124
- if (asked < BEHIND_HEAD_REPAIR_MIN_ASKS) {
6125
- return;
6126
- }
6127
- // Counted over a WINDOW, not over the run: a scan is many indices at once,
6128
- // while the same map left to accumulate would eventually hold every
6129
- // behind-head request a long run ever saw and switch the repair off for
6130
- // good.
6131
- const scanSince = Date.now() - BEHIND_HEAD_SCAN_WINDOW_MS;
6132
- let distinctBehind = 0;
6133
- for (const record of session.behindHeadAsks?.values() ?? []) {
6134
- if (record.at >= scanSince) {
6135
- distinctBehind += 1;
6136
- }
6137
- }
6138
- if (distinctBehind > BEHIND_HEAD_SCAN_INDICES) {
6139
- // A scan, not a wait. Moving the encoder to one of these is moving it to
6140
- // a number the player picked at random.
6141
- return;
6142
- }
6143
- const wantedAt = session.firstWantedAt?.get(index);
6144
- if (!Number.isFinite(wantedAt) || Date.now() - wantedAt < BEHIND_HEAD_REPAIR_MS) {
6145
- return;
6146
- }
6147
- const target = Math.max(0, index - SEEK_BACKOFF_SEGMENTS);
6148
- // The breaker has already refused this target repeatedly. Re-arming for it
6149
- // would log and re-arm on every poll for as long as the request is held,
6150
- // and move nothing.
6151
- if (target === session.failedStartAt && session.failedStartCount >= MAX_FAILED_STARTS) {
6152
- return;
6153
- }
6154
- logger.warn(
6155
- `transcode ${session.id} segment #${index} is behind the run (#${head}) and has waited ` +
6156
- `${Date.now() - wantedAt}ms — nothing this run does can produce it; moving the encoder there`
6157
- );
6158
- // Through the same settle a viewer's own seek goes through, so a burst of
6159
- // behind-head requests produces one restart and not one each.
6160
- session.seekTarget = target;
6161
- session.seekFirstFarAt = Date.now();
6162
- session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), SEEK_SETTLE_MS);
6163
- session.seekSettleTimer.unref?.();
6164
- }
6165
-
6166
6032
  /**
6167
6033
  * Fire a settled server-side seek: restart the encoder once at the target
6168
6034
  * recorded during the settle window. Enforces the restart cooldown as a
@@ -8057,8 +7923,13 @@ export class HlsSessionManager {
8057
7923
  // made — so the piece this one had open must not be left looking like one
8058
7924
  // of them. Not awaited: the caller's own work does not depend on it, and
8059
7925
  // the wait is for a process that has already been told to go.
7926
+ // WHAT THE RUN ITSELF NAMED, which is the only thing that can say a piece
7927
+ // is whole — a piece cut short still decodes. Written 2026-09-06 and never
7928
+ // passed from here, so the clearing-up ran with nothing proven and fell
7929
+ // back to bounding itself by the run's declared stretch alone.
7930
+ const provenName = typeof run.provenName === "string" ? run.provenName : null;
8060
7931
  void waitForChildExit(ffmpeg, ENCODE_RUN_TERMINATE_GRACE_MS).then(() =>
8061
- this.#discardUnfinishedPiece(session, session.dirPath, stoppedSpan)
7932
+ this.#discardUnfinishedPiece(session, session.dirPath, stoppedSpan, provenName)
8062
7933
  );
8063
7934
  }
8064
7935
  logger.info(`transcode ${session.id} ${running.length} encoder(s) stopped: ${reason}`);
@@ -9952,7 +9823,7 @@ export class HlsSessionManager {
9952
9823
  * @param {string | null | undefined} runDirPath
9953
9824
  * @returns {Promise<void>}
9954
9825
  */
9955
- async #discardUnfinishedPiece(session, runDirPath, within = null) {
9826
+ async #discardUnfinishedPiece(session, runDirPath, within = null, provenName = null) {
9956
9827
  const canJudgeTracks =
9957
9828
  typeof session.segmentFormat?.hasEveryTrack === "function" &&
9958
9829
  session.initBytes &&
@@ -9968,7 +9839,8 @@ export class HlsSessionManager {
9968
9839
  : raw;
9969
9840
  return session.segmentFormat.hasEveryTrack(bytes, session.initBytes);
9970
9841
  }
9971
- : null
9842
+ : null,
9843
+ provenName
9972
9844
  );
9973
9845
  if (removed !== null) {
9974
9846
  // Removed on purpose, so the index must not go on answering with it.
@@ -10003,21 +9875,31 @@ export class HlsSessionManager {
10003
9875
  // failed did the player move to the segment it actually needed — 63 s of
10004
9876
  // spinner after a track that had been made ready in 7.
10005
9877
  //
10006
- // Narrow on purpose. A request behind the head is USUALLY temporary: the
10007
- // repair moves the encoder back for anything within its reach, and a
10008
- // reported seek is about to move it anyway. Refusing those was 2.14.1,
10009
- // and it left a viewer retrying a 404 for ever.
9878
+ // WHETHER ANYBODY IS COMING FOR IT, asked of the priority map rather than
9879
+ // measured in segments. The distance used to decide, against the reach of
9880
+ // a repair that moved the encoder from a request both are gone. A
9881
+ // number inside somebody's zone will be made, so holding it is holding it
9882
+ // for a viewer; a number in nobody's zone will not, and holding it spends
9883
+ // the player's patience for nothing.
9884
+ // AN EMPTY MAP IS NOT AN ANSWER. It says the map has not been built yet —
9885
+ // a session created a moment ago, before the first pass — and that is a
9886
+ // different thing from "nobody is coming", which is what this refusal
9887
+ // needs. Told apart, because conflating them answers every behind-head
9888
+ // request as absent for as long as a fresh session has no map.
9889
+ const zones = this.encodeOrchestrator.demand.mapOn(session.outputKey ?? "");
9890
+ const nobodyIsComing = zones.length > 0 &&
9891
+ !zones.some((zone) => requestedIndex >= zone.from && requestedIndex <= zone.to);
10010
9892
  if (
10011
9893
  Number.isFinite(requestedIndex) &&
10012
9894
  requestedIndex < (earliestRunStart(session) ?? 0) &&
10013
- (earliestRunStart(session) ?? 0) - requestedIndex > BEHIND_HEAD_REPAIR_MAX_SEGMENTS &&
9895
+ nobodyIsComing &&
10014
9896
  liveRunsOf(session).length > 0 &&
10015
9897
  session.seekTarget == null &&
10016
9898
  session.seekSettleTimer == null
10017
9899
  ) {
10018
9900
  logger.info(
10019
9901
  `transcode ${session.id} segment #${requestedIndex} is ${(earliestRunStart(session) ?? 0) - requestedIndex} ` +
10020
- `segments behind the run and beyond the repair's reach; answered as absent rather than held`
9902
+ "segments behind the run and in nobody's zone; answered as absent rather than held"
10021
9903
  );
10022
9904
  return { kind: "not-found" };
10023
9905
  }
@@ -21,12 +21,12 @@
21
21
  */
22
22
 
23
23
  import { spawn } from "node:child_process";
24
- import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
24
+ import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
25
25
  import { mkdtemp, readFile, rm } from "node:fs/promises";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
28
28
  import { fitDecodeCost } from "./decode-cost-fit.js";
29
- import { penaltiesFrom } from "./contention.js";
29
+ import { penaltiesFrom } from "./encode/contention.js";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import {
32
32
  parseFfmpegBitrateKbps,