@torrent-tv/proxy 2.9.141 → 2.11.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.
@@ -148,15 +148,37 @@ function safeDimensions(targetWidth, targetHeight) {
148
148
 
149
149
  /**
150
150
  * Force a keyframe on every segment boundary so each HLS segment is
151
- * independently decodable and exactly `segmentDurationSec` long.
151
+ * independently decodable.
152
+ *
153
+ * Two grids exist. The usual one is even — a keyframe every
154
+ * `segmentDurationSec` — and the encoder is free to place them because it is
155
+ * producing every frame anyway. The other is the SOURCE's own keyframe times,
156
+ * used when this encode has to be interchangeable with a stream that is
157
+ * COPIED: a copy can only be cut where the source already has a keyframe, so a
158
+ * rung meant to splice into it must be cut at exactly those times and nowhere
159
+ * else. Then the times are given outright.
152
160
  *
153
161
  * @param {number} segmentDurationSec
162
+ * @param {number[] | null} [forcedTimes] - Run-relative seconds, ascending.
154
163
  * @returns {string[]}
155
164
  */
156
- function keyFrameArgs(segmentDurationSec) {
165
+ function keyFrameArgs(segmentDurationSec, forcedTimes = null) {
166
+ if (Array.isArray(forcedTimes) && forcedTimes.length > 0) {
167
+ return ["-force_key_frames", forcedTimes.join(",")];
168
+ }
157
169
  return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
158
170
  }
159
171
 
172
+ /**
173
+ * Whether an explicit cut list was supplied.
174
+ *
175
+ * @param {number[] | null | undefined} forcedTimes
176
+ * @returns {boolean}
177
+ */
178
+ function hasForcedTimes(forcedTimes) {
179
+ return Array.isArray(forcedTimes) && forcedTimes.length > 0;
180
+ }
181
+
160
182
  /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
161
183
  export function softwareDescriptor() {
162
184
  return {
@@ -164,7 +186,7 @@ export function softwareDescriptor() {
164
186
  kind: "software",
165
187
  device: null,
166
188
  inputArgs: [],
167
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap }) {
189
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap, forcedKeyframeTimes }) {
168
190
  const { w, h } = safeDimensions(targetWidth, targetHeight);
169
191
  const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
170
192
  // Output frame rate: inherited from the source (rounded/capped) by the
@@ -200,11 +222,24 @@ export function softwareDescriptor() {
200
222
  // independent of the PTS offset used on seek-restart — every HLS segment
201
223
  // is exactly segmentDurationSec long and starts on a keyframe, so segment
202
224
  // boundaries line up with the synthetic playlist with no gaps. (The old
203
- // `-force_key_frames expr:gte(t,n_forced*SEG)` broke after a seek because
204
- // `t` is offset by `-output_ts_offset`, forcing keyframes at the wrong
205
- // places.)
225
+ // the OLD `expr:` form of -force_key_frames broke after a seek, because
226
+ // the `t` it reads is shifted by `-output_ts_offset`.)
227
+ //
228
+ // An explicit cut LIST is a different thing and does work: verified by
229
+ // running it, its times are on the run's own timeline — the same one
230
+ // `-segment_times` is measured on — so both are given one list and
231
+ // cannot drift apart. It replaces the frame-count GOP, which cannot
232
+ // describe the source's keyframes because they are not evenly spaced.
233
+ // `-g` stays as an upper bound on the interval: an extra keyframe
234
+ // inside a segment costs a little bitrate and cuts nothing, while
235
+ // leaving the interval unbounded means a driver that ignores the list
236
+ // produces one enormous segment instead of a wrong but cut one.
237
+ // `-keyint_min` goes, since a MINIMUM interval is the one thing that
238
+ // could argue with a forced keyframe.
206
239
  "-g", String(segmentDurationSec * outFps),
207
- "-keyint_min", String(segmentDurationSec * outFps),
240
+ ...(hasForcedTimes(forcedKeyframeTimes)
241
+ ? keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
242
+ : ["-keyint_min", String(segmentDurationSec * outFps)]),
208
243
  "-sc_threshold", "0"
209
244
  ];
210
245
  }
@@ -224,14 +259,14 @@ function vaapiDescriptor(device) {
224
259
  inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
225
260
  // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
226
261
  // grid via time-based -force_key_frames, so it already honours source fps.
227
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
262
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
228
263
  const { w, h } = safeDimensions(targetWidth, targetHeight);
229
264
  return [
230
265
  "-vf",
231
266
  `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
232
267
  "-c:v", "h264_vaapi",
233
268
  "-qp", "24",
234
- ...keyFrameArgs(segmentDurationSec)
269
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
235
270
  ];
236
271
  }
237
272
  };
@@ -247,13 +282,13 @@ function qsvDescriptor(device) {
247
282
  kind: "qsv",
248
283
  device,
249
284
  inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
250
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
285
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
251
286
  const { w, h } = safeDimensions(targetWidth, targetHeight);
252
287
  return [
253
288
  "-vf", `scale_qsv=w=${w}:h=${h}`,
254
289
  "-c:v", "h264_qsv",
255
290
  "-global_quality", "24",
256
- ...keyFrameArgs(segmentDurationSec)
291
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
257
292
  ];
258
293
  }
259
294
  };
@@ -269,7 +304,7 @@ function nvencDescriptor() {
269
304
  // No fps filter: NVENC is fast and places keyframes by time-based
270
305
  // -force_key_frames, so it inherits the exact source rate (fractional
271
306
  // included) with no need to round or cap. Same rationale as VAAPI/QSV.
272
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
307
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
273
308
  const { w, h } = safeDimensions(targetWidth, targetHeight);
274
309
  return [
275
310
  "-vf",
@@ -278,7 +313,7 @@ function nvencDescriptor() {
278
313
  "-preset", "p4",
279
314
  "-cq", "24",
280
315
  "-pix_fmt", "yuv420p",
281
- ...keyFrameArgs(segmentDurationSec)
316
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
282
317
  ];
283
318
  }
284
319
  };
@@ -296,7 +331,7 @@ function v4l2m2mDescriptor() {
296
331
  kind: "v4l2m2m",
297
332
  device: null,
298
333
  inputArgs: [],
299
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps }) {
334
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps, forcedKeyframeTimes }) {
300
335
  const { w, h } = safeDimensions(targetWidth, targetHeight);
301
336
  const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
302
337
  return [
@@ -308,8 +343,12 @@ function v4l2m2mDescriptor() {
308
343
  // userspace").
309
344
  "-num_capture_buffers", "32",
310
345
  "-b:v", "3M",
346
+ // Kept even with an explicit cut list, as an upper bound on the
347
+ // interval: this encoder is the one known not always to honour keyframe
348
+ // hints, and without any bound a list it ignores yields one segment for
349
+ // the whole file rather than a wrongly-cut one.
311
350
  "-g", String(outFps * segmentDurationSec),
312
- ...keyFrameArgs(segmentDurationSec)
351
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
313
352
  ];
314
353
  }
315
354
  };
@@ -322,7 +361,7 @@ function v4l2m2mDescriptor() {
322
361
  * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
323
362
  * @property {string|null} device
324
363
  * @property {string[]} inputArgs
325
- * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number }) => string[]} buildVideoArgs
364
+ * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null }) => string[]} buildVideoArgs
326
365
  */
327
366
 
328
367
  /**
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @file A segment request below the running encode must not be held for ever.
3
+ *
4
+ * The encoder only moves forward from where its run began, so a request BELOW
5
+ * that point cannot be answered by anything the run does. Every other far
6
+ * request is a claim the running encode may yet reach; this one is a hole.
7
+ *
8
+ * Measured 2026-08-11: a quality switch placed a run at segment #770 while the
9
+ * player needed #757, and the request was held for two minutes forty-one while
10
+ * the encoder produced 409 s of video nobody had asked for at 2.48x. The
11
+ * placement that caused it is fixed; this is the guard that stops the SHAPE
12
+ * from hanging a session again, whatever puts it there.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { mkdtemp, rm } from "node:fs/promises";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
21
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
22
+
23
+ const SESSION_ID = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff";
24
+ const SEGMENT_SECONDS = 4;
25
+ const RUN_STARTS_AT = 770;
26
+ const WANTED = 757;
27
+
28
+ /**
29
+ * A session whose run began well past the segment being asked for.
30
+ *
31
+ * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string, restarts: number[] }>}
32
+ */
33
+ async function managerWithRunAhead() {
34
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "behind-head-"));
35
+ const manager = new HlsSessionManager({
36
+ enabled: true,
37
+ ffmpegBin: "ffmpeg",
38
+ localBindHost: "127.0.0.1",
39
+ localPort: 9090
40
+ });
41
+ const session = {
42
+ id: SESSION_ID,
43
+ dirPath,
44
+ state: "ready",
45
+ fileName: "film.avi",
46
+ startedAt: Date.now(),
47
+ createEntryMs: Date.now(),
48
+ lastAccessedAt: Date.now(),
49
+ lastError: "",
50
+ consumers: new Set(),
51
+ segmentFormat: fmp4Format,
52
+ transcodeVideo: true,
53
+ useSyntheticPlaylist: true,
54
+ playlistText: "#EXTM3U\n",
55
+ segmentBoundaries: Array.from({ length: 1937 }, (_, index) => index * SEGMENT_SECONDS),
56
+ segmentCount: 1936,
57
+ encodeStartIndex: RUN_STARTS_AT,
58
+ encodeRunGeneration: 0,
59
+ lastRestartAt: 0,
60
+ seekFailureTarget: -1,
61
+ seekFailureCount: 0,
62
+ seekSettleTimer: null,
63
+ seekTarget: null,
64
+ waitEpoch: 0,
65
+ firstWantedAt: new Map(),
66
+ ffmpeg: { pid: 4321, exitCode: null, signalCode: null, kill() {}, once(event, handler) { if (event === "exit") handler(); } },
67
+ progress: { state: "running", processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS + 400, startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
68
+ };
69
+ manager.sessionsById.set(SESSION_ID, session);
70
+ const restarts = [];
71
+ return { manager, session, dirPath, restarts };
72
+ }
73
+
74
+ test("a request behind the run is repaired once it has waited", async (t) => {
75
+ const { manager, session, dirPath } = await managerWithRunAhead();
76
+ t.after(async () => {
77
+ await manager.disposeAll();
78
+ await rm(dirPath, { recursive: true, force: true });
79
+ });
80
+ // Asked for four seconds ago and still unanswerable.
81
+ session.firstWantedAt.set(WANTED, Date.now() - 4000);
82
+
83
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
84
+
85
+ assert.equal(
86
+ session.seekTarget,
87
+ WANTED - 1,
88
+ "the encoder must be moved back to it — one segment early, for the preceding keyframe"
89
+ );
90
+ });
91
+
92
+ test("a request behind the run is left alone at first", async (t) => {
93
+ const { manager, session, dirPath } = await managerWithRunAhead();
94
+ t.after(async () => {
95
+ await manager.disposeAll();
96
+ await rm(dirPath, { recursive: true, force: true });
97
+ });
98
+
99
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
100
+
101
+ assert.equal(
102
+ session.seekTarget,
103
+ null,
104
+ "a burst around a reported seek settles by itself, and the seek is what should move the encoder"
105
+ );
106
+ });
107
+
108
+ test("a seek already settling is not overridden", async (t) => {
109
+ const { manager, session, dirPath } = await managerWithRunAhead();
110
+ t.after(async () => {
111
+ await manager.disposeAll();
112
+ await rm(dirPath, { recursive: true, force: true });
113
+ });
114
+ session.firstWantedAt.set(WANTED, Date.now() - 4000);
115
+ // The viewer has stated where they are and it is about to be acted on.
116
+ session.seekTarget = 1200;
117
+ session.seekSettleTimer = setTimeout(() => {}, 60_000);
118
+ t.after(() => clearTimeout(session.seekSettleTimer));
119
+
120
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
121
+
122
+ assert.equal(
123
+ session.seekTarget,
124
+ 1200,
125
+ "a statement from the viewer outranks anything inferred from what the player is fetching"
126
+ );
127
+ });
128
+
129
+ test("a playlist scan far below the run is left where it belongs", async (t) => {
130
+ const { manager, session, dirPath } = await managerWithRunAhead();
131
+ t.after(async () => {
132
+ await manager.disposeAll();
133
+ await rm(dirPath, { recursive: true, force: true });
134
+ });
135
+ // A player that cannot get what it wants scans the playlist: field log
136
+ // 2026-08-02, probes at #178, #681, #725, #807, #74, #245, #387 within half a
137
+ // second. Steering on the lowest of those put the encoder at the start of the
138
+ // film and left the viewer's own requests unreachable ahead of it — the exact
139
+ // reason request-steering was removed from this proxy.
140
+ const probe = 74;
141
+ session.firstWantedAt.set(probe, Date.now() - 30_000);
142
+
143
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(probe), { requestSeq: 1 });
144
+
145
+ assert.equal(
146
+ session.seekTarget,
147
+ null,
148
+ "a misplaced run is out by a buffer; a scan probe is out by anything, and the two must not be confused"
149
+ );
150
+ });
151
+
152
+ test("a rung whose encoder was stopped is not brought back by a held request", async (t) => {
153
+ const { manager, session, dirPath } = await managerWithRunAhead();
154
+ t.after(async () => {
155
+ await manager.disposeAll();
156
+ await rm(dirPath, { recursive: true, force: true });
157
+ });
158
+ // What a quality switch leaves behind: the rung nobody is watching, parked.
159
+ session.ffmpeg = null;
160
+ session.firstWantedAt.set(WANTED, Date.now() - 4000);
161
+
162
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
163
+
164
+ assert.equal(
165
+ session.seekTarget,
166
+ null,
167
+ "restarting it would put a second encoder on a host sized for one, for a rung nobody is watching"
168
+ );
169
+ });
170
+
171
+ test("a request ahead of the run is not touched", async (t) => {
172
+ const { manager, session, dirPath } = await managerWithRunAhead();
173
+ t.after(async () => {
174
+ await manager.disposeAll();
175
+ await rm(dirPath, { recursive: true, force: true });
176
+ });
177
+ const ahead = RUN_STARTS_AT + 400;
178
+ session.firstWantedAt.set(ahead, Date.now() - 30_000);
179
+
180
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(ahead), { requestSeq: 1 });
181
+
182
+ assert.equal(
183
+ session.seekTarget,
184
+ null,
185
+ "the running encode may yet reach it; restarting on a far request is what produced nine restarts in a minute"
186
+ );
187
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file How well a container's keyframe index describes its own file.
3
+ *
4
+ * The cut times of a copied video ARE its index — ffmpeg can only cut where a
5
+ * keyframe already is — and an index can be wrong: measured 2026-08-06, one
6
+ * claimed a keyframe at 157.99 s where the real ones were 153.82 and 164.247.
7
+ * Whether a re-encoded rung can be cut on that same grid and spliced into the
8
+ * copy depends entirely on how often that happens, so it is counted.
9
+ *
10
+ * No scan is involved and no undownloaded byte is touched: each produced piece
11
+ * states where it truly begins, and it is already read whole in order to be
12
+ * stamped. Only boundaries that were actually produced are counted — the parts
13
+ * somebody watched.
14
+ */
15
+
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { newIndexCheck, noteIndexDeviation } from "../services/hls-session-manager.js";
19
+
20
+ test("an index that describes its file exactly is reported as such", () => {
21
+ const check = newIndexCheck();
22
+
23
+ for (let index = 0; index < 4; index += 1) {
24
+ noteIndexDeviation(check, index, 0);
25
+ }
26
+
27
+ assert.equal(check.checked, 4);
28
+ assert.equal(check.disagreed, 0, "nothing disagreed — which is a finding, not silence");
29
+ assert.equal(check.maxDeviationSec, 0);
30
+ });
31
+
32
+ test("a boundary the index placed wrongly is counted, with how far out it was", () => {
33
+ const check = newIndexCheck();
34
+
35
+ noteIndexDeviation(check, 0, 0);
36
+ // The measured shape: the playlist said 157.99 s, the file cut at 153.82 s.
37
+ noteIndexDeviation(check, 2, 4.17);
38
+ noteIndexDeviation(check, 3, 0.01);
39
+
40
+ assert.equal(check.checked, 3);
41
+ assert.equal(check.disagreed, 1);
42
+ assert.equal(check.firstDisagreementIndex, 2);
43
+ assert.equal(
44
+ check.maxDeviationSec,
45
+ 4.17,
46
+ "the size of the error is what decides whether a rung can be cut on this grid"
47
+ );
48
+ });
49
+
50
+ test("a deviation within tolerance is not a disagreement, but still shows in the worst case", () => {
51
+ const check = newIndexCheck();
52
+
53
+ noteIndexDeviation(check, 0, 0.2);
54
+
55
+ assert.equal(check.disagreed, 0, "rounding in a container's timestamps is not the index being wrong");
56
+ assert.equal(check.maxDeviationSec, 0.2, "and it is still worth knowing how close to the line it ran");
57
+ });
58
+
59
+ test("a segment requested again is not new evidence", () => {
60
+ const check = newIndexCheck();
61
+
62
+ noteIndexDeviation(check, 1, 0.9);
63
+ noteIndexDeviation(check, 1, 0.9);
64
+ noteIndexDeviation(check, 1, 0.9);
65
+
66
+ assert.equal(check.checked, 1, "a repeat request is the same boundary, counted once");
67
+ assert.equal(check.disagreed, 1);
68
+ });