@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 +9 -0
- package/package.json +4 -1
- package/routes/api/transcode-sessions/post.js +42 -3
- package/server.js +1 -1
- package/services/hls-session-manager.js +138 -2
- package/test/input-unavailable.test.js +48 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|