@torrent-tv/proxy 2.90.0 → 2.92.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 (146) hide show
  1. package/bin/cli.js +5 -2
  2. package/package.json +18 -2
  3. package/routes/api/playback-plan/post.js +21 -4
  4. package/routes/api/sources/files/get.js +20 -28
  5. package/routes/api/sources/post.js +4 -2
  6. package/routes/api/sources/stats/get.js +4 -11
  7. package/routes/api/sources/viewer/post.js +16 -0
  8. package/routes/api/sources/warm/post.js +6 -215
  9. package/routes/api/subtitles/get.js +22 -125
  10. package/routes/api/subtitles/post.js +26 -0
  11. package/routes/api/transcode-sessions/post.js +24 -2
  12. package/routes/media/get.js +80 -0
  13. package/routes/stream/get.js +26 -94
  14. package/routes/transcode/audio-warm/get.js +9 -21
  15. package/routes/transcode/variant-warm/get.js +8 -14
  16. package/server.js +410 -46
  17. package/services/encode/AdmittedInput.js +136 -0
  18. package/services/encode/CushionReport.js +1 -76
  19. package/services/encode/EncodeInputs.js +142 -0
  20. package/services/encode/EncodeOrchestrator.js +4 -1
  21. package/services/encode/EncodeRun.js +13 -4
  22. package/services/encode/EncodeRuns.js +132 -52
  23. package/services/encode/InputFailures.js +37 -0
  24. package/services/encode/MatroskaInput.js +158 -0
  25. package/services/encode/OutputOpening.js +16 -64
  26. package/services/encode/Renditions.js +1 -17
  27. package/services/encode/admitted-command.js +60 -0
  28. package/services/encode/annex-b-input.js +37 -0
  29. package/services/encode/bit-payload.js +29 -0
  30. package/services/encode/output/OutputCatalog.js +1 -1
  31. package/services/encode/output/Timeline.js +11 -0
  32. package/services/encode/pcm-normalization.js +30 -0
  33. package/services/encode/piece-completeness.js +54 -1
  34. package/services/encode/quality/HostLoad.js +19 -73
  35. package/services/encode/quality/QualityOffer.js +1 -1
  36. package/services/encode/run-command.js +1 -589
  37. package/services/encode/segment-formats/mp4-boxes.js +3 -1
  38. package/services/encode/segment-formats/presentation-segment.js +114 -0
  39. package/services/media/ContainerOrchestrator.js +118 -48
  40. package/services/media/KeyframeTables.js +16 -115
  41. package/services/media/MediaReadRequests.js +120 -0
  42. package/services/media/ProbeRequests.js +135 -0
  43. package/services/media/SegmentInputs.js +48 -0
  44. package/services/media/SourceFile.js +6 -0
  45. package/services/media/SubtitleOrchestrator.js +190 -4
  46. package/services/media/await-plan.js +47 -0
  47. package/services/media/container/AsfContainer.js +217 -0
  48. package/services/media/container/AviContainer.js +343 -34
  49. package/services/media/container/CadencedPictureOrder.js +45 -0
  50. package/services/media/container/Container.js +7 -3
  51. package/services/media/container/ContainerFactory.js +10 -3
  52. package/services/media/container/FfprobeContainer.js +222 -0
  53. package/services/media/container/MatroskaContainer.js +72 -46
  54. package/services/media/container/Mp4Container.js +472 -139
  55. package/services/media/container/MpegPsContainer.js +281 -0
  56. package/services/media/container/MpegTsContainer.js +277 -0
  57. package/services/media/container/PacketIndex.js +270 -0
  58. package/services/media/container/PacketRecords.js +237 -0
  59. package/services/media/container/RetainedBytes.js +39 -0
  60. package/services/media/container/RetainedReads.js +51 -0
  61. package/services/media/container/ac3-frame.js +46 -0
  62. package/services/media/container/asf-packets.js +153 -0
  63. package/services/media/container/avc-picture-order.js +50 -0
  64. package/services/media/container/avi-open-dml.js +47 -0
  65. package/services/media/container/dts-frame.js +51 -0
  66. package/services/media/container/ebml-stream.js +12 -1
  67. package/services/media/container/ffprobe-record.js +45 -0
  68. package/services/media/container/flv-packet-ranges.js +27 -0
  69. package/services/media/container/following-packet-time.js +37 -0
  70. package/services/media/container/h264-configuration.js +183 -0
  71. package/services/media/container/h264-elementary-index.js +245 -0
  72. package/services/media/container/hevc-configuration.js +165 -0
  73. package/services/media/container/hevc-elementary-index.js +132 -0
  74. package/services/media/container/hevc-picture-order.js +66 -0
  75. package/services/media/container/latm-frame.js +99 -0
  76. package/services/media/container/matroska-packets.js +207 -0
  77. package/services/media/container/memory-unavailable.js +8 -0
  78. package/services/media/container/mp3-packet-dependencies.js +41 -0
  79. package/services/media/container/mp4-fragments.js +148 -0
  80. package/services/media/container/mpeg-audio-frame.js +19 -0
  81. package/services/media/container/mpeg-audio-reservoir.js +27 -0
  82. package/services/media/container/mpeg-elementary-index.js +196 -0
  83. package/services/media/container/mpeg-sequence-reader.js +69 -0
  84. package/services/media/container/mpeg-ts-packets.js +93 -0
  85. package/services/media/container/mpeg-video-configuration.js +50 -0
  86. package/services/media/container/mpeg4-picture-timing.js +108 -0
  87. package/services/media/container/pes-header.js +34 -0
  88. package/services/media/playback-declarations.js +57 -0
  89. package/services/media/playback-planner.js +72 -230
  90. package/services/media/probe-packets.js +61 -0
  91. package/services/media/read-playback-declarations.js +18 -0
  92. package/services/media/tracks/AudioTrack.js +9 -0
  93. package/services/media/tracks/TextSubtitleTrack.js +4 -1
  94. package/services/server/OutputLifecycle.js +2 -0
  95. package/services/server/SegmentServing.js +40 -33
  96. package/services/server/ViewerRequests.js +22 -41
  97. package/services/server/controllers/PlaybackController.js +4 -0
  98. package/services/server/controllers/SubtitleController.js +8 -29
  99. package/services/server/transcode-session-files.js +58 -99
  100. package/services/server/wire-outputs.js +58 -15
  101. package/services/storage/IndexMemory.js +85 -0
  102. package/services/storage/MachineBudget.js +27 -3
  103. package/services/storage/allowance.js +8 -1
  104. package/services/storage/piece-store/piece-disk-store.js +18 -3
  105. package/services/storage/piece-store/piece-lru.js +8 -4
  106. package/services/storage/piece-store/shared-piece-store.js +26 -11
  107. package/services/storage/segment-store/SegmentStore.js +49 -18
  108. package/services/storage/wire.js +7 -0
  109. package/services/torrent/Contents.js +52 -20
  110. package/services/torrent/demand/DemandRegister.js +13 -4
  111. package/services/torrent/demand/Window.js +13 -2
  112. package/services/torrent/demand-progress.js +25 -0
  113. package/services/torrent/download/DeadlineScheduler.js +167 -0
  114. package/services/torrent/download/FutureDownload.js +166 -0
  115. package/services/torrent/download/SwarmSelection.js +30 -59
  116. package/services/torrent/download/registry.js +45 -5
  117. package/services/torrent/download/withdraw-claim.js +2 -0
  118. package/services/torrent/torrent-pool.js +39 -600
  119. package/services/torrent/worker/channel.js +8 -1
  120. package/services/torrent/worker/client.js +74 -72
  121. package/services/torrent/worker/files-in-use.js +9 -0
  122. package/services/torrent/worker/handle-state.js +12 -0
  123. package/services/torrent/worker/held-bytes.js +82 -99
  124. package/services/torrent/worker/held-file-bytes.js +17 -0
  125. package/services/torrent/worker/piece-reader.js +67 -683
  126. package/services/torrent/worker/pool-adapter.js +37 -123
  127. package/services/torrent/worker/protocol.js +6 -17
  128. package/services/torrent/worker/whole-file-stats.js +2 -0
  129. package/services/torrent/worker/worker.js +86 -156
  130. package/services/transport/data-channel-handler.js +56 -13
  131. package/services/transport/tunnel-client.js +111 -20
  132. package/services/viewer/DownloadMaps.js +168 -0
  133. package/services/viewer/NativeSourceMap.js +33 -0
  134. package/services/viewer/PriorityMap.js +49 -40
  135. package/services/viewer/PriorityOrchestrator.js +42 -13
  136. package/services/viewer/SourcePreparation.js +278 -0
  137. package/services/viewer/Viewer.js +11 -0
  138. package/services/viewer/Viewers.js +109 -2
  139. package/services/viewer/playback-readiness.js +50 -63
  140. package/services/viewer/segment-demands.js +37 -0
  141. package/services/viewer/urgent-output-ready.js +26 -0
  142. package/services/encode/run-inputs.js +0 -53
  143. package/services/media/keyframe-probe.js +0 -157
  144. package/services/media/media-info-probe.js +0 -93
  145. package/services/torrent/worker/background-fill.js +0 -213
  146. package/services/torrent/worker/resume-warm.js +0 -107
@@ -4,8 +4,7 @@
4
4
  * modes:
5
5
  *
6
6
  * - Embedded track: ?sourceKey&fileIndex=<video>&trackIndex=<sub stream N>
7
- * ffmpeg extracts the text subtitle stream (`-map 0:s:N -f webvtt`),
8
- * streamed as it is produced.
7
+ * indexed text packets are read from available source bytes.
9
8
  * - External file: ?sourceKey&fileIndex=<subtitle file> (no trackIndex)
10
9
  * the subtitle FILE is read, decoded (UTF-8/Windows-1251), and converted
11
10
  * (.srt/.ass/.ssa → WebVTT) here on the proxy.
@@ -18,20 +17,12 @@
18
17
  * @param {{
19
18
  * sourceRegistry: ReturnType<import("../../../store/source-registry.js").createSourceRegistry>,
20
19
  * torrentPool: import("../../../services/torrent/torrent-pool.js").TorrentPool,
21
- * ffmpegBin: string,
22
- * localBaseUrl: string
23
20
  * }} deps
24
21
  * @returns {Promise<void>}
25
22
  */
26
23
 
27
24
  import { deriveSourceKey } from "../../../utils/torrent-source-key.js";
28
- import { spawn } from "node:child_process";
29
- import { TextSubtitleTrack } from "../../../services/media/tracks/TextSubtitleTrack.js";
30
25
  import { SubtitleController } from "../../../services/server/controllers/SubtitleController.js";
31
- import { logger } from "../../../utils/logger.js";
32
-
33
- // Safety cap: no embedded extraction may outlive this.
34
- const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
35
26
 
36
27
  /** Set the detected-language response headers (no-op when detection failed). */
37
28
  function setLanguageHeaders(reply, lang) {
@@ -45,7 +36,7 @@ function setLanguageHeaders(reply, lang) {
45
36
  }
46
37
  }
47
38
 
48
- export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers, subtitles }) {
39
+ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, viewers, subtitles }) {
49
40
  const query = req.query ?? {};
50
41
  const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
51
42
  const fileIndex = Number(query.fileIndex);
@@ -55,6 +46,9 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
55
46
  if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
56
47
  return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
57
48
  }
49
+ if (hasTrackIndex && (!Number.isSafeInteger(trackIndex) || trackIndex < 0)) {
50
+ return reply.code(400).send({ error: "trackIndex must be a non-negative integer.", canRetry: false });
51
+ }
58
52
 
59
53
  // Turning subtitles on is a fact about the VIEWER, and this is the only place
60
54
  // that knows both the person and the file, so this is where it is recorded.
@@ -85,17 +79,30 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
85
79
  const controller = new SubtitleController({ sourceRegistry, torrentPool, subtitles });
86
80
  const since = Number.parseInt(String(req.query?.since ?? ""), 10);
87
81
  const after = Number.parseFloat(String(req.query?.after ?? ""));
88
- const result = await controller.getSubtitle({
82
+ const cancellation = new AbortController();
83
+ const closed = () => cancellation.abort(new DOMException("Subtitle caller closed.", "AbortError"));
84
+ reply.raw.once("close", closed);
85
+ let result;
86
+ try { result = await controller.getSubtitle({
89
87
  sourceKey,
90
88
  fileIndex,
91
89
  trackIndex: hasTrackIndex ? trackIndex : undefined,
92
90
  since: Number.isInteger(since) ? since : null,
93
- after: Number.isFinite(after) ? after : null
94
- });
91
+ after: Number.isFinite(after) ? after : null,
92
+ signal: cancellation.signal
93
+ }); } finally { reply.raw.removeListener("close", closed); }
95
94
 
96
95
  if (result.error) {
97
96
  return reply.code(result.status ?? 400).send({ error: result.error });
98
97
  }
98
+ if (hasTrackIndex) {
99
+ reply.header("X-Subtitle-Delivery", "push");
100
+ reply.header("Access-Control-Expose-Headers",
101
+ "X-Subtitle-Delivery, X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor");
102
+ }
103
+ if (result.pending && !hasTrackIndex) {
104
+ return reply.code(202).send({ pending: true });
105
+ }
99
106
  if (result.vtt !== undefined) {
100
107
  if (result.vtt !== null) {
101
108
  // External file or cluster-held cues — controller already detected language.
@@ -109,122 +116,12 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
109
116
  for (const [k, v] of Object.entries(result.headers)) reply.header(k, String(v));
110
117
  reply.raw.setHeader(
111
118
  "Access-Control-Expose-Headers",
112
- "X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor"
119
+ "X-Subtitle-Delivery, X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor"
113
120
  );
114
121
  }
115
122
  }
116
123
  return reply.send(result.vtt);
117
124
  }
118
125
  }
119
- // If controller returned pending, fall through to ffmpeg extraction below.
120
-
121
- // ---- Embedded track — controller had no held cues, try cluster path directly for headers compatibility
122
- // The controller's getSubtitle already attempted the cluster walk; reaching here means it returned pending.
123
- if (!hasTrackIndex) {
124
- // Should have been handled above — pending for external is unsupported format
125
- return reply.code(422).send({ error: "Unsupported subtitle format" });
126
- }
127
- if (!Number.isInteger(trackIndex) || trackIndex < 0) {
128
- return reply.code(400).send({ error: "trackIndex must be a non-negative integer." });
129
- }
130
-
131
- const inputUrl = new URL("/stream", `${localBaseUrl}/`);
132
- inputUrl.searchParams.set("sourceKey", sourceKey);
133
- inputUrl.searchParams.set("fileIndex", String(fileIndex));
134
-
135
- const key = `${sourceKey}:${fileIndex}:${trackIndex}`;
136
- const known = extractions.get(key);
137
- if (known?.state === "done") {
138
- setLanguageHeaders(reply, known.language);
139
- reply.header("content-type", "text/vtt; charset=utf-8");
140
- reply.header("cache-control", "no-store");
141
- reply.header("access-control-allow-origin", "*");
142
- return reply.send(known.body);
143
- }
144
- if (known?.state === "failed") {
145
- return reply.code(422).send({ error: known.error });
146
- }
147
- if (known?.state !== "running") {
148
- startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex });
149
- }
150
- // Being prepared. The connection is NOT held: extracting an embedded track
151
- // makes ffmpeg read the whole file, because subtitles are interleaved through
152
- // it, and that means downloading the film for the sake of a few kilobytes of
153
- // text. Measured 2026-08-19: track 0 of one release produced 3040 bytes over
154
- // **752 seconds**, with the data channel idle the whole time — the browser
155
- // gave up at its own sixty-second limit, and every retry started the same
156
- // twelve-minute scan again. So the work runs once in the background and the
157
- // caller is told to come back.
158
126
  return reply.code(202).send({ pending: true });
159
127
  }
160
-
161
- /**
162
- * Extractions by `sourceKey:fileIndex:trackIndex`, so the scan happens once per
163
- * track however many times it is asked for.
164
- *
165
- * @type {Map<string, { state: "running" | "done" | "failed", body?: Buffer, language?: { code: string, name: string } | null, error?: string }>}
166
- */
167
- const extractions = new Map();
168
-
169
- /**
170
- * Run one extraction to completion in the background, keeping the result.
171
- *
172
- * @param {{ key: string, ffmpegBin: string, localBaseUrl: string, sourceKey: string, fileIndex: number, trackIndex: number }} params
173
- * @returns {void}
174
- */
175
- function startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex }) {
176
- const inputUrl = new URL("/stream", `${localBaseUrl}/`);
177
- inputUrl.searchParams.set("sourceKey", sourceKey);
178
- inputUrl.searchParams.set("fileIndex", String(fileIndex));
179
-
180
- extractions.set(key, { state: "running" });
181
- const startedAt = Date.now();
182
- const ffmpeg = spawn(
183
- ffmpegBin,
184
- ["-hide_banner", "-loglevel", "error", "-i", inputUrl.toString(), "-map", `0:s:${trackIndex}`, "-f", "webvtt", "pipe:1"],
185
- { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
186
- );
187
-
188
- let stderr = "";
189
- ffmpeg.stderr.on("data", (chunk) => {
190
- if (stderr.length < 4096) {
191
- stderr += String(chunk);
192
- }
193
- });
194
-
195
- /** @type {Buffer[]} */
196
- const chunks = [];
197
- ffmpeg.stdout.on("data", (chunk) => chunks.push(chunk));
198
-
199
- const killTimer = setTimeout(() => {
200
- if (!ffmpeg.killed) {
201
- ffmpeg.kill("SIGKILL");
202
- }
203
- }, EXTRACTION_TIMEOUT_MS);
204
- killTimer.unref?.();
205
-
206
- const settle = () => {
207
- clearTimeout(killTimer);
208
- const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
209
- const body = Buffer.concat(chunks);
210
- if (body.length === 0) {
211
- extractions.set(key, {
212
- state: "failed",
213
- error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}`
214
- });
215
- logger.warn(`subtitles ${key}: nothing produced after ${seconds}s`);
216
- return;
217
- }
218
- // The whole document, decoded as one string, and only its cue text.
219
- // Detecting on the first 4096 BYTES was wrong twice over: a byte cut lands
220
- // mid-character on any non-Latin track, and most of those bytes are
221
- // timestamps rather than words. This runs once per track in the background,
222
- // so reading all of it costs nothing anybody waits for.
223
- extractions.set(key, { state: "done", body, language: TextSubtitleTrack.detectLanguageFromVtt(body.toString("utf8")) });
224
- logger.info(`subtitles ${key}: ${body.length} bytes in ${seconds}s`);
225
- };
226
- ffmpeg.once("close", settle);
227
- ffmpeg.once("error", settle);
228
- }
229
-
230
-
@@ -0,0 +1,26 @@
1
+ /** Record subtitle choice independently of document delivery. */
2
+ export async function handleApiSubtitlesPost(req, reply, { viewers, sourceRegistry, subtitleFilesFor, subtitleTracksFor }) {
3
+ const body = req.body ?? {};
4
+ const { consumerId, sourceKey, fileIndex, trackIndex = null, off = false } = body;
5
+ if (typeof consumerId !== "string" || !consumerId || typeof sourceKey !== "string" ||
6
+ !Number.isSafeInteger(fileIndex) || fileIndex < 0 || typeof off !== "boolean" ||
7
+ (trackIndex !== null && (!Number.isSafeInteger(trackIndex) || trackIndex < 0))) {
8
+ return reply.code(400).send({ error: "A viewer, source and subtitle file are required.", canRetry: false });
9
+ }
10
+ const viewer = viewers.get(consumerId);
11
+ if (!sourceRegistry.get(sourceKey) || !viewer || viewer.gone || viewer.source?.sourceKey !== sourceKey ||
12
+ (!off && trackIndex === null && !subtitleFilesFor(sourceKey, viewer.source.selectedFileIndex).includes(fileIndex))) {
13
+ return reply.code(409).send({ code: "REQUEST_OBSOLETE", error: "The subtitle selection is no longer current.", canRetry: false });
14
+ }
15
+ if (!off && trackIndex !== null && fileIndex === viewer.source.selectedFileIndex) {
16
+ const track = subtitleTracksFor(sourceKey, fileIndex).find(track => track.declaredIndex === trackIndex);
17
+ if (!track || track.isTextBased?.() !== true) {
18
+ return reply.code(422).send({ error: "The subtitle track is not supported.", canRetry: false });
19
+ }
20
+ }
21
+ if (off) viewers.clearsSubtitle(consumerId, sourceKey);
22
+ else if (!viewers.selectsSubtitle(consumerId, sourceKey, fileIndex, trackIndex)) {
23
+ return reply.code(409).send({ code: "REQUEST_OBSOLETE", canRetry: false });
24
+ }
25
+ return reply.send({ received: true });
26
+ }
@@ -12,6 +12,7 @@
12
12
  import { logger } from "../../../utils/logger.js";
13
13
  import { OUTPUT_NO_CAPACITY, OUTPUT_UNAVAILABLE } from "../../../services/encode/output/index.js";
14
14
  import { replyNoCapacity, replyOutputUnavailable } from "../../../services/server/http-responses.js";
15
+ import { waitForPlan } from "../../../services/media/await-plan.js";
15
16
 
16
17
  /**
17
18
  * Extract a plain object from the request body, guarding against
@@ -28,7 +29,7 @@ function getPayload(body) {
28
29
  }
29
30
 
30
31
 
31
- export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequests, renditions, quality, outputs, lookaheadSeconds, sourceRegistry, torrentPool }) {
32
+ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequests, renditions, quality, outputs, lookaheadSeconds, sourceRegistry, torrentPool, subscribeSource }) {
32
33
  const payload = getPayload(req.body);
33
34
  const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
34
35
  const fileIndex = Number(payload.fileIndex);
@@ -79,8 +80,13 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
79
80
  return reply.code(400).send({ error: "consumerId is required." });
80
81
  }
81
82
 
83
+ const cancellation = new AbortController();
84
+ const cancelled = () => cancellation.abort();
85
+ reply.raw?.once?.("close", cancelled);
86
+ if (reply.raw?.destroyed) cancelled();
82
87
  try {
83
- const session = await viewerRequests.createOrGetSession({
88
+ const open = () => viewerRequests.createOrGetSession({
89
+ signal: cancellation.signal,
84
90
  sourceKey,
85
91
  fileIndex,
86
92
  transcodeVideo,
@@ -107,6 +113,16 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
107
113
  Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
108
114
  segmentFormatId
109
115
  });
116
+ const session = subscribeSource ? (await waitForPlan({ signal: cancellation.signal,
117
+ subscribe: listener => subscribeSource(sourceKey, fileIndex, listener),
118
+ read: async () => {
119
+ try { return { pending: false, session: await open() }; }
120
+ catch (error) {
121
+ if (error?.code === "MEDIA_BYTES_UNAVAILABLE") return { pending: true };
122
+ throw error;
123
+ }
124
+ }
125
+ })).session : await open();
110
126
  // The index of quality variants, when this session has more than one to
111
127
  // offer. Its presence is what tells the browser it can change quality
112
128
  // without a new session: the player switches variants itself, appending the
@@ -152,6 +168,10 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
152
168
  lookaheadSeconds: lookaheadSeconds
153
169
  });
154
170
  } catch (error) {
171
+ if (cancellation.signal.aborted || error?.name === "AbortError") return;
172
+ if (error?.code === "SOURCE_FORGOTTEN") {
173
+ return reply.code(410).send({ error: error.message, retryable: false, terminal: true });
174
+ }
155
175
  if (error instanceof Error && error.code === "TRANSCODE_DISABLED") {
156
176
  return reply.code(409).send({ error: error.message });
157
177
  }
@@ -174,5 +194,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
174
194
  `${error instanceof Error ? (error.stack ?? "") : ""}`
175
195
  );
176
196
  return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
197
+ } finally {
198
+ reply.raw?.removeListener?.("close", cancelled);
177
199
  }
178
200
  }
@@ -0,0 +1,80 @@
1
+ import { Readable } from "node:stream";
2
+
3
+ function mediaRange(header, length) {
4
+ if (!header) return { start: 0, end: length - 1 };
5
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header);
6
+ if (!match || (!match[1] && !match[2])) return null;
7
+ if (!match[1]) {
8
+ const suffix = Number(match[2]);
9
+ if (!Number.isSafeInteger(suffix) || suffix <= 0) return null;
10
+ return { start: Math.max(0, length - suffix), end: length - 1 };
11
+ }
12
+ const start = Number(match[1]);
13
+ const end = match[2] ? Number(match[2]) : length - 1;
14
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= length || end < start) return null;
15
+ return { start, end: Math.min(end, length - 1) };
16
+ }
17
+
18
+ /** Serve existing downloaded bytes without creating source demand. */
19
+ export async function handleMediaGet(req, reply, { torrentPool, onMissing = () => {} }) {
20
+ const sourceKey = typeof req.query?.sourceKey === "string" ? req.query.sourceKey : "";
21
+ const rawIndex = String(req.query?.fileIndex ?? "");
22
+ if (!sourceKey || !/^\d+$/.test(rawIndex)) {
23
+ return reply.code(400).send({ error: "A source and file index are required." });
24
+ }
25
+ const fileIndex = Number(rawIndex);
26
+ const torrent = torrentPool.knownTorrent(sourceKey);
27
+ const file = torrent?.files?.[fileIndex];
28
+ if (!file || !(file.length > 0)) {
29
+ return reply.code(404).send({ error: "Media file is not available." });
30
+ }
31
+ const rangeHeader = req.headers?.range;
32
+ const range = mediaRange(rangeHeader, file.length);
33
+ if (!range || range.start >= file.length) {
34
+ reply.header("Content-Range", `bytes */${file.length}`);
35
+ return reply.code(416).send({ error: "Invalid media byte range." });
36
+ }
37
+ const portion = Number(torrent.pieceLength);
38
+ if (!Number.isSafeInteger(portion) || portion <= 0) {
39
+ return reply.code(500).send({ error: "Source piece length is unavailable." });
40
+ }
41
+ if (req.method === "HEAD") {
42
+ reply.header("Accept-Ranges", "bytes");
43
+ reply.header("Content-Length", String(file.length));
44
+ reply.header("Content-Type", "application/octet-stream");
45
+ return reply.send();
46
+ }
47
+ const missing = (start, end) => {
48
+ onMissing({ sourceKey, fileIndex, start, end, requestId: req.query?.readId ?? req.id });
49
+ const error = new Error(`Media bytes ${start}-${end} have not arrived.`);
50
+ error.code = "MEDIA_BYTES_UNAVAILABLE";
51
+ return error;
52
+ };
53
+ const endOfPortion = (start) => Math.min(range.end, start + portion - 1);
54
+ const firstEnd = endOfPortion(range.start);
55
+ const first = await torrentPool.readHeldOf(torrent, fileIndex, range.start, firstEnd);
56
+ if (!first || first.length !== firstEnd - range.start + 1) {
57
+ const error = missing(range.start, firstEnd);
58
+ return reply.code(409).send({ kind: "needs-ranges", ranges: [[range.start, firstEnd]], error: error.message });
59
+ }
60
+ reply.header("Accept-Ranges", "bytes");
61
+ reply.header("Content-Type", "application/octet-stream");
62
+ reply.header("Content-Length", String(range.end - range.start + 1));
63
+ reply.header("Cache-Control", "no-store");
64
+ if (rangeHeader) {
65
+ reply.code(206);
66
+ reply.header("Content-Range", `bytes ${range.start}-${range.end}/${file.length}`);
67
+ }
68
+ async function* bytes() {
69
+ yield first;
70
+ for (let start = firstEnd + 1; start <= range.end;) {
71
+ if (reply.raw?.destroyed) return;
72
+ const end = endOfPortion(start);
73
+ const data = await torrentPool.readHeldOf(torrent, fileIndex, start, end);
74
+ if (!data || data.length !== end - start + 1) throw missing(start, end);
75
+ yield data;
76
+ start = end + 1;
77
+ }
78
+ }
79
+ return reply.send(Readable.from(bytes()));
80
+ }
@@ -60,43 +60,19 @@ function getSourceParams(query, sourceRegistry) {
60
60
  return { sourceType, source, sourceKey };
61
61
  }
62
62
 
63
- /**
64
- * How long a request may wait for a torrent that is still being added.
65
- *
66
- * Adding a magnet takes as long as its metadata does — seconds when peers
67
- * answer, forever when none do — and `getTorrent` waits for it. Awaiting that
68
- * with no bound is what made this route answer nothing at all.
69
- */
70
- const TORRENT_READY_TIMEOUT_MS = 10_000;
71
-
72
- /**
73
- * The torrent for a source, or a `TORRENT_NOT_READY` error once the wait has
74
- * gone on long enough to be worth reporting.
75
- *
76
- * The underlying add is NOT cancelled: it keeps running and warms the pool, so
77
- * the client's next attempt is likely to find it ready.
78
- *
79
- * @param {import("../../services/torrent/torrent-pool.js").TorrentPool} torrentPool
80
- * @param {string} sourceType
81
- * @param {string} source
82
- * @returns {Promise<import("webtorrent").Torrent>}
83
- */
84
- async function waitForTorrent(torrentPool, sourceType, source) {
85
- let timer = null;
86
- const expiry = new Promise((_resolve, reject) => {
87
- timer = setTimeout(() => {
88
- const error = new Error("Torrent metadata is not available yet.");
89
- error.code = "TORRENT_NOT_READY";
90
- reject(error);
91
- }, TORRENT_READY_TIMEOUT_MS);
92
- timer.unref?.();
93
- });
63
+ /** Torrent metadata has no elapsed-time failure; a disconnected caller cancels its wait. */
64
+ async function waitForTorrent(torrentPool, sourceType, source, req, reply) {
65
+ let cancelled;
66
+ const until = new Promise((_resolve, reject) => { cancelled = () => reject(new DOMException("Source read was cancelled.", "AbortError")); });
67
+ const closed = () => { if (!reply.raw?.writableEnded) cancelled(); };
68
+ req.raw?.once?.("aborted", cancelled);
69
+ reply.raw?.once?.("close", closed);
94
70
  try {
95
- return await Promise.race([torrentPool.getTorrent(sourceType, source), expiry]);
71
+ if (req.raw?.aborted) throw new DOMException("Source read was cancelled.", "AbortError");
72
+ return await Promise.race([torrentPool.getTorrent(sourceType, source), until]);
96
73
  } finally {
97
- if (timer) {
98
- clearTimeout(timer);
99
- }
74
+ req.raw?.off?.("aborted", cancelled);
75
+ reply.raw?.off?.("close", closed);
100
76
  }
101
77
  }
102
78
 
@@ -212,18 +188,9 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
212
188
 
213
189
  let torrent;
214
190
  try {
215
- torrent = await waitForTorrent(torrentPool, sourceType, source);
191
+ torrent = await waitForTorrent(torrentPool, sourceType, source, req, reply);
216
192
  } catch (error) {
217
- if (error instanceof Error && error.code === "TORRENT_NOT_READY") {
218
- // Say so, rather than holding the connection until the client gives up.
219
- // Reproduced 2026-08-04 on a magnet whose metadata never arrived: both a
220
- // ranged GET and a HEAD returned nothing at all for the full 30 s the
221
- // probe was willing to wait, and the route had written neither a status
222
- // nor a header — from the client that is indistinguishable from the proxy
223
- // having died, and it left no trace in the log either.
224
- reply.header("Retry-After", "1");
225
- return reply.code(503).send({ error: "Torrent metadata is not available yet." });
226
- }
193
+ if (error?.name === "AbortError") return;
227
194
  const message = error instanceof Error ? error.message : String(error);
228
195
  return reply.code(500).send({ error: `Failed to load torrent source: ${message}` });
229
196
  }
@@ -259,20 +226,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
259
226
  }
260
227
 
261
228
  const range = parseRange(req.headers.range, file.length);
262
- // Prioritize the pieces at this read position so a seek (a request at a new
263
- // byte offset) downloads first instead of waiting behind the sequential
264
- // backlog — this is what caused ~15-18 s stalls when seeking into an
265
- // undownloaded region.
266
- // Only the encoder's own input read tracks where the viewer is. Everything
267
- // else that comes through here — the codec probe, the keyframe index, a
268
- // subtitle fetch — reads the file's edges, and treating those as a viewer
269
- // position made every session start and every encoder restart look like a
270
- // burst of seeks (measured: two spurious "the viewer moved" per start).
271
- torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0, undefined, {
272
- wholeFileRead: range === null,
273
- isPlaybackRead: req.query.reader === "playback"
274
- });
275
-
276
229
  const start = range ? range.start : 0;
277
230
  const end = range ? range.end : file.length - 1;
278
231
  const contentLength = end - start + 1;
@@ -281,16 +234,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
281
234
  // no copy on either thread, at the cost of doing the writing by hand, because
282
235
  // only the write callback tells us when a piece may be released. Falls back to
283
236
  // the ordinary stream for sources without a shared pool.
284
- // How far ahead of its own read head this reader should ask the swarm for.
285
- // Supplied by whoever knows the media's byte rate — the transcode session
286
- // puts it on the ffmpeg input URL, sized in seconds of playback — because
287
- // this thread knows only bytes, and 32 MB is half a minute of a 1080p film
288
- // but four seconds of a disc remux. Absent or unusable, the reader's own
289
- // default stands.
290
- const windowBytesRaw = Number(req.query.windowBytes);
291
- const windowBytes =
292
- Number.isFinite(windowBytesRaw) && windowBytesRaw > 0 ? windowBytesRaw : undefined;
293
-
294
237
  // Which transcode session this read feeds, when it feeds one. Put on the URL
295
238
  // by the session that builds it, because this route otherwise knows only a
296
239
  // file — and two sessions can read one file, so the file cannot stand in for
@@ -303,7 +246,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
303
246
  : null;
304
247
 
305
248
  const fragments = typeof file.createFragmentReader === "function"
306
- ? file.createFragmentReader({ start, end, windowBytes })
249
+ ? file.createFragmentReader({ start, end })
307
250
  : null;
308
251
 
309
252
  if (fragments) {
@@ -327,10 +270,11 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
327
270
  fragment.release();
328
271
  break;
329
272
  }
330
- await new Promise((resolve, reject) => {
331
- reply.raw.write(fragment.bytes, (error) => (error ? reject(error) : resolve()));
332
- });
333
- sent += fragment.bytes.length;
273
+ try {
274
+ await new Promise((resolve, reject) => {
275
+ reply.raw.write(fragment.bytes, (error) => (error ? reject(error) : resolve()));
276
+ });
277
+ sent += fragment.bytes.length;
334
278
  // Say so, if this read belongs to a transcode session. It is the only
335
279
  // proof that a session which has produced nothing yet is nevertheless
336
280
  // being fed: the encoder's own progress cannot move until its first
@@ -345,7 +289,9 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
345
289
  // Only now are these bytes gone: the piece can be unpinned, and the
346
290
  // slot it occupies reused. Releasing before this point corrupts the
347
291
  // response silently.
348
- fragment.release();
292
+ } finally {
293
+ fragment.release();
294
+ }
349
295
  }
350
296
  // THE BODY MUST BE AS LONG AS THE HEADER PROMISED, and nothing checked.
351
297
  //
@@ -365,11 +311,8 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
365
311
  // whether the body was short — which either names the cause or removes
366
312
  // the last candidate.
367
313
  if (sent !== contentLength) {
368
- logger.error(
369
- `stream: read of "${file.name}" bytes ${start}-${end} ENDED SHORT — ` +
370
- `${sent} of ${contentLength} bytes sent under a Content-Length that promised all of them. ` +
371
- "Whatever is reading this has a truncated body and no way to know it."
372
- );
314
+ if (req.raw.aborted || reply.raw.destroyed) return;
315
+ throw new Error(`Source response is incomplete: ${sent} of ${contentLength} bytes.`);
373
316
  }
374
317
  reply.raw.end();
375
318
  } catch (error) {
@@ -400,17 +343,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
400
343
  return;
401
344
  }
402
345
 
403
- reply.header("Accept-Ranges", "bytes");
404
- reply.header("Content-Type", "application/octet-stream");
405
- reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`);
406
-
407
- if (!range) {
408
- reply.header("Content-Length", String(file.length));
409
- return reply.send(file.createReadStream());
410
- }
411
-
412
- reply.code(206);
413
- reply.header("Content-Length", String(contentLength));
414
- reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
415
- return reply.send(file.createReadStream({ start, end }));
346
+ return reply.code(500).send({ error: "The map-governed source reader is unavailable.",
347
+ code: "SOURCE_READER_UNAVAILABLE", canRetry: false });
416
348
  }
@@ -16,13 +16,10 @@
16
16
  * start as a spinner over a stopped picture (measured 2026-08-15).
17
17
  *
18
18
  * Answers 204 when the segment at that position is ready, so the caller can
19
- * switch into bytes that already exist; 503 while it is still being made.
19
+ * switch into bytes that already exist. Otherwise it waits for readiness or refusal.
20
20
  */
21
21
 
22
- import { waitForSessionFile } from "../../../services/server/transcode-session-files.js";
23
-
24
- /** How long to hold the request before telling the caller to retry. */
25
- const WARM_WAIT_MS = 12_000;
22
+ import { waitForRequestedFile } from "../../../services/server/transcode-session-files.js";
26
23
 
27
24
  /**
28
25
  * @param {import("fastify").FastifyRequest} req
@@ -65,19 +62,19 @@ export async function handleTranscodeAudioWarmGet(req, reply, { renditions, serv
65
62
  );
66
63
  } catch (error) {
67
64
  const message = error instanceof Error ? error.message : String(error);
68
- reply.header("Retry-After", "1");
69
- return reply.code(503).send({ error: `Could not prepare the audio track: ${message}` });
65
+ return reply.code(500).send({ canRetry: false, error: `Could not prepare the audio track: ${message}` });
70
66
  }
71
67
  if (!prepared) {
72
68
  return reply.code(404).send({ error: "No such audio track for this transcode session." });
73
69
  }
74
70
 
75
- const result = await waitForSessionFile(
76
- serving,
71
+ const result = await waitForRequestedFile(
72
+ req, reply, serving,
77
73
  prepared.sessionId,
78
74
  prepared.fileName,
79
- { holdMs: WARM_WAIT_MS }
75
+ consumerId
80
76
  );
77
+ if (result.kind === "cancelled") return;
81
78
  if (result.kind === "file") {
82
79
  // The bytes are the player's to fetch; the handle opened to reach them is
83
80
  // ours to close, or a long-running proxy walks to EMFILE one track change
@@ -86,16 +83,7 @@ export async function handleTranscodeAudioWarmGet(req, reply, { renditions, serv
86
83
  return reply.code(204).send();
87
84
  }
88
85
  if (result.kind === "failed") {
89
- return reply.code(500).send({ error: result.message });
86
+ return reply.code(500).send({ error: result.message, canRetry: false });
90
87
  }
91
- // Still being produced. The caller may switch anyway — it will wait where it
92
- // would have waited before — or ask again.
93
- //
94
- // `warming: true` says the track and how it is produced ARE recorded for this
95
- // viewer — `prepareAudioTrack` records them before the wait — which the
96
- // status alone cannot say: the 503 above is a preparation that recorded
97
- // nothing. A page restating its soundtrack after a reconnect needs to know
98
- // which of the two it got.
99
- reply.header("Retry-After", "1");
100
- return reply.code(503).send({ error: "The audio track is still warming up.", warming: true });
88
+ return reply.code(404).send({ error: "The requested output is no longer wanted.", canRetry: false });
101
89
  }