@torrent-tv/proxy 2.15.0 → 2.15.2

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,11 @@
1
+ ## 2.15.2
2
+
3
+ - **Fix**: A segment request that can never be answered is answered as absent instead of being held for a minute. Changing audio track makes hls.js ask the new stream for segment #0 before anything else; the run was at #354, the repair reaches sixty segments back and no further, no seek was coming, and an encoder only moves forward — so the request was unanswerable from the moment it arrived, and holding it simply spent the player's own patience. Measured 2026-08-15: the track was made ready in 7.1 s at the viewer's position, and the viewer then watched a spinner for **63 s** — sixty of them the hold, the rest the player recovering after it failed. Deliberately narrower than the refusal 2.14.1 shipped and 2.14.2 withdrew: a request within the repair's reach, or one with a seek on its way, is still held, because for those the encoder is about to be moved there.
4
+
5
+ ## 2.15.1
6
+
7
+ - **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.
8
+
1
9
  ## 2.15.0
2
10
 
3
11
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.15.0",
3
+ "version": "2.15.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -6067,6 +6067,35 @@ export class HlsSessionManager {
6067
6067
  // at this position (server-side seeking). The caller long-polls.
6068
6068
  if (!isPlaylist) {
6069
6069
  const requestedIndex = session.segmentFormat.segmentIndexFromName(fileName);
6070
+ // Unanswerable, and known to be: behind a run that only moves forward,
6071
+ // too far behind for the repair to fetch it, and no seek on its way to
6072
+ // move the encoder there. Holding it changes nothing about whether it can
6073
+ // be produced — it only spends the player's patience.
6074
+ //
6075
+ // This is what a track change costs when it is held instead: measured
6076
+ // 2026-08-15, hls.js asked the new track for segment #0 while the run was
6077
+ // at #354, the request was held for the full minute, and only when it
6078
+ // failed did the player move to the segment it actually needed — 63 s of
6079
+ // spinner after a track that had been made ready in 7.
6080
+ //
6081
+ // Narrow on purpose. A request behind the head is USUALLY temporary: the
6082
+ // repair moves the encoder back for anything within its reach, and a
6083
+ // reported seek is about to move it anyway. Refusing those was 2.14.1,
6084
+ // and it left a viewer retrying a 404 for ever.
6085
+ if (
6086
+ Number.isFinite(requestedIndex) &&
6087
+ requestedIndex < (session.encodeStartIndex ?? 0) &&
6088
+ (session.encodeStartIndex ?? 0) - requestedIndex > BEHIND_HEAD_REPAIR_MAX_SEGMENTS &&
6089
+ session.ffmpeg != null &&
6090
+ session.seekTarget == null &&
6091
+ session.seekSettleTimer == null
6092
+ ) {
6093
+ logger.info(
6094
+ `transcode ${session.id} segment #${requestedIndex} is ${(session.encodeStartIndex ?? 0) - requestedIndex} ` +
6095
+ `segments behind the run and beyond the repair's reach; answered as absent rather than held`
6096
+ );
6097
+ return { kind: "not-found" };
6098
+ }
6070
6099
  this.#ensureEncodingFor(
6071
6100
  session,
6072
6101
  requestedIndex,
@@ -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
  }
@@ -108,12 +108,11 @@ test("a burst of different segments behind the run is a scan, and moves nothing"
108
108
  for (const index of scanned) {
109
109
  session.firstWantedAt.set(index, Date.now() - 1000);
110
110
  }
111
- // Interleaved, as they arrive on the wire: the player opens them together
112
- // rather than finishing with one before opening the next.
113
- for (const seq of [1, 2]) {
114
- for (const index of scanned) {
115
- await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(index), { requestSeq: seq });
116
- }
111
+ // Asked ONCE each, which is what a scan is: the player opens them together
112
+ // and abandons them together. Field log 2026-08-02 #178, #681, #725, #807,
113
+ // #74, #245, #387 within half a second, none of them repeated.
114
+ for (const index of scanned) {
115
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(index), { requestSeq: 1 });
117
116
  }
118
117
 
119
118
  assert.equal(
@@ -219,3 +218,34 @@ test("a request ahead of the run is not touched", async (t) => {
219
218
  "the running encode may yet reach it; restarting on a far request is what produced nine restarts in a minute"
220
219
  );
221
220
  });
221
+
222
+ test("a request far beyond the repair's reach is answered at once, not held", async (t) => {
223
+ const { manager, session, dirPath } = await managerWithRunAhead();
224
+ t.after(async () => {
225
+ await manager.disposeAll();
226
+ await rm(dirPath, { recursive: true, force: true });
227
+ });
228
+ // What a track change does: hls.js asks the new stream for segment #0 while
229
+ // the run is hundreds of segments in. The repair cannot reach it, no seek is
230
+ // coming, and the run only moves forward — so it can never be produced.
231
+ // Measured 2026-08-15: held for the full minute, and the viewer watched a
232
+ // spinner for 63 s after a track that had been made ready in 7.
233
+ const answer = await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(0), { requestSeq: 1 });
234
+
235
+ assert.equal(answer.kind, "not-found", "answered, so the player can move on to what it can have");
236
+ assert.equal(session.seekTarget, null, "and the encoder was not sent to the start of the film for it");
237
+ });
238
+
239
+ test("a request just behind the run is still held, because the repair will fetch it", async (t) => {
240
+ const { manager, session, dirPath } = await managerWithRunAhead();
241
+ t.after(async () => {
242
+ await manager.disposeAll();
243
+ await rm(dirPath, { recursive: true, force: true });
244
+ });
245
+ const name = fmp4Format.segmentFileName(WANTED);
246
+ session.firstWantedAt.set(WANTED, Date.now() - 1000);
247
+
248
+ const answer = await manager.getFileStream(SESSION_ID, name, { requestSeq: 1 });
249
+
250
+ assert.equal(answer.kind, "warming-up", "within reach: the encoder is about to be moved there");
251
+ });
@@ -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
+ });