@torrent-tv/proxy 2.75.0 → 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 -1461
- package/CLAUDE.md +11 -1
- package/biome.json +182 -1
- package/docs/container-architecture.md +2 -1
- 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 +206 -201
- package/services/container/AviContainer.js +1 -1
- package/services/container/Container.js +33 -1
- package/services/container/MatroskaContainer.js +1 -1
- package/services/container/Mp4Container.js +1 -1
- 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 -775
- 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 +18 -0
- 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/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/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/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/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/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
package/CLAUDE.md
CHANGED
|
@@ -33,6 +33,14 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
|
|
|
33
33
|
`deselect` or `critical`, and `registry.js`, which holds one per torrent and
|
|
34
34
|
owns the cross-torrent rule that withholds the speculative levels while
|
|
35
35
|
anything urgent is missing anywhere. See `docs/download-architecture.md`.
|
|
36
|
+
- `output/` — domain layer: `OutputSpec`, `VideoOutput`, `AudioOutput`,
|
|
37
|
+
`CutGrid`. What a session PRODUCES — which tracks, in what form, cut how,
|
|
38
|
+
packaged how — and therefore its identity: two outputs whose parameters
|
|
39
|
+
agree ARE the same output, and the encoded result is reused by definition.
|
|
40
|
+
Nothing about a VIEWER appears in it (not the consumer id, not where they
|
|
41
|
+
started, not their viewport), and nothing about the request that does not
|
|
42
|
+
change a byte of the result. `hls-session-manager` builds one and keys the
|
|
43
|
+
session on it.
|
|
36
44
|
- `container/` — domain layer: `Container` (abstract, RFC 9559 / ISO 14496-12),
|
|
37
45
|
`MatroskaContainer` / `Mp4Container` / `AviContainer`, `ContainerFactory`
|
|
38
46
|
(sniff 16 bytes → precise subclass). See `docs/container-architecture.md`.
|
|
@@ -43,7 +51,9 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
|
|
|
43
51
|
RFC 9559 §5.1.4.1, FlagOriginal/Commentary only on audio, tkhd
|
|
44
52
|
track_enabled / alternate_group, elng BCP47).
|
|
45
53
|
- `orchestrators/` — application layer: `ContainerOrchestrator` (detect + per-file
|
|
46
|
-
cache, `getTracks`/`getKeyframeIndex`
|
|
54
|
+
cache, `getTracks`/`getMediaInfo`/`getKeyframeIndex` — the keyframe table is
|
|
55
|
+
a property of immutable bytes like the other two, memoized on the container
|
|
56
|
+
and read once per file by whoever asks), `SubtitleOrchestrator` (wraps
|
|
47
57
|
`torrent-worker/subtitle-cues.js` + `Container` tracks, warm/push). The walk
|
|
48
58
|
itself is `MatroskaContainer.walkHeldClusters` / `Mp4Container.readHeldSamples`;
|
|
49
59
|
`subtitle-cues.js` supplies the torrent's read policy and keeps the cursor.
|
package/biome.json
CHANGED
|
@@ -17,5 +17,186 @@
|
|
|
17
17
|
"noPrivateImports": "error"
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
}
|
|
20
|
+
},
|
|
21
|
+
"overrides": [
|
|
22
|
+
{
|
|
23
|
+
"includes": [
|
|
24
|
+
"services/demand/**",
|
|
25
|
+
"services/piece-store/**",
|
|
26
|
+
"services/segment-formats/**",
|
|
27
|
+
"services/tracks/**",
|
|
28
|
+
"services/output/**",
|
|
29
|
+
"services/viewer/**",
|
|
30
|
+
"services/encode/**",
|
|
31
|
+
"services/source/**",
|
|
32
|
+
"services/torrent/**"
|
|
33
|
+
],
|
|
34
|
+
"linter": {
|
|
35
|
+
"rules": {
|
|
36
|
+
"style": {
|
|
37
|
+
"noRestrictedImports": {
|
|
38
|
+
"level": "error",
|
|
39
|
+
"options": {
|
|
40
|
+
"patterns": [
|
|
41
|
+
{
|
|
42
|
+
"group": ["../**"],
|
|
43
|
+
"message": "This layer states facts and imports nothing above itself. Anything it needs from outside — a logger, a clock, a way to read bytes — is passed in."
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"includes": ["services/download/**"],
|
|
54
|
+
"linter": {
|
|
55
|
+
"rules": {
|
|
56
|
+
"style": {
|
|
57
|
+
"noRestrictedImports": {
|
|
58
|
+
"level": "error",
|
|
59
|
+
"options": {
|
|
60
|
+
"patterns": [
|
|
61
|
+
{
|
|
62
|
+
"group": [
|
|
63
|
+
"../*",
|
|
64
|
+
"../container/**",
|
|
65
|
+
"../controllers/**",
|
|
66
|
+
"../encode/**",
|
|
67
|
+
"../orchestrators/**",
|
|
68
|
+
"../output/**",
|
|
69
|
+
"../segment-formats/**",
|
|
70
|
+
"../torrent-worker/**",
|
|
71
|
+
"../tracks/**",
|
|
72
|
+
"../viewer/**"
|
|
73
|
+
],
|
|
74
|
+
"message": "What the swarm is told is decided from what is wanted (demand/) and what is held (piece-store/), and from nothing else."
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"includes": ["services/container/**"],
|
|
85
|
+
"linter": {
|
|
86
|
+
"rules": {
|
|
87
|
+
"style": {
|
|
88
|
+
"noRestrictedImports": {
|
|
89
|
+
"level": "error",
|
|
90
|
+
"options": {
|
|
91
|
+
"patterns": [
|
|
92
|
+
{
|
|
93
|
+
"group": [
|
|
94
|
+
"../*",
|
|
95
|
+
"../controllers/**",
|
|
96
|
+
"../demand/**",
|
|
97
|
+
"../download/**",
|
|
98
|
+
"../encode/**",
|
|
99
|
+
"../orchestrators/**",
|
|
100
|
+
"../output/**",
|
|
101
|
+
"../piece-store/**",
|
|
102
|
+
"../segment-formats/**",
|
|
103
|
+
"../torrent-worker/**",
|
|
104
|
+
"../viewer/**"
|
|
105
|
+
],
|
|
106
|
+
"message": "A container answers for itself and hands out tracks/. It knows nothing of torrents, encoders or viewers: the four functions it is built with are its whole outside world."
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"includes": ["services/orchestrators/**"],
|
|
117
|
+
"linter": {
|
|
118
|
+
"rules": {
|
|
119
|
+
"style": {
|
|
120
|
+
"noRestrictedImports": {
|
|
121
|
+
"level": "error",
|
|
122
|
+
"options": {
|
|
123
|
+
"patterns": [
|
|
124
|
+
{
|
|
125
|
+
"group": [
|
|
126
|
+
"../*",
|
|
127
|
+
"../controllers/**",
|
|
128
|
+
"../demand/**",
|
|
129
|
+
"../download/**",
|
|
130
|
+
"../output/**",
|
|
131
|
+
"../piece-store/**",
|
|
132
|
+
"../segment-formats/**",
|
|
133
|
+
"../viewer/**"
|
|
134
|
+
],
|
|
135
|
+
"message": "An orchestrator composes the layers below it. Reaching sideways into serving or downloading is what put a scenario in two places before."
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"includes": ["services/controllers/**"],
|
|
146
|
+
"linter": {
|
|
147
|
+
"rules": {
|
|
148
|
+
"style": {
|
|
149
|
+
"noRestrictedImports": {
|
|
150
|
+
"level": "error",
|
|
151
|
+
"options": {
|
|
152
|
+
"patterns": [
|
|
153
|
+
{
|
|
154
|
+
"group": [
|
|
155
|
+
"../*",
|
|
156
|
+
"../demand/**",
|
|
157
|
+
"../download/**",
|
|
158
|
+
"../encode/**",
|
|
159
|
+
"../output/**",
|
|
160
|
+
"../piece-store/**",
|
|
161
|
+
"../segment-formats/**",
|
|
162
|
+
"../torrent-worker/**",
|
|
163
|
+
"../viewer/**"
|
|
164
|
+
],
|
|
165
|
+
"message": "A controller translates a request for an orchestrator. Every layer below the orchestrator is reached through it, never around it."
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"includes": ["services/torrent-worker/**"],
|
|
176
|
+
"linter": {
|
|
177
|
+
"rules": {
|
|
178
|
+
"style": {
|
|
179
|
+
"noRestrictedImports": {
|
|
180
|
+
"level": "error",
|
|
181
|
+
"options": {
|
|
182
|
+
"patterns": [
|
|
183
|
+
{
|
|
184
|
+
"group": [
|
|
185
|
+
"../hls-session-manager.js",
|
|
186
|
+
"../controllers/**",
|
|
187
|
+
"../encode/**",
|
|
188
|
+
"../output/**",
|
|
189
|
+
"../viewer/**",
|
|
190
|
+
"../segment-formats/**"
|
|
191
|
+
],
|
|
192
|
+
"message": "The torrent thread serves bytes. What is produced from them, and for whom, is decided in the main thread and never asked of this one."
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
]
|
|
21
202
|
}
|
|
@@ -162,7 +162,8 @@ flowchart TB
|
|
|
162
162
|
## Orchestrators & Controllers
|
|
163
163
|
|
|
164
164
|
- `ContainerFactory.create({readRange,fileSize})` — sniffs 16 bytes, returns precise `Container` subclass. No torrent knowledge.
|
|
165
|
-
- `ContainerOrchestrator` — per-file cache (`sourceKey:fileIndex`), `getTracks()` / `getKeyframeIndex()`. Transport-agnostic.
|
|
165
|
+
- `ContainerOrchestrator` — per-file cache (`sourceKey:fileIndex`), `getTracks()` / `getMediaInfo()` / `getKeyframeIndex()`. Transport-agnostic.
|
|
166
|
+
- **The keyframe table is read once per file**, like the other two. `Container.readKeyframeIndex` memoizes and each format implements `parseKeyframeIndex`, so the wait belongs to the FILE: two sessions created in the same moment join one read rather than making two, which is exactly what two viewers opening one film do. A read that THREW is not remembered — the bytes it needed may simply not have arrived. Who asks: `torrent-worker/container-tracks.js` `containerKeyframesOf`, over the torrent, reached from the session manager by the `container-keyframes` command. The manager's own HTTP read remains for a manager wired without that path (every unit test).
|
|
166
167
|
- `SubtitleOrchestrator` — wraps `torrent-worker/subtitle-cues.js` (`planFor`, `cuesHeldFor`, `warmSubtitleCues`) behind the `ContainerTrack` abstraction. Routes depend on this, not on the worker directly. The reading itself is the containers' — that module supplies the torrent's read policy and keeps the cursor.
|
|
167
168
|
- `PlaybackController` / `SubtitleController` — thin interface adapters; `routes/api/*` delegate to them, handle HTTP headers (`X-Subtitle-Language`, `X-Subtitle-Cursor`) only.
|
|
168
169
|
|
package/docs/encode-run-state.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- GENERATED from services/encode-run-state.js by scripts/render-run-graph.js.
|
|
1
|
+
<!-- GENERATED from services/encode/encode-run-state.js by scripts/render-run-graph.js.
|
|
2
2
|
Do not edit by hand: change the table and run `npm run graph`. -->
|
|
3
3
|
|
|
4
4
|
# The encoder run — states and transitions
|
package/knip.json
ADDED
package/package.json
CHANGED
|
@@ -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
|
}
|