@torrent-tv/proxy 2.14.3 → 2.15.1

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,13 @@
1
+ ## 2.15.1
2
+
3
+ - **Fix**: A magnet that never found its metadata no longer makes the same film unplayable from its own `.torrent`. One infohash is one torrent, so the second add is refused and the pool takes the one already there — which is right when it is ready and wrong when it is not: a magnet whose swarm has not answered has no file list, and everything bound to it is answered 404. Measured 2026-08-15 on the addon host: a magnet with no reachable trackers was added first, the film's own `.torrent` then joined that empty torrent instead of replacing it, `/stream` answered 404, the encoder died on its first read, and the film stayed unplayable until the proxy was restarted. A `.torrent` carries the file list, the piece hashes and the trackers outright, so when it meets a torrent with no metadata it now replaces it; two magnets still wait, because neither has anything the other lacks.
4
+
5
+ ## 2.15.0
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+
1
11
  ## 2.14.3
2
12
 
3
13
  - **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.1",
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
 
@@ -315,6 +315,30 @@ function computeDefaultDiskCap(storePath) {
315
315
  * @param {string} source - Magnet URI or base64-encoded .torrent file.
316
316
  * @returns {string | Buffer}
317
317
  */
318
+ /**
319
+ * What to do when WebTorrent refuses an add because that infohash is already
320
+ * here: take the one that exists, replace it, or wait for it to be ready.
321
+ *
322
+ * The same content arrives as a `.torrent` and as a magnet — different pool
323
+ * keys, one swarm — so a duplicate is ordinary. What is NOT ordinary is a
324
+ * duplicate with no metadata: a magnet whose swarm has not answered has no file
325
+ * list, and every request bound to it is answered 404 for as long as it lives.
326
+ * If this add carries metadata — a `.torrent` holds the files, the piece hashes
327
+ * and the trackers outright — waiting for the swarm to supply what is already
328
+ * in our hands is waiting for nothing. Measured 2026-08-15: one magnet with no
329
+ * reachable peers made the same film unplayable from its own `.torrent` until
330
+ * the proxy was restarted.
331
+ *
332
+ * @param {{ existingIsReady: boolean, incomingHasMetadata: boolean }} params
333
+ * @returns {"adopt" | "replace" | "wait"}
334
+ */
335
+ export function duplicateAddDecision({ existingIsReady, incomingHasMetadata }) {
336
+ if (existingIsReady) {
337
+ return "adopt";
338
+ }
339
+ return incomingHasMetadata ? "replace" : "wait";
340
+ }
341
+
318
342
  function decodeTorrentSource(sourceType, source) {
319
343
  if (sourceType === "magnet") {
320
344
  return source;
@@ -972,11 +996,58 @@ export class TorrentPool {
972
996
  this.#pending.delete(key);
973
997
  resolve(existing);
974
998
  };
975
- if (existing.ready) {
999
+ const decision = duplicateAddDecision({
1000
+ existingIsReady: existing.ready === true,
1001
+ incomingHasMetadata: Buffer.isBuffer(torrentId)
1002
+ });
1003
+ if (decision === "adopt") {
976
1004
  settle();
977
- } else {
978
- existing.once("ready", settle);
1005
+ return;
1006
+ }
1007
+ // The one already here has no metadata, and THIS add carries it —
1008
+ // a `.torrent` holds the file list, the piece hashes and the
1009
+ // trackers outright. Waiting for the other one to become ready is
1010
+ // waiting on the swarm to supply what is already in our hands, and
1011
+ // when the swarm cannot (a magnet with no reachable peers) it never
1012
+ // arrives: measured 2026-08-15, a magnet added first left every
1013
+ // later attempt at the same infohash — including the complete
1014
+ // `.torrent` — bound to an empty torrent, `/stream` answering 404
1015
+ // and playback impossible until the proxy was restarted.
1016
+ if (decision === "replace") {
1017
+ logger.info(
1018
+ `torrent-pool: [${dupMatch[1].slice(0, 8)}] replacing a torrent with no metadata ` +
1019
+ `with the .torrent just given, which has it`
1020
+ );
1021
+ // Everything this class remembers about the old one goes with it,
1022
+ // or a later request finds it through a map and works with a
1023
+ // torrent the client no longer has.
1024
+ for (const [otherKey, value] of this.torrents) {
1025
+ if (value === existing) {
1026
+ this.torrents.delete(otherKey);
1027
+ }
1028
+ }
1029
+ this.fileUsageByTorrent.delete(existing);
1030
+ this.#lastAccess.delete(existing);
1031
+ this.#readPositionByTorrent.delete(existing);
1032
+ this.client.remove(existing, { destroyStore: true }, () => {
1033
+ this.client.add(torrentId, {
1034
+ store: SharedPieceStore,
1035
+ storeCacheSlots: 0,
1036
+ storeOpts: { memoryBytes: this.#memoryBytes }
1037
+ }, (replacement) => {
1038
+ this.torrents.set(key, replacement);
1039
+ this.#lastAccess.set(replacement, Date.now());
1040
+ this.#attachSwarmDiagnostics(dupMatch[1].slice(0, 8), replacement);
1041
+ this.#pending.delete(key);
1042
+ resolve(replacement);
1043
+ });
1044
+ });
1045
+ return;
979
1046
  }
1047
+ // Both are magnets: there is nothing here the other does not have,
1048
+ // so wait for the swarm — the caller's own bound answers if it
1049
+ // takes too long.
1050
+ existing.once("ready", settle);
980
1051
  return;
981
1052
  }
982
1053
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @file One infohash, two sources: what happens when the second add is refused.
3
+ *
4
+ * The same film arrives as a magnet and as a `.torrent`. WebTorrent refuses the
5
+ * second add — one infohash, one torrent — and the pool then has to decide what
6
+ * to hand back. Adopting the one already there is right when it is ready, and
7
+ * wrong when it is not: a magnet whose swarm has not answered has no file list,
8
+ * so everything bound to it is answered 404.
9
+ *
10
+ * Measured 2026-08-15 on the addon host: a magnet with no reachable trackers
11
+ * was added first and never became ready; the same film's own `.torrent`, which
12
+ * carries the file list, the piece hashes and the trackers, then joined that
13
+ * empty torrent instead of replacing it. `/stream` answered 404, the encoder
14
+ * died on its first read, and the film stayed unplayable until the proxy was
15
+ * restarted — one bad magnet poisoning every later attempt at that infohash.
16
+ */
17
+
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { duplicateAddDecision } from "../services/torrent-pool.js";
21
+
22
+ test("a ready torrent is adopted, whatever the new source carries", () => {
23
+ assert.equal(
24
+ duplicateAddDecision({ existingIsReady: true, incomingHasMetadata: true }),
25
+ "adopt",
26
+ "one swarm per infohash: a second copy would download the same pieces twice"
27
+ );
28
+ assert.equal(
29
+ duplicateAddDecision({ existingIsReady: true, incomingHasMetadata: false }),
30
+ "adopt"
31
+ );
32
+ });
33
+
34
+ test("an unready torrent is replaced when this add brings the metadata", () => {
35
+ assert.equal(
36
+ duplicateAddDecision({ existingIsReady: false, incomingHasMetadata: true }),
37
+ "replace",
38
+ "the .torrent holds what the swarm was being asked for; waiting for it is waiting for nothing"
39
+ );
40
+ });
41
+
42
+ test("two magnets wait, because neither has anything the other lacks", () => {
43
+ assert.equal(
44
+ duplicateAddDecision({ existingIsReady: false, incomingHasMetadata: false }),
45
+ "wait",
46
+ "replacing here would only restart the same search, and the caller's own bound answers"
47
+ );
48
+ });
@@ -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
+ });