@torrent-tv/proxy 2.9.111 → 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,7 @@
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
+
1
5
  ## 2.9.111
2
6
 
3
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.111",
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": {
@@ -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
@@ -2506,6 +2540,45 @@ export class HlsSessionManager {
2506
2540
  session.seekFailureTarget = -1;
2507
2541
  session.seekFailureCount = 0;
2508
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
+ }
2509
2582
  session.state = "failed";
2510
2583
  session.progress.state = "failed";
2511
2584
  session.progress.updatedAt = Date.now();
@@ -2949,6 +3022,12 @@ export class HlsSessionManager {
2949
3022
  if (!session || !isSafeFileName(fileName, session.segmentFormat)) {
2950
3023
  return { kind: "not-found" };
2951
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
+ }
2952
3031
  if (session.state === "failed") {
2953
3032
  return {
2954
3033
  kind: "failed",
@@ -3064,6 +3143,9 @@ export class HlsSessionManager {
3064
3143
  // — the time from session-create entry to a playable first segment.
3065
3144
  if (!isPlaylist && !session.firstSegmentLogged) {
3066
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;
3067
3149
  this.#rememberFirstSegmentLatency(Date.now() - session.createEntryMs);
3068
3150
  logger.info(
3069
3151
  `cold-start ${sessionId.slice(0, 8)}: first-segment ready +${Date.now() - session.createEntryMs}ms`
@@ -3262,6 +3344,10 @@ export class HlsSessionManager {
3262
3344
  clearTimeout(session.seekSettleTimer);
3263
3345
  session.seekSettleTimer = null;
3264
3346
  }
3347
+ if (session.inputRetryTimer) {
3348
+ clearTimeout(session.inputRetryTimer);
3349
+ session.inputRetryTimer = null;
3350
+ }
3265
3351
 
3266
3352
  this.#resumeEncoder(session, "session disposed");
3267
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
+ });