@torrent-tv/proxy 2.74.1 → 2.76.0
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 +1504 -1453
- package/CLAUDE.md +19 -6
- package/biome.json +182 -1
- package/docs/container-architecture.md +27 -6
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +9 -214
- package/services/container/AviContainer.js +266 -81
- package/services/container/Container.js +281 -80
- package/services/container/ContainerFactory.js +67 -0
- package/services/container/MatroskaContainer.js +327 -8
- package/services/container/Mp4Container.js +373 -27
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11073 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -747
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +370 -333
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/AudioTrack.js +131 -40
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/audio-inventory.test.js +176 -177
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/video-facts.test.js +102 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/container-index/avi.js +0 -167
- package/services/container-index/index.js +0 -118
- package/services/container-index/matroska.js +0 -336
- package/services/container-index/mp4.js +0 -358
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{container-index → container}/ebml-reader.js +0 -0
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
|
@@ -1,185 +1,185 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Create or return an existing HLS transcode session for a torrent file.
|
|
3
|
-
*
|
|
4
|
-
* POST /api/transcode-sessions
|
|
5
|
-
*
|
|
6
|
-
* @param {import("fastify").FastifyRequest} req
|
|
7
|
-
* @param {import("fastify").FastifyReply} reply
|
|
8
|
-
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager, sourceRegistry: object, torrentPool: object }} deps
|
|
9
|
-
* @returns {Promise<void>}
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { logger } from "../../../utils/logger.js";
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Extract a plain object from the request body, guarding against
|
|
16
|
-
* non-object payloads (arrays, primitives, null).
|
|
17
|
-
*
|
|
18
|
-
* @param {unknown} body
|
|
19
|
-
* @returns {Record<string, unknown>}
|
|
20
|
-
*/
|
|
21
|
-
function getPayload(body) {
|
|
22
|
-
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
23
|
-
return body;
|
|
24
|
-
}
|
|
25
|
-
return {};
|
|
26
|
-
}
|
|
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
|
-
|
|
62
|
-
export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
|
|
63
|
-
const payload = getPayload(req.body);
|
|
64
|
-
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
65
|
-
const fileIndex = Number(payload.fileIndex);
|
|
66
|
-
const transcodeVideo = payload.transcodeVideo === true;
|
|
67
|
-
const transcodeAudio = payload.transcodeAudio === true;
|
|
68
|
-
const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
|
|
69
|
-
const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
|
|
70
|
-
const targetWidth = Number(payload.targetWidth);
|
|
71
|
-
const targetHeight = Number(payload.targetHeight);
|
|
72
|
-
// Manual quality: the target box is a user-forced resolution, encoded exactly
|
|
73
|
-
// (capped to source), with the realtime budget's auto-downscale + runtime
|
|
74
|
-
// downswitch disabled for the session.
|
|
75
|
-
const manualQuality = payload.manualQuality === true;
|
|
76
|
-
// Whether this browser will take its audio from a separate rendition group in
|
|
77
|
-
// the master playlist rather than muxed into the picture. It has to say so:
|
|
78
|
-
// publishing renditions AND muxing the same audio would play it twice, while
|
|
79
|
-
// a browser that does not know about them would get no sound at all.
|
|
80
|
-
const audioRenditions = payload.audioRenditions === true;
|
|
81
|
-
const startPositionSeconds = Number(payload.startPositionSeconds);
|
|
82
|
-
const audioTrackIndex = Number(payload.audioTrackIndex);
|
|
83
|
-
// Which container to produce. The browser knows what its media stack will
|
|
84
|
-
// accept for the tracks it asked to be copied; an absent or unknown value
|
|
85
|
-
// leaves the proxy's own `--segment-format` in charge.
|
|
86
|
-
const segmentFormatId =
|
|
87
|
-
typeof payload.segmentFormat === "string" ? payload.segmentFormat.trim() : "";
|
|
88
|
-
|
|
89
|
-
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
90
|
-
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
const session = await hlsSessionManager.createOrGetSession({
|
|
95
|
-
sourceKey,
|
|
96
|
-
fileIndex,
|
|
97
|
-
transcodeVideo,
|
|
98
|
-
transcodeAudio,
|
|
99
|
-
consumerId,
|
|
100
|
-
fileName,
|
|
101
|
-
targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
|
|
102
|
-
targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
|
|
103
|
-
manualQuality,
|
|
104
|
-
audioRenditions,
|
|
105
|
-
startPositionSeconds:
|
|
106
|
-
Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
107
|
-
? startPositionSeconds
|
|
108
|
-
: 0,
|
|
109
|
-
audioTrackIndex:
|
|
110
|
-
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
|
-
})
|
|
127
|
-
});
|
|
128
|
-
// The index of quality variants, when this session has more than one to
|
|
129
|
-
// offer. Its presence is what tells the browser it can change quality
|
|
130
|
-
// without a new session: the player switches variants itself, appending the
|
|
131
|
-
// new one after what is already buffered. Absent for a copied video, whose
|
|
132
|
-
// segments are cut at the source's own keyframes and so cannot be spliced
|
|
133
|
-
// with a re-encoded rung.
|
|
134
|
-
const hasVariants = hlsSessionManager.buildMasterPlaylist(session.id) !== null;
|
|
135
|
-
return reply.send({
|
|
136
|
-
sessionId: session.id,
|
|
137
|
-
playlistPath: `/transcode/${session.id}/index.m3u8`,
|
|
138
|
-
...(hasVariants
|
|
139
|
-
? {
|
|
140
|
-
masterPath: `/transcode/${session.id}/master.m3u8`,
|
|
141
|
-
// Which of the master's variants this session IS. The browser pins
|
|
142
|
-
// the player to it, so loading the master costs nothing: an encoder
|
|
143
|
-
// is already producing that height, and any other rung would be a
|
|
144
|
-
// second cold start before the first frame.
|
|
145
|
-
variantHeight: hlsSessionManager.variantHeightOf(session)
|
|
146
|
-
}
|
|
147
|
-
: {}),
|
|
148
|
-
// The heights this host will actually serve this file at, largest first
|
|
149
|
-
// — the ladder minus every rung it cannot produce faster than it is
|
|
150
|
-
// watched. The browser needs it whether or not a master exists: without
|
|
151
|
-
// it, it fell back to a ladder of its own invention and offered rungs the
|
|
152
|
-
// proxy had just refused, and picking one re-opened the session at a
|
|
153
|
-
// height measured at a third of realtime.
|
|
154
|
-
offeredHeights: hlsSessionManager.offeredHeights(session),
|
|
155
|
-
// What this session's output will carry, stated rather than left to be
|
|
156
|
-
// discovered. The browser checks what it actually got against this: a
|
|
157
|
-
// track that never arrives is otherwise noticed only by its absence,
|
|
158
|
-
// minutes later, as a black picture with working sound.
|
|
159
|
-
tracks: hlsSessionManager.declaredTracks(session),
|
|
160
|
-
// How far ahead of the viewer this proxy lets the encoder run, in seconds
|
|
161
|
-
// of playback. The browser sizes its own forward buffer from it, so the
|
|
162
|
-
// two sides agree by construction instead of each carrying a constant of
|
|
163
|
-
// its own — which is how the browser came to hold thirty seconds while
|
|
164
|
-
// two minutes stood produced on disk (roadmap item 4).
|
|
165
|
-
lookaheadSeconds: hlsSessionManager.lookaheadSeconds
|
|
166
|
-
});
|
|
167
|
-
} catch (error) {
|
|
168
|
-
if (error instanceof Error && error.code === "TRANSCODE_DISABLED") {
|
|
169
|
-
return reply.code(409).send({ error: error.message });
|
|
170
|
-
}
|
|
171
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
-
// Say why on the proxy's own log, not only in the answer. This route
|
|
173
|
-
// answered 500 for every viewer of proxy 2.9.101-2.9.102 (an undeclared
|
|
174
|
-
// constant) and the addon log carried nothing but the data-channel layer's
|
|
175
|
-
// bare "→ 500": the cause had to be recovered by replaying the request
|
|
176
|
-
// against the live proxy. The stack is worth the two lines it costs — a
|
|
177
|
-
// programming error here is invisible to the viewer, who only sees that
|
|
178
|
-
// nothing plays.
|
|
179
|
-
logger.error(
|
|
180
|
-
`transcode-sessions: ${sourceKey}:${fileIndex} failed to prepare: ${message}\n` +
|
|
181
|
-
`${error instanceof Error ? (error.stack ?? "") : ""}`
|
|
182
|
-
);
|
|
183
|
-
return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
|
|
184
|
-
}
|
|
185
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Create or return an existing HLS transcode session for a torrent file.
|
|
3
|
+
*
|
|
4
|
+
* POST /api/transcode-sessions
|
|
5
|
+
*
|
|
6
|
+
* @param {import("fastify").FastifyRequest} req
|
|
7
|
+
* @param {import("fastify").FastifyReply} reply
|
|
8
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager, sourceRegistry: object, torrentPool: object }} deps
|
|
9
|
+
* @returns {Promise<void>}
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { logger } from "../../../utils/logger.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract a plain object from the request body, guarding against
|
|
16
|
+
* non-object payloads (arrays, primitives, null).
|
|
17
|
+
*
|
|
18
|
+
* @param {unknown} body
|
|
19
|
+
* @returns {Record<string, unknown>}
|
|
20
|
+
*/
|
|
21
|
+
function getPayload(body) {
|
|
22
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
23
|
+
return body;
|
|
24
|
+
}
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
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
|
+
|
|
62
|
+
export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
|
|
63
|
+
const payload = getPayload(req.body);
|
|
64
|
+
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
65
|
+
const fileIndex = Number(payload.fileIndex);
|
|
66
|
+
const transcodeVideo = payload.transcodeVideo === true;
|
|
67
|
+
const transcodeAudio = payload.transcodeAudio === true;
|
|
68
|
+
const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
|
|
69
|
+
const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
|
|
70
|
+
const targetWidth = Number(payload.targetWidth);
|
|
71
|
+
const targetHeight = Number(payload.targetHeight);
|
|
72
|
+
// Manual quality: the target box is a user-forced resolution, encoded exactly
|
|
73
|
+
// (capped to source), with the realtime budget's auto-downscale + runtime
|
|
74
|
+
// downswitch disabled for the session.
|
|
75
|
+
const manualQuality = payload.manualQuality === true;
|
|
76
|
+
// Whether this browser will take its audio from a separate rendition group in
|
|
77
|
+
// the master playlist rather than muxed into the picture. It has to say so:
|
|
78
|
+
// publishing renditions AND muxing the same audio would play it twice, while
|
|
79
|
+
// a browser that does not know about them would get no sound at all.
|
|
80
|
+
const audioRenditions = payload.audioRenditions === true;
|
|
81
|
+
const startPositionSeconds = Number(payload.startPositionSeconds);
|
|
82
|
+
const audioTrackIndex = Number(payload.audioTrackIndex);
|
|
83
|
+
// Which container to produce. The browser knows what its media stack will
|
|
84
|
+
// accept for the tracks it asked to be copied; an absent or unknown value
|
|
85
|
+
// leaves the proxy's own `--segment-format` in charge.
|
|
86
|
+
const segmentFormatId =
|
|
87
|
+
typeof payload.segmentFormat === "string" ? payload.segmentFormat.trim() : "";
|
|
88
|
+
|
|
89
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
90
|
+
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const session = await hlsSessionManager.createOrGetSession({
|
|
95
|
+
sourceKey,
|
|
96
|
+
fileIndex,
|
|
97
|
+
transcodeVideo,
|
|
98
|
+
transcodeAudio,
|
|
99
|
+
consumerId,
|
|
100
|
+
fileName,
|
|
101
|
+
targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
|
|
102
|
+
targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
|
|
103
|
+
manualQuality,
|
|
104
|
+
audioRenditions,
|
|
105
|
+
startPositionSeconds:
|
|
106
|
+
Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
107
|
+
? startPositionSeconds
|
|
108
|
+
: 0,
|
|
109
|
+
audioTrackIndex:
|
|
110
|
+
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
|
+
})
|
|
127
|
+
});
|
|
128
|
+
// The index of quality variants, when this session has more than one to
|
|
129
|
+
// offer. Its presence is what tells the browser it can change quality
|
|
130
|
+
// without a new session: the player switches variants itself, appending the
|
|
131
|
+
// new one after what is already buffered. Absent for a copied video, whose
|
|
132
|
+
// segments are cut at the source's own keyframes and so cannot be spliced
|
|
133
|
+
// with a re-encoded rung.
|
|
134
|
+
const hasVariants = hlsSessionManager.buildMasterPlaylist(session.id) !== null;
|
|
135
|
+
return reply.send({
|
|
136
|
+
sessionId: session.id,
|
|
137
|
+
playlistPath: `/transcode/${session.id}/index.m3u8`,
|
|
138
|
+
...(hasVariants
|
|
139
|
+
? {
|
|
140
|
+
masterPath: `/transcode/${session.id}/master.m3u8`,
|
|
141
|
+
// Which of the master's variants this session IS. The browser pins
|
|
142
|
+
// the player to it, so loading the master costs nothing: an encoder
|
|
143
|
+
// is already producing that height, and any other rung would be a
|
|
144
|
+
// second cold start before the first frame.
|
|
145
|
+
variantHeight: hlsSessionManager.liveOutputs.variantHeightOf(session)
|
|
146
|
+
}
|
|
147
|
+
: {}),
|
|
148
|
+
// The heights this host will actually serve this file at, largest first
|
|
149
|
+
// — the ladder minus every rung it cannot produce faster than it is
|
|
150
|
+
// watched. The browser needs it whether or not a master exists: without
|
|
151
|
+
// it, it fell back to a ladder of its own invention and offered rungs the
|
|
152
|
+
// proxy had just refused, and picking one re-opened the session at a
|
|
153
|
+
// height measured at a third of realtime.
|
|
154
|
+
offeredHeights: hlsSessionManager.offeredHeights(session),
|
|
155
|
+
// What this session's output will carry, stated rather than left to be
|
|
156
|
+
// discovered. The browser checks what it actually got against this: a
|
|
157
|
+
// track that never arrives is otherwise noticed only by its absence,
|
|
158
|
+
// minutes later, as a black picture with working sound.
|
|
159
|
+
tracks: hlsSessionManager.declaredTracks(session),
|
|
160
|
+
// How far ahead of the viewer this proxy lets the encoder run, in seconds
|
|
161
|
+
// of playback. The browser sizes its own forward buffer from it, so the
|
|
162
|
+
// two sides agree by construction instead of each carrying a constant of
|
|
163
|
+
// its own — which is how the browser came to hold thirty seconds while
|
|
164
|
+
// two minutes stood produced on disk (roadmap item 4).
|
|
165
|
+
lookaheadSeconds: hlsSessionManager.lookaheadSeconds
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof Error && error.code === "TRANSCODE_DISABLED") {
|
|
169
|
+
return reply.code(409).send({ error: error.message });
|
|
170
|
+
}
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
// Say why on the proxy's own log, not only in the answer. This route
|
|
173
|
+
// answered 500 for every viewer of proxy 2.9.101-2.9.102 (an undeclared
|
|
174
|
+
// constant) and the addon log carried nothing but the data-channel layer's
|
|
175
|
+
// bare "→ 500": the cause had to be recovered by replaying the request
|
|
176
|
+
// against the live proxy. The stack is worth the two lines it costs — a
|
|
177
|
+
// programming error here is invisible to the viewer, who only sees that
|
|
178
|
+
// nothing plays.
|
|
179
|
+
logger.error(
|
|
180
|
+
`transcode-sessions: ${sourceKey}:${fileIndex} failed to prepare: ${message}\n` +
|
|
181
|
+
`${error instanceof Error ? (error.stack ?? "") : ""}`
|
|
182
|
+
);
|
|
183
|
+
return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -14,7 +14,11 @@ export async function handleApiTranscodeSessionsProgressGet(req, reply, { hlsSes
|
|
|
14
14
|
return reply.code(400).send({ error: "sessionId is required." });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Whose progress. One picture serves everyone watching it, and after a
|
|
18
|
+
// quality change the stream on screen is another session — a different one
|
|
19
|
+
// for each viewer who changed.
|
|
20
|
+
const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
|
|
21
|
+
const progress = await hlsSessionManager.getSessionProgress(sessionId, consumerId);
|
|
18
22
|
if (!progress) {
|
|
19
23
|
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
20
24
|
}
|
|
@@ -25,7 +25,17 @@ export async function handleTranscodeAudioFileGet(req, reply, { hlsSessionManage
|
|
|
25
25
|
const trackIndex = Number(req.params.trackIndex);
|
|
26
26
|
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Which viewer is asking. One picture serves everyone watching it, and the
|
|
29
|
+
// soundtrack each of them chose is their own: without this, a segment request
|
|
30
|
+
// from one viewer would be read as everybody moving to that track, and the
|
|
31
|
+
// other viewer's encoder would be stopped once per segment.
|
|
32
|
+
const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
|
|
33
|
+
const resolved = await hlsSessionManager.resolveAudioRenditionFile(
|
|
34
|
+
baseSessionId,
|
|
35
|
+
trackIndex,
|
|
36
|
+
fileName,
|
|
37
|
+
consumerId
|
|
38
|
+
);
|
|
29
39
|
if (resolved.error) {
|
|
30
40
|
// Retryable, like every other not-ready answer on this path: a 500 for a
|
|
31
41
|
// rendition playlist would end the stream over something the next attempt
|
|
@@ -36,9 +36,19 @@ export async function handleTranscodeAudioWarmGet(req, reply, { hlsSessionManage
|
|
|
36
36
|
return reply.code(400).send({ error: "A track index and a non-negative position are required." });
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Whose track change this is. Another viewer of the same picture may be
|
|
40
|
+
// listening to something else, and preparing a track must not be read as
|
|
41
|
+
// everybody moving to it.
|
|
42
|
+
const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
|
|
43
|
+
|
|
39
44
|
let prepared;
|
|
40
45
|
try {
|
|
41
|
-
prepared = await hlsSessionManager.prepareAudioTrack(
|
|
46
|
+
prepared = await hlsSessionManager.prepareAudioTrack(
|
|
47
|
+
baseSessionId,
|
|
48
|
+
trackIndex,
|
|
49
|
+
positionSeconds,
|
|
50
|
+
consumerId
|
|
51
|
+
);
|
|
42
52
|
} catch (error) {
|
|
43
53
|
const message = error instanceof Error ? error.message : String(error);
|
|
44
54
|
reply.header("Retry-After", "1");
|
|
@@ -117,7 +117,7 @@ export async function serveSessionFile(req, reply, { hlsSessionManager, sessionI
|
|
|
117
117
|
// where the viewer was.
|
|
118
118
|
logger.info(
|
|
119
119
|
`[hold] ${fileName} refused: the viewer is at ` +
|
|
120
|
-
`${hlsSessionManager.viewerPositionOf(sessionId).toFixed(1)}s and this is not the segment there`
|
|
120
|
+
`${hlsSessionManager.viewerPositionOf(sessionId, consumerId).toFixed(1)}s and this is not the segment there`
|
|
121
121
|
);
|
|
122
122
|
reply.header("Retry-After", "0");
|
|
123
123
|
return reply.code(503).send({ error: "Superseded by a seek." });
|
|
@@ -26,7 +26,16 @@ export async function handleTranscodeVariantFileGet(req, reply, { hlsSessionMana
|
|
|
26
26
|
const height = Number(req.params.height);
|
|
27
27
|
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Which viewer is asking. Two viewers of one picture can be on two rungs, and
|
|
30
|
+
// a segment request is what says which rung a viewer is watching — read as
|
|
31
|
+
// the session's own, one of them would take the other off their step.
|
|
32
|
+
const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
|
|
33
|
+
const resolved = await hlsSessionManager.resolveVariantFile(
|
|
34
|
+
baseSessionId,
|
|
35
|
+
height,
|
|
36
|
+
fileName,
|
|
37
|
+
consumerId
|
|
38
|
+
);
|
|
30
39
|
if (resolved.error) {
|
|
31
40
|
// Preparing the variant failed — a probe, a keyframe index, an input that
|
|
32
41
|
// is not there yet. Retryable, like every other not-ready answer on this
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
mayRestart,
|
|
29
29
|
processCanBeSignalled,
|
|
30
30
|
wireState
|
|
31
|
-
} from "../services/encode-run-state.js";
|
|
31
|
+
} from "../services/encode/encode-run-state.js";
|
|
32
32
|
|
|
33
33
|
/** Where the rendered document lives. */
|
|
34
34
|
export const GRAPH_DOC_PATH = path.join(
|
|
@@ -97,7 +97,7 @@ export function renderRunGraphMarkdown() {
|
|
|
97
97
|
const lines = [];
|
|
98
98
|
|
|
99
99
|
lines.push(
|
|
100
|
-
"<!-- GENERATED from services/encode-run-state.js by scripts/render-run-graph.js.",
|
|
100
|
+
"<!-- GENERATED from services/encode/encode-run-state.js by scripts/render-run-graph.js.",
|
|
101
101
|
" Do not edit by hand: change the table and run `npm run graph`. -->",
|
|
102
102
|
"",
|
|
103
103
|
"# The encoder run — states and transitions",
|
package/server.js
CHANGED
|
@@ -210,6 +210,23 @@ export async function startProxyServer({
|
|
|
210
210
|
return null;
|
|
211
211
|
}
|
|
212
212
|
},
|
|
213
|
+
// Where the file's keyframes are, read by the same container that answered
|
|
214
|
+
// the two above. It used to be read by the session itself over this proxy's
|
|
215
|
+
// own HTTP, once per session — so two viewers opening one film read the
|
|
216
|
+
// same table twice, and each of them could get a different answer about
|
|
217
|
+
// whether the picture can be copied at all.
|
|
218
|
+
getContainerKeyframes: async ({ sourceKey, fileIndex }) => {
|
|
219
|
+
const record = sourceRegistry.get(sourceKey);
|
|
220
|
+
if (!record || typeof torrentPool.getContainerKeyframes !== "function") {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
225
|
+
return await torrentPool.getContainerKeyframes(torrent, fileIndex);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
213
230
|
// Pull one whole file onto the disk. Used for a soundtrack that ships beside
|
|
214
231
|
// the picture, once the encoder is as far ahead of the viewer as it is
|
|
215
232
|
// allowed to get — the one moment the swarm's capacity is demonstrably
|
|
@@ -232,6 +249,14 @@ export async function startProxyServer({
|
|
|
232
249
|
await torrentPool.fillFileInBackground?.(torrent, fileIndex);
|
|
233
250
|
}
|
|
234
251
|
});
|
|
252
|
+
// What the last life of this process left on the disk. The kernel kills this
|
|
253
|
+
// one often enough for that to be an ordinary state rather than an odd one —
|
|
254
|
+
// twice in a single viewing on 2026-09-02 — and when it does, no exit handler
|
|
255
|
+
// runs and nothing is cleared up. So this is both the cleanup and the only
|
|
256
|
+
// record that those encoders ended at all: it says what it found before it
|
|
257
|
+
// decides anything, keeps the segments whose closure is proven, and removes
|
|
258
|
+
// the one piece per output that was being written when the process died.
|
|
259
|
+
hlsSessionManager.adoptSegmentsLeftBehind();
|
|
235
260
|
const playbackPlanner = createPlaybackPlanner({
|
|
236
261
|
ffmpegBin,
|
|
237
262
|
transcodeAudioEnabled: transcodeAudio,
|