@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.
- package/bin/cli.js +5 -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 +61 -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/transport/tunnel-client.js +111 -20
- 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
|
@@ -2,45 +2,19 @@
|
|
|
2
2
|
* @file Playback planner service.
|
|
3
3
|
*
|
|
4
4
|
* Determines whether a torrent file can be served directly or requires
|
|
5
|
-
* HLS audio transcoding
|
|
5
|
+
* HLS audio transcoding from the file's shared container declarations.
|
|
6
6
|
* Results are cached indefinitely (keyed by source + file index).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { spawn } from "node:child_process";
|
|
10
9
|
import { logger } from "../../utils/logger.js";
|
|
11
10
|
import { Container } from "./container/Container.js";
|
|
12
11
|
import { buildAudioInventory, enrichAudioInventoryFromSidecar } from "./audio-inventory.js";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
parseFfmpegStartTimeSeconds,
|
|
16
|
-
parseFfmpegBitDepth,
|
|
17
|
-
parseFfmpegBitrateKbps,
|
|
18
|
-
parseFfmpegVideoFps,
|
|
19
|
-
parseFfmpegHdr,
|
|
20
|
-
parseFfmpegStreamCounts
|
|
21
|
-
} from "./ffmpeg-banner.js";
|
|
12
|
+
import { waitForPlan } from "./await-plan.js";
|
|
13
|
+
import { playbackDeclarations } from "./playback-declarations.js";
|
|
22
14
|
|
|
23
15
|
/** Audio codecs that browsers can decode natively without transcoding. */
|
|
24
16
|
const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
|
|
25
17
|
|
|
26
|
-
// Once the plan probe succeeds, warm the START of the file body so the
|
|
27
|
-
// transcode session's ffmpeg reads hit downloaded data instead of paying
|
|
28
|
-
// piece latency at encode time (the edge prefetch only covers head+tail for
|
|
29
|
-
// the codec probe). ~16 MB ≈ the first segments of typical media.
|
|
30
|
-
const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* How long the plan waits for a file's own header before offering its
|
|
34
|
-
* soundtrack without what that header would have said.
|
|
35
|
-
*
|
|
36
|
-
* Not a measurement, and nothing is derived from it: it is the point past which
|
|
37
|
-
* holding the viewer costs more than the language and flags being waited for —
|
|
38
|
-
* which the folder name supplies anyway, from the torrent's file list, at no
|
|
39
|
-
* cost. The read continues asynchronously after this response and is shared
|
|
40
|
-
* with the refresh request, so a later answer can use the same container read.
|
|
41
|
-
*/
|
|
42
|
-
const SIDECAR_HEADER_WAIT_MS = 3_000;
|
|
43
|
-
|
|
44
18
|
/** Subtitle codecs that can be converted to WebVTT (text-based). */
|
|
45
19
|
const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
|
|
46
20
|
|
|
@@ -177,81 +151,6 @@ export function parseStreamCodecs(ffmpegOutput) {
|
|
|
177
151
|
};
|
|
178
152
|
}
|
|
179
153
|
|
|
180
|
-
/**
|
|
181
|
-
* Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
|
|
182
|
-
* Times out after `timeoutMs` and returns empty strings on failure.
|
|
183
|
-
*
|
|
184
|
-
* @param {object} options
|
|
185
|
-
* @param {string} options.ffmpegBin
|
|
186
|
-
* @param {string} options.inputUrl
|
|
187
|
-
* @param {string} [options.userAgent=""]
|
|
188
|
-
* @param {number} [options.timeoutMs=8000]
|
|
189
|
-
* @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
|
|
190
|
-
* Parsed banner fields plus the raw `stderr`, so the caller can derive the
|
|
191
|
-
* full media info (fps/startTime/HDR) without a second ffmpeg scan.
|
|
192
|
-
*/
|
|
193
|
-
function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
|
|
194
|
-
return new Promise((resolve) => {
|
|
195
|
-
const args = ["-hide_banner", "-loglevel", "info"];
|
|
196
|
-
if (typeof userAgent === "string" && userAgent.trim().length > 0) {
|
|
197
|
-
args.push("-user_agent", userAgent.trim());
|
|
198
|
-
}
|
|
199
|
-
// Decode a tiny slice of all streams (no per-stream -map, so video-only
|
|
200
|
-
// files probe correctly too). The ffmpeg banner that precedes decoding
|
|
201
|
-
// gives us audio/video codecs, the container format and the duration in a
|
|
202
|
-
// single pass.
|
|
203
|
-
args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
|
|
204
|
-
|
|
205
|
-
const ffmpeg = spawn(ffmpegBin, args, {
|
|
206
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
207
|
-
windowsHide: true
|
|
208
|
-
});
|
|
209
|
-
let stderr = "";
|
|
210
|
-
let settled = false;
|
|
211
|
-
|
|
212
|
-
const finish = (codecs) => {
|
|
213
|
-
if (settled) {
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
settled = true;
|
|
217
|
-
resolve(codecs);
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const timeoutId = setTimeout(() => {
|
|
221
|
-
if (!ffmpeg.killed) {
|
|
222
|
-
ffmpeg.kill("SIGTERM");
|
|
223
|
-
}
|
|
224
|
-
finish({ ...parseStreamCodecs(stderr), stderr });
|
|
225
|
-
}, timeoutMs);
|
|
226
|
-
|
|
227
|
-
ffmpeg.stderr.on("data", (chunk) => {
|
|
228
|
-
stderr += String(chunk);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
ffmpeg.on("error", () => {
|
|
232
|
-
clearTimeout(timeoutId);
|
|
233
|
-
finish({ audioCodec: "", videoCodec: "", stderr: "" });
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
ffmpeg.on("exit", () => {
|
|
237
|
-
clearTimeout(timeoutId);
|
|
238
|
-
finish({ ...parseStreamCodecs(stderr), stderr });
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Resolve after a given number of milliseconds.
|
|
245
|
-
*
|
|
246
|
-
* @param {number} ms
|
|
247
|
-
* @returns {Promise<void>}
|
|
248
|
-
*/
|
|
249
|
-
function delay(ms) {
|
|
250
|
-
return new Promise((resolve) => {
|
|
251
|
-
setTimeout(resolve, ms);
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
|
|
255
154
|
/**
|
|
256
155
|
* Build the direct stream URL for a source file served by the local proxy.
|
|
257
156
|
*
|
|
@@ -278,12 +177,11 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
|
|
|
278
177
|
* @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
|
|
279
178
|
* @property {number} videoWidth - Source coded width (0 if unknown).
|
|
280
179
|
* @property {number} videoHeight - Source coded height (0 if unknown).
|
|
281
|
-
* @property {boolean} audioTracksPending - At least one sidecar
|
|
180
|
+
* @property {boolean} audioTracksPending - At least one sidecar declaration still needs source bytes.
|
|
282
181
|
*/
|
|
283
182
|
|
|
284
183
|
/**
|
|
285
184
|
* @typedef {Object} PlaybackPlannerOptions
|
|
286
|
-
* @property {string} ffmpegBin
|
|
287
185
|
* @property {boolean} transcodeAudioEnabled
|
|
288
186
|
* @property {string} localBaseUrl
|
|
289
187
|
* @property {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
|
|
@@ -298,7 +196,6 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
|
|
|
298
196
|
* @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan>, refreshAudioTracks: (params: { sourceKey: string, fileIndex: number }) => Promise<{ audioTracks: object[], pending: boolean }> }}
|
|
299
197
|
*/
|
|
300
198
|
export function createPlaybackPlanner({
|
|
301
|
-
ffmpegBin,
|
|
302
199
|
transcodeAudioEnabled,
|
|
303
200
|
localBaseUrl,
|
|
304
201
|
sourceRegistry,
|
|
@@ -320,6 +217,8 @@ export function createPlaybackPlanner({
|
|
|
320
217
|
// happened in the torrent thread and came back over the channel. It happens
|
|
321
218
|
// on this thread now, and what is passed here is the ordinary container read.
|
|
322
219
|
declaredTracksOf = null,
|
|
220
|
+
readDeclarations = null,
|
|
221
|
+
subscribeSource = null,
|
|
323
222
|
// Optional. Called once the file's edges are downloaded, so the keyframe
|
|
324
223
|
// index — which reads the same tail of the file — is fetched alongside the
|
|
325
224
|
// codec probe instead of after it. It goes straight to `KeyframeTables`,
|
|
@@ -333,6 +232,7 @@ export function createPlaybackPlanner({
|
|
|
333
232
|
}) {
|
|
334
233
|
/** @type {Map<string, PlaybackPlan>} */
|
|
335
234
|
const cache = new Map();
|
|
235
|
+
const lifetimes = new Map();
|
|
336
236
|
const pendingSidecarHeaders = new Map();
|
|
337
237
|
const declaredAudioReads = new Map();
|
|
338
238
|
/**
|
|
@@ -461,22 +361,8 @@ export function createPlaybackPlanner({
|
|
|
461
361
|
() => { if (declaredAudioReads.get(readKey) === readPromise) declaredAudioReads.delete(readKey); }
|
|
462
362
|
);
|
|
463
363
|
}
|
|
464
|
-
let timer = null;
|
|
465
364
|
try {
|
|
466
|
-
return await
|
|
467
|
-
readPromise,
|
|
468
|
-
new Promise((resolve) => {
|
|
469
|
-
timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
|
|
470
|
-
timer.unref?.();
|
|
471
|
-
})
|
|
472
|
-
]).then((tracks) => {
|
|
473
|
-
if (tracks === null) {
|
|
474
|
-
logger.info(
|
|
475
|
-
`audio tracks: "${label}" did not answer within ${SIDECAR_HEADER_WAIT_MS / 1000}s — ` +
|
|
476
|
-
"offered without what its header would say"
|
|
477
|
-
);
|
|
478
|
-
return { tracks: [], complete: false, timedOut: true };
|
|
479
|
-
}
|
|
365
|
+
return await readPromise.then((tracks) => {
|
|
480
366
|
return {
|
|
481
367
|
tracks: Array.isArray(tracks?.tracks) ? tracks.tracks : [],
|
|
482
368
|
complete: tracks?.complete === true,
|
|
@@ -486,8 +372,6 @@ export function createPlaybackPlanner({
|
|
|
486
372
|
} catch (error) {
|
|
487
373
|
logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
|
|
488
374
|
return { tracks: [], complete: false, timedOut: false };
|
|
489
|
-
} finally {
|
|
490
|
-
if (timer !== null) clearTimeout(timer);
|
|
491
375
|
}
|
|
492
376
|
}
|
|
493
377
|
|
|
@@ -564,6 +448,7 @@ export function createPlaybackPlanner({
|
|
|
564
448
|
for (const sidecar of sidecarFiles) {
|
|
565
449
|
if (!pendingIndexes.has(sidecar.fileIndex)) continue;
|
|
566
450
|
const read = await declaredAudioOf(sourceKey, sidecar.fileIndex, sidecar.name);
|
|
451
|
+
if (cache.get(cacheKey) !== plan) return { audioTracks: [], pending: false };
|
|
567
452
|
if (read.timedOut) continue;
|
|
568
453
|
if (read.tracks.length === 0) {
|
|
569
454
|
if (read.complete) pendingIndexes.delete(sidecar.fileIndex);
|
|
@@ -589,6 +474,7 @@ export function createPlaybackPlanner({
|
|
|
589
474
|
);
|
|
590
475
|
}
|
|
591
476
|
}
|
|
477
|
+
if (cache.get(cacheKey) !== plan) return { audioTracks: [], pending: false };
|
|
592
478
|
pendingSidecarHeaders.set(cacheKey, [...pendingIndexes]);
|
|
593
479
|
plan.audioTracksPending = pendingIndexes.size > 0;
|
|
594
480
|
return { audioTracks: plan.audioTracks, pending: pendingIndexes.size > 0 };
|
|
@@ -638,6 +524,16 @@ export function createPlaybackPlanner({
|
|
|
638
524
|
}
|
|
639
525
|
|
|
640
526
|
return {
|
|
527
|
+
forget(sourceKey) {
|
|
528
|
+
for (const records of [cache, lifetimes, pendingSidecarHeaders, declaredAudioReads, mediaInfoCache]) {
|
|
529
|
+
for (const key of records.keys()) if (key.startsWith(`${sourceKey}:`)) records.delete(key);
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
getReadyPlan(params, { signal } = {}) {
|
|
533
|
+
if (!subscribeSource) throw new Error("Event-driven source preparation is unavailable.");
|
|
534
|
+
return waitForPlan({ read: () => this.getPlan(params), signal,
|
|
535
|
+
subscribe: listener => subscribeSource(params.sourceKey, params.fileIndex, listener) });
|
|
536
|
+
},
|
|
641
537
|
/**
|
|
642
538
|
* Media info the planner already probed for this file, or `null`. Lets the
|
|
643
539
|
* HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
|
|
@@ -670,23 +566,28 @@ export function createPlaybackPlanner({
|
|
|
670
566
|
* Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
|
|
671
567
|
* when the source or file cannot be located.
|
|
672
568
|
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
* `pending: true` (NOT cached). The caller polls again — each call keeps the
|
|
678
|
-
* header prioritised and downloading — until a real plan comes back. This
|
|
679
|
-
* avoids a single long request racing the transport's request timeout.
|
|
569
|
+
* A probe reads only bytes already stored. Missing input becomes demand in
|
|
570
|
+
* the shared map and returns `pending: true`, which is never cached as a
|
|
571
|
+
* playback plan. Storage changes repeat unfinished probes; another request
|
|
572
|
+
* reads their latest result without creating a separate download request.
|
|
680
573
|
*
|
|
681
574
|
* @param {object} params
|
|
682
575
|
* @param {string} params.sourceKey
|
|
683
576
|
* @param {number} params.fileIndex
|
|
684
577
|
* @param {string} [params.userAgent=""]
|
|
685
|
-
* @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
|
|
686
578
|
* @returns {Promise<PlaybackPlan & { pending?: boolean }>}
|
|
687
579
|
*/
|
|
688
|
-
async getPlan({ sourceKey, fileIndex,
|
|
580
|
+
async getPlan({ sourceKey, fileIndex, background = false }) {
|
|
689
581
|
const cacheKey = `${sourceKey}:${fileIndex}`;
|
|
582
|
+
let lifetime = lifetimes.get(cacheKey);
|
|
583
|
+
if (!lifetime) lifetimes.set(cacheKey, lifetime = {});
|
|
584
|
+
const ensureCurrent = () => {
|
|
585
|
+
if (lifetimes.get(cacheKey) === lifetime) return;
|
|
586
|
+
const error = new Error("Source preparation was withdrawn.");
|
|
587
|
+
error.code = "SOURCE_FORGOTTEN";
|
|
588
|
+
error.canRetry = false;
|
|
589
|
+
throw error;
|
|
590
|
+
};
|
|
690
591
|
const cached = cache.get(cacheKey);
|
|
691
592
|
if (cached) {
|
|
692
593
|
return withHostTimings(cached);
|
|
@@ -698,7 +599,6 @@ export function createPlaybackPlanner({
|
|
|
698
599
|
// cached, and nothing said which part of it was slow.
|
|
699
600
|
const planEntryMs = Date.now();
|
|
700
601
|
let torrentReadyMs = 0;
|
|
701
|
-
let edgesReadyMs = 0;
|
|
702
602
|
|
|
703
603
|
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
704
604
|
if (!sourceRecord) {
|
|
@@ -708,6 +608,7 @@ export function createPlaybackPlanner({
|
|
|
708
608
|
}
|
|
709
609
|
|
|
710
610
|
const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
611
|
+
ensureCurrent();
|
|
711
612
|
torrentReadyMs = Date.now() - planEntryMs;
|
|
712
613
|
const file = torrent.files[fileIndex];
|
|
713
614
|
if (!file) {
|
|
@@ -736,94 +637,47 @@ export function createPlaybackPlanner({
|
|
|
736
637
|
return withHostTimings(plan);
|
|
737
638
|
}
|
|
738
639
|
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
// codecs. A transient empty probe must NOT be cached: otherwise the wrong
|
|
743
|
-
// plan (file treated as directly playable) sticks permanently for this
|
|
744
|
-
// file, and an unsupported codec like xvid gets copied → black video.
|
|
745
|
-
// Awaited in the literal sense: this answer cannot be given until the
|
|
746
|
-
// file has said what is in it, and a person is watching a loading screen
|
|
747
|
-
// for as long as that takes.
|
|
748
|
-
await torrentPool.prefetchFileEdges(torrent, fileIndex, {
|
|
749
|
-
awaited: !background,
|
|
750
|
-
timeoutMs: background ? 10_000 : 300_000
|
|
751
|
-
});
|
|
752
|
-
edgesReadyMs = Date.now() - planEntryMs;
|
|
753
|
-
// The keyframe index reads the tail of the file, which the probe has just
|
|
754
|
-
// waited for as well. Started here it overlaps the probe instead of
|
|
755
|
-
// following the whole plan — worth 311-430 ms of the time before the
|
|
756
|
-
// first segment. Fire and forget: a session that finds the table
|
|
757
|
-
// unanswered joins this very read rather than starting a second one.
|
|
640
|
+
// Structural reads state their exact missing bytes through
|
|
641
|
+
// the shared download map. No separate edge or body demand is created.
|
|
642
|
+
if (typeof declaredTracksOf === "function") await declaredTracksOf({ sourceKey, fileIndex });
|
|
758
643
|
if (!background) {
|
|
759
644
|
warmKeyframeIndex?.({ sourceKey, fileIndex, logName: file.name });
|
|
760
645
|
}
|
|
761
|
-
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
awaited: !background,
|
|
773
|
-
timeoutMs: background ? 10_000 : 300_000
|
|
774
|
-
});
|
|
775
|
-
probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
|
|
646
|
+
if (typeof readDeclarations !== "function") throw new Error("Source container declarations are unavailable.");
|
|
647
|
+
const probeResult = await readDeclarations({ sourceKey, fileIndex });
|
|
648
|
+
ensureCurrent();
|
|
649
|
+
if (probeResult.kind === "terminal") {
|
|
650
|
+
const error = new Error(probeResult.message || probeResult.reason);
|
|
651
|
+
error.code = probeResult.reason;
|
|
652
|
+
error.canRetry = false;
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
if (probeResult.kind === "cancelled") {
|
|
656
|
+
throw new DOMException("Playback preparation was cancelled.", "AbortError");
|
|
776
657
|
}
|
|
658
|
+
const probe = probeResult.kind === "result" ? playbackDeclarations({ ...probeResult.value, fileBytes: file.length })
|
|
659
|
+
: { audioCodec: "", videoCodec: "", container: "", durationSeconds: null,
|
|
660
|
+
videoWidth: 0, videoHeight: 0, audioTracks: [], subtitleTracks: [] };
|
|
777
661
|
const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
|
|
778
662
|
const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
|
|
663
|
+
if (!codecsDetected && probeResult?.kind === "result") {
|
|
664
|
+
const error = new Error("The available source bytes declare no playable audio or video track.");
|
|
665
|
+
error.code = "media-probe-no-tracks";
|
|
666
|
+
error.canRetry = false;
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
779
669
|
logger.info(
|
|
780
670
|
`plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
|
|
781
|
-
`
|
|
782
|
-
|
|
783
|
-
`${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
|
|
671
|
+
`probe=${Date.now() - planEntryMs - torrentReadyMs}ms total=${Date.now() - planEntryMs}ms ` +
|
|
672
|
+
`${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codec declaration awaits source bytes"}`
|
|
784
673
|
);
|
|
785
674
|
|
|
786
|
-
// The picture's own facts come from two readings and only one was ever
|
|
787
|
-
// used: every figure the encode is planned from came from ffmpeg's
|
|
788
|
-
// banner, while the `VideoTrack` the container declares was read and used
|
|
789
|
-
// for nothing but a line in the log.
|
|
790
|
-
let declaredVideo = null;
|
|
791
|
-
if (typeof declaredTracksOf === "function") {
|
|
792
|
-
try {
|
|
793
|
-
// One, because ffmpeg's `0:v:0` is what everything downstream is built
|
|
794
|
-
// on and a second video stream is a cover image far more often than a
|
|
795
|
-
// second film.
|
|
796
|
-
declaredVideo = (await declaredTracksOf({ sourceKey, fileIndex }))
|
|
797
|
-
.filter((track) => track?.type === "video")
|
|
798
|
-
.sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))[0] ?? null;
|
|
799
|
-
} catch (error) {
|
|
800
|
-
logger.info(`video track: could not be read (${error?.message ?? error})`);
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
// `mode` is advisory only (audio-codec based). The browser makes the
|
|
804
|
-
// authoritative decision independently per stream via canPlayType /
|
|
805
|
-
// mediaCapabilities, transcoding only what it cannot play.
|
|
806
675
|
const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const videoFacts = Container.mergeVideoFacts(
|
|
810
|
-
{
|
|
811
|
-
width: videoWidth,
|
|
812
|
-
height: videoHeight,
|
|
813
|
-
fps: parseFfmpegVideoFps(probe.stderr),
|
|
814
|
-
isHdr: parseFfmpegHdr(probe.stderr),
|
|
815
|
-
bitDepth: parseFfmpegBitDepth(probe.stderr)
|
|
816
|
-
},
|
|
817
|
-
declaredVideo
|
|
818
|
-
);
|
|
819
|
-
if (videoFacts.disagreements.length > 0) {
|
|
820
|
-
logger.info(
|
|
821
|
-
`video track: the file and the probe disagree — ${videoFacts.disagreements.join("; ")}; ` +
|
|
822
|
-
"the size and frame rate are the probe's, the bit depth and HDR the file's"
|
|
823
|
-
);
|
|
824
|
-
}
|
|
676
|
+
const videoFacts = Container.mergeVideoFacts({ width: videoWidth, height: videoHeight,
|
|
677
|
+
fps: probe.fps, isHdr: probe.isHdr, bitDepth: probe.bitDepth }, probe.video ?? null);
|
|
825
678
|
const sidecars = sidecarsOf(torrent, fileIndex);
|
|
826
679
|
const inventory = await buildInventory(sourceKey, torrent, fileIndex, audioTracks ?? []);
|
|
680
|
+
ensureCurrent();
|
|
827
681
|
const plan = {
|
|
828
682
|
mode: requiresTranscode ? "hls" : "direct",
|
|
829
683
|
directUrl,
|
|
@@ -871,7 +725,7 @@ export function createPlaybackPlanner({
|
|
|
871
725
|
width: videoFacts.width,
|
|
872
726
|
height: videoFacts.height,
|
|
873
727
|
fps: videoFacts.fps,
|
|
874
|
-
bitrateKbps:
|
|
728
|
+
bitrateKbps: probe.bitrateKbps,
|
|
875
729
|
// Which family of the decode measurement prices this source. A video
|
|
876
730
|
// that has to be re-encoded is one the browser could not play, so it
|
|
877
731
|
// is usually NOT H.264, and H.264 constants are wrong for it.
|
|
@@ -884,12 +738,12 @@ export function createPlaybackPlanner({
|
|
|
884
738
|
fileIndex
|
|
885
739
|
}
|
|
886
740
|
};
|
|
741
|
+
ensureCurrent();
|
|
887
742
|
// Only cache a plan whose codecs were actually detected. An empty probe is
|
|
888
743
|
// a "header not downloaded yet" signal, not a valid result — caching it
|
|
889
744
|
// would permanently mis-plan the file. In that case flag the plan
|
|
890
|
-
// `pending
|
|
891
|
-
|
|
892
|
-
if (codecsDetected) {
|
|
745
|
+
// `pending`; the media request's missing ranges remain in the shared map.
|
|
746
|
+
if (codecsDetected && durationSeconds > 0) {
|
|
893
747
|
cache.set(cacheKey, plan);
|
|
894
748
|
pendingSidecarHeaders.set(cacheKey, inventory.pendingFileIndexes);
|
|
895
749
|
// Cache the full media info from THIS probe's banner (same helpers the
|
|
@@ -906,29 +760,17 @@ export function createPlaybackPlanner({
|
|
|
906
760
|
// guard expecting zero tracks and therefore accepting any header.
|
|
907
761
|
videoCodec: plan.videoCodec,
|
|
908
762
|
audioCodec: plan.audioCodec,
|
|
909
|
-
durationSeconds:
|
|
763
|
+
durationSeconds: durationSeconds,
|
|
910
764
|
width: videoFacts.width,
|
|
911
765
|
height: videoFacts.height,
|
|
912
|
-
bitrateKbps:
|
|
766
|
+
bitrateKbps: probe.bitrateKbps,
|
|
913
767
|
fps: videoFacts.fps,
|
|
914
|
-
startTime:
|
|
768
|
+
startTime: probe.startTimeSeconds,
|
|
915
769
|
isHdr: videoFacts.isHdr,
|
|
916
770
|
bitDepth: videoFacts.bitDepth,
|
|
917
|
-
//
|
|
918
|
-
|
|
919
|
-
// its media info from this cache without probing again — so a field
|
|
920
|
-
// left out here was "not recorded" on every failure (field 2026-09-29).
|
|
921
|
-
streamCounts: parseFfmpegStreamCounts(probe.stderr)
|
|
771
|
+
// Retain every declared stream count for failed-run diagnostics.
|
|
772
|
+
streamCounts: probe.streamCounts
|
|
922
773
|
});
|
|
923
|
-
// Warm the file-body start for the transcode session that follows.
|
|
924
|
-
// Fire-and-forget: never delays the plan response.
|
|
925
|
-
void torrentPool
|
|
926
|
-
.prefetchFileEdges(torrent, fileIndex, {
|
|
927
|
-
headBytes: BODY_PREFETCH_BYTES,
|
|
928
|
-
tailBytes: 0,
|
|
929
|
-
timeoutMs: 60_000
|
|
930
|
-
})
|
|
931
|
-
.catch(() => {});
|
|
932
774
|
return withHostTimings(plan);
|
|
933
775
|
}
|
|
934
776
|
return withHostTimings({ ...plan, pending: true });
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { ffprobeRecord } from "./container/ffprobe-record.js";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
function bundledProbe() {
|
|
7
|
+
try { return require("@ffprobe-installer/ffprobe").path; }
|
|
8
|
+
catch { return "ffprobe"; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Read only the strict available-byte endpoint; missing reads abort through ProbeRequests. */
|
|
12
|
+
export function probePackets({ url, statement, signal, onRecord = () => {}, ffprobeBin = bundledProbe(), spawnProcess = spawn }) {
|
|
13
|
+
if (!["streams", "packets"].includes(statement)) throw new TypeError("Unknown ffprobe statement.");
|
|
14
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Probe cancelled."));
|
|
15
|
+
onRecord({ kind: "scan-start" });
|
|
16
|
+
const entries = statement === "streams"
|
|
17
|
+
? "stream=index,codec_type,codec_name,width,height,sample_rate,channels,bits_per_sample,bits_per_raw_sample,r_frame_rate,has_b_frames,start_time,duration,extradata,extradata_size:stream_tags=language,title:stream_disposition=default,forced:stream_side_data=:format=format_name,start_time,duration"
|
|
18
|
+
: "packet=stream_index,pts_time,dts_time,duration_time,pos,size,flags,data_hash:packet_side_data=";
|
|
19
|
+
const args = ["-v", "error", ...(statement === "streams" ? ["-show_streams", "-show_format", "-show_data"] : ["-show_packets", "-show_data_hash", "sha256"]),
|
|
20
|
+
"-show_entries", entries, "-of", "compact=p=1:nk=0:escape=c", "-i", url];
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const records = [];
|
|
23
|
+
let remainder = "", diagnostics = "", failure = null;
|
|
24
|
+
const child = spawnProcess(ffprobeBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
25
|
+
const aborted = () => { failure = signal.reason ?? new Error("Probe cancelled."); child.kill(); };
|
|
26
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
27
|
+
if (signal?.aborted) aborted();
|
|
28
|
+
child.stdout.setEncoding("utf8");
|
|
29
|
+
child.stderr.setEncoding("utf8");
|
|
30
|
+
const accept = line => {
|
|
31
|
+
if (!line || failure) return;
|
|
32
|
+
const record = ffprobeRecord(line);
|
|
33
|
+
onRecord(record);
|
|
34
|
+
if (statement === "streams") records.push(record);
|
|
35
|
+
};
|
|
36
|
+
child.stdout.on("data", chunk => {
|
|
37
|
+
if (failure) return;
|
|
38
|
+
remainder += chunk;
|
|
39
|
+
try {
|
|
40
|
+
let newline;
|
|
41
|
+
while ((newline = remainder.indexOf("\n")) >= 0) {
|
|
42
|
+
accept(remainder.slice(0, newline));
|
|
43
|
+
remainder = remainder.slice(newline + 1);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) { failure = error; child.kill(); }
|
|
46
|
+
});
|
|
47
|
+
child.stderr.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-8192); });
|
|
48
|
+
child.once("error", error => { failure = error; });
|
|
49
|
+
child.once("close", code => {
|
|
50
|
+
signal?.removeEventListener("abort", aborted);
|
|
51
|
+
try {
|
|
52
|
+
if (!failure && remainder) accept(remainder);
|
|
53
|
+
if (!failure && code === 0 && !diagnostics.trim()) onRecord({ kind: "scan-end" });
|
|
54
|
+
}
|
|
55
|
+
catch (error) { failure = error; }
|
|
56
|
+
if (failure) reject(failure);
|
|
57
|
+
else if (code !== 0 || diagnostics.trim()) reject(new Error(`ffprobe refused the input (${code}): ${diagnostics.trim()}`));
|
|
58
|
+
else resolve(records);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Request a complete timeline when the container header declares no duration. */
|
|
2
|
+
export async function readPlaybackDeclarations(orchestrator, params) {
|
|
3
|
+
const tracks = await orchestrator.inspect(params, "tracks");
|
|
4
|
+
if (tracks.kind !== "result") return tracks;
|
|
5
|
+
let media = await orchestrator.inspect(params, "media-info");
|
|
6
|
+
if (media.kind !== "result") return media;
|
|
7
|
+
if (!(media.value.durationSeconds > 0)) {
|
|
8
|
+
const packets = await orchestrator.inspect({ ...params, packetInterval: undefined }, "packets");
|
|
9
|
+
if (packets.kind !== "result") return packets;
|
|
10
|
+
media = await orchestrator.inspect(params, "media-info");
|
|
11
|
+
if (media.kind !== "result") return media;
|
|
12
|
+
if (!(media.value.durationSeconds > 0)) {
|
|
13
|
+
return { kind: "terminal", reason: "media-duration-unavailable",
|
|
14
|
+
message: "The complete source index does not declare a positive playback duration." };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { kind: "result", value: { tracks: tracks.value, media: media.value } };
|
|
18
|
+
}
|
|
@@ -86,6 +86,8 @@ export class AudioTrack extends ContainerTrack {
|
|
|
86
86
|
this.isVisualImpaired = params.isVisualImpaired === true;
|
|
87
87
|
this.channels = Number.isFinite(params.channels) ? params.channels : null;
|
|
88
88
|
this.samplingFrequency = Number.isFinite(params.samplingFrequency) ? params.samplingFrequency : null;
|
|
89
|
+
this.bitDepth = Number.isSafeInteger(params.bitDepth) && params.bitDepth > 0 ? params.bitDepth : null;
|
|
90
|
+
this.codecDelaySeconds = Number.isFinite(params.codecDelaySeconds) && params.codecDelaySeconds >= 0 ? params.codecDelaySeconds : 0;
|
|
89
91
|
// What the codec is configured as, and the most that configuration allows
|
|
90
92
|
// the track to carry — a statement of the file, read with the rest of the
|
|
91
93
|
// track table (`audio-bound.js`). Own properties rather than getters, so a
|
|
@@ -97,6 +99,13 @@ export class AudioTrack extends ContainerTrack {
|
|
|
97
99
|
samplingFrequency: this.samplingFrequency
|
|
98
100
|
});
|
|
99
101
|
this.peakKbps = peakKbpsOf(this.codecParameters);
|
|
102
|
+
if (this.codecParameters?.frameLength > 0 && this.codecParameters.sampleRate > 0) {
|
|
103
|
+
this.defaultDurationSeconds = this.codecParameters.frameLength / this.codecParameters.sampleRate;
|
|
104
|
+
this.seekPrerollSeconds = this.defaultDurationSeconds;
|
|
105
|
+
}
|
|
106
|
+
if (Number.isFinite(params.seekPrerollSeconds) && params.seekPrerollSeconds > 0) {
|
|
107
|
+
this.seekPrerollSeconds = Math.max(this.seekPrerollSeconds ?? 0, params.seekPrerollSeconds);
|
|
108
|
+
}
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
/**
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
import { SubtitleTrack } from "./SubtitleTrack.js";
|
|
29
29
|
import { detectLanguage } from "./language-detect.js";
|
|
30
30
|
|
|
31
|
-
const TEXT_CODECS_MATROSKA = new Set(["S_TEXT/UTF8", "S_TEXT/
|
|
31
|
+
const TEXT_CODECS_MATROSKA = new Set(["S_TEXT/UTF8", "S_TEXT/ASCII", "S_TEXT/ASS", "S_TEXT/SSA", "S_ASS", "S_SSA",
|
|
32
|
+
"S_TEXT/WEBVTT", "D_WEBVTT/SUBTITLES", "D_WEBVTT/CAPTIONS", "D_WEBVTT/DESCRIPTIONS", "D_WEBVTT/METADATA"]);
|
|
32
33
|
const TEXT_FORMATS_MP4 = new Set(["tx3g", "text", "wvtt"]);
|
|
33
34
|
|
|
34
35
|
/** How a cue's text is marked up, once the container's framing is off. */
|
|
@@ -55,6 +56,8 @@ export const MarkupKind = {
|
|
|
55
56
|
const MARKUP_BY_CODEC = new Map([
|
|
56
57
|
["S_TEXT/ASS", MarkupKind.ASS],
|
|
57
58
|
["S_TEXT/SSA", MarkupKind.ASS],
|
|
59
|
+
["S_ASS", MarkupKind.ASS],
|
|
60
|
+
["S_SSA", MarkupKind.ASS],
|
|
58
61
|
[".ass", MarkupKind.ASS],
|
|
59
62
|
[".ssa", MarkupKind.ASS],
|
|
60
63
|
["S_TEXT/UTF8", MarkupKind.NONE],
|
|
@@ -389,6 +389,7 @@ export class OutputLifecycle {
|
|
|
389
389
|
}
|
|
390
390
|
const watched = this.#host.viewers.get(consumerId)?.outputs;
|
|
391
391
|
if (!watched || watched.size === 0) {
|
|
392
|
+
this.#host.viewers.hasGone(consumerId);
|
|
392
393
|
return 0;
|
|
393
394
|
}
|
|
394
395
|
// Copied before anything is released: releasing walks the same set.
|
|
@@ -396,6 +397,7 @@ export class OutputLifecycle {
|
|
|
396
397
|
for (const outputId of outputs) {
|
|
397
398
|
await this.releaseSessionConsumer(outputId, consumerId, because);
|
|
398
399
|
}
|
|
400
|
+
this.#host.viewers.hasGone(consumerId);
|
|
399
401
|
return outputs.length;
|
|
400
402
|
}
|
|
401
403
|
|