@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.
Files changed (124) hide show
  1. package/CHANGELOG.md +1504 -1453
  2. package/CLAUDE.md +19 -6
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +27 -6
  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 +9 -214
  18. package/services/container/AviContainer.js +266 -81
  19. package/services/container/Container.js +281 -80
  20. package/services/container/ContainerFactory.js +67 -0
  21. package/services/container/MatroskaContainer.js +327 -8
  22. package/services/container/Mp4Container.js +373 -27
  23. package/services/container/SubtitleFileContainer.js +0 -1
  24. package/services/controllers/SubtitleController.js +128 -128
  25. package/services/demand/index.js +7 -10
  26. package/services/download/registry.js +0 -14
  27. package/services/encode/CoverageMap.js +281 -0
  28. package/services/encode/EncodePlan.js +255 -0
  29. package/services/encode/EncodeRun.js +587 -0
  30. package/services/encode/Encoder.js +84 -0
  31. package/services/encode/NvencEncoder.js +45 -0
  32. package/services/encode/QsvEncoder.js +47 -0
  33. package/services/encode/SegmentDemand.js +0 -0
  34. package/services/encode/SegmentStore.js +529 -0
  35. package/services/encode/SoftwareEncoder.js +111 -0
  36. package/services/encode/V4l2m2mEncoder.js +53 -0
  37. package/services/encode/VaapiEncoder.js +53 -0
  38. package/services/encode/args.js +200 -0
  39. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  40. package/services/encode/index.js +9 -0
  41. package/services/encode/run-command.js +647 -0
  42. package/services/hls-session-manager.js +11073 -10711
  43. package/services/hwaccel.js +1688 -1992
  44. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  45. package/services/output/LiveOutputs.js +213 -0
  46. package/services/output/Output.js +94 -0
  47. package/services/output/OutputSpec.js +195 -0
  48. package/services/output/Timeline.js +220 -0
  49. package/services/output/index.js +1 -0
  50. package/services/output/ladder.js +26 -0
  51. package/services/playback-planner.js +806 -747
  52. package/services/produced-index.js +222 -300
  53. package/services/source/SourceFile.js +346 -0
  54. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  55. package/services/torrent/naming.js +619 -0
  56. package/services/torrent-worker/client.js +10 -0
  57. package/services/torrent-worker/container-tracks.js +71 -43
  58. package/services/torrent-worker/pool-adapter.js +370 -333
  59. package/services/torrent-worker/protocol.js +7 -0
  60. package/services/torrent-worker/subtitle-cues.js +549 -549
  61. package/services/torrent-worker/worker.js +18 -0
  62. package/services/tracks/AudioTrack.js +131 -40
  63. package/services/tracks/TextSubtitleTrack.js +287 -287
  64. package/services/tracks/index.js +15 -14
  65. package/services/viewer/Viewer.js +145 -0
  66. package/services/viewer/Viewers.js +124 -0
  67. package/test/audio-inventory.test.js +176 -177
  68. package/test/auto-quality-step.test.js +508 -506
  69. package/test/behind-head-repair.test.js +17 -7
  70. package/test/coverage-map.test.js +153 -0
  71. package/test/cut-times-timeline.test.js +6 -5
  72. package/test/cuts-follow-published-grid.test.js +4 -4
  73. package/test/decode-cost.test.js +31 -12
  74. package/test/encode-exit.test.js +1 -1
  75. package/test/encode-orchestrator.test.js +196 -0
  76. package/test/encode-plan.test.js +245 -0
  77. package/test/encode-run-state.test.js +2 -2
  78. package/test/encode-run.test.js +168 -0
  79. package/test/encoder-kinds.test.js +122 -0
  80. package/test/held-request-width.test.js +9 -3
  81. package/test/helpers/encode-run.js +128 -0
  82. package/test/keyframe-index-accuracy.test.js +19 -12
  83. package/test/keyframes-belong-to-the-file.test.js +132 -0
  84. package/test/matroska-cues-track.test.js +192 -192
  85. package/test/mp4-composition-times.test.js +0 -0
  86. package/test/orchestrator-wired.test.js +164 -0
  87. package/test/output-shape.test.js +68 -0
  88. package/test/output-spec.test.js +157 -0
  89. package/test/produced-copy-choice.test.js +58 -92
  90. package/test/produced-index.test.js +142 -188
  91. package/test/quality-variants.test.js +1079 -1075
  92. package/test/run-graph-drift.test.js +1 -1
  93. package/test/run-intervals.test.js +329 -0
  94. package/test/run-position-follows-published-grid.test.js +4 -4
  95. package/test/seek-landing.test.js +8 -8
  96. package/test/seek-target-not-superseded.test.js +21 -9
  97. package/test/segment-demand.test.js +82 -0
  98. package/test/segment-serve-wiring.test.js +47 -52
  99. package/test/segment-store.test.js +187 -0
  100. package/test/segments-are-shared.test.js +175 -0
  101. package/test/sidecar-naming.test.js +142 -0
  102. package/test/source-file.test.js +133 -0
  103. package/test/stale-request-after-seek.test.js +18 -12
  104. package/test/subtitle-language.test.js +252 -252
  105. package/test/timeline.test.js +95 -0
  106. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  107. package/test/torrent-naming.test.js +255 -0
  108. package/test/tracks-begin-together.test.js +44 -32
  109. package/test/two-viewers-one-picture.test.js +347 -0
  110. package/test/video-facts.test.js +102 -0
  111. package/test/viewer-outputs.test.js +273 -0
  112. package/test/viewer.test.js +91 -0
  113. package/utils/perf.js +1 -63
  114. package/services/container/index.js +0 -6
  115. package/services/container-index/avi.js +0 -167
  116. package/services/container-index/index.js +0 -118
  117. package/services/container-index/matroska.js +0 -336
  118. package/services/container-index/mp4.js +0 -358
  119. package/services/controllers/index.js +0 -2
  120. package/services/download/index.js +0 -8
  121. package/services/orchestrators/index.js +0 -2
  122. /package/services/{container-index → container}/ebml-reader.js +0 -0
  123. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  124. /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
- 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
  }
@@ -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
- const resolved = await hlsSessionManager.resolveAudioRenditionFile(baseSessionId, trackIndex, fileName);
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(baseSessionId, trackIndex, positionSeconds);
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
- const resolved = await hlsSessionManager.resolveVariantFile(baseSessionId, height, fileName);
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,