@torrent-tv/proxy 2.91.0 → 2.92.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.
- package/bin/cli.js +3 -2
- package/package.json +18 -2
- package/routes/api/playback-plan/post.js +21 -4
- package/routes/api/sources/files/get.js +20 -28
- package/routes/api/sources/post.js +4 -2
- package/routes/api/sources/stats/get.js +4 -11
- package/routes/api/sources/viewer/post.js +16 -0
- package/routes/api/sources/warm/post.js +6 -215
- package/routes/api/subtitles/get.js +22 -125
- package/routes/api/subtitles/post.js +26 -0
- package/routes/api/transcode-sessions/post.js +24 -2
- package/routes/media/get.js +80 -0
- package/routes/stream/get.js +26 -94
- package/routes/transcode/audio-warm/get.js +9 -21
- package/routes/transcode/variant-warm/get.js +8 -14
- package/server.js +410 -46
- package/services/encode/AdmittedInput.js +136 -0
- package/services/encode/CushionReport.js +1 -76
- package/services/encode/EncodeInputs.js +142 -0
- package/services/encode/EncodeOrchestrator.js +4 -1
- package/services/encode/EncodeRun.js +13 -4
- package/services/encode/EncodeRuns.js +132 -52
- package/services/encode/InputFailures.js +37 -0
- package/services/encode/MatroskaInput.js +158 -0
- package/services/encode/OutputOpening.js +16 -64
- package/services/encode/Renditions.js +1 -17
- package/services/encode/admitted-command.js +60 -0
- package/services/encode/annex-b-input.js +37 -0
- package/services/encode/bit-payload.js +29 -0
- package/services/encode/output/OutputCatalog.js +1 -1
- package/services/encode/output/Timeline.js +11 -0
- package/services/encode/pcm-normalization.js +30 -0
- package/services/encode/piece-completeness.js +54 -1
- package/services/encode/quality/HostLoad.js +19 -73
- package/services/encode/quality/QualityOffer.js +1 -1
- package/services/encode/run-command.js +1 -589
- package/services/encode/segment-formats/mp4-boxes.js +3 -1
- package/services/encode/segment-formats/presentation-segment.js +114 -0
- package/services/media/ContainerOrchestrator.js +118 -48
- package/services/media/KeyframeTables.js +16 -115
- package/services/media/MediaReadRequests.js +120 -0
- package/services/media/ProbeRequests.js +135 -0
- package/services/media/SegmentInputs.js +48 -0
- package/services/media/SourceFile.js +6 -0
- package/services/media/SubtitleOrchestrator.js +190 -4
- package/services/media/await-plan.js +47 -0
- package/services/media/container/AsfContainer.js +217 -0
- package/services/media/container/AviContainer.js +343 -34
- package/services/media/container/CadencedPictureOrder.js +45 -0
- package/services/media/container/Container.js +7 -3
- package/services/media/container/ContainerFactory.js +10 -3
- package/services/media/container/FfprobeContainer.js +222 -0
- package/services/media/container/MatroskaContainer.js +72 -46
- package/services/media/container/Mp4Container.js +472 -139
- package/services/media/container/MpegPsContainer.js +281 -0
- package/services/media/container/MpegTsContainer.js +277 -0
- package/services/media/container/PacketIndex.js +270 -0
- package/services/media/container/PacketRecords.js +237 -0
- package/services/media/container/RetainedBytes.js +39 -0
- package/services/media/container/RetainedReads.js +51 -0
- package/services/media/container/ac3-frame.js +46 -0
- package/services/media/container/asf-packets.js +153 -0
- package/services/media/container/avc-picture-order.js +50 -0
- package/services/media/container/avi-open-dml.js +47 -0
- package/services/media/container/dts-frame.js +51 -0
- package/services/media/container/ebml-stream.js +12 -1
- package/services/media/container/ffprobe-record.js +45 -0
- package/services/media/container/flv-packet-ranges.js +27 -0
- package/services/media/container/following-packet-time.js +37 -0
- package/services/media/container/h264-configuration.js +183 -0
- package/services/media/container/h264-elementary-index.js +245 -0
- package/services/media/container/hevc-configuration.js +165 -0
- package/services/media/container/hevc-elementary-index.js +132 -0
- package/services/media/container/hevc-picture-order.js +66 -0
- package/services/media/container/latm-frame.js +99 -0
- package/services/media/container/matroska-packets.js +207 -0
- package/services/media/container/memory-unavailable.js +8 -0
- package/services/media/container/mp3-packet-dependencies.js +41 -0
- package/services/media/container/mp4-fragments.js +148 -0
- package/services/media/container/mpeg-audio-frame.js +19 -0
- package/services/media/container/mpeg-audio-reservoir.js +27 -0
- package/services/media/container/mpeg-elementary-index.js +196 -0
- package/services/media/container/mpeg-sequence-reader.js +69 -0
- package/services/media/container/mpeg-ts-packets.js +93 -0
- package/services/media/container/mpeg-video-configuration.js +50 -0
- package/services/media/container/mpeg4-picture-timing.js +108 -0
- package/services/media/container/pes-header.js +34 -0
- package/services/media/playback-declarations.js +57 -0
- package/services/media/playback-planner.js +72 -230
- package/services/media/probe-packets.js +66 -0
- package/services/media/read-playback-declarations.js +18 -0
- package/services/media/tracks/AudioTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +4 -1
- package/services/server/OutputLifecycle.js +2 -0
- package/services/server/SegmentServing.js +40 -33
- package/services/server/ViewerRequests.js +22 -41
- package/services/server/controllers/PlaybackController.js +4 -0
- package/services/server/controllers/SubtitleController.js +8 -29
- package/services/server/transcode-session-files.js +58 -99
- package/services/server/wire-outputs.js +58 -15
- package/services/storage/IndexMemory.js +85 -0
- package/services/storage/MachineBudget.js +27 -3
- package/services/storage/allowance.js +8 -1
- package/services/storage/piece-store/piece-disk-store.js +18 -3
- package/services/storage/piece-store/piece-lru.js +8 -4
- package/services/storage/piece-store/shared-piece-store.js +26 -11
- package/services/storage/segment-store/SegmentStore.js +49 -18
- package/services/storage/wire.js +7 -0
- package/services/torrent/Contents.js +52 -20
- package/services/torrent/demand/DemandRegister.js +13 -4
- package/services/torrent/demand/Window.js +13 -2
- package/services/torrent/demand-progress.js +25 -0
- package/services/torrent/download/DeadlineScheduler.js +167 -0
- package/services/torrent/download/FutureDownload.js +166 -0
- package/services/torrent/download/SwarmSelection.js +30 -59
- package/services/torrent/download/registry.js +45 -5
- package/services/torrent/download/withdraw-claim.js +2 -0
- package/services/torrent/torrent-pool.js +39 -600
- package/services/torrent/worker/channel.js +8 -1
- package/services/torrent/worker/client.js +74 -72
- package/services/torrent/worker/files-in-use.js +9 -0
- package/services/torrent/worker/handle-state.js +12 -0
- package/services/torrent/worker/held-bytes.js +82 -99
- package/services/torrent/worker/held-file-bytes.js +17 -0
- package/services/torrent/worker/piece-reader.js +67 -683
- package/services/torrent/worker/pool-adapter.js +37 -123
- package/services/torrent/worker/protocol.js +6 -17
- package/services/torrent/worker/whole-file-stats.js +2 -0
- package/services/torrent/worker/worker.js +86 -156
- package/services/transport/data-channel-handler.js +56 -13
- package/services/viewer/DownloadMaps.js +168 -0
- package/services/viewer/NativeSourceMap.js +33 -0
- package/services/viewer/PriorityMap.js +49 -40
- package/services/viewer/PriorityOrchestrator.js +42 -13
- package/services/viewer/SourcePreparation.js +278 -0
- package/services/viewer/Viewer.js +11 -0
- package/services/viewer/Viewers.js +109 -2
- package/services/viewer/playback-readiness.js +50 -63
- package/services/viewer/segment-demands.js +37 -0
- package/services/viewer/urgent-output-ready.js +26 -0
- package/services/encode/run-inputs.js +0 -53
- package/services/media/keyframe-probe.js +0 -157
- package/services/media/media-info-probe.js +0 -93
- package/services/torrent/worker/background-fill.js +0 -213
- 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
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
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
|
+
}
|
package/routes/stream/get.js
CHANGED
|
@@ -60,43 +60,19 @@ function getSourceParams(query, sourceRegistry) {
|
|
|
60
60
|
return { sourceType, source, sourceKey };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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.
|
|
404
|
-
|
|
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
|
|
19
|
+
* switch into bytes that already exist. Otherwise it waits for readiness or refusal.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import {
|
|
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.
|
|
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
|
|
76
|
-
serving,
|
|
71
|
+
const result = await waitForRequestedFile(
|
|
72
|
+
req, reply, serving,
|
|
77
73
|
prepared.sessionId,
|
|
78
74
|
prepared.fileName,
|
|
79
|
-
|
|
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
|
-
|
|
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
|
}
|