@torrent-tv/proxy 2.11.0 → 2.12.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,12 @@
1
+ ## 2.12.0
2
+
3
+ - **Fix**: A rung warmed for a switch the viewer did not make is stopped. Only becoming active stopped the rung being left, so trying two rungs in a row left the first encoding for nobody — three encoders at once on a host sized for one, which is the opposite of what warming is for.
4
+ - **Fix**: The warm-up closes the handle it opened. It answers without sending the bytes, and on formats whose segments are served straight off disk that left a file descriptor behind on every quality pick; enough of them and every read fails, segments included.
5
+
6
+ - **New**: A quality rung is prepared before the player is told to switch to it — `GET /transcode/:id/v/:height/warm?position=<seconds>`. A rung is an encoder that does not exist until it is asked for, so switching first and waiting second put the whole of its cold start on screen as a spinner: measured 2026-08-11, the first segment of a 240p rung producing at 1.2x took 15 988 ms, and the viewer watched all of it. The rung on screen deliberately keeps its own encoder until the player actually moves, so the wait happens behind a picture that is still playing. Both encoders run for the length of the warm-up, which is what the switch costs to be invisible.
7
+ - **Fix**: A viewer who names a resolution keeps the ladder beneath it. Forcing a rung disabled the realtime budget outright, so on 2026-08-11 a viewer picked 480p on a host that encodes it at 0.27-0.78x and the stream simply never caught up — nothing could step in, because the one thing that steps in had been switched off. The encode now STARTS at the size asked for and may still be stepped down under it. The rung's height is its name and does not move with a downshift, so the player goes on addressing it by the height it chose; what changes is the picture, and a smaller picture that plays beats a correct label that freezes.
8
+ - **New**: When a produced segment starts somewhere other than the playlist says, the line now names which boundary it DOES fall on. The two possible faults need opposite fixes and the numbers alone do not separate them: matching boundary #N-1 means this proxy's own numbering is shifted, matching none means the container's index describes times the file does not have. Measured 2026-08-11 on a 1080p Matroska, three samples out by 3.5-4.6 s, all matching #N-1.
9
+
1
10
  ## 2.11.0
2
11
 
3
12
  - **Fix**: A quality change places the new rung where the PLAYER asked for it, not where the rung being left had read to. After a level switch hls.js discards what it had buffered ahead and fetches from the picture's own position, so its first request for the new rung IS that position; the read head is a whole buffer further on. Measured 2026-08-11 on a switch back up to 400p: a 240p rung encoding at 5-6x had read 56 s past the picture, the run was placed at 3084 s, the player needed 3028 s, and nothing it asked for was ever produced.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.11.0",
3
+ "version": "2.12.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": {
@@ -140,7 +140,7 @@ export async function serveSessionFile(req, reply, { hlsSessionManager, sessionI
140
140
  * @param {number} timeoutMs
141
141
  * @returns {Promise<Awaited<ReturnType<import("../../../services/hls-session-manager.js").HlsSessionManager["getFileStream"]>>>}
142
142
  */
143
- async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
143
+ export async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
144
144
  const startedAt = Date.now();
145
145
  // One sequence number for THIS request, reused by every poll below, so the
146
146
  // session can tell a newly-arrived request apart from an old one polling
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @file GET /transcode/:sessionId/v/:height/warm?position=<seconds> — prepare a
3
+ * quality rung before the player is told to switch to it.
4
+ *
5
+ * A rung is an encoder that does not exist until someone asks for it, so a
6
+ * switch made first and waited for second shows the viewer a spinner for as
7
+ * long as the first segment takes to produce — 15 988 ms, measured 2026-08-11
8
+ * on a rung producing at 1.2x. Asking first and switching second moves that
9
+ * wait to where it cannot be seen: the rung on screen goes on playing, and it
10
+ * keeps its own encoder until the player actually moves.
11
+ *
12
+ * Answers when the segment at that position is ready, so the caller can switch
13
+ * knowing there is something to fetch.
14
+ */
15
+
16
+ import { waitForSessionFile } from "../session-file/get.js";
17
+
18
+ /** How long to hold the warm-up request before telling the caller to retry. */
19
+ const WARM_WAIT_MS = 30_000;
20
+
21
+ /**
22
+ * @param {import("fastify").FastifyRequest} req
23
+ * @param {import("fastify").FastifyReply} reply
24
+ * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
25
+ * @returns {Promise<void>}
26
+ */
27
+ export async function handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager }) {
28
+ const baseSessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
29
+ const height = Number(req.params.height);
30
+ const positionSeconds = Number(req.query?.position);
31
+
32
+ if (!Number.isInteger(height) || height <= 0 || !Number.isFinite(positionSeconds) || positionSeconds < 0) {
33
+ return reply.code(400).send({ error: "A height and a non-negative position are required." });
34
+ }
35
+
36
+ let prepared;
37
+ try {
38
+ prepared = await hlsSessionManager.prepareVariant(baseSessionId, height, positionSeconds);
39
+ } catch (error) {
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ reply.header("Retry-After", "1");
42
+ return reply.code(503).send({ error: `Could not prepare the quality variant: ${message}` });
43
+ }
44
+ if (!prepared) {
45
+ return reply.code(404).send({ error: "No such quality variant for this transcode session." });
46
+ }
47
+
48
+ const result = await waitForSessionFile(
49
+ hlsSessionManager,
50
+ prepared.sessionId,
51
+ prepared.fileName,
52
+ WARM_WAIT_MS
53
+ );
54
+ if (result.kind === "file") {
55
+ // The bytes are not sent — the player fetches them itself the moment it
56
+ // switches, and by then they are on disk — but the handle opened to reach
57
+ // them is ours to close. Some formats answer with a real file descriptor
58
+ // rather than bytes already in memory, and one left behind per quality pick
59
+ // walks a long-running proxy to EMFILE, where every read fails, segments
60
+ // included.
61
+ result.stream?.destroy?.();
62
+ return reply.code(204).send();
63
+ }
64
+ if (result.kind === "failed") {
65
+ return reply.code(500).send({ error: result.message });
66
+ }
67
+ // Still being produced. The caller may switch anyway — it will simply wait
68
+ // where it would have waited before — or ask again.
69
+ reply.header("Retry-After", "1");
70
+ return reply.code(503).send({ error: "The quality variant is still warming up." });
71
+ }
package/server.js CHANGED
@@ -30,6 +30,7 @@ import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessio
30
30
  import { handleStreamGet } from "./routes/stream/get.js";
31
31
  import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
32
32
  import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
33
+ import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
33
34
  import { createSourceRegistry } from "./store/source-registry.js";
34
35
  import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
35
36
  import { HlsSessionManager } from "./services/hls-session-manager.js";
@@ -219,6 +220,12 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
219
220
  // A quality variant's files. Registered before the static handler for the
220
221
  // same reason as the line above, and kept a separate route rather than a
221
222
  // wildcard so the height stays a parsed parameter.
223
+ // Registered BEFORE the variant file route: `warm` is not a file name, and
224
+ // Fastify matches a static segment ahead of a parameter either way — stated
225
+ // here so the order is not "tidied" into a bug.
226
+ app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
227
+ handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
228
+ );
222
229
  app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
223
230
  handleTranscodeVariantFileGet(req, reply, { hlsSessionManager })
224
231
  );
@@ -155,6 +155,34 @@ export function noteIndexDeviation(check, index, deviationSec) {
155
155
  }
156
156
  }
157
157
 
158
+ /**
159
+ * The same budget, starting at the top of its own ladder.
160
+ *
161
+ * The automatic choice takes the highest rung this host can encode faster than
162
+ * realtime. A viewer who names a resolution has already made that choice, so
163
+ * the encode starts where they said — and the ladder stays, because a host that
164
+ * turns out unable to keep up must still have somewhere to go.
165
+ *
166
+ * @param {{ ladder: { width: number, height: number }[], rungIndex: number } | null} budget
167
+ * @param {number} outputFps
168
+ * @param {unknown} benchmark
169
+ * @returns {object | null}
170
+ */
171
+ function startAtLadderTop(budget, outputFps, benchmark) {
172
+ const top = budget?.ladder?.[0];
173
+ if (!top) {
174
+ return null;
175
+ }
176
+ const fps = Number.isInteger(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
177
+ return {
178
+ ...budget,
179
+ width: top.width,
180
+ height: top.height,
181
+ preset: pickSoftwarePreset(benchmark, top.width * top.height * fps),
182
+ rungIndex: 0
183
+ };
184
+ }
185
+
158
186
  /**
159
187
  * The consumer a base session registers on its variants.
160
188
  *
@@ -1414,16 +1442,23 @@ export class HlsSessionManager {
1414
1442
  // resolution, so encode exactly that box (capped to source by the scale
1415
1443
  // filter) with the default preset, and the runtime downswitch is skipped
1416
1444
  // for the session (budgetLadder stays null).
1417
- const encodeBudget = forceManualQuality
1418
- ? null
1419
- : this.#chooseEncodeBudget({
1420
- transcodeVideo,
1421
- targetWidth: normalizedTargetWidth,
1422
- targetHeight: normalizedTargetHeight,
1423
- sourceWidth,
1424
- sourceHeight,
1425
- outputFps
1426
- });
1445
+ const chosenBudget = this.#chooseEncodeBudget({
1446
+ transcodeVideo,
1447
+ targetWidth: normalizedTargetWidth,
1448
+ targetHeight: normalizedTargetHeight,
1449
+ sourceWidth,
1450
+ sourceHeight,
1451
+ outputFps
1452
+ });
1453
+ // A forced resolution starts at exactly that size — the viewer asked for it
1454
+ // — but KEEPS the ladder beneath it. Discarding the ladder is what left a
1455
+ // viewer with no picture at all on 2026-08-11: they picked 480p on a host
1456
+ // that encodes it at 0.27-0.78x, and with the runtime downshift disabled
1457
+ // nothing could step in, so the stream simply never caught up. A smaller
1458
+ // picture that plays beats a correct label that freezes. The rung's NAME is
1459
+ // settled separately and does not move with a downshift, so the player goes
1460
+ // on addressing it by the height it chose.
1461
+ const encodeBudget = forceManualQuality ? startAtLadderTop(chosenBudget, outputFps, this.softwarePresetBenchmark) : chosenBudget;
1427
1462
  const softwarePreset = encodeBudget?.preset ?? null;
1428
1463
  // Effective encode box: the budget's downscaled resolution when applied,
1429
1464
  // otherwise the client target (0 = keep source, handled by buildVideoArgs).
@@ -1607,8 +1642,12 @@ export class HlsSessionManager {
1607
1642
  `${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
1608
1643
  // Effective encode resolution: budget-on (auto downscale from the
1609
1644
  // ceiling), manual (user-forced, budget off), or unset (keep source).
1610
- `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
1611
- `${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
1645
+ `${transcodeVideo && encodeBudget
1646
+ ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} ` +
1647
+ `quality=${forceManualQuality ? "manual" : "auto"} ` +
1648
+ `budget=${encodeBudget.ladder ? `rung ${encodeBudget.rungIndex + 1}/${encodeBudget.ladder.length}` : "off"} `
1649
+ : ""}` +
1650
+ `${transcodeVideo && !encodeBudget && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual budget=off ` : ""}` +
1612
1651
  // HDR source and whether the tone-map chain was applied (vs washed-out
1613
1652
  // fallback when the filters are missing or on a hardware encoder).
1614
1653
  `${transcodeVideo && mediaInfo.isHdr ? `hdr=1 tonemap=${applyTonemap ? "on" : "off"} ` : ""}` +
@@ -3735,9 +3774,18 @@ export class HlsSessionManager {
3735
3774
  session.indexCheck ??= newIndexCheck();
3736
3775
  noteIndexDeviation(session.indexCheck, index, deviation);
3737
3776
  if (deviation > SEGMENT_START_DISAGREEMENT_SEC) {
3777
+ // Which boundary the true start DOES match, if any. This is what tells
3778
+ // the two possible faults apart, and they need opposite fixes: matching
3779
+ // boundary #N-1 means our numbering is shifted by one — a fault in this
3780
+ // code, where the run begins — while matching nothing means the container
3781
+ // index describes times the file does not have. Measured 2026-08-11,
3782
+ // three samples all matched N-1, which is why the line now says so
3783
+ // instead of leaving it to be inferred from the numbers.
3784
+ const at = this.#boundaryIndexAt(session, trueStart);
3738
3785
  logger.warn(
3739
3786
  `transcode ${session.id} segment #${index} really starts at ` +
3740
- `${trueStart.toFixed(3)}s, the playlist says ${declaredStart.toFixed(3)}s ` +
3787
+ `${trueStart.toFixed(3)}s (boundary ${at === null ? "none" : `#${at}`}), ` +
3788
+ `the playlist says ${declaredStart.toFixed(3)}s — ` +
3741
3789
  (session.transcodeVideo
3742
3790
  // A re-encode was TOLD to put a keyframe here and did not, so this
3743
3791
  // rung's segments no longer stand where the stream it accompanies
@@ -3748,6 +3796,29 @@ export class HlsSessionManager {
3748
3796
  }
3749
3797
  }
3750
3798
 
3799
+ /**
3800
+ * The boundary a time falls on, or null when it falls on none of them.
3801
+ *
3802
+ * Within the same tolerance a disagreement is judged by, so "matches boundary
3803
+ * #N-1" and "matches nothing" mean what they say.
3804
+ *
3805
+ * @param {HlsSession} session
3806
+ * @param {number} seconds
3807
+ * @returns {number | null}
3808
+ */
3809
+ #boundaryIndexAt(session, seconds) {
3810
+ const boundaries = session.segmentBoundaries;
3811
+ if (!Array.isArray(boundaries)) {
3812
+ return null;
3813
+ }
3814
+ for (let index = 0; index < boundaries.length; index += 1) {
3815
+ if (Math.abs(boundaries[index] - seconds) <= SEGMENT_START_DISAGREEMENT_SEC) {
3816
+ return index;
3817
+ }
3818
+ }
3819
+ return null;
3820
+ }
3821
+
3751
3822
  /**
3752
3823
  * What this session learned about its container's keyframe index, as one
3753
3824
  * line, at the end.
@@ -4133,6 +4204,68 @@ export class HlsSessionManager {
4133
4204
  return { sessionId: variant.id };
4134
4205
  }
4135
4206
 
4207
+ /**
4208
+ * Prepare a rung the viewer is about to switch to, without switching to it.
4209
+ *
4210
+ * The rung does not exist until it is asked for, so the moment the player is
4211
+ * told to switch it has nothing to fetch and the viewer watches a spinner
4212
+ * while an encoder starts from nothing — measured 2026-08-11 at 15 988 ms for
4213
+ * the first segment of a rung producing at 1.2x. Nothing can make that
4214
+ * production instant; what CAN be done is to have it happen while the rung
4215
+ * the viewer is on is still playing.
4216
+ *
4217
+ * So this creates and positions the variant and says which segment to wait
4218
+ * for, and deliberately does NOT mark it active: the rung on screen keeps its
4219
+ * encoder until the player actually moves. Both encoders run for the length
4220
+ * of the warm-up, which is the price of the switch not being visible.
4221
+ *
4222
+ * @param {string} baseSessionId
4223
+ * @param {number} height
4224
+ * @param {number} positionSeconds - Where the switch will happen.
4225
+ * @returns {Promise<{ sessionId: string, fileName: string } | null>}
4226
+ */
4227
+ async prepareVariant(baseSessionId, height, positionSeconds) {
4228
+ if (!isSafeSessionId(baseSessionId)) {
4229
+ return null;
4230
+ }
4231
+ const base = this.sessionsById.get(baseSessionId);
4232
+ if (!base || base.state === "disposed") {
4233
+ return null;
4234
+ }
4235
+ if (!this.#variantHeights(base).includes(height)) {
4236
+ return null;
4237
+ }
4238
+ const index = this.#segmentIndexForTime(base, positionSeconds);
4239
+ const variant = await this.resolveVariantSession(baseSessionId, height, index);
4240
+ if (!variant) {
4241
+ return null;
4242
+ }
4243
+ // A rung warmed for a switch that was never made. Nothing else would ever
4244
+ // stop it: only becoming active stops the rung being left, so a viewer
4245
+ // trying two rungs in a row would leave the first encoding for nobody until
4246
+ // the look-ahead cap suspended it — three encoders at once on a host sized
4247
+ // for one, which is the opposite of what warming is for.
4248
+ const stillWarming = base.warmingVariantId;
4249
+ if (stillWarming && stillWarming !== variant.id) {
4250
+ const abandoned = this.sessionsById.get(stillWarming);
4251
+ if (abandoned && abandoned.id !== this.#activeVariant(base).id) {
4252
+ this.#stopEncodeRun(abandoned, "warmed for a switch the viewer did not make");
4253
+ }
4254
+ }
4255
+ base.warmingVariantId = variant.id === base.id ? null : variant.id;
4256
+ // An existing rung may be parked wherever it was left, so it is pointed at
4257
+ // the switch position exactly as an activation would — the difference is
4258
+ // only that the rung on screen keeps its own encoder meanwhile.
4259
+ variant.lastAccessedAt = Date.now();
4260
+ if (variant.id !== base.id) {
4261
+ this.requestSeek(variant.id, this.#segmentStartTime(base, index));
4262
+ }
4263
+ logger.info(
4264
+ `transcode ${base.id} warming ${height}p at ${positionSeconds.toFixed(1)}s (segment #${index})`
4265
+ );
4266
+ return { sessionId: variant.id, fileName: variant.segmentFormat.segmentFileName(index) };
4267
+ }
4268
+
4136
4269
  /**
4137
4270
  * Record which variant the viewer is watching, and give it the encoder.
4138
4271
  *
@@ -4148,6 +4281,18 @@ export class HlsSessionManager {
4148
4281
  */
4149
4282
  #noteVariantActive(base, variant, wantedIndex = -1) {
4150
4283
  const previous = this.#activeVariant(base);
4284
+ // Whatever was warmed is decided now: either it is the rung being switched
4285
+ // to, or the viewer went elsewhere and it must stop like any other rung
4286
+ // nobody is watching. Nothing else would ever stop it — only the rung being
4287
+ // LEFT is stopped below.
4288
+ const warmed = base.warmingVariantId;
4289
+ base.warmingVariantId = null;
4290
+ if (warmed && warmed !== variant.id && warmed !== previous.id) {
4291
+ const abandoned = this.sessionsById.get(warmed);
4292
+ if (abandoned) {
4293
+ this.#stopEncodeRun(abandoned, "warmed for a switch the viewer did not make");
4294
+ }
4295
+ }
4151
4296
  if (previous.id === variant.id) {
4152
4297
  return;
4153
4298
  }
@@ -283,6 +283,33 @@ test("a rung is placed where the player asked it for, not where the other rung h
283
283
  );
284
284
  });
285
285
 
286
+ test("warming a rung prepares it without taking the encoder from the one on screen", async (t) => {
287
+ const { manager, base, dirPath } = await managerWithBase();
288
+ t.after(async () => {
289
+ await manager.disposeAll();
290
+ await rm(dirPath, { recursive: true, force: true });
291
+ });
292
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
293
+ variant.variantHeight = 540;
294
+ variant.variantBases = new Set([BASE_ID]);
295
+ manager.sessionsById.set(VARIANT_ID, variant);
296
+ base.variants = new Map([[540, VARIANT_ID]]);
297
+ const encoder = fakeEncoder();
298
+ base.ffmpeg = encoder;
299
+
300
+ const prepared = await manager.prepareVariant(BASE_ID, 540, 240);
301
+
302
+ assert.deepEqual(
303
+ prepared,
304
+ { sessionId: VARIANT_ID, fileName: "segment-00060.mp4" },
305
+ "the caller is told which segment to wait for — 240 s on a four-second grid"
306
+ );
307
+ assert.equal(variant.seekTarget, 59, "the rung is pointed at the switch position, one back for the keyframe");
308
+ assert.equal(base.activeVariantId, undefined, "nothing has switched yet");
309
+ assert.equal(base.ffmpeg, encoder, "the picture on screen keeps its encoder until the player actually moves");
310
+ assert.deepEqual(encoder.signals, [], "stopping it here is what would put the spinner back");
311
+ });
312
+
286
313
  test("the viewer's position is kept current by the segments they ask for", async (t) => {
287
314
  const { manager, base, dirPath } = await managerWithBase();
288
315
  t.after(async () => {