@torrent-tv/proxy 2.9.110 → 2.9.112

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.9.112
2
+
3
+ - **New**: A session whose data went away now waits for it to come back instead of dying. Losing the input is not the session failing — the torrent can be added again and the pieces downloaded again — but a run that died that way marked the session terminal, and every request for the playlist answered 500 from then on, although the swarm was right there and the data would have returned in seconds. Such a run is now retried at the position the viewer is waiting at, backing off from 2 s to at most 15 s so a source that is genuinely unavailable costs a process every few seconds rather than continuously, and the requests being held are simply held: nothing is broken and there is nothing for the viewer to retry. The circuit breaker stays for what it was built for — a target that truly cannot be encoded — and no longer condemns a session that merely lost its data. Which of the two happened is decided by the message, tested against the exact ones the field produced.
4
+
5
+ ## 2.9.111
6
+
7
+ - **Fix**: The film being watched could be deleted mid-seek. A torrent's data is protected only by a claim that READS take, and a seek leaves a gap with no read at all — the old encoder is dead, the new one has not started. The thirty-second disk sweep met that gap on 2026-08-06: with the cap exceeded it evicted "the idle torrent" that a viewer was in the middle of, deleted six gigabytes, and the new encoder found nothing to read. The session's own thirty minutes never governed the data underneath it, because the pool was never told a session existed. A session now holds its source for as long as it lives, and lets go when it is disposed.
8
+ - **Fix**: The encoder is no longer suspended on ffmpeg's word alone. How far it has run ahead was taken from the position ffmpeg reports for itself, and that is not evidence: field 2026-08-06, it claimed 6012 s processed at `speed=1.18e+03x` on a file one percent downloaded with exactly one segment on disk. The limiter believed it, suspended the encoder twelve seconds into the session, and segment #1 — which nobody was now producing — was held for 45.7 s until the viewer gave up and seeked. It is now measured by the segments that exist, which is what the viewer can actually be served, and a wide disagreement between the two is logged, since that is the only trace of whatever made ffmpeg report a position it had not reached.
9
+
1
10
  ## 2.9.110
2
11
 
3
12
  - **Fix**: A torrent the pool had destroyed was still being handed to readers, which killed every later session for that source. The torrent thread remembers each source as a promise and only ever forgot one when the ADD failed — but the pool destroys a torrent that has gone unread for a quarter of an hour, and under disk pressure, clearing its own map and knowing nothing about this one. The promise then resolved to a corpse: a destroyed torrent keeps its object and loses its files. Nothing noticed, because by then everything else answers from cache — measured 2026-08-06 on two sessions in a row, the plan came back in 23 ms and the session was created in 2 ms, so no step waited for metadata, and ffmpeg's first read died 130 ms in with `File 0 not found in torrent:…`; every request for the playlist then answered 500 until the viewer gave up. Both sessions were from a phone on a cellular link, which is what made it look like a connectivity problem — ICE had in fact connected in 0.84 s over reflexive addresses and both data channels were open. A handle that cannot be read from is now replaced rather than returned: the source is added again, using the recipe the thread now keeps for exactly this. Covered by tests.
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.110",
3
+ "version": "2.9.112",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
9
  "type": "module",
10
+ "engines": {
11
+ "node": ">=24 <25"
12
+ },
10
13
  "bin": {
11
14
  "torrent-tv-proxy": "./bin/cli.js"
12
15
  },
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @param {import("fastify").FastifyRequest} req
7
7
  * @param {import("fastify").FastifyReply} reply
8
- * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
8
+ * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager, sourceRegistry: object, torrentPool: object }} deps
9
9
  * @returns {Promise<void>}
10
10
  */
11
11
 
@@ -25,7 +25,41 @@ function getPayload(body) {
25
25
  return {};
26
26
  }
27
27
 
28
- export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager }) {
28
+ /**
29
+ * Claim the source's file so the pool cannot clean it up under a live session.
30
+ *
31
+ * Asynchronous underneath — the torrent lives on another thread — but the
32
+ * caller needs a release function immediately, so the claim is chased and the
33
+ * release waits for it.
34
+ *
35
+ * @param {{ sourceRegistry: object, torrentPool: object, sourceKey: string, fileIndex: number }} params
36
+ * @returns {() => void}
37
+ */
38
+ function holdSource({ sourceRegistry, torrentPool, sourceKey, fileIndex }) {
39
+ let release = null;
40
+ let releasedEarly = false;
41
+ const record = sourceRegistry?.get?.(sourceKey);
42
+ if (!record) {
43
+ return () => {};
44
+ }
45
+ void Promise.resolve(torrentPool.getTorrent(record.sourceType, record.source))
46
+ .then((torrent) => {
47
+ release = torrentPool.acquireFile(torrent, fileIndex);
48
+ if (releasedEarly) {
49
+ release();
50
+ }
51
+ })
52
+ .catch(() => {});
53
+ return () => {
54
+ releasedEarly = true;
55
+ if (typeof release === "function") {
56
+ release();
57
+ release = null;
58
+ }
59
+ };
60
+ }
61
+
62
+ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
29
63
  const payload = getPayload(req.body);
30
64
  const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
31
65
  const fileIndex = Number(payload.fileIndex);
@@ -68,7 +102,12 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
68
102
  : 0,
69
103
  audioTrackIndex:
70
104
  Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
71
- segmentFormatId
105
+ segmentFormatId,
106
+ // Hold the torrent for as long as this session lives. Reads take a claim
107
+ // only while they run, and a seek leaves a gap with no read at all — the
108
+ // disk sweep caught that gap on 2026-08-06 and deleted the film being
109
+ // watched.
110
+ acquireSource: () => holdSource({ sourceRegistry, torrentPool, sourceKey, fileIndex })
72
111
  });
73
112
  return reply.send({
74
113
  sessionId: session.id,
package/server.js CHANGED
@@ -198,7 +198,7 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
198
198
  handleStreamGet(req, reply, { sourceRegistry, torrentPool })
199
199
  );
200
200
  app.post("/api/transcode-sessions", async (req, reply) =>
201
- handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager })
201
+ handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool })
202
202
  );
203
203
  app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
204
204
  handleApiTranscodeSessionReleasePost(req, reply, { hlsSessionManager })
@@ -36,6 +36,33 @@ import {
36
36
  } from "./ffmpeg-banner.js";
37
37
  import { resolveSegmentFormat, SEGMENT_FORMAT_IDS } from "./segment-formats/index.js";
38
38
 
39
+ /**
40
+ * Whether an encoder run died because its INPUT went away, rather than because
41
+ * of anything about the encode itself.
42
+ *
43
+ * These are the messages the read path and ffmpeg's HTTP client produce when
44
+ * the torrent is gone, being re-added, or has no data for the range yet — all
45
+ * of them temporary by nature: the source can be added again and the pieces
46
+ * fetched again.
47
+ *
48
+ * @param {string} message
49
+ * @returns {boolean}
50
+ */
51
+ export function isInputUnavailable(message) {
52
+ const text = typeof message === "string" ? message : "";
53
+ return (
54
+ /Error reading HTTP response/i.test(text) ||
55
+ /not found in (?:magnet|torrent):/i.test(text) ||
56
+ /Unknown source/i.test(text) ||
57
+ /is gone and cannot be re-added/i.test(text) ||
58
+ /Read error at pos/i.test(text) ||
59
+ /Server returned 5\d\d/i.test(text) ||
60
+ /Input\/output error/i.test(text) ||
61
+ /Connection reset by peer/i.test(text) ||
62
+ /End of file/i.test(text)
63
+ );
64
+ }
65
+
39
66
  const PLAYLIST_FILE_NAME = "index.m3u8";
40
67
  const CLEANUP_INTERVAL_MS = 30_000;
41
68
  const DEFAULT_SEGMENT_DURATION_SEC = 4;
@@ -130,6 +157,13 @@ const SEEK_FAST_FAIL_MS = 2_000;
130
157
  // for whatever residual case still fails — not a second competing "fix" that
131
158
  // blindly retries the identical command hoping for a different result.
132
159
  const MAX_SEEK_FAILURES = 3;
160
+ // A run that lost its INPUT is retried rather than condemned: the torrent can
161
+ // be added again and the pieces downloaded again, so the data being gone is a
162
+ // wait, not a verdict. Backed off so a source that is truly unavailable costs a
163
+ // process every few seconds rather than continuously, and never given up on —
164
+ // the session's own idle TTL is what ends it if the viewer leaves.
165
+ const INPUT_RETRY_BASE_MS = 2_000;
166
+ const INPUT_RETRY_MAX_MS = 15_000;
133
167
  // Idle TTL: a session is disposed this long after the last segment/playlist
134
168
  // access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
135
169
  // turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
@@ -935,7 +969,12 @@ export class HlsSessionManager {
935
969
  startPositionSeconds = 0,
936
970
  audioTrackIndex = 0,
937
971
  manualQuality = false,
938
- segmentFormatId = ""
972
+ segmentFormatId = "",
973
+ // Called once for a session that is actually created, and expected to
974
+ // return a function that lets the source go. It is what keeps the torrent's
975
+ // data alive for as long as a viewer has a session on it — see
976
+ // disposeSession.
977
+ acquireSource = null
939
978
  }) {
940
979
  if (!this.enabled) {
941
980
  const error = new Error("Audio transcoding is disabled on this proxy.");
@@ -1318,6 +1357,13 @@ export class HlsSessionManager {
1318
1357
  lastLoggedAt: 0
1319
1358
  }
1320
1359
  };
1360
+ if (typeof acquireSource === "function") {
1361
+ try {
1362
+ session.releaseSource = acquireSource();
1363
+ } catch {
1364
+ session.releaseSource = null;
1365
+ }
1366
+ }
1321
1367
  this.sessionsById.set(sessionId, session);
1322
1368
  this.sessionIdBySource.set(sourceMapKey, sessionId);
1323
1369
 
@@ -1780,10 +1826,31 @@ export class HlsSessionManager {
1780
1826
  if (!session || session.state === "disposed" || !session.ffmpeg) {
1781
1827
  return;
1782
1828
  }
1783
- const encodedTo = Number(session.progress?.processedSeconds);
1829
+ // How far the encoder has got, measured by what EXISTS. ffmpeg's own
1830
+ // report of its timeline position is not evidence: field 2026-08-06, it
1831
+ // claimed 6012 s at `speed=1.18e+03x` on a file that was one percent
1832
+ // downloaded and had produced exactly one segment. The limiter believed it,
1833
+ // suspended the encoder twelve seconds into the session, and segment #1 —
1834
+ // which nobody was now making — was held for 45.7 s until the viewer gave
1835
+ // up and seeked. A segment on disk is something the viewer can be served;
1836
+ // a number from ffmpeg is not.
1837
+ const producedThrough = this.#latestProducedSegment(session);
1838
+ if (producedThrough === null) {
1839
+ return;
1840
+ }
1841
+ const encodedTo = this.#segmentStartTime(session, producedThrough + 1);
1784
1842
  if (!Number.isFinite(encodedTo)) {
1785
1843
  return;
1786
1844
  }
1845
+ // Worth knowing when the two disagree wildly — it is the only trace of
1846
+ // whatever made ffmpeg report a position it had not reached.
1847
+ const claimed = Number(session.progress?.processedSeconds);
1848
+ if (Number.isFinite(claimed) && Math.abs(claimed - encodedTo) > LOOKAHEAD_PAUSE_SECONDS) {
1849
+ logger.info(
1850
+ `transcode ${session.id} ffmpeg claims ${Math.round(claimed)}s processed ` +
1851
+ `but has produced through ${Math.round(encodedTo)}s (segment #${producedThrough})`
1852
+ );
1853
+ }
1787
1854
  // Where the viewer is. Before the first segment request, the position the
1788
1855
  // run started at — so a session nobody has read from yet is bounded too.
1789
1856
  const viewerAt = Number.isInteger(session.lastRequestedSegment)
@@ -2473,6 +2540,45 @@ export class HlsSessionManager {
2473
2540
  session.seekFailureTarget = -1;
2474
2541
  session.seekFailureCount = 0;
2475
2542
  }
2543
+ // Losing the INPUT is not the session failing — it is the data not being
2544
+ // there YET. The torrent can be re-added and re-downloaded, so the
2545
+ // honest answer to the viewer is "still working", not an error screen.
2546
+ // Field 2026-08-06: a torrent evicted mid-seek took the film with it, the
2547
+ // run died on `File 0 not found`, the session went terminal and answered
2548
+ // 500 to every request from then on — although the swarm was there and
2549
+ // the data would have come back in seconds. The circuit breaker below
2550
+ // stays for what it was built for, a target that genuinely cannot be
2551
+ // encoded; it must not condemn a session whose data merely went away.
2552
+ if (isInputUnavailable(session.lastError)) {
2553
+ session.state = "recovering";
2554
+ // On the wire it is simply "not ready yet" — a state the browser has
2555
+ // always known how to wait through. Only the proxy needs the
2556
+ // distinction between waiting for data and having given up.
2557
+ session.progress.state = "starting";
2558
+ session.progress.updatedAt = Date.now();
2559
+ session.inputRetryCount = (session.inputRetryCount ?? 0) + 1;
2560
+ const delayMs = Math.min(
2561
+ INPUT_RETRY_MAX_MS,
2562
+ INPUT_RETRY_BASE_MS * 2 ** Math.min(session.inputRetryCount - 1, 6)
2563
+ );
2564
+ logger.warn(
2565
+ `transcode ${session.id} ${session.runLabel ?? "run#?"} lost its input ` +
2566
+ `(${session.lastError}); retrying in ${Math.round(delayMs / 1000)}s ` +
2567
+ `(attempt ${session.inputRetryCount})`
2568
+ );
2569
+ session.inputRetryTimer = setTimeout(() => {
2570
+ session.inputRetryTimer = null;
2571
+ if (session.state !== "recovering") {
2572
+ return;
2573
+ }
2574
+ const at = Number.isInteger(session.lastRequestedSegment)
2575
+ ? session.lastRequestedSegment
2576
+ : (session.encodeStartIndex ?? 0);
2577
+ this.#startEncodeRun(session, at).catch(() => {});
2578
+ }, delayMs);
2579
+ session.inputRetryTimer.unref?.();
2580
+ return;
2581
+ }
2476
2582
  session.state = "failed";
2477
2583
  session.progress.state = "failed";
2478
2584
  session.progress.updatedAt = Date.now();
@@ -2916,6 +3022,12 @@ export class HlsSessionManager {
2916
3022
  if (!session || !isSafeFileName(fileName, session.segmentFormat)) {
2917
3023
  return { kind: "not-found" };
2918
3024
  }
3025
+ if (session.state === "recovering") {
3026
+ // The data went away and is being fetched again. Holding the request is
3027
+ // the truthful answer: nothing is broken and there is nothing for the
3028
+ // viewer to retry.
3029
+ return { kind: "warming-up" };
3030
+ }
2919
3031
  if (session.state === "failed") {
2920
3032
  return {
2921
3033
  kind: "failed",
@@ -3031,6 +3143,9 @@ export class HlsSessionManager {
3031
3143
  // — the time from session-create entry to a playable first segment.
3032
3144
  if (!isPlaylist && !session.firstSegmentLogged) {
3033
3145
  session.firstSegmentLogged = true;
3146
+ // Data is flowing again, so the next loss starts its backoff afresh
3147
+ // rather than inheriting the delay of the last one.
3148
+ session.inputRetryCount = 0;
3034
3149
  this.#rememberFirstSegmentLatency(Date.now() - session.createEntryMs);
3035
3150
  logger.info(
3036
3151
  `cold-start ${sessionId.slice(0, 8)}: first-segment ready +${Date.now() - session.createEntryMs}ms`
@@ -3206,12 +3321,33 @@ export class HlsSessionManager {
3206
3321
  this.sessionsById.delete(sessionId);
3207
3322
  this.sessionIdBySource.delete(session.sourceMapKey);
3208
3323
 
3324
+ // Let go of the source. While a session exists its torrent must survive
3325
+ // both cleanups the pool runs, and until now neither knew about it: the
3326
+ // only claim on a file is taken by a READ, and during a seek there is no
3327
+ // read at all — the old encoder is dead and the new one has not started.
3328
+ // Field 2026-08-06, that window met the thirty-second disk sweep and the
3329
+ // film being watched was evicted mid-seek, six gigabytes deleted, after
3330
+ // which the new encoder had nothing to read. The session's own thirty
3331
+ // minutes governed the session, never the data under it.
3332
+ if (typeof session.releaseSource === "function") {
3333
+ try {
3334
+ session.releaseSource();
3335
+ } catch {
3336
+ // Best effort — a session must always finish being disposed.
3337
+ }
3338
+ session.releaseSource = null;
3339
+ }
3340
+
3209
3341
  // Clear any pending seek-settle timer so it cannot fire and restart a
3210
3342
  // disposed session.
3211
3343
  if (session.seekSettleTimer) {
3212
3344
  clearTimeout(session.seekSettleTimer);
3213
3345
  session.seekSettleTimer = null;
3214
3346
  }
3347
+ if (session.inputRetryTimer) {
3348
+ clearTimeout(session.inputRetryTimer);
3349
+ session.inputRetryTimer = null;
3350
+ }
3215
3351
 
3216
3352
  this.#resumeEncoder(session, "session disposed");
3217
3353
  if (session.ffmpeg && !session.ffmpeg.killed) {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @file Telling "the data is not here yet" from "this cannot be encoded".
3
+ *
4
+ * A run that dies because its input went away used to condemn the whole
5
+ * session: state `failed`, and every request for the playlist answered 500
6
+ * from then on. But the torrent can be added again and the pieces downloaded
7
+ * again, so the data being gone is a wait, not a verdict — measured 2026-08-06,
8
+ * a torrent evicted mid-seek killed a session whose swarm was right there and
9
+ * whose data would have come back in seconds.
10
+ *
11
+ * The classification is what decides which of the two happened, so it is tested
12
+ * on the exact messages the field produced.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { isInputUnavailable } from "../services/hls-session-manager.js";
18
+
19
+ test("the messages the field produced when data went away are all temporary", () => {
20
+ for (const message of [
21
+ "[http @ 0x7f99b19f00] Error reading HTTP response: End of file",
22
+ "File 0 not found in torrent:17c46f5c36b94865858bfeaa412693c097328a50.",
23
+ "File 5 not found in magnet:8d3b6c2e74df473e3649521f927ae33b20cd9e67.",
24
+ "Unknown source 9711bbde2debdcd0d1fbd8cf88d68fd9612e5d31.",
25
+ "[in#0/matroska,webm @ 0x7f9005f340] Read error at pos. 138556 (0x21d3c)",
26
+ "Server returned 503 Service Unavailable",
27
+ "Input/output error"
28
+ ]) {
29
+ assert.equal(isInputUnavailable(message), true, `should be retried: ${message}`);
30
+ }
31
+ });
32
+
33
+ test("a real encoding failure is not mistaken for one", () => {
34
+ for (const message of [
35
+ "Cannot write moov atom before AC3 packets. Set the delay_moov flag to fix this.",
36
+ "Could not write header (incorrect codec parameters ?): Invalid argument",
37
+ "Unknown encoder 'h264_v4l2m2m'",
38
+ "ffmpeg exited with code 1"
39
+ ]) {
40
+ assert.equal(isInputUnavailable(message), false, `should stay terminal: ${message}`);
41
+ }
42
+ });
43
+
44
+ test("nothing at all is not a reason to retry for ever", () => {
45
+ assert.equal(isInputUnavailable(""), false);
46
+ assert.equal(isInputUnavailable(undefined), false);
47
+ assert.equal(isInputUnavailable(null), false);
48
+ });