@torrent-tv/proxy 2.14.3 → 2.15.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,9 @@
1
+ ## 2.15.0
2
+
3
+ - **New**: An audio track is prepared before the player is told to change to it — `GET /transcode/:id/a/:track/warm?position=<seconds>`, the same shape the quality rung has had since 2.12.0. Changing track makes the player discard the audio it holds, and it cannot show a frame until the new track covers the playhead: switching first and producing second therefore put the track's whole cold start on screen as a spinner over a stopped picture. Prepared first, the player finds the bytes already made. A track prepared for a change the viewer then did not make is stopped, as a warmed rung is.
4
+ - **Fix**: The audio track a viewer leaves is stopped, and a seek reaches only the track being listened to. Each track is an ffmpeg process AND a reader holding pieces of the torrent, and one viewer who had changed track once had three readers on one file — picture, the track chosen and the track left. At a seek all three revived their windows at once, every resident piece was pinned, a read ended with zero bytes, ffmpeg read that as the end of the file, and every encoder died; the sessions answered 500 to everything after that until the viewer gave up.
5
+ - **Fix**: A read waits for a piece to be released instead of failing outright. Every resident piece being read at once is not a permanent condition — a pin lasts one read of one piece — so the store now waits for one, and a released pin wakes whoever is waiting. Failing there ended a read with zero bytes, which is indistinguishable from the end of the file to the process reading it. A five-second deadline keeps a genuine deadlock visible, and the wait re-checks on a timer: waiting on events alone hung, because when everything is pinned and nothing else is in flight there is no event left to fire — it hung this store's own test for the ten minutes a run is allowed.
6
+
1
7
  ## 2.14.3
2
8
 
3
9
  - **Fix**: A separately published audio track begins where the PICTURE is, measured rather than guessed. The position this class keeps is the read head, and the viewer sits behind it by whatever the player has buffered — a figure the browser already reports with every link report, so the playhead is one subtraction away (less one segment of margin, since the report can be ten seconds old). 2.14.2 subtracted the whole look-ahead instead, which was safe but made the encoder produce up to two minutes of audio nobody would hear before reaching the part that was wanted. A report older than fifteen seconds is ignored — a viewer may have seeked since — and then the whole look-ahead is subtracted as before.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.14.3",
3
+ "version": "2.15.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": {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @file GET /transcode/:sessionId/a/:track/warm?position=<seconds> — prepare an
3
+ * audio track before the player is told to change to it.
4
+ *
5
+ * The picture keeps playing while a quality rung is warmed (`variant-warm`),
6
+ * and a track change deserves the same: the player discards the audio it holds
7
+ * the moment it switches and cannot show a frame until the new track covers the
8
+ * playhead, so switching first and producing second shows the track's cold
9
+ * start as a spinner over a stopped picture (measured 2026-08-15).
10
+ *
11
+ * Answers 204 when the segment at that position is ready, so the caller can
12
+ * switch into bytes that already exist; 503 while it is still being made.
13
+ */
14
+
15
+ import { waitForSessionFile } from "../session-file/get.js";
16
+
17
+ /** How long to hold the request before telling the caller to retry. */
18
+ const WARM_WAIT_MS = 12_000;
19
+
20
+ /**
21
+ * @param {import("fastify").FastifyRequest} req
22
+ * @param {import("fastify").FastifyReply} reply
23
+ * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
24
+ */
25
+ export async function handleTranscodeAudioWarmGet(req, reply, { hlsSessionManager }) {
26
+ const baseSessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
27
+ const trackIndex = Number(req.params.track);
28
+ const positionSeconds = Number(req.query?.position);
29
+
30
+ if (
31
+ !Number.isInteger(trackIndex) ||
32
+ trackIndex < 0 ||
33
+ !Number.isFinite(positionSeconds) ||
34
+ positionSeconds < 0
35
+ ) {
36
+ return reply.code(400).send({ error: "A track index and a non-negative position are required." });
37
+ }
38
+
39
+ let prepared;
40
+ try {
41
+ prepared = await hlsSessionManager.prepareAudioTrack(baseSessionId, trackIndex, positionSeconds);
42
+ } catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ reply.header("Retry-After", "1");
45
+ return reply.code(503).send({ error: `Could not prepare the audio track: ${message}` });
46
+ }
47
+ if (!prepared) {
48
+ return reply.code(404).send({ error: "No such audio track for this transcode session." });
49
+ }
50
+
51
+ const result = await waitForSessionFile(
52
+ hlsSessionManager,
53
+ prepared.sessionId,
54
+ prepared.fileName,
55
+ WARM_WAIT_MS
56
+ );
57
+ if (result.kind === "file") {
58
+ // The bytes are the player's to fetch; the handle opened to reach them is
59
+ // ours to close, or a long-running proxy walks to EMFILE one track change
60
+ // at a time.
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 wait where it
68
+ // would have waited before — or ask again.
69
+ reply.header("Retry-After", "1");
70
+ return reply.code(503).send({ error: "The audio track is still warming up." });
71
+ }
package/server.js CHANGED
@@ -32,6 +32,7 @@ import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/g
32
32
  import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
33
33
  import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
34
34
  import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
35
+ import { handleTranscodeAudioWarmGet } from "./routes/transcode/audio-warm/get.js";
35
36
  import { createSourceRegistry } from "./store/source-registry.js";
36
37
  import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
37
38
  import { HlsSessionManager } from "./services/hls-session-manager.js";
@@ -249,6 +250,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
249
250
  app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
250
251
  handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
251
252
  );
253
+ app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
254
+ handleTranscodeAudioWarmGet(req, reply, { hlsSessionManager })
255
+ );
252
256
  app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
253
257
  handleTranscodeAudioFileGet(req, reply, { hlsSessionManager })
254
258
  );
@@ -3720,7 +3720,16 @@ export class HlsSessionManager {
3720
3720
  // treated as a seek anywhere in this class, so after a forward jump the
3721
3721
  // audio would be held, refused, and left grinding forward from where it
3722
3722
  // was — the picture playing over silence for as long as the jump was.
3723
- for (const renditionId of named.audioRenditionSessions?.values() ?? []) {
3723
+ // Only the track being LISTENED to. A track the viewer left keeps its place
3724
+ // but not an encoder, and seeking it would start one for nobody — which is
3725
+ // how a single viewer came to have three ffmpeg processes and three readers
3726
+ // on one file (2026-08-15), enough to pin every resident piece and kill the
3727
+ // session outright.
3728
+ const listening = named.activeAudioTrackIndex ?? named.audioTrackIndex;
3729
+ for (const [trackIndex, renditionId] of named.audioRenditionSessions ?? []) {
3730
+ if (trackIndex !== listening) {
3731
+ continue;
3732
+ }
3724
3733
  const rendition = this.sessionsById.get(renditionId);
3725
3734
  if (rendition && rendition.state !== "disposed") {
3726
3735
  rendition.lastAccessedAt = Date.now();
@@ -5129,6 +5138,54 @@ export class HlsSessionManager {
5129
5138
  * @param {number} positionSeconds - Where the switch will happen.
5130
5139
  * @returns {Promise<{ sessionId: string, fileName: string } | null>}
5131
5140
  */
5141
+ /**
5142
+ * Prepare an audio track at a position, so a change of track is instant.
5143
+ *
5144
+ * The player, told to change track, discards the audio it holds and cannot
5145
+ * show a frame until the new track covers the playhead — so switching first
5146
+ * and producing second puts the whole of the track's cold start on screen as
5147
+ * a spinner. Measured 2026-08-15: the picture stopped for as long as the
5148
+ * first piece took. Prepared first, the player finds the bytes already there.
5149
+ *
5150
+ * The same shape as {@link prepareVariant}, and for the same reason.
5151
+ *
5152
+ * @param {string} baseSessionId
5153
+ * @param {number} trackIndex
5154
+ * @param {number} positionSeconds
5155
+ * @returns {Promise<{ sessionId: string, fileName: string } | null>}
5156
+ */
5157
+ async prepareAudioTrack(baseSessionId, trackIndex, positionSeconds) {
5158
+ const base = this.sessionsById.get(baseSessionId);
5159
+ if (!base || base.state === "disposed" || !this.#servesAudioSeparately(base)) {
5160
+ return null;
5161
+ }
5162
+ if (!this.#audioRenditionsOf(base).some((track) => track.trackIndex === trackIndex)) {
5163
+ return null;
5164
+ }
5165
+ const rendition = await this.#resolveAudioRenditionSession(base, trackIndex);
5166
+ if (!rendition) {
5167
+ return null;
5168
+ }
5169
+ // A track prepared for a change the viewer did not make would otherwise
5170
+ // encode for nobody until its own idle timer noticed — the same trap
5171
+ // warming a quality rung has, and the same answer.
5172
+ const stillWarming = base.warmingAudioSessionId;
5173
+ if (stillWarming && stillWarming !== rendition.id) {
5174
+ const abandoned = this.sessionsById.get(stillWarming);
5175
+ const listening = base.activeAudioTrackIndex ?? base.audioTrackIndex;
5176
+ const active = base.audioRenditionSessions?.get(listening);
5177
+ if (abandoned && abandoned.id !== active) {
5178
+ this.#stopEncodeRun(abandoned, "prepared for a track change the viewer did not make");
5179
+ }
5180
+ }
5181
+ base.warmingAudioSessionId = rendition.id;
5182
+ // Pointed at the position the switch will land on: an existing track is
5183
+ // parked wherever the viewer left it.
5184
+ this.#seekSession(rendition, positionSeconds);
5185
+ const index = this.#segmentIndexForTime(rendition, positionSeconds);
5186
+ return { sessionId: rendition.id, fileName: rendition.segmentFormat.segmentFileName(index) };
5187
+ }
5188
+
5132
5189
  async prepareVariant(baseSessionId, height, positionSeconds) {
5133
5190
  if (!isSafeSessionId(baseSessionId)) {
5134
5191
  return null;
@@ -5387,9 +5444,53 @@ export class HlsSessionManager {
5387
5444
  );
5388
5445
  return { sessionId: null, error: message };
5389
5446
  }
5447
+ if (isSegment && rendition) {
5448
+ this.#noteAudioTrackActive(base, rendition, trackIndex);
5449
+ }
5390
5450
  return { sessionId: rendition?.id ?? null };
5391
5451
  }
5392
5452
 
5453
+ /**
5454
+ * A SEGMENT of this track is what says the viewer is listening to it — the
5455
+ * player fetches the playlist and the init of tracks it may never choose.
5456
+ *
5457
+ * Every other track is then stopped. Each one is an ffmpeg process AND a
5458
+ * reader holding pieces of the torrent in memory, and the store can only
5459
+ * spill a piece nobody is reading: on 2026-08-15 a viewer who had changed
5460
+ * track once had three readers on one file — picture, the track they chose
5461
+ * and the track they left — and at a seek all three revived their windows at
5462
+ * once, every resident piece was pinned, a read ended with zero bytes, and
5463
+ * every encoder took that for the end of the file and died. Playback was over
5464
+ * for good; the sessions answered 500 to everything after that.
5465
+ *
5466
+ * Stopped, not disposed: the track keeps its place, its grid and its
5467
+ * position, so switching back does not build it again — the same treatment a
5468
+ * quality rung gets when the viewer moves off it.
5469
+ *
5470
+ * @param {HlsSession} base
5471
+ * @param {HlsSession} active
5472
+ * @param {number} trackIndex
5473
+ */
5474
+ #noteAudioTrackActive(base, active, trackIndex) {
5475
+ if (base.activeAudioTrackIndex === trackIndex) {
5476
+ return;
5477
+ }
5478
+ base.activeAudioTrackIndex = trackIndex;
5479
+ for (const [otherIndex, sessionId] of base.audioRenditionSessions ?? []) {
5480
+ if (otherIndex === trackIndex) {
5481
+ continue;
5482
+ }
5483
+ const other = this.sessionsById.get(sessionId);
5484
+ if (!other || other.state === "disposed" || other.ffmpeg == null) {
5485
+ continue;
5486
+ }
5487
+ // Requests held on it are for segments nobody will produce now, and the
5488
+ // player stopped waiting for them the moment it changed track.
5489
+ other.waitEpoch = (other.waitEpoch ?? 0) + 1;
5490
+ this.#stopEncodeRun(other, `the viewer moved to audio track ${trackIndex}`);
5491
+ }
5492
+ }
5493
+
5393
5494
  /**
5394
5495
  * The session producing one audio track of this file, made on first request.
5395
5496
  *
@@ -109,6 +109,14 @@ const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
109
109
  * in. With one, a single reader would deadlock the store against itself.
110
110
  */
111
111
  const MIN_RESIDENT_PIECES = 2;
112
+ /**
113
+ * How long a caller waits for a pinned piece to be released before the store
114
+ * calls it a deadlock. A pin lasts one read of one piece — milliseconds — so
115
+ * anything approaching this is a reader waiting for itself.
116
+ */
117
+ const PINNED_WAIT_MS = 5_000;
118
+ /** How often a wait for a slot looks again when no event is due to wake it. */
119
+ const CLAIM_RETRY_MS = 50;
112
120
 
113
121
  /**
114
122
  * A chunk store holding pieces in a `SharedArrayBuffer`, spilling to disk.
@@ -151,6 +159,8 @@ export class SharedPieceStore {
151
159
  * store is exhausted, when in fact it is merely mid-flight.
152
160
  */
153
161
  #outstandingSlots = 0;
162
+ /** When the wait for a pinned piece began; 0 when nothing is waiting. */
163
+ #pinnedWaitStartedAt = 0;
154
164
  /** Resolvers waiting for a slot to become claimable. @type {(() => void)[]} */
155
165
  #waiters = [];
156
166
  #lru;
@@ -169,7 +179,8 @@ export class SharedPieceStore {
169
179
  fromDisk: 0,
170
180
  spills: 0,
171
181
  revivals: 0,
172
- blockedByPins: 0
182
+ blockedByPins: 0,
183
+ waitedForPins: 0
173
184
  };
174
185
 
175
186
  /**
@@ -308,6 +319,8 @@ export class SharedPieceStore {
308
319
  */
309
320
  unpin(index) {
310
321
  this.#lru.unpin(index);
322
+ // A released pin can be exactly what a caller waiting for a slot needs.
323
+ this.#wake();
311
324
  }
312
325
 
313
326
  /**
@@ -330,6 +343,15 @@ export class SharedPieceStore {
330
343
  for (const spill of this.#evicting.values()) {
331
344
  void spill.then(() => this.#wake(), () => this.#wake());
332
345
  }
346
+ // A wake is not guaranteed to come. Waiting for a spill is safe — one
347
+ // is in flight and will finish — but waiting for a PIN to be released
348
+ // is not: if every piece is held and nothing else is happening, there
349
+ // is no event left to fire, and the deadline that gives up cannot be
350
+ // reached because it is only tested inside an attempt. That is a hang,
351
+ // and it hung this store's own test for the full ten minutes a run is
352
+ // allowed. So the wait also re-checks on a timer.
353
+ const retry = setTimeout(() => this.#wake(), CLAIM_RETRY_MS);
354
+ retry.unref?.();
333
355
  });
334
356
  }
335
357
  }
@@ -394,11 +416,34 @@ export class SharedPieceStore {
394
416
  if (this.#evicting.size > 0 || this.#outstandingSlots > 0) {
395
417
  return null;
396
418
  }
397
- // Every resident piece is being read. Taking one anyway is precisely the
398
- // failure this store exists to make impossible.
419
+ // Every resident piece is being READ right now. That is not a permanent
420
+ // condition: a pin lasts as long as one read of one piece, and the reader
421
+ // releases it a moment later. So wait for that, exactly as the loop above
422
+ // waits for a spill — pins now wake the waiters.
423
+ //
424
+ // It became reachable when a viewer could have three readers on one file
425
+ // (2026-08-15: picture, the audio track chosen and the one left behind);
426
+ // failing here ended a read with zero bytes, which ffmpeg reads as the
427
+ // end of the file, so every encoder died and the session answered 500 to
428
+ // everything after that.
429
+ //
430
+ // The deadline is what keeps a genuine deadlock visible: a reader that
431
+ // holds a pin while waiting for a slot would otherwise wait for itself
432
+ // for ever.
433
+ if (this.#pinnedWaitStartedAt === 0) {
434
+ this.#pinnedWaitStartedAt = Date.now();
435
+ }
436
+ if (Date.now() - this.#pinnedWaitStartedAt < PINNED_WAIT_MS) {
437
+ this.#counters.waitedForPins += 1;
438
+ return null;
439
+ }
440
+ this.#pinnedWaitStartedAt = 0;
399
441
  this.#counters.blockedByPins += 1;
400
- throw new Error("Every resident piece is pinned; no slot can be freed.");
442
+ throw new Error(
443
+ `Every resident piece is pinned and none was released in ${PINNED_WAIT_MS}ms; no slot can be freed.`
444
+ );
401
445
  }
446
+ this.#pinnedWaitStartedAt = 0;
402
447
 
403
448
  const slot = this.#slotOf.get(victim);
404
449
 
@@ -703,3 +703,36 @@ test("a stale buffer report is not used to place an audio track", async (t) => {
703
703
  "the whole look-ahead is subtracted instead — it cannot leave the run ahead of the viewer"
704
704
  );
705
705
  });
706
+
707
+ test("an audio track is prepared at the position the switch will land on", async (t) => {
708
+ const { manager, base, dirPath } = await managerWithBase();
709
+ t.after(async () => {
710
+ await manager.disposeAll();
711
+ await rm(dirPath, { recursive: true, force: true });
712
+ });
713
+ base.audioSeparate = true;
714
+ manager.getCachedAudioTracks = () => [
715
+ { index: 0, language: "rus", title: "", isDefault: true },
716
+ { index: 1, language: "eng", title: "", isDefault: false }
717
+ ];
718
+ const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
719
+ rendition.audioOnly = true;
720
+ rendition.audioTrackIndex = 1;
721
+ rendition.ffmpeg = fakeEncoder();
722
+ rendition.encodeStartIndex = 0;
723
+ manager.sessionsById.set(VARIANT_ID, rendition);
724
+ base.audioRenditionSessions = new Map([[1, VARIANT_ID]]);
725
+
726
+ const prepared = await manager.prepareAudioTrack(BASE_ID, 1, 240);
727
+
728
+ assert.deepEqual(
729
+ prepared,
730
+ { sessionId: VARIANT_ID, fileName: "segment-00060.mp4" },
731
+ "the caller is told which segment to wait for — 240 s on a four-second grid"
732
+ );
733
+ assert.equal(
734
+ rendition.seekTarget,
735
+ 59,
736
+ "and the track is pointed at the switch position, one back for the preceding keyframe"
737
+ );
738
+ });