@torrent-tv/proxy 2.9.110 → 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 +5 -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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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
|
+
|
|
1
6
|
## 2.9.110
|
|
2
7
|
|
|
3
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.
|
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) {
|