@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
|
@@ -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
|
+
});
|