@torrent-tv/proxy 2.83.3 → 2.83.5
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 +32 -0
- package/package.json +1 -1
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -0
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -0
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -0
- package/routes/api/sources/files/get.js +23 -9
- package/routes/api/sources/warm/post.js +26 -22
- package/routes/api/transcode-sessions/post.js +1 -49
- package/routes/stream/get.js +2 -36
- package/services/controllers/SubtitleController.js +0 -3
- package/services/download/withdraw-claim.js +80 -0
- package/services/hls-session-manager.js +17 -110
- package/services/orchestrators/EncodeOrchestrator.js +133 -0
- package/services/piece-store/piece-disk-store.js +24 -1
- package/services/piece-store/shared-piece-store.js +71 -1
- package/services/playback-planner.js +12 -13
- package/services/priority/PriorityOrchestrator.js +40 -6
- package/services/torrent/Contents.js +324 -0
- package/services/torrent/files.js +8 -1
- package/services/torrent-pool.js +378 -102
- package/services/torrent-worker/client.js +0 -24
- package/services/torrent-worker/piece-reader.js +30 -1
- package/services/torrent-worker/pool-adapter.js +3 -33
- package/services/torrent-worker/protocol.js +0 -4
- package/services/torrent-worker/worker.js +60 -60
- package/test/file-edges.test.js +191 -0
- package/test/input-lost-quiets-the-plan.test.js +261 -0
- package/test/logger-repeats.test.js +120 -0
- package/test/priority-map-emptied.test.js +207 -0
- package/test/read-survives-withdrawal.test.js +164 -0
- package/test/source-files-route.test.js +103 -0
- package/test/stream-route.test.js +4 -8
- package/test/swarm-follows-readers.test.js +96 -14
- package/test/torrent-contents.test.js +235 -0
- package/test/upload-hurry.test.js +66 -28
- package/test/withdraw-piece-claim.test.js +212 -0
- package/utils/logger.js +105 -7
- package/services/torrent-worker/file-claims.js +0 -91
- package/test/file-claims.test.js +0 -64
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { contentsOf } from "../../../../services/torrent/Contents.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* List the files of a registered source (torrent file OR magnet).
|
|
3
5
|
*
|
|
@@ -70,17 +72,29 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
const torrent = result;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
// WHAT IS IN THIS TORRENT, decided here and nowhere else. The browser used to
|
|
76
|
+
// decide it again — a list of video extensions in its parser and a second,
|
|
77
|
+
// shorter pair inside its picker — and the three answers had already diverged
|
|
78
|
+
// (measured 2026-09-12: `.dat` was offered there and not counted here, which
|
|
79
|
+
// also decides whether a sidecar whose name matches nothing can belong to the
|
|
80
|
+
// only video present). It ships the paths already relative to the torrent
|
|
81
|
+
// root, so there is no stripping rule on the other side either.
|
|
82
|
+
const contents = contentsOf(torrent);
|
|
81
83
|
return reply.send({
|
|
82
84
|
name: torrent.name ?? "",
|
|
83
85
|
infoHash: torrent.infoHash ?? "",
|
|
84
|
-
|
|
86
|
+
// In the order a person reads them — by folder, then by name, with runs of
|
|
87
|
+
// digits compared as numbers. A torrent's own order is whatever the tool
|
|
88
|
+
// that made it chose, and it is routinely by size.
|
|
89
|
+
files: contents.files(),
|
|
90
|
+
// The pictures, each with what belongs to it. By index, because the files
|
|
91
|
+
// themselves are in the list above and saying them twice is how two copies
|
|
92
|
+
// of one fact start.
|
|
93
|
+
items: contents.items.map((item) => ({
|
|
94
|
+
fileIndex: item.fileIndex,
|
|
95
|
+
audio: item.audio.map((part) => part.fileIndex),
|
|
96
|
+
subtitles: item.subtitles.map((part) => part.fileIndex),
|
|
97
|
+
images: item.images.map((part) => part.fileIndex)
|
|
98
|
+
}))
|
|
85
99
|
});
|
|
86
100
|
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { logger } from "../../../../utils/logger.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
matchSidecarFiles,
|
|
5
|
-
TEXT_SUBTITLE_SIDECAR_EXTENSIONS
|
|
6
|
-
} from "../../../../services/torrent/files.js";
|
|
2
|
+
import { TEXT_SUBTITLE_SIDECAR_EXTENSIONS } from "../../../../services/torrent/files.js";
|
|
3
|
+
import { contentsOf } from "../../../../services/torrent/Contents.js";
|
|
7
4
|
|
|
8
5
|
/**
|
|
9
6
|
* Start fetching a source before anyone asks to play it.
|
|
@@ -70,17 +67,26 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
|
|
|
70
67
|
return reply.send({ started: false, swarm: false, edges: false });
|
|
71
68
|
}
|
|
72
69
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
70
|
+
// WHICH FILE THIS IS ABOUT. The caller names one when it knows — a torrent
|
|
71
|
+
// with a single video, or an episode the viewer has settled on — and on a
|
|
72
|
+
// season pack it names none, because nobody has chosen yet.
|
|
73
|
+
//
|
|
74
|
+
// Then the first item of the torrent stands in for the choice. One item, not
|
|
75
|
+
// twenty: warming a whole pack's worth of edges would spend the pool owner's
|
|
76
|
+
// bandwidth on nineteen files nobody opened, while one item is two pieces and
|
|
77
|
+
// is also the likeliest pick. It is stated as something nobody is waiting
|
|
78
|
+
// for, so on a proxy serving somebody else it costs nothing at all until
|
|
79
|
+
// their own film has everything it needs.
|
|
80
|
+
const contents = contentsOf(torrent);
|
|
81
|
+
const named = fileIndex !== null && torrent.files?.[fileIndex] ? fileIndex : null;
|
|
82
|
+
const candidate = named ?? contents.items[0]?.fileIndex ?? null;
|
|
83
|
+
|
|
78
84
|
let edges = false;
|
|
79
|
-
if (
|
|
85
|
+
if (candidate !== null) {
|
|
80
86
|
edges = true;
|
|
81
87
|
// Deliberately not awaited: this is the multi-second part, and the point of
|
|
82
88
|
// the whole route is that the viewer goes on choosing while it happens.
|
|
83
|
-
Promise.resolve(torrentPool.prefetchFileEdges(torrent,
|
|
89
|
+
Promise.resolve(torrentPool.prefetchFileEdges(torrent, candidate)).catch((error) => {
|
|
84
90
|
const message = error instanceof Error ? error.message : String(error);
|
|
85
91
|
logger.warn(`warm ${sourceKey.slice(0, 8)}: file edges failed: ${message}`);
|
|
86
92
|
});
|
|
@@ -88,8 +94,8 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
|
|
|
88
94
|
// the playback plan, so they must not queue behind a region nobody is
|
|
89
95
|
// reading yet. This one only has to arrive before the encoder does, and the
|
|
90
96
|
// encoder is a plan and a session away.
|
|
91
|
-
if (positionSeconds > 0 && typeof torrentPool.warmResumePosition === "function") {
|
|
92
|
-
Promise.resolve(torrentPool.warmResumePosition(torrent,
|
|
97
|
+
if (named !== null && positionSeconds > 0 && typeof torrentPool.warmResumePosition === "function") {
|
|
98
|
+
Promise.resolve(torrentPool.warmResumePosition(torrent, named, positionSeconds)).catch(
|
|
93
99
|
(error) => {
|
|
94
100
|
const message = error instanceof Error ? error.message : String(error);
|
|
95
101
|
logger.warn(`warm ${sourceKey.slice(0, 8)}: the viewer's position failed: ${message}`);
|
|
@@ -116,13 +122,8 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
|
|
|
116
122
|
// does. The rest of it is fetched when it is played, and nothing here spends
|
|
117
123
|
// the pool owner's bandwidth on a track nobody chose.
|
|
118
124
|
let sidecars = 0;
|
|
119
|
-
if (
|
|
120
|
-
const matched =
|
|
121
|
-
files: torrent.files,
|
|
122
|
-
videoIndex: fileIndex,
|
|
123
|
-
torrentName: typeof torrent.name === "string" ? torrent.name : "",
|
|
124
|
-
videoCount: countVideoFiles(torrent.files)
|
|
125
|
-
});
|
|
125
|
+
if (candidate !== null && Array.isArray(torrent.files)) {
|
|
126
|
+
const matched = contents.sidecarsOf(candidate);
|
|
126
127
|
const warmOne = (file, options) => {
|
|
127
128
|
sidecars += 1;
|
|
128
129
|
// Not awaited, like the picture's own edges above: the point of this route
|
|
@@ -162,7 +163,10 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
|
|
|
162
163
|
|
|
163
164
|
logger.info(
|
|
164
165
|
`warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
|
|
165
|
-
(edges
|
|
166
|
+
(edges
|
|
167
|
+
? `, fetching the edges of file ${candidate}` +
|
|
168
|
+
(named === null ? " — the first item, since nothing is chosen yet" : "")
|
|
169
|
+
: ", and it holds nothing to fetch the edges of") +
|
|
166
170
|
(sidecars > 0 ? ` and of ${sidecars} file(s) beside it` : "")
|
|
167
171
|
);
|
|
168
172
|
|
|
@@ -25,39 +25,6 @@ function getPayload(body) {
|
|
|
25
25
|
return {};
|
|
26
26
|
}
|
|
27
27
|
|
|
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
28
|
|
|
62
29
|
export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
|
|
63
30
|
const payload = getPayload(req.body);
|
|
@@ -108,22 +75,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
108
75
|
: 0,
|
|
109
76
|
audioTrackIndex:
|
|
110
77
|
Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
|
|
111
|
-
segmentFormatId
|
|
112
|
-
// Hold the torrent for as long as this session lives. Reads take a claim
|
|
113
|
-
// only while they run, and a seek leaves a gap with no read at all — the
|
|
114
|
-
// disk sweep caught that gap on 2026-08-06 and deleted the film being
|
|
115
|
-
// watched.
|
|
116
|
-
// Takes the file to hold, because a session does not always read the file
|
|
117
|
-
// it was created for: a release whose dub ships as its own file gives that
|
|
118
|
-
// soundtrack a session of its own, reading a different index of the same
|
|
119
|
-
// torrent. Defaults to the picture, which is every other case.
|
|
120
|
-
acquireSource: (heldFileIndex = fileIndex) =>
|
|
121
|
-
holdSource({
|
|
122
|
-
sourceRegistry,
|
|
123
|
-
torrentPool,
|
|
124
|
-
sourceKey,
|
|
125
|
-
fileIndex: Number.isInteger(heldFileIndex) ? heldFileIndex : fileIndex
|
|
126
|
-
})
|
|
78
|
+
segmentFormatId
|
|
127
79
|
});
|
|
128
80
|
// The index of quality variants, when this session has more than one to
|
|
129
81
|
// offer. Its presence is what tells the browser it can change quality
|
package/routes/stream/get.js
CHANGED
|
@@ -208,8 +208,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
208
208
|
return;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
|
|
212
|
-
|
|
213
211
|
const range = parseRange(req.headers.range, file.length);
|
|
214
212
|
// Prioritize the pieces at this read position so a seek (a request at a new
|
|
215
213
|
// byte offset) downloads first instead of waiting behind the sequential
|
|
@@ -343,8 +341,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
343
341
|
logger.warn(line);
|
|
344
342
|
}
|
|
345
343
|
reply.raw.destroy();
|
|
346
|
-
} finally {
|
|
347
|
-
releaseFile();
|
|
348
344
|
}
|
|
349
345
|
return;
|
|
350
346
|
}
|
|
@@ -355,41 +351,11 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
355
351
|
|
|
356
352
|
if (!range) {
|
|
357
353
|
reply.header("Content-Length", String(file.length));
|
|
358
|
-
|
|
359
|
-
bindRelease(stream, reply, releaseFile);
|
|
360
|
-
return reply.send(stream);
|
|
354
|
+
return reply.send(file.createReadStream());
|
|
361
355
|
}
|
|
362
356
|
|
|
363
357
|
reply.code(206);
|
|
364
358
|
reply.header("Content-Length", String(contentLength));
|
|
365
359
|
reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
|
|
366
|
-
|
|
367
|
-
bindRelease(stream, reply, releaseFile);
|
|
368
|
-
return reply.send(stream);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Attach event listeners that release the file reference exactly once when
|
|
373
|
-
* the stream or the underlying HTTP connection closes.
|
|
374
|
-
*
|
|
375
|
-
* @param {import("node:stream").Readable} stream
|
|
376
|
-
* @param {import("fastify").FastifyReply} reply
|
|
377
|
-
* @param {() => void} release
|
|
378
|
-
* @returns {void}
|
|
379
|
-
*/
|
|
380
|
-
function bindRelease(stream, reply, release) {
|
|
381
|
-
let released = false;
|
|
382
|
-
const releaseOnce = () => {
|
|
383
|
-
if (released) {
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
released = true;
|
|
387
|
-
release();
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
stream.on("close", releaseOnce);
|
|
391
|
-
stream.on("end", releaseOnce);
|
|
392
|
-
stream.on("error", releaseOnce);
|
|
393
|
-
reply.raw.once("close", releaseOnce);
|
|
394
|
-
reply.raw.once("finish", releaseOnce);
|
|
360
|
+
return reply.send(file.createReadStream({ start, end }));
|
|
395
361
|
}
|
|
@@ -51,7 +51,6 @@ export class SubtitleController {
|
|
|
51
51
|
if (!hasTrack) {
|
|
52
52
|
const name = file.name ?? "";
|
|
53
53
|
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
54
|
-
const release = this.torrentPool.acquireFile(torrent, fileIndex);
|
|
55
54
|
try {
|
|
56
55
|
const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
|
|
57
56
|
const text = SubtitleFileContainer.decodeBytes(bytes);
|
|
@@ -65,8 +64,6 @@ export class SubtitleController {
|
|
|
65
64
|
return { vtt, language: TextSubtitleTrack.detectLanguageFromVtt(vtt), headers: {} };
|
|
66
65
|
} catch (e) {
|
|
67
66
|
return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
|
|
68
|
-
} finally {
|
|
69
|
-
release();
|
|
70
67
|
}
|
|
71
68
|
}
|
|
72
69
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Withdrawing the claim that this proxy has a piece.
|
|
3
|
+
*
|
|
4
|
+
* **The fact has one owner and it is the store.** The store holds the bytes, so
|
|
5
|
+
* it says what this proxy has. The library keeps a second copy of that fact in
|
|
6
|
+
* its completion bitfield, and until 2026-09-12 nothing reconciled the two: the
|
|
7
|
+
* disk tier drops a piece once every reader is past it — correctly, and that is
|
|
8
|
+
* what bounds the spill — while the bitfield went on saying the piece was
|
|
9
|
+
* verified. A read then concluded the piece was had, asked for it, was told it
|
|
10
|
+
* was absent, and failed. Nor was it ever fetched again, because the library
|
|
11
|
+
* does not download what it believes it already owns.
|
|
12
|
+
*
|
|
13
|
+
* Field 2026-09-12: a film played 80 seconds; the encoder ran on to 725 s, so
|
|
14
|
+
* some 565 spilled pieces fell behind every read head and were dropped,
|
|
15
|
+
* including piece 0; the encoder then lost its input and restarted, which
|
|
16
|
+
* re-opens the input at byte 0; and `/stream` answered `0 of 2363497962 bytes:
|
|
17
|
+
* Piece 0 is verified but absent from the store` to every read for the next 92
|
|
18
|
+
* minutes while the browser retried one segment and the picture stood still.
|
|
19
|
+
*
|
|
20
|
+
* So the eviction's own bargain — "a seek back re-downloads it" — is made true
|
|
21
|
+
* here, and the bitfield becomes a projection of what the store holds.
|
|
22
|
+
*
|
|
23
|
+
* **Why it is a plain function.** It takes the values it needs and holds
|
|
24
|
+
* nothing: no pool, no store, no client. That is what lets it be exercised with
|
|
25
|
+
* an object literal, and it is the rule the layers are checked against — a layer
|
|
26
|
+
* must be usable with plain values alone, with no process, no disk and no clock.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tell the library it no longer has a piece, so the next read of it waits for a
|
|
31
|
+
* download instead of failing.
|
|
32
|
+
*
|
|
33
|
+
* **What it deliberately does not do.** The library's `_markUnverified` would
|
|
34
|
+
* also re-select the piece; it does not here, because every torrent is added
|
|
35
|
+
* with `deselect: true`, which sets the library's own `_startAsDeselected` and
|
|
36
|
+
* makes it skip that call. The download set has exactly one owner —
|
|
37
|
+
* `SwarmSelection`, from the priority map — and a withdrawn piece is fetched
|
|
38
|
+
* again when a read states it, which is the same statement every other piece
|
|
39
|
+
* waits on.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} what
|
|
42
|
+
* @param {number} what.index - The piece the store can no longer produce.
|
|
43
|
+
* @param {object[]} [what.files] - The store's own files; the torrent that owns
|
|
44
|
+
* them is found through one of them, because the store has no idea what a
|
|
45
|
+
* torrent is.
|
|
46
|
+
* @param {object} [what.torrent] - Given directly instead of through `files`.
|
|
47
|
+
* @param {(line: string) => void} [what.warn] - Said when the library refuses.
|
|
48
|
+
* @returns {"withdrawn" | "nothing-to-withdraw" | "no-torrent" | "refused"}
|
|
49
|
+
*/
|
|
50
|
+
export function withdrawClaim({ index, files, torrent, warn = () => undefined }) {
|
|
51
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
52
|
+
return "nothing-to-withdraw";
|
|
53
|
+
}
|
|
54
|
+
const owner = torrent ?? files?.[0]?._torrent ?? null;
|
|
55
|
+
// A torrent being destroyed is the ordinary case at the end of a session, and
|
|
56
|
+
// it has no claim left to withdraw: the store is going with it.
|
|
57
|
+
if (!owner || owner.destroyed || typeof owner._markUnverified !== "function") {
|
|
58
|
+
return "no-torrent";
|
|
59
|
+
}
|
|
60
|
+
// NOTHING TO WITHDRAW, which is most of the time: the store also drops pieces
|
|
61
|
+
// it never completed, and marking one unverified that the library already
|
|
62
|
+
// knows is missing would re-create the piece and discard whatever blocks are
|
|
63
|
+
// in flight for it.
|
|
64
|
+
if (!owner.bitfield?.get?.(index)) {
|
|
65
|
+
return "nothing-to-withdraw";
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
owner._markUnverified(index);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// This reaches into the library's own bookkeeping. If a later version
|
|
71
|
+
// changes it, the honest result is a line saying so rather than an eviction
|
|
72
|
+
// that fails.
|
|
73
|
+
warn(
|
|
74
|
+
`could not withdraw the claim on piece ${index} of ${owner.name ?? "?"}: ` +
|
|
75
|
+
`${error?.message ?? error}`
|
|
76
|
+
);
|
|
77
|
+
return "refused";
|
|
78
|
+
}
|
|
79
|
+
return "withdrawn";
|
|
80
|
+
}
|
|
@@ -441,13 +441,6 @@ const START_FAST_FAIL_MS = 2_000;
|
|
|
441
441
|
// for whatever residual case still fails — not a second competing "fix" that
|
|
442
442
|
// blindly retries the identical command hoping for a different result.
|
|
443
443
|
const MAX_FAILED_STARTS = 3;
|
|
444
|
-
// A run that lost its INPUT is retried rather than condemned: the torrent can
|
|
445
|
-
// be added again and the pieces downloaded again, so the data being gone is a
|
|
446
|
-
// wait, not a verdict. Backed off so a source that is truly unavailable costs a
|
|
447
|
-
// process every few seconds rather than continuously, and never given up on —
|
|
448
|
-
// the session's own idle TTL is what ends it if the viewer leaves.
|
|
449
|
-
const INPUT_RETRY_BASE_MS = 2_000;
|
|
450
|
-
const INPUT_RETRY_MAX_MS = 15_000;
|
|
451
444
|
// Idle TTL: a session is disposed this long after the last segment/playlist
|
|
452
445
|
// access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
|
|
453
446
|
// turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
|
|
@@ -1577,6 +1570,11 @@ export class HlsSessionManager {
|
|
|
1577
1570
|
contentionPenalties: this.contentionPenalties,
|
|
1578
1571
|
startingSpeedFor: (address) => this.encodeCost.speedForOutput(address),
|
|
1579
1572
|
segmentStore: this.segmentStore,
|
|
1573
|
+
// HOW IT ASKS TO DECIDE AGAIN. A plan that refuses to place anything
|
|
1574
|
+
// because an output's input is away needs something to bring it back:
|
|
1575
|
+
// nothing about the state changes while the data is missing, so no event
|
|
1576
|
+
// arrives on its own.
|
|
1577
|
+
planSoon: () => this.planEncodersSoon(),
|
|
1580
1578
|
logger
|
|
1581
1579
|
});
|
|
1582
1580
|
// What a start and a stop were measured to cost here, before any viewer
|
|
@@ -1680,12 +1678,7 @@ export class HlsSessionManager {
|
|
|
1680
1678
|
// keyframe times and which container they were read from. Present only for
|
|
1681
1679
|
// a variant of a session cut at the source's keyframes, and it is what
|
|
1682
1680
|
// makes the two interchangeable.
|
|
1683
|
-
inheritedGrid = null
|
|
1684
|
-
// Called once for a session that is actually created, and expected to
|
|
1685
|
-
// return a function that lets the source go. It is what keeps the torrent's
|
|
1686
|
-
// data alive for as long as a viewer has a session on it — see
|
|
1687
|
-
// disposeSession.
|
|
1688
|
-
acquireSource = null
|
|
1681
|
+
inheritedGrid = null
|
|
1689
1682
|
}) {
|
|
1690
1683
|
if (!this.enabled) {
|
|
1691
1684
|
const error = new Error("Audio transcoding is disabled on this proxy.");
|
|
@@ -2540,40 +2533,6 @@ export class HlsSessionManager {
|
|
|
2540
2533
|
lastLoggedAt: 0
|
|
2541
2534
|
}
|
|
2542
2535
|
};
|
|
2543
|
-
// Kept so a variant of this session can take its own hold on the same
|
|
2544
|
-
// source: a variant is another encode of the same file and must keep the
|
|
2545
|
-
// torrent's data alive exactly as this one does.
|
|
2546
|
-
session.acquireSource = typeof acquireSource === "function" ? acquireSource : null;
|
|
2547
|
-
if (typeof acquireSource === "function") {
|
|
2548
|
-
// One claim per file this session READS. Almost always that is one file;
|
|
2549
|
-
// a muxed session whose soundtrack ships beside the picture reads two, and
|
|
2550
|
-
// holding only the picture would leave the sound to be swept off the disk
|
|
2551
|
-
// from under a running encoder.
|
|
2552
|
-
const claim = (heldFileIndex) => {
|
|
2553
|
-
try {
|
|
2554
|
-
const release = acquireSource(heldFileIndex);
|
|
2555
|
-
return typeof release === "function" ? release : null;
|
|
2556
|
-
} catch {
|
|
2557
|
-
return null;
|
|
2558
|
-
}
|
|
2559
|
-
};
|
|
2560
|
-
const releases = [claim(undefined)];
|
|
2561
|
-
if (audioInputUrl) {
|
|
2562
|
-
releases.push(claim(audioSource.fileIndex));
|
|
2563
|
-
}
|
|
2564
|
-
const held = releases.filter((release) => typeof release === "function");
|
|
2565
|
-
session.releaseSource = held.length > 0
|
|
2566
|
-
? () => {
|
|
2567
|
-
for (const release of held) {
|
|
2568
|
-
try {
|
|
2569
|
-
release();
|
|
2570
|
-
} catch {
|
|
2571
|
-
// Best effort — a session must always finish being disposed.
|
|
2572
|
-
}
|
|
2573
|
-
}
|
|
2574
|
-
}
|
|
2575
|
-
: null;
|
|
2576
|
-
}
|
|
2577
2536
|
// The viewer who asked for this session, so a browser that names itself
|
|
2578
2537
|
// never has to have requested a segment first for its own soundtrack choice
|
|
2579
2538
|
// to be known — nor for its own POSITION to be known, which is the same
|
|
@@ -5748,33 +5707,19 @@ export class HlsSessionManager {
|
|
|
5748
5707
|
// condemn a session whose data merely went away.
|
|
5749
5708
|
if (ended.ending === ENCODE_EXIT.INPUT_LOST) {
|
|
5750
5709
|
session.inputRetryCount = (session.inputRetryCount ?? 0) + 1;
|
|
5751
|
-
const delayMs = Math.min(
|
|
5752
|
-
INPUT_RETRY_MAX_MS,
|
|
5753
|
-
INPUT_RETRY_BASE_MS * 2 ** Math.min(session.inputRetryCount - 1, 6)
|
|
5754
|
-
);
|
|
5755
5710
|
logger.warn(
|
|
5756
5711
|
`transcode ${session.id} encode-run #${ended.from}..#${ended.to} lost its input ` +
|
|
5757
|
-
`(${session.lastError})
|
|
5758
|
-
`(attempt ${session.inputRetryCount})`
|
|
5712
|
+
`(${session.lastError}) (attempt ${session.inputRetryCount})`
|
|
5759
5713
|
);
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5714
|
+
// HOW LONG TO WAIT IS THE PLAN'S, and this says only what happened. The
|
|
5715
|
+
// delay used to be timed here, against the dead run, which the plan never
|
|
5716
|
+
// consults — so it placed a fresh run at the same spot as fast as ffmpeg
|
|
5717
|
+
// could fail there: 2432 starts in 23 minutes in the field 2026-09-12,
|
|
5718
|
+
// against a delay that had reached its 15 s ceiling long before. The
|
|
5719
|
+
// orchestrator holds it now, beside the decision it governs.
|
|
5720
|
+
if (run.state === ENCODE_RUN_STATE.RETRY_WAIT) {
|
|
5765
5721
|
run.retryDue();
|
|
5766
|
-
|
|
5767
|
-
// decision. Where to start is the plan's, from where the viewers are;
|
|
5768
|
-
// this used to start one at the segment last requested, which is the
|
|
5769
|
-
// player's read head rather than anybody's position, and is a number
|
|
5770
|
-
// requests are explicitly not allowed to steer an encoder by.
|
|
5771
|
-
//
|
|
5772
|
-
// What the delay is for stays: the plan is a function of the state, and
|
|
5773
|
-
// nothing about the state changes while the torrent is away, so it would
|
|
5774
|
-
// command the same start as fast as ffmpeg could fail.
|
|
5775
|
-
this.planEncodersSoon();
|
|
5776
|
-
}, delayMs);
|
|
5777
|
-
session.inputRetryTimer.unref?.();
|
|
5722
|
+
}
|
|
5778
5723
|
return;
|
|
5779
5724
|
}
|
|
5780
5725
|
// A run that exits THIS fast never did real work: it failed at the start
|
|
@@ -7686,14 +7631,6 @@ export class HlsSessionManager {
|
|
|
7686
7631
|
* @returns {void}
|
|
7687
7632
|
*/
|
|
7688
7633
|
#stopEncodeRun(session, reason) {
|
|
7689
|
-
// Everything armed to re-decide on this session's behalf. A stop that
|
|
7690
|
-
// leaves the input-retry timer running is not a stop: it fires seconds
|
|
7691
|
-
// later and has the plan asked again for a session nobody is watching, and
|
|
7692
|
-
// torrent starvation, which is what arms it, is routine here.
|
|
7693
|
-
if (session.inputRetryTimer) {
|
|
7694
|
-
clearTimeout(session.inputRetryTimer);
|
|
7695
|
-
session.inputRetryTimer = null;
|
|
7696
|
-
}
|
|
7697
7634
|
const running = liveRunsOf(session);
|
|
7698
7635
|
if (running.length === 0) {
|
|
7699
7636
|
return;
|
|
@@ -7839,8 +7776,7 @@ export class HlsSessionManager {
|
|
|
7839
7776
|
// creations (field 2026-08-17, corrections of 0.6-2.9 s).
|
|
7840
7777
|
published: base.timeline.published
|
|
7841
7778
|
}
|
|
7842
|
-
: null
|
|
7843
|
-
acquireSource: base.acquireSource
|
|
7779
|
+
: null
|
|
7844
7780
|
})
|
|
7845
7781
|
.then(async (variant) => {
|
|
7846
7782
|
// Making a session takes seconds — a probe and a keyframe index — and
|
|
@@ -8628,15 +8564,7 @@ export class HlsSessionManager {
|
|
|
8628
8564
|
boundaries: base.timeline.boundaries,
|
|
8629
8565
|
published: base.timeline.published
|
|
8630
8566
|
}
|
|
8631
|
-
: null
|
|
8632
|
-
// Hold the file this rendition will READ. For a soundtrack shipped beside
|
|
8633
|
-
// the picture that is a different file of the same torrent, and nothing
|
|
8634
|
-
// else claims it: the base holds the picture, and the disk sweep deletes
|
|
8635
|
-
// what nobody is holding — which is how a film being watched was deleted
|
|
8636
|
-
// on 2026-08-06.
|
|
8637
|
-
acquireSource: () => base.acquireSource?.(
|
|
8638
|
-
this.#resolveAudioSource(base.file.sourceKey, base.file.fileIndex, trackIndex).fileIndex
|
|
8639
|
-
)
|
|
8567
|
+
: null
|
|
8640
8568
|
});
|
|
8641
8569
|
return rendition ?? null;
|
|
8642
8570
|
}
|
|
@@ -10017,27 +9945,6 @@ export class HlsSessionManager {
|
|
|
10017
9945
|
}
|
|
10018
9946
|
}
|
|
10019
9947
|
|
|
10020
|
-
// Let go of the source. While a session exists its torrent must survive
|
|
10021
|
-
// both cleanups the pool runs, and until now neither knew about it: the
|
|
10022
|
-
// only claim on a file is taken by a READ, and during a seek there is no
|
|
10023
|
-
// read at all — the old encoder is dead and the new one has not started.
|
|
10024
|
-
// Field 2026-08-06, that window met the thirty-second disk sweep and the
|
|
10025
|
-
// film being watched was evicted mid-seek, six gigabytes deleted, after
|
|
10026
|
-
// which the new encoder had nothing to read. The session's own thirty
|
|
10027
|
-
// minutes governed the session, never the data under it.
|
|
10028
|
-
if (typeof session.releaseSource === "function") {
|
|
10029
|
-
try {
|
|
10030
|
-
session.releaseSource();
|
|
10031
|
-
} catch {
|
|
10032
|
-
// Best effort — a session must always finish being disposed.
|
|
10033
|
-
}
|
|
10034
|
-
session.releaseSource = null;
|
|
10035
|
-
}
|
|
10036
|
-
|
|
10037
|
-
if (session.inputRetryTimer) {
|
|
10038
|
-
clearTimeout(session.inputRetryTimer);
|
|
10039
|
-
session.inputRetryTimer = null;
|
|
10040
|
-
}
|
|
10041
9948
|
|
|
10042
9949
|
// Whether the process is still RUNNING, not whether anyone has called kill
|
|
10043
9950
|
// on it: `.killed` means only that a signal was sent, and a run that ended
|