@torrent-tv/proxy 2.9.109 → 2.9.111
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 +52 -2
- package/services/torrent-worker/handle-state.js +23 -0
- package/services/torrent-worker/worker.js +43 -1
- package/test/dead-torrent-handle.test.js +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.9.111
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
- **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.
|
|
5
|
+
|
|
6
|
+
## 2.9.110
|
|
7
|
+
|
|
8
|
+
- **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.
|
|
9
|
+
|
|
1
10
|
## 2.9.109
|
|
2
11
|
|
|
3
12
|
- **Chore**: A session now outlives a vanished browser by thirty minutes instead of ten. The number means something different since server 0.8.103: a browser that holds a session re-asserts it every 30 s, so an open tab never consumes this at all — not while paused, not across a three-hour film. What is left is the case where the browser has genuinely gone, and keeping the session means such a viewer returns to a warm encoder rather than a cold start. While nobody is there the encoder is suspended and burns no CPU; the cost is disk for the produced segments, already bounded by the pool's 10 GB cap with eviction. Thirty minutes covers a meal, a phone call or a lift ride.
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.111",
|
|
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 })
|
|
@@ -935,7 +935,12 @@ export class HlsSessionManager {
|
|
|
935
935
|
startPositionSeconds = 0,
|
|
936
936
|
audioTrackIndex = 0,
|
|
937
937
|
manualQuality = false,
|
|
938
|
-
segmentFormatId = ""
|
|
938
|
+
segmentFormatId = "",
|
|
939
|
+
// Called once for a session that is actually created, and expected to
|
|
940
|
+
// return a function that lets the source go. It is what keeps the torrent's
|
|
941
|
+
// data alive for as long as a viewer has a session on it — see
|
|
942
|
+
// disposeSession.
|
|
943
|
+
acquireSource = null
|
|
939
944
|
}) {
|
|
940
945
|
if (!this.enabled) {
|
|
941
946
|
const error = new Error("Audio transcoding is disabled on this proxy.");
|
|
@@ -1318,6 +1323,13 @@ export class HlsSessionManager {
|
|
|
1318
1323
|
lastLoggedAt: 0
|
|
1319
1324
|
}
|
|
1320
1325
|
};
|
|
1326
|
+
if (typeof acquireSource === "function") {
|
|
1327
|
+
try {
|
|
1328
|
+
session.releaseSource = acquireSource();
|
|
1329
|
+
} catch {
|
|
1330
|
+
session.releaseSource = null;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1321
1333
|
this.sessionsById.set(sessionId, session);
|
|
1322
1334
|
this.sessionIdBySource.set(sourceMapKey, sessionId);
|
|
1323
1335
|
|
|
@@ -1780,10 +1792,31 @@ export class HlsSessionManager {
|
|
|
1780
1792
|
if (!session || session.state === "disposed" || !session.ffmpeg) {
|
|
1781
1793
|
return;
|
|
1782
1794
|
}
|
|
1783
|
-
|
|
1795
|
+
// How far the encoder has got, measured by what EXISTS. ffmpeg's own
|
|
1796
|
+
// report of its timeline position is not evidence: field 2026-08-06, it
|
|
1797
|
+
// claimed 6012 s at `speed=1.18e+03x` on a file that was one percent
|
|
1798
|
+
// downloaded and had produced exactly one segment. The limiter believed it,
|
|
1799
|
+
// suspended the encoder twelve seconds into the session, and segment #1 —
|
|
1800
|
+
// which nobody was now making — was held for 45.7 s until the viewer gave
|
|
1801
|
+
// up and seeked. A segment on disk is something the viewer can be served;
|
|
1802
|
+
// a number from ffmpeg is not.
|
|
1803
|
+
const producedThrough = this.#latestProducedSegment(session);
|
|
1804
|
+
if (producedThrough === null) {
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
const encodedTo = this.#segmentStartTime(session, producedThrough + 1);
|
|
1784
1808
|
if (!Number.isFinite(encodedTo)) {
|
|
1785
1809
|
return;
|
|
1786
1810
|
}
|
|
1811
|
+
// Worth knowing when the two disagree wildly — it is the only trace of
|
|
1812
|
+
// whatever made ffmpeg report a position it had not reached.
|
|
1813
|
+
const claimed = Number(session.progress?.processedSeconds);
|
|
1814
|
+
if (Number.isFinite(claimed) && Math.abs(claimed - encodedTo) > LOOKAHEAD_PAUSE_SECONDS) {
|
|
1815
|
+
logger.info(
|
|
1816
|
+
`transcode ${session.id} ffmpeg claims ${Math.round(claimed)}s processed ` +
|
|
1817
|
+
`but has produced through ${Math.round(encodedTo)}s (segment #${producedThrough})`
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1787
1820
|
// Where the viewer is. Before the first segment request, the position the
|
|
1788
1821
|
// run started at — so a session nobody has read from yet is bounded too.
|
|
1789
1822
|
const viewerAt = Number.isInteger(session.lastRequestedSegment)
|
|
@@ -3206,6 +3239,23 @@ export class HlsSessionManager {
|
|
|
3206
3239
|
this.sessionsById.delete(sessionId);
|
|
3207
3240
|
this.sessionIdBySource.delete(session.sourceMapKey);
|
|
3208
3241
|
|
|
3242
|
+
// Let go of the source. While a session exists its torrent must survive
|
|
3243
|
+
// both cleanups the pool runs, and until now neither knew about it: the
|
|
3244
|
+
// only claim on a file is taken by a READ, and during a seek there is no
|
|
3245
|
+
// read at all — the old encoder is dead and the new one has not started.
|
|
3246
|
+
// Field 2026-08-06, that window met the thirty-second disk sweep and the
|
|
3247
|
+
// film being watched was evicted mid-seek, six gigabytes deleted, after
|
|
3248
|
+
// which the new encoder had nothing to read. The session's own thirty
|
|
3249
|
+
// minutes governed the session, never the data under it.
|
|
3250
|
+
if (typeof session.releaseSource === "function") {
|
|
3251
|
+
try {
|
|
3252
|
+
session.releaseSource();
|
|
3253
|
+
} catch {
|
|
3254
|
+
// Best effort — a session must always finish being disposed.
|
|
3255
|
+
}
|
|
3256
|
+
session.releaseSource = null;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3209
3259
|
// Clear any pending seek-settle timer so it cannot fire and restart a
|
|
3210
3260
|
// disposed session.
|
|
3211
3261
|
if (session.seekSettleTimer) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Whether a torrent handle can still be read from.
|
|
3
|
+
*
|
|
4
|
+
* Its own module so it can be tested: importing the worker starts a torrent
|
|
5
|
+
* client and a piece pool, which a unit test has no business doing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether a torrent handle can still be read from.
|
|
10
|
+
*
|
|
11
|
+
* `destroyed` is WebTorrent's own flag and the earliest signal. The empty file
|
|
12
|
+
* list is the symptom that actually reaches a reader — it is what produced
|
|
13
|
+
* `File 0 not found` in the field — and it is also true of a handle whose
|
|
14
|
+
* metadata has not arrived yet, in which case adding the source again is
|
|
15
|
+
* equally right: the add de-duplicates and yields the same torrent once it is
|
|
16
|
+
* ready.
|
|
17
|
+
*
|
|
18
|
+
* @param {{ destroyed?: boolean, files?: unknown[] } | null | undefined} torrent
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function isUsableTorrentHandle(torrent) {
|
|
22
|
+
return Boolean(torrent) && torrent.destroyed !== true && (torrent.files?.length ?? 0) > 0;
|
|
23
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// before WebTorrent can reach the native one. Two isolates using
|
|
22
22
|
// node-datachannel at once abort the process, and the torrent's wss trackers
|
|
23
23
|
// create peer connections of their own.
|
|
24
|
+
import { isUsableTorrentHandle } from "./handle-state.js";
|
|
24
25
|
import "./install-webrtc-shim.js";
|
|
25
26
|
import { parentPort, workerData } from "node:worker_threads";
|
|
26
27
|
import { createSendStream } from "./channel.js";
|
|
@@ -43,6 +44,15 @@ const pool = new TorrentPool({
|
|
|
43
44
|
|
|
44
45
|
/** Torrents by sourceKey — the main thread names them, this thread owns them. */
|
|
45
46
|
const torrentsByKey = new Map();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How each source was named when it was added, so a torrent that has since been
|
|
50
|
+
* destroyed can be added again. Kept separately from {@link torrentsByKey}
|
|
51
|
+
* because that map holds the promise, not the recipe.
|
|
52
|
+
*
|
|
53
|
+
* @type {Map<string, { sourceType: string, source: string }>}
|
|
54
|
+
*/
|
|
55
|
+
const sourceRecipes = new Map();
|
|
46
56
|
/** File claims, each with its own identity — see `file-claims.js`. */
|
|
47
57
|
const fileClaims = createFileClaims();
|
|
48
58
|
/** In-flight reads, so a cancel can stop one mid-body. */
|
|
@@ -82,9 +92,36 @@ async function requireTorrent(sourceKey) {
|
|
|
82
92
|
if (!pending) {
|
|
83
93
|
throw new Error(`Unknown source ${sourceKey}.`);
|
|
84
94
|
}
|
|
85
|
-
|
|
95
|
+
const torrent = await pending;
|
|
96
|
+
if (isUsableTorrentHandle(torrent)) {
|
|
97
|
+
return torrent;
|
|
98
|
+
}
|
|
99
|
+
// The pool destroys a torrent that has gone unread for a quarter of an hour,
|
|
100
|
+
// and under disk pressure. It clears its OWN map when it does; this one it
|
|
101
|
+
// knows nothing about, so the promise here went on resolving to a corpse: a
|
|
102
|
+
// destroyed torrent keeps its object but loses its files. Every later session
|
|
103
|
+
// for that source then failed the same way — the plan and the codec probe
|
|
104
|
+
// answered from cache in milliseconds, nothing waited for metadata because
|
|
105
|
+
// everything believed the torrent was known, and ffmpeg's first read died on
|
|
106
|
+
// `File N not found` 130 ms in, after which the session answered 500 for
|
|
107
|
+
// ever. Measured 2026-08-06 on two sessions in a row, both from a phone,
|
|
108
|
+
// which is what made it look like a mobile problem.
|
|
109
|
+
const recipe = sourceRecipes.get(sourceKey);
|
|
110
|
+
if (!recipe) {
|
|
111
|
+
torrentsByKey.delete(sourceKey);
|
|
112
|
+
throw new Error(`Source ${sourceKey} is gone and cannot be re-added.`);
|
|
113
|
+
}
|
|
114
|
+
const revived = pool.getTorrent(recipe.sourceType, recipe.source);
|
|
115
|
+
torrentsByKey.set(sourceKey, revived);
|
|
116
|
+
revived.catch(() => {
|
|
117
|
+
if (torrentsByKey.get(sourceKey) === revived) {
|
|
118
|
+
torrentsByKey.delete(sourceKey);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return revived;
|
|
86
122
|
}
|
|
87
123
|
|
|
124
|
+
|
|
88
125
|
/**
|
|
89
126
|
* Fragments waiting for the main thread to say it has finished reading them,
|
|
90
127
|
* keyed by request id. One per read, because only one fragment is in flight.
|
|
@@ -237,6 +274,10 @@ async function runCommand(command, params, id) {
|
|
|
237
274
|
// is being added waits for it instead of being told it does not exist.
|
|
238
275
|
// Reusing the same promise for a repeated add also collapses two callers
|
|
239
276
|
// racing to open the same torrent into one.
|
|
277
|
+
sourceRecipes.set(params.sourceKey, {
|
|
278
|
+
sourceType: params.sourceType,
|
|
279
|
+
source: params.source
|
|
280
|
+
});
|
|
240
281
|
let pending = torrentsByKey.get(params.sourceKey);
|
|
241
282
|
if (!pending) {
|
|
242
283
|
pending = pool.getTorrent(params.sourceType, params.source);
|
|
@@ -344,6 +385,7 @@ async function runCommand(command, params, id) {
|
|
|
344
385
|
case Command.DESTROY_ALL: {
|
|
345
386
|
fileClaims.closeAll();
|
|
346
387
|
torrentsByKey.clear();
|
|
388
|
+
sourceRecipes.clear();
|
|
347
389
|
await pool.destroyAll();
|
|
348
390
|
return true;
|
|
349
391
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A torrent that was destroyed must not be handed to a reader.
|
|
3
|
+
*
|
|
4
|
+
* The worker remembers each source as a promise, and only ever forgot one when
|
|
5
|
+
* the ADD failed. But the pool destroys a torrent that has gone unread for a
|
|
6
|
+
* quarter of an hour, and under disk pressure — clearing its own map, not this
|
|
7
|
+
* one. The promise then went on resolving to a corpse: a destroyed torrent
|
|
8
|
+
* keeps its object and loses its files.
|
|
9
|
+
*
|
|
10
|
+
* What that did to a viewer, measured 2026-08-06 on two sessions in a row: the
|
|
11
|
+
* plan answered from cache in 23 ms, the session was created from cache in
|
|
12
|
+
* 2 ms, nothing waited for metadata because everything believed the torrent was
|
|
13
|
+
* known, and ffmpeg's first read died 130 ms in with `File 0 not found`. Every
|
|
14
|
+
* request for the playlist then answered 500 until the viewer gave up.
|
|
15
|
+
*
|
|
16
|
+
* The rule under test is the whole fix: a handle that cannot be read from is
|
|
17
|
+
* not returned, it is replaced.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { isUsableTorrentHandle } from "../services/torrent-worker/handle-state.js";
|
|
23
|
+
|
|
24
|
+
test("a live torrent is usable", () => {
|
|
25
|
+
assert.equal(isUsableTorrentHandle({ destroyed: false, files: [{ name: "a.mkv" }] }), true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a destroyed torrent is not, even while it still lists files", () => {
|
|
29
|
+
assert.equal(
|
|
30
|
+
isUsableTorrentHandle({ destroyed: true, files: [{ name: "a.mkv" }] }),
|
|
31
|
+
false,
|
|
32
|
+
"WebTorrent's own flag is the earliest signal that a handle is finished"
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a torrent with no files is not — that is what the reader actually hits", () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
isUsableTorrentHandle({ destroyed: false, files: [] }),
|
|
39
|
+
false,
|
|
40
|
+
"an empty file list is what produced `File 0 not found` in the field"
|
|
41
|
+
);
|
|
42
|
+
assert.equal(
|
|
43
|
+
isUsableTorrentHandle({ destroyed: false }),
|
|
44
|
+
false,
|
|
45
|
+
"and a handle with no file list at all is the same case"
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("nothing at all is not usable", () => {
|
|
50
|
+
assert.equal(isUsableTorrentHandle(null), false);
|
|
51
|
+
assert.equal(isUsableTorrentHandle(undefined), false);
|
|
52
|
+
});
|