@torrent-tv/proxy 2.81.2 → 2.82.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.82.0
2
+
3
+ - **Fix**: THE ENCODER PING-PONGED BECAUSE NOTHING MEASURED WHAT A START COSTS. The plan compares when the wanted pieces appear under each arrangement, and for that it needs how long a fresh encoder takes to produce anything and how long killing one takes. Both were learned only from runs that had ENDED, so at a cold open both were zero — and zero does not read as “not measured”, it reads as “free”. Keeping a warming encoder then cost one piece and moving it cost `0 + 0 + one piece`: the same figure to the millisecond, so the tie fell to position and any advantage however small won. Field 2026-09-08: start at #68, a second later kill and start at #69, half a second later kill and start at #68 again, each dying having produced nothing. Over two days 153 runs were stopped that way and 68 of them made no segment at all.
4
+ **Both are measured at startup now, from one ffmpeg run**: spawn to the first piece the encoder itself announces closed, then SIGTERM to exit. 0.68 s and 0.02 s on the developer’s desktop. Readings from real runs replace them as they arrive. Worth stating because it is what the arithmetic already assumed: subtract keeping from moving and what is left is the killing plus the time the run has already lived — the warm-up a move throws away. That term exists in the formula and vanishes with the measurement, which is why the plan behaved as though a move were free.
5
+ - **Fix**: The decode cost and the contention penalty were asked only where the chosen encoder was SOFTWARE, and neither is about the encoder. A host with a GPU decodes in software just the same — no hardware decoder is asked for anywhere — and what a second job costs is a property of the machine. So a GPU host had no decode model, no contention penalty and no encoder throughput at all, and the quality offer, which is arithmetic over those three, had nothing to compute from.
6
+ - **New**: The throughput benchmark walks the CHOSEN encoder’s own speed ladder — libx264’s presets, NVENC’s `p1`…`p7`, QSV’s `veryfast`…`veryslow`, VAAPI’s quality levels. Each kind says how to measure itself (`Encoder.benchmarkArgs`), which is what the ladders declared and nothing ever ran. A kind with no ladder is measured once, which is still a reading where there was none.
7
+ - **Chore**: 1000 checks pass, biome clean. NOT yet seen in the field. What the next session must show: `a start costs …s to a first piece and a stop …s on this host` once at startup, `firstByte=` and `kill=` non-zero in the very first `encode-plan` line, and no run stopped with “scores worse than standing at” an adjacent number.
8
+
1
9
  ## 2.81.2
2
10
 
3
11
  - **Fix**: `disk: 0MB free` on a host with 103 GB. The free space was read from the segments’ own directory, which is made when the first session starts and removed when the proxy stops — so at every start, and after every clean exit, the reading failed and answered zero, which means “no room” to everything downstream. It reads the nearest ancestor that exists; the disk is the same disk either way.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.81.2",
3
+ "version": "2.82.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
package/server.js CHANGED
@@ -40,6 +40,7 @@ import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
40
40
  import { HlsSessionManager } from "./services/hls-session-manager.js";
41
41
  import { createPlaybackPlanner } from "./services/playback-planner.js";
42
42
  import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, benchmarkContention, benchmarkCopySpeed, detectTonemapSupport } from "./services/hwaccel.js";
43
+ import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
43
44
  import { logger } from "./utils/logger.js";
44
45
 
45
46
  const __filename = fileURLToPath(import.meta.url);
@@ -126,23 +127,31 @@ export async function startProxyServer({
126
127
  const videoEncoder = transcodeAudio
127
128
  ? await detectVideoEncoder({ ffmpegBin, logger })
128
129
  : null;
129
- // For software libx264, benchmark preset throughput once at startup so the
130
- // session manager can pick the highest-quality preset that still encodes each
131
- // stream faster than realtime. Hardware encoders use their own fixed preset.
132
- // The decode model first, and the preset benchmark secondthey are
133
- // independent now (the presets are timed on raw frames), but the order costs
134
- // nothing and keeps the two figures side by side in the log.
135
- const decodeCostModel = videoEncoder?.kind === "software"
130
+ // WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
131
+ // was gated on the chosen encoder being SOFTWARE, and two of the three are
132
+ // not about the encoder at all: a host with a GPU decodes in software just
133
+ // the same no hardware decoder is asked for anywhere and what a second
134
+ // job costs is a property of the machine. So a GPU host had no decode cost,
135
+ // no contention penalty and no encoder throughput, and the quality offer,
136
+ // which is arithmetic over those three, had nothing to compute from.
137
+ //
138
+ // The decode model first and the throughput second — they are independent
139
+ // (the rungs are timed on raw frames), but the order keeps the two figures
140
+ // side by side in the log.
141
+ const decodeCostModel = transcodeAudio
136
142
  ? await benchmarkDecodeCost({ ffmpegBin, logger })
137
143
  : null;
138
144
  // What a second job costs on this host. Measured because the budget adds
139
145
  // independent prices and this host says two jobs that each fit alone do not
140
146
  // fit together — 2.6× on the addon box (2026-08-18).
141
- const contentionPenalties = videoEncoder?.kind === "software"
147
+ const contentionPenalties = transcodeAudio
142
148
  ? await benchmarkContention({ ffmpegBin, logger })
143
149
  : null;
144
- const softwarePresetBenchmark = videoEncoder?.kind === "software"
145
- ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
150
+ // The chosen encoder walked over its OWN speed ladder: libx264's presets,
151
+ // NVENC's p1…p7, QSV's veryfast…veryslow, VAAPI's quality levels. A kind with
152
+ // no ladder is measured once, which is still a reading where there was none.
153
+ const softwarePresetBenchmark = videoEncoder
154
+ ? await benchmarkSoftwarePresets({ ffmpegBin, logger, encoder: videoEncoder })
146
155
  : null;
147
156
  // What this host does with a picture it does NOT re-encode. Every other
148
157
  // startup measurement prices encoding or decoding, and a copied picture does
@@ -155,6 +164,15 @@ export async function startProxyServer({
155
164
  const copySpeedX = transcodeAudio
156
165
  ? await benchmarkCopySpeed({ ffmpegBin, logger })
157
166
  : null;
167
+ // WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
168
+ // thing — leave an encoder where it stands, or kill it and start another —
169
+ // and both used to be learned only from runs that had ENDED, so at a cold
170
+ // open they were zero. Zero does not read as "not measured": it reads as
171
+ // "free", and a free move is always taken. Field 2026-09-08: an encoder moved
172
+ // between two adjacent numbers every half second and produced nothing.
173
+ const startStopCost = transcodeAudio
174
+ ? await measureStartAndStop({ ffmpegBin, encoder: videoEncoder, logger })
175
+ : null;
158
176
  // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
159
177
  // Detected once; the session manager applies the tonemap chain only for HDR
160
178
  // sources on the software path when available.
@@ -171,6 +189,7 @@ export async function startProxyServer({
171
189
  decodeCostModel,
172
190
  contentionPenalties,
173
191
  copySpeedX,
192
+ startStopCost,
174
193
  tonemapSupported,
175
194
  segmentFormatId: segmentFormat,
176
195
  stateDir,
@@ -178,15 +197,15 @@ export async function startProxyServer({
178
197
  // CPU-bound transcode from a download-starved input before downscaling.
179
198
  // What every torrent here has moved, so the proxy can price its own
180
199
  // downloading, hashing and delivery against the machine (roadmap item 7).
181
- // What the spilled pieces weigh on the torrent thread, and how to tell them
182
- // their share of the disk. The owner of the disk is on this side, where the
183
- // segments are; the pieces are on the other.
184
- spillDisk: typeof torrentPool.allowSpillBytes === "function"
185
- ? {
186
- held: () => torrentPool.spilledBytes ?? 0,
187
- allow: (bytes) => torrentPool.allowSpillBytes(bytes)
188
- }
189
- : null,
200
+ // What the spilled pieces weigh on the torrent thread, and how to tell them
201
+ // their share of the disk. The owner of the disk is on this side, where the
202
+ // segments are; the pieces are on the other.
203
+ spillDisk: typeof torrentPool.allowSpillBytes === "function"
204
+ ? {
205
+ held: () => torrentPool.spilledBytes ?? 0,
206
+ allow: (bytes) => torrentPool.allowSpillBytes(bytes)
207
+ }
208
+ : null,
190
209
  getTorrentTotals: async () => {
191
210
  if (typeof torrentPool.getTorrentTotals !== "function") {
192
211
  return null;
@@ -81,4 +81,31 @@ export class Encoder {
81
81
  buildVideoArgs(_options) {
82
82
  throw new Error(`${this.name} does not say how to build its video arguments.`);
83
83
  }
84
+
85
+ /**
86
+ * How to encode raw frames at one rung of this kind's speed ladder, for the
87
+ * startup benchmark and nothing else.
88
+ *
89
+ * SEPARATE FROM `buildVideoArgs` on purpose. That one produces a picture from
90
+ * a film: it scales, it tone-maps, it forces keyframes onto a grid, it caps a
91
+ * bitrate. The benchmark is fed raw frames of a known size and wants the
92
+ * encoder and the rung with nothing else in the way — otherwise the reading
93
+ * prices a scaler as though it were the encoder.
94
+ *
95
+ * WHY EVERY KIND MUST ANSWER IT. Until now the throughput benchmark existed
96
+ * only for libx264 and was gated on the chosen encoder being software, so a
97
+ * host with NVENC, QSV, VAAPI or V4L2M2M measured its encoder not at all —
98
+ * and the quality offer, which is arithmetic over pixels per second, had no
99
+ * pixels per second to work with. NVENC's own ladder is `p1`…`p7` and QSV's
100
+ * is `veryfast`…`veryslow`; both are declared here and neither was ever run.
101
+ *
102
+ * @param {string | null} rung - One value of {@link speedLadder}, or null
103
+ * where the kind has no ladder and there is one thing to measure.
104
+ * @returns {string[]}
105
+ */
106
+ benchmarkArgs(rung = null) {
107
+ const ladder = this.speedLadder;
108
+ const setting = ladder?.flag && rung ? [ladder.flag, rung] : [];
109
+ return ["-c:v", this.name, ...setting];
110
+ }
84
111
  }
@@ -35,6 +35,12 @@ export class QsvEncoder extends Encoder {
35
35
  };
36
36
  }
37
37
 
38
+ /** @param {string | null} rung @returns {string[]} */
39
+ benchmarkArgs(rung = null) {
40
+ const preset = rung ? ["-preset", rung] : [];
41
+ return ["-c:v", "h264_qsv", "-global_quality", "24", ...preset];
42
+ }
43
+
38
44
  buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
39
45
  const { w, h } = safeDimensions(targetWidth, targetHeight);
40
46
  return [
@@ -42,6 +42,11 @@ export class SoftwareEncoder extends Encoder {
42
42
  };
43
43
  }
44
44
 
45
+ /** @param {string | null} rung @returns {string[]} */
46
+ benchmarkArgs(rung = null) {
47
+ return ["-c:v", "libx264", "-preset", rung ?? SOFTWARE_PRESET, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p"];
48
+ }
49
+
45
50
  buildVideoArgs({
46
51
  targetWidth,
47
52
  targetHeight,
@@ -40,6 +40,14 @@ export class VaapiEncoder extends Encoder {
40
40
 
41
41
  // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
42
42
  // grid via time-based -force_key_frames, so it already honours source fps.
43
+ /** @param {string | null} rung @returns {string[]} */
44
+ benchmarkArgs(rung = null) {
45
+ // Raw frames live in this process; VAAPI encodes what is in the device, so
46
+ // the upload is part of what this kind costs and belongs in the reading.
47
+ const quality = rung ? ["-quality", rung] : [];
48
+ return ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...quality];
49
+ }
50
+
43
51
  buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
44
52
  const { w, h } = safeDimensions(targetWidth, targetHeight);
45
53
  return [
@@ -48,6 +48,40 @@ export class RunCosts {
48
48
  /** How long the first output took to appear, in milliseconds. @type {number[]} */
49
49
  #firstOutput = [];
50
50
 
51
+ /**
52
+ * What this host was measured to do at startup, before any viewer existed.
53
+ *
54
+ * WITHOUT IT BOTH FIGURES ARE ZERO AT A COLD OPEN, and zero does not read as
55
+ * "not measured" — it reads as "free". The whole comparison the plan makes is
56
+ * between leaving an encoder where it stands, which costs the remainder of its
57
+ * warm-up, and moving it, which costs the killing plus a warm-up from the
58
+ * beginning. Subtract one from the other and what is left is the killing plus
59
+ * the time the run has already lived — the warm-up a move throws away. Set the
60
+ * warm-up to zero and that difference collapses to zero as well: keeping and
61
+ * moving cost exactly the same, the tie falls to position, and any advantage
62
+ * however small wins. Field 2026-09-08: an encoder moved between two adjacent
63
+ * numbers every half second, produced nothing, and was killed each time.
64
+ *
65
+ * Readings from real runs replace it as they arrive; this is where the plan
66
+ * starts from, not where it stays.
67
+ *
68
+ * @type {{ firstByteWaitSec: number, killCostSec: number } | null}
69
+ */
70
+ #atStartup = null;
71
+
72
+ /**
73
+ * Take what the startup measurement found on this host.
74
+ *
75
+ * @param {{ firstByteWaitSec: number, killCostSec: number } | null} measured
76
+ * @returns {void}
77
+ */
78
+ noteStartup(measured) {
79
+ this.#atStartup =
80
+ Number.isFinite(measured?.firstByteWaitSec) && measured.firstByteWaitSec > 0
81
+ ? { firstByteWaitSec: measured.firstByteWaitSec, killCostSec: Math.max(0, measured.killCostSec ?? 0) }
82
+ : null;
83
+ }
84
+
51
85
  /**
52
86
  * Take the two readings a finished run carries. Either may be absent — a run
53
87
  * that was never told to stop did not die on command, and one that produced
@@ -112,8 +146,9 @@ export class RunCosts {
112
146
  // piece's encoding. Whoever uses it separates the two, because the piece
113
147
  // costs more when encoders share the machine and the spawn does not.
114
148
  return {
115
- killCostSec: (middleOf(this.#dying) ?? 0) / 1000,
116
- firstByteWaitSec: (middleOf(this.#firstOutput) ?? 0) / 1000,
149
+ killCostSec: (middleOf(this.#dying) ?? 0) / 1000 || (this.#atStartup?.killCostSec ?? 0),
150
+ firstByteWaitSec:
151
+ (middleOf(this.#firstOutput) ?? 0) / 1000 || (this.#atStartup?.firstByteWaitSec ?? 0),
117
152
  samples: Math.min(this.#dying.length, this.#firstOutput.length)
118
153
  };
119
154
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @file What starting and stopping an encoder costs on THIS host, measured
3
+ * before any viewer exists.
4
+ *
5
+ * WHY IT HAS TO BE MEASURED AT STARTUP. Both figures decide one thing: whether
6
+ * to leave an encoder where it stands or kill it and start another elsewhere.
7
+ * The plan compares when the wanted pieces appear under each arrangement, and
8
+ * for that it needs how long a fresh encoder takes to produce anything and how
9
+ * long killing one takes.
10
+ *
11
+ * Until now both were learned only from runs that had ENDED, so at a cold open
12
+ * they were zero — and zero does not read as "not measured", it reads as
13
+ * "free". A warming encoder then owed one piece and a moved one owed
14
+ * `0 + 0 + one piece`: the same figure to the millisecond. The tie fell to
15
+ * position, so any advantage however small won, and the plan moved the encoder
16
+ * on every pass. Field 2026-09-08, the first fifteen seconds of a session:
17
+ * start at #68, a second later kill and start at #69, half a second later kill
18
+ * and start at #68 again, each dying having produced nothing. Over two days,
19
+ * 153 runs stopped that way and 68 of them made no segment at all.
20
+ *
21
+ * ONE RUN GIVES BOTH READINGS. An encoder is started on a generated picture
22
+ * through the same pipeline a session uses, timed until it says it has closed
23
+ * its first piece, then killed and timed until it exits. Nothing about the
24
+ * measurement is chosen: it is the same encoder, the same muxer, the same
25
+ * channel the encoder announces finished pieces on.
26
+ */
27
+
28
+ import { spawn } from "node:child_process";
29
+ import { mkdtempSync, rmSync } from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+
33
+ /**
34
+ * How long the measurement may take before it is abandoned.
35
+ *
36
+ * Not a property of the host and not a figure anything is derived from: it
37
+ * bounds a startup step so a machine that cannot produce a piece at all does not
38
+ * hold the proxy closed. A host that hits it has said something useful — that
39
+ * its first piece takes longer than this — and the plan is told the bound rather
40
+ * than a zero.
41
+ */
42
+ const GIVE_UP_AFTER_MS = 30_000;
43
+
44
+ /**
45
+ * Measure a start and a stop on this host.
46
+ *
47
+ * @param {object} params
48
+ * @param {string} params.ffmpegBin
49
+ * @param {import("./Encoder.js").Encoder} params.encoder - The encoder this
50
+ * proxy has chosen, so the reading is of the thing that will actually run.
51
+ * @param {number} [params.segmentDurationSec]
52
+ * @param {{ info: Function, warn: Function }} [params.logger]
53
+ * @param {() => number} [params.now]
54
+ * @returns {Promise<{ firstByteWaitSec: number, killCostSec: number } | null>}
55
+ * Null where nothing could be measured, which is said rather than passed off
56
+ * as a zero.
57
+ */
58
+ export async function measureStartAndStop({
59
+ ffmpegBin,
60
+ encoder,
61
+ segmentDurationSec = 4,
62
+ logger = null,
63
+ now = Date.now
64
+ }) {
65
+ const log = logger ?? { info: () => {}, warn: () => {} };
66
+ const dir = mkdtempSync(path.join(os.tmpdir(), "tt-startstop-"));
67
+ try {
68
+ const args = [
69
+ "-hide_banner",
70
+ "-nostats",
71
+ "-loglevel",
72
+ "error",
73
+ // A generated picture: the reading is of this host's encoder and muxer,
74
+ // and a file would add its own reading and its own download.
75
+ "-f",
76
+ "lavfi",
77
+ "-i",
78
+ `testsrc2=size=640x360:rate=25`,
79
+ "-t",
80
+ String(segmentDurationSec * 4),
81
+ // The encoder this proxy has chosen, asked for its own arguments: the
82
+ // reading must be of the thing that will actually run, since what a start
83
+ // costs is mostly the encoder opening.
84
+ ...(typeof encoder?.buildVideoArgs === "function"
85
+ ? encoder.buildVideoArgs({ targetWidth: 640, targetHeight: 360, segmentDurationSec, fps: 25 })
86
+ : ["-c:v", "libx264", "-preset", "ultrafast"]),
87
+ "-an",
88
+ "-f",
89
+ "segment",
90
+ "-segment_time",
91
+ String(segmentDurationSec),
92
+ // The channel the encoder names its finished pieces on — the same one a
93
+ // session reads, so "the first piece exists" means here what it means
94
+ // there.
95
+ "-segment_list",
96
+ "pipe:3",
97
+ "-segment_list_flags",
98
+ "+live",
99
+ "-segment_format",
100
+ "mp4",
101
+ path.join(dir, "seg-%05d.mp4")
102
+ ];
103
+
104
+ const spawnedAt = now();
105
+ const child = spawn(ffmpegBin, args, {
106
+ stdio: ["ignore", "ignore", "pipe", "pipe"],
107
+ windowsHide: true
108
+ });
109
+
110
+ const firstPiece = await new Promise((resolve) => {
111
+ const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
112
+ timer.unref?.();
113
+ child.stdio?.[3]?.on("data", () => {
114
+ clearTimeout(timer);
115
+ resolve(now() - spawnedAt);
116
+ });
117
+ child.on("error", () => {
118
+ clearTimeout(timer);
119
+ resolve(null);
120
+ });
121
+ child.on("exit", () => {
122
+ clearTimeout(timer);
123
+ resolve(null);
124
+ });
125
+ });
126
+
127
+ if (firstPiece === null) {
128
+ try {
129
+ child.kill("SIGKILL");
130
+ } catch {
131
+ // Already gone.
132
+ }
133
+ log.warn("hwaccel: a start could not be measured; the plan is told so rather than told zero");
134
+ return null;
135
+ }
136
+
137
+ const killedAt = now();
138
+ const died = await new Promise((resolve) => {
139
+ const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
140
+ timer.unref?.();
141
+ child.on("exit", () => {
142
+ clearTimeout(timer);
143
+ resolve(now() - killedAt);
144
+ });
145
+ try {
146
+ child.kill("SIGTERM");
147
+ } catch {
148
+ clearTimeout(timer);
149
+ resolve(null);
150
+ }
151
+ });
152
+
153
+ const firstByteWaitSec = firstPiece / 1000;
154
+ const killCostSec = died === null ? 0 : died / 1000;
155
+ log.info(
156
+ `hwaccel: a start costs ${firstByteWaitSec.toFixed(2)}s to a first piece and ` +
157
+ `a stop ${killCostSec.toFixed(2)}s on this host — measured before any viewer, ` +
158
+ "because a plan told zero reads it as free and moves an encoder for nothing"
159
+ );
160
+ return { firstByteWaitSec, killCostSec };
161
+ } catch (error) {
162
+ log.warn(
163
+ `hwaccel: a start and a stop could not be measured: ` +
164
+ `${error instanceof Error ? error.message : String(error)}`
165
+ );
166
+ return null;
167
+ } finally {
168
+ try {
169
+ rmSync(dir, { recursive: true, force: true });
170
+ } catch {
171
+ // best effort
172
+ }
173
+ }
174
+ }
@@ -1399,6 +1399,7 @@ export class HlsSessionManager {
1399
1399
  stateDir = "",
1400
1400
  segmentStore = null,
1401
1401
  getTorrentTotals,
1402
+ startStopCost = null,
1402
1403
  spillDisk = null}) {
1403
1404
  this.enabled = Boolean(enabled);
1404
1405
  this.ffmpegBin = ffmpegBin;
@@ -1576,6 +1577,11 @@ export class HlsSessionManager {
1576
1577
  segmentStore: this.segmentStore,
1577
1578
  logger
1578
1579
  });
1580
+ // What a start and a stop were measured to cost here, before any viewer
1581
+ // existed. Without it both read zero at a cold open, and zero is not
1582
+ // "unmeasured" — it is "free", which is what moved an encoder between two
1583
+ // adjacent numbers every half second in the field.
1584
+ this.encodeOrchestrator.noteStartupCosts(startStopCost);
1579
1585
  // Where each file is cut, held once per file and grid rather than once per
1580
1586
  // session. Two sessions of one film MUST agree about this to the
1581
1587
  // millisecond — a segment made by either has to be appendable where the