@torrent-tv/proxy 2.81.2 → 2.83.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 +17 -0
- package/CLAUDE.md +11 -0
- package/docs/disk-architecture.md +161 -0
- package/docs/encode-architecture.md +36 -7
- package/package.json +1 -1
- package/server.js +38 -19
- package/services/disk/keep.js +48 -0
- package/services/disk/returns.js +103 -0
- package/services/encode/Encoder.js +42 -0
- package/services/encode/QsvEncoder.js +11 -0
- package/services/encode/SegmentStore.js +14 -0
- package/services/encode/SoftwareEncoder.js +5 -0
- package/services/encode/VaapiEncoder.js +13 -0
- package/services/encode/run-costs.js +37 -2
- package/services/encode/start-stop-cost.js +178 -0
- package/services/hls-session-manager.js +23 -1
- package/services/hwaccel.js +1854 -1843
- package/services/orchestrators/EncodeOrchestrator.js +11 -0
- package/services/piece-store/piece-disk-store.js +71 -6
- package/services/piece-store/piece-lru.js +17 -0
- package/services/piece-store/shared-piece-store.js +1556 -1549
- package/services/torrent-pool.js +8 -7
- package/test/keeping-period.test.js +83 -0
- package/test/piece-disk-store.test.js +88 -0
- package/test/startup-readings.test.js +119 -0
|
@@ -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:
|
|
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,178 @@
|
|
|
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
|
+
// The device, where the kind takes one.
|
|
74
|
+
...(typeof encoder?.benchmarkInputArgs === "function" ? encoder.benchmarkInputArgs() : []),
|
|
75
|
+
// A generated picture: the reading is of this host's encoder and muxer,
|
|
76
|
+
// and a file would add its own reading and its own download.
|
|
77
|
+
"-f",
|
|
78
|
+
"lavfi",
|
|
79
|
+
"-i",
|
|
80
|
+
`testsrc2=size=640x360:rate=25`,
|
|
81
|
+
"-t",
|
|
82
|
+
String(segmentDurationSec * 4),
|
|
83
|
+
// The encoder this proxy has chosen, asked for its own arguments: the
|
|
84
|
+
// reading must be of the thing that will actually run, since what a start
|
|
85
|
+
// costs is mostly the encoder opening.
|
|
86
|
+
// The same arguments the throughput benchmark uses, for the same reason:
|
|
87
|
+
// a start is timed on the encoder itself, not on a scaler in front of it.
|
|
88
|
+
...(typeof encoder?.benchmarkArgs === "function"
|
|
89
|
+
? encoder.benchmarkArgs(null)
|
|
90
|
+
: ["-c:v", "libx264", "-preset", "ultrafast"]),
|
|
91
|
+
"-an",
|
|
92
|
+
"-f",
|
|
93
|
+
"segment",
|
|
94
|
+
"-segment_time",
|
|
95
|
+
String(segmentDurationSec),
|
|
96
|
+
// The channel the encoder names its finished pieces on — the same one a
|
|
97
|
+
// session reads, so "the first piece exists" means here what it means
|
|
98
|
+
// there.
|
|
99
|
+
"-segment_list",
|
|
100
|
+
"pipe:3",
|
|
101
|
+
"-segment_list_flags",
|
|
102
|
+
"+live",
|
|
103
|
+
"-segment_format",
|
|
104
|
+
"mp4",
|
|
105
|
+
path.join(dir, "seg-%05d.mp4")
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const spawnedAt = now();
|
|
109
|
+
const child = spawn(ffmpegBin, args, {
|
|
110
|
+
stdio: ["ignore", "ignore", "pipe", "pipe"],
|
|
111
|
+
windowsHide: true
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const firstPiece = await new Promise((resolve) => {
|
|
115
|
+
const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
|
|
116
|
+
timer.unref?.();
|
|
117
|
+
child.stdio?.[3]?.on("data", () => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve(now() - spawnedAt);
|
|
120
|
+
});
|
|
121
|
+
child.on("error", () => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
resolve(null);
|
|
124
|
+
});
|
|
125
|
+
child.on("exit", () => {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
resolve(null);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (firstPiece === null) {
|
|
132
|
+
try {
|
|
133
|
+
child.kill("SIGKILL");
|
|
134
|
+
} catch {
|
|
135
|
+
// Already gone.
|
|
136
|
+
}
|
|
137
|
+
log.warn("hwaccel: a start could not be measured; the plan is told so rather than told zero");
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const killedAt = now();
|
|
142
|
+
const died = await new Promise((resolve) => {
|
|
143
|
+
const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
|
|
144
|
+
timer.unref?.();
|
|
145
|
+
child.on("exit", () => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
resolve(now() - killedAt);
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
child.kill("SIGTERM");
|
|
151
|
+
} catch {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
resolve(null);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const firstByteWaitSec = firstPiece / 1000;
|
|
158
|
+
const killCostSec = died === null ? 0 : died / 1000;
|
|
159
|
+
log.info(
|
|
160
|
+
`hwaccel: a start costs ${firstByteWaitSec.toFixed(2)}s to a first piece and ` +
|
|
161
|
+
`a stop ${killCostSec.toFixed(2)}s on this host — measured before any viewer, ` +
|
|
162
|
+
"because a plan told zero reads it as free and moves an encoder for nothing"
|
|
163
|
+
);
|
|
164
|
+
return { firstByteWaitSec, killCostSec };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
log.warn(
|
|
167
|
+
`hwaccel: a start and a stop could not be measured: ` +
|
|
168
|
+
`${error instanceof Error ? error.message : String(error)}`
|
|
169
|
+
);
|
|
170
|
+
return null;
|
|
171
|
+
} finally {
|
|
172
|
+
try {
|
|
173
|
+
rmSync(dir, { recursive: true, force: true });
|
|
174
|
+
} catch {
|
|
175
|
+
// best effort
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -94,6 +94,8 @@ import { LiveOutputs } from "./output/LiveOutputs.js";
|
|
|
94
94
|
import { variantHeightsFor } from "./output/ladder.js";
|
|
95
95
|
import { EncodeOrchestrator } from "./orchestrators/EncodeOrchestrator.js";
|
|
96
96
|
import { wireDiskSpace } from "./disk/wire.js";
|
|
97
|
+
import { IDLE_KEEP_MS } from "./disk/keep.js";
|
|
98
|
+
import { Returns } from "./disk/returns.js";
|
|
97
99
|
import { freeBytesFor } from "./disk/free.js";
|
|
98
100
|
|
|
99
101
|
/**
|
|
@@ -474,7 +476,7 @@ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
|
|
|
474
476
|
* not this; this only stops something nobody has touched all day from sitting
|
|
475
477
|
* there for the life of the process.
|
|
476
478
|
*/
|
|
477
|
-
const SEGMENT_STORE_IDLE_MS =
|
|
479
|
+
const SEGMENT_STORE_IDLE_MS = IDLE_KEEP_MS;
|
|
478
480
|
const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
479
481
|
// Realtime budget — runtime downswitch (software encoder only). Periodically
|
|
480
482
|
// check each active software-transcode session's ffmpeg `speed`; when it stays
|
|
@@ -1399,6 +1401,7 @@ export class HlsSessionManager {
|
|
|
1399
1401
|
stateDir = "",
|
|
1400
1402
|
segmentStore = null,
|
|
1401
1403
|
getTorrentTotals,
|
|
1404
|
+
startStopCost = null,
|
|
1402
1405
|
spillDisk = null}) {
|
|
1403
1406
|
this.enabled = Boolean(enabled);
|
|
1404
1407
|
this.ffmpegBin = ffmpegBin;
|
|
@@ -1576,6 +1579,11 @@ export class HlsSessionManager {
|
|
|
1576
1579
|
segmentStore: this.segmentStore,
|
|
1577
1580
|
logger
|
|
1578
1581
|
});
|
|
1582
|
+
// What a start and a stop were measured to cost here, before any viewer
|
|
1583
|
+
// existed. Without it both read zero at a cold open, and zero is not
|
|
1584
|
+
// "unmeasured" — it is "free", which is what moved an encoder between two
|
|
1585
|
+
// adjacent numbers every half second in the field.
|
|
1586
|
+
this.encodeOrchestrator.noteStartupCosts(startStopCost);
|
|
1579
1587
|
// Where each file is cut, held once per file and grid rather than once per
|
|
1580
1588
|
// session. Two sessions of one film MUST agree about this to the
|
|
1581
1589
|
// millisecond — a segment made by either has to be appendable where the
|
|
@@ -1599,6 +1607,10 @@ export class HlsSessionManager {
|
|
|
1599
1607
|
void this.cleanupExpired();
|
|
1600
1608
|
}, CLEANUP_INTERVAL_MS);
|
|
1601
1609
|
this.cleanupTimer.unref();
|
|
1610
|
+
// How long after material stops being read somebody asks for it again — the
|
|
1611
|
+
// one term of the keeping period that is guessed rather than measured, and
|
|
1612
|
+
// the only place it can be measured from.
|
|
1613
|
+
this.returns = new Returns();
|
|
1602
1614
|
// One owner of the disk, and the list of what takes it lives with the owner.
|
|
1603
1615
|
this.diskSpace = wireDiskSpace({
|
|
1604
1616
|
segmentStore: this.segmentStore,
|
|
@@ -2251,6 +2263,10 @@ export class HlsSessionManager {
|
|
|
2251
2263
|
// session was never registered, and no sweep looks for one. Proxy
|
|
2252
2264
|
// 2.9.101-2.9.102 failed here on every single request and the leftovers
|
|
2253
2265
|
// were the only trace of it on disk.
|
|
2266
|
+
// A RETURN, if this output was held before — and its age, which is the one
|
|
2267
|
+
// term of the keeping period that nothing measures. Read BEFORE the
|
|
2268
|
+
// directory is claimed, since claiming it is what marks it read.
|
|
2269
|
+
this.returns.note({ lastReadAt: this.segmentStore.lastReadAt(spec.toKey()), now: Date.now() });
|
|
2254
2270
|
this.segmentStore.directoryFor(spec.toKey());
|
|
2255
2271
|
this.segmentStore.useFormat(spec.toKey(), segmentFormat);
|
|
2256
2272
|
|
|
@@ -9672,6 +9688,12 @@ export class HlsSessionManager {
|
|
|
9672
9688
|
// last read, and how much room the disk has for the lot.
|
|
9673
9689
|
// The room is the disk owner's to divide; this asks what the share is now.
|
|
9674
9690
|
await this.diskSpace.revise();
|
|
9691
|
+
// What viewers actually do, beside the period that stands in for it. Said
|
|
9692
|
+
// where it can be read against the disk figures rather than on its own.
|
|
9693
|
+
const returns = this.returns.describe(IDLE_KEEP_MS);
|
|
9694
|
+
if (returns !== null) {
|
|
9695
|
+
logger.info(returns);
|
|
9696
|
+
}
|
|
9675
9697
|
this.segmentStore.enforce({
|
|
9676
9698
|
idleMs: SEGMENT_STORE_IDLE_MS,
|
|
9677
9699
|
maxBytes: this.diskSpace.segmentBytes(),
|