@torrent-tv/proxy 2.75.0 → 2.76.1

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +1509 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11100 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /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`), `SubtitleOrchestrator` (wraps
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
 
@@ -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
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://unpkg.com/knip@6/schema.json",
3
+ "entry": [
4
+ "test/*.test.js"
5
+ ],
6
+ "project": [
7
+ "bin/**/*.js",
8
+ "routes/**/*.js",
9
+ "services/**/*.js",
10
+ "utils/**/*.js",
11
+ "server.js",
12
+ "test/**/*.js"
13
+ ]
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.75.0",
3
+ "version": "2.76.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -3,7 +3,7 @@ import {
3
3
  countVideoFiles,
4
4
  matchSidecarFiles,
5
5
  TEXT_SUBTITLE_SIDECAR_EXTENSIONS
6
- } from "../../../../services/sidecar-files.js";
6
+ } from "../../../../services/torrent/files.js";
7
7
 
8
8
  /**
9
9
  * Start fetching a source before anyone asks to play it.
@@ -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
- const progress = await hlsSessionManager.getSessionProgress(sessionId);
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
  }