@torrent-tv/proxy 2.64.9 → 2.65.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/CHANGELOG.md +15 -0
- package/docs/container-architecture.md +32 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +57 -2
- package/routes/api/transcode-sessions/post.js +11 -1
- package/services/audio-inventory.js +411 -0
- package/services/hls-session-manager.js +313 -24
- package/services/playback-planner.js +133 -2
- package/services/sidecar-files.js +352 -0
- package/services/torrent-worker/client.js +14 -0
- package/services/torrent-worker/container-tracks.js +243 -0
- package/services/torrent-worker/pool-adapter.js +38 -0
- package/services/torrent-worker/protocol.js +8 -0
- package/services/torrent-worker/worker.js +19 -0
- package/services/tracks/index.js +7 -1
- package/test/audio-inventory.test.js +177 -0
- package/test/sidecar-files.test.js +178 -0
- package/services/tracks/ExternalSubtitleFile.js +0 -27
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
parseFfmpegHdr
|
|
60
60
|
} from "./ffmpeg-banner.js";
|
|
61
61
|
import { resolveSegmentFormat, SEGMENT_FORMAT_IDS } from "./segment-formats/index.js";
|
|
62
|
+
import { audioRenditionName } from "./audio-inventory.js";
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
65
|
* Whether an encoder run died because its INPUT went away, rather than because
|
|
@@ -842,7 +843,7 @@ function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSec
|
|
|
842
843
|
* @param {string | URL} inputUrl - URL of the stream to probe.
|
|
843
844
|
* @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
|
|
844
845
|
*/
|
|
845
|
-
async function probeInputMediaInfo(ffmpegBin, inputUrl) {
|
|
846
|
+
async function probeInputMediaInfo(ffmpegBin, inputUrl, { expectVideo = true } = {}) {
|
|
846
847
|
return new Promise((resolve) => {
|
|
847
848
|
const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
|
|
848
849
|
stdio: ["ignore", "ignore", "pipe"],
|
|
@@ -881,9 +882,15 @@ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
|
|
|
881
882
|
// The header ("Duration:" then the "Video: … WxH" stream line) is printed
|
|
882
883
|
// before any decoding. Bail as soon as both are present instead of letting
|
|
883
884
|
// `-f null -` decode the whole stream until the 8 s timeout.
|
|
885
|
+
//
|
|
886
|
+
// A file with NO picture never prints that stream line, so waiting for it
|
|
887
|
+
// means waiting out the whole timeout — every time, on the path where a
|
|
888
|
+
// viewer is changing soundtrack and the browser refuses a switch that is
|
|
889
|
+
// not ready in time. `Duration:` carries the start time this is asked for,
|
|
890
|
+
// and it is the last thing such a file has to say about itself.
|
|
884
891
|
const duration = parseFfmpegDurationSeconds(stderr);
|
|
885
892
|
const dims = parseFfmpegVideoDimensions(stderr);
|
|
886
|
-
if (duration != null && dims.width != null) {
|
|
893
|
+
if (duration != null && (dims.width != null || !expectVideo)) {
|
|
887
894
|
clearTimeout(timeoutId);
|
|
888
895
|
if (!ffmpeg.killed) {
|
|
889
896
|
ffmpeg.kill("SIGTERM");
|
|
@@ -1792,6 +1799,13 @@ export class HlsSessionManager {
|
|
|
1792
1799
|
: 0;
|
|
1793
1800
|
const normalizedAudioTrack =
|
|
1794
1801
|
Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
|
|
1802
|
+
// Which FILE the chosen soundtrack lives in, and which track it is inside
|
|
1803
|
+
// that file. A release often ships its dub as a file of its own beside the
|
|
1804
|
+
// picture, and the number that travels between the browser, this route and
|
|
1805
|
+
// the `a/<n>/` address is flat across both — see `audio-inventory.js`. This
|
|
1806
|
+
// is the one place that resolves it, so nothing downstream carries two
|
|
1807
|
+
// vocabularies.
|
|
1808
|
+
const audioSource = this.#resolveAudioSource(sourceKey, fileIndex, normalizedAudioTrack);
|
|
1795
1809
|
const forceManualQuality = manualQuality === true && transcodeVideo;
|
|
1796
1810
|
const sourceMapKey = [
|
|
1797
1811
|
sourceKey,
|
|
@@ -1852,7 +1866,23 @@ export class HlsSessionManager {
|
|
|
1852
1866
|
const sessionDir = createSessionDirPath(sessionId);
|
|
1853
1867
|
const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
|
|
1854
1868
|
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
1855
|
-
|
|
1869
|
+
// A soundtrack shipped as its own file is encoded FROM that file, and an
|
|
1870
|
+
// audio rendition carries nothing else — so it reads the sidecar directly and
|
|
1871
|
+
// needs no second input at all. The muxed case, where a browser takes its
|
|
1872
|
+
// audio inside the picture's own stream, is the one that reads two files; its
|
|
1873
|
+
// second input is `audioInputUrl` below.
|
|
1874
|
+
const readsSidecarAlone = audioOnly === true && audioSource.isSidecar;
|
|
1875
|
+
inputUrl.searchParams.set(
|
|
1876
|
+
"fileIndex",
|
|
1877
|
+
String(readsSidecarAlone ? audioSource.fileIndex : fileIndex)
|
|
1878
|
+
);
|
|
1879
|
+
// The second input, for a muxed session whose sound comes from another file.
|
|
1880
|
+
let audioInputUrl = null;
|
|
1881
|
+
if (!readsSidecarAlone && audioSource.isSidecar) {
|
|
1882
|
+
audioInputUrl = new URL("/stream", `${this.localBaseUrl}/`);
|
|
1883
|
+
audioInputUrl.searchParams.set("sourceKey", sourceKey);
|
|
1884
|
+
audioInputUrl.searchParams.set("fileIndex", String(audioSource.fileIndex));
|
|
1885
|
+
}
|
|
1856
1886
|
|
|
1857
1887
|
// Media info (duration/resolution/fps/startTime/HDR) up front, so we can
|
|
1858
1888
|
// serve a complete VOD playlist (#EXT-X-ENDLIST) with the correct total
|
|
@@ -1871,15 +1901,40 @@ export class HlsSessionManager {
|
|
|
1871
1901
|
cachedMediaInfo.width > 0 &&
|
|
1872
1902
|
Number.isFinite(cachedMediaInfo.height) &&
|
|
1873
1903
|
cachedMediaInfo.height > 0;
|
|
1904
|
+
// Always the PICTURE's, even when this session reads a soundtrack from
|
|
1905
|
+
// another file: the timeline, the duration and the cut grid are the
|
|
1906
|
+
// picture's, and a rendition exists to be played WITH it. Only where the
|
|
1907
|
+
// sidecar's own timeline begins is read from the sidecar, just below.
|
|
1908
|
+
const pictureUrl = readsSidecarAlone
|
|
1909
|
+
? (() => {
|
|
1910
|
+
const url = new URL("/stream", `${this.localBaseUrl}/`);
|
|
1911
|
+
url.searchParams.set("sourceKey", sourceKey);
|
|
1912
|
+
url.searchParams.set("fileIndex", String(fileIndex));
|
|
1913
|
+
return url;
|
|
1914
|
+
})()
|
|
1915
|
+
: inputUrl;
|
|
1874
1916
|
const mediaInfo = cachedUsable
|
|
1875
1917
|
? cachedMediaInfo
|
|
1876
|
-
: await probeInputMediaInfo(this.ffmpegBin,
|
|
1918
|
+
: await probeInputMediaInfo(this.ffmpegBin, pictureUrl.toString());
|
|
1877
1919
|
const mediaInfoMs = Date.now() - mediaInfoStartMs;
|
|
1878
1920
|
const mediaInfoSource = cachedUsable ? "cached" : "probed";
|
|
1879
1921
|
const durationSeconds = mediaInfo.durationSeconds;
|
|
1880
1922
|
const sourceWidth = mediaInfo.width;
|
|
1881
1923
|
const sourceHeight = mediaInfo.height;
|
|
1882
1924
|
const sourceStartTime = Number.isFinite(mediaInfo.startTime) ? mediaInfo.startTime : 0;
|
|
1925
|
+
// Where the timeline of the file this session actually READS begins.
|
|
1926
|
+
//
|
|
1927
|
+
// For every session until now that was the picture's own file, so one figure
|
|
1928
|
+
// served both purposes. A soundtrack shipped separately has a start time of
|
|
1929
|
+
// its own, and it is the one that must be subtracted when the output is
|
|
1930
|
+
// relabelled onto a zero-based timeline: subtract the picture's instead and
|
|
1931
|
+
// the sound sits at a fixed offset from it for the whole film. Measured from
|
|
1932
|
+
// the file rather than assumed to be zero, because assuming it is exactly
|
|
1933
|
+
// the fault being avoided.
|
|
1934
|
+
const audioFileStartTime = audioSource.isSidecar
|
|
1935
|
+
? await this.#sidecarStartTimeSeconds(sourceKey, audioSource.fileIndex)
|
|
1936
|
+
: sourceStartTime;
|
|
1937
|
+
const inputStartTime = readsSidecarAlone ? audioFileStartTime : sourceStartTime;
|
|
1883
1938
|
// Tone-map an HDR source to SDR only when re-encoding video on the software
|
|
1884
1939
|
// path and this ffmpeg has the filters. Hardware encoders keep their own
|
|
1885
1940
|
// (untone-mapped) path for now; when unavailable, HDR falls back to a plain
|
|
@@ -1900,7 +1955,17 @@ export class HlsSessionManager {
|
|
|
1900
1955
|
// the file's own average byte rate, which is size ÷ duration; the size
|
|
1901
1956
|
// comes from the same stats call the realtime budget uses. Best effort —
|
|
1902
1957
|
// without it the reader keeps its own byte default.
|
|
1903
|
-
|
|
1958
|
+
//
|
|
1959
|
+
// Sized for the file being READ, which for a soundtrack shipped separately
|
|
1960
|
+
// is that file: it is a twentieth of the picture's size over the same
|
|
1961
|
+
// duration, so the picture's byte rate would buy a window twenty times
|
|
1962
|
+
// wider than the seconds it is meant to represent, and the piece store
|
|
1963
|
+
// would hold it.
|
|
1964
|
+
const readWindowBytes = await this.#readWindowBytesFor(
|
|
1965
|
+
sourceKey,
|
|
1966
|
+
readsSidecarAlone ? audioSource.fileIndex : fileIndex,
|
|
1967
|
+
durationSeconds
|
|
1968
|
+
);
|
|
1904
1969
|
if (readWindowBytes > 0) {
|
|
1905
1970
|
inputUrl.searchParams.set("windowBytes", String(readWindowBytes));
|
|
1906
1971
|
// This read, and only this read, follows the viewer. The codec probe and
|
|
@@ -2169,6 +2234,22 @@ export class HlsSessionManager {
|
|
|
2169
2234
|
transcodeVideo,
|
|
2170
2235
|
transcodeAudio,
|
|
2171
2236
|
audioTrackIndex: normalizedAudioTrack,
|
|
2237
|
+
// Where that soundtrack actually is. The number above is flat across the
|
|
2238
|
+
// picture's own tracks and the files beside it, and these two are what it
|
|
2239
|
+
// resolves to: which file, and which `0:a:N` inside that file. Equal to
|
|
2240
|
+
// `fileIndex` and to the flat number for an ordinary embedded track, which
|
|
2241
|
+
// is what every session was before soundtracks in their own files existed.
|
|
2242
|
+
audioFileIndex: audioSource.fileIndex,
|
|
2243
|
+
audioSourceTrackIndex: audioSource.sourceTrackIndex,
|
|
2244
|
+
// The second input, present only for a muxed session whose sound is in
|
|
2245
|
+
// another file. An audio rendition reads its sidecar as its only input, so
|
|
2246
|
+
// it has none.
|
|
2247
|
+
audioInputUrl: audioInputUrl ? audioInputUrl.toString() : "",
|
|
2248
|
+
// Where the timeline of the file being READ begins, against the picture's
|
|
2249
|
+
// own `sourceStartTime` below. The two differ only when the sound comes
|
|
2250
|
+
// from a separate file.
|
|
2251
|
+
inputStartTime,
|
|
2252
|
+
audioFileStartTime,
|
|
2172
2253
|
// What this session's output carries. `audioOnly` is a rendition — one
|
|
2173
2254
|
// audio track, no picture; `videoOnly` is a stream whose audio the viewer
|
|
2174
2255
|
// takes from such a rendition. Neither is set on the ordinary muxed
|
|
@@ -2373,11 +2454,34 @@ export class HlsSessionManager {
|
|
|
2373
2454
|
// torrent's data alive exactly as this one does.
|
|
2374
2455
|
session.acquireSource = typeof acquireSource === "function" ? acquireSource : null;
|
|
2375
2456
|
if (typeof acquireSource === "function") {
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2457
|
+
// One claim per file this session READS. Almost always that is one file;
|
|
2458
|
+
// a muxed session whose soundtrack ships beside the picture reads two, and
|
|
2459
|
+
// holding only the picture would leave the sound to be swept off the disk
|
|
2460
|
+
// from under a running encoder.
|
|
2461
|
+
const claim = (heldFileIndex) => {
|
|
2462
|
+
try {
|
|
2463
|
+
const release = acquireSource(heldFileIndex);
|
|
2464
|
+
return typeof release === "function" ? release : null;
|
|
2465
|
+
} catch {
|
|
2466
|
+
return null;
|
|
2467
|
+
}
|
|
2468
|
+
};
|
|
2469
|
+
const releases = [claim(undefined)];
|
|
2470
|
+
if (audioInputUrl) {
|
|
2471
|
+
releases.push(claim(audioSource.fileIndex));
|
|
2472
|
+
}
|
|
2473
|
+
const held = releases.filter((release) => typeof release === "function");
|
|
2474
|
+
session.releaseSource = held.length > 0
|
|
2475
|
+
? () => {
|
|
2476
|
+
for (const release of held) {
|
|
2477
|
+
try {
|
|
2478
|
+
release();
|
|
2479
|
+
} catch {
|
|
2480
|
+
// Best effort — a session must always finish being disposed.
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
: null;
|
|
2381
2485
|
}
|
|
2382
2486
|
this.sessionsById.set(sessionId, session);
|
|
2383
2487
|
this.sessionIdBySource.set(sourceMapKey, sessionId);
|
|
@@ -2540,9 +2644,17 @@ export class HlsSessionManager {
|
|
|
2540
2644
|
// declare — its warning about a short header would then fire on every one.
|
|
2541
2645
|
const carriesVideo = session.audioOnly !== true;
|
|
2542
2646
|
const carriesAudio = !this.#servesAudioSeparately(session);
|
|
2647
|
+
// The soundtrack of this session may not be in the file that was probed. A
|
|
2648
|
+
// release that ships its dub as a separate file often ships the picture with
|
|
2649
|
+
// no sound of its own at all, and then the picture's probe says there is no
|
|
2650
|
+
// audio while the output plainly carries some — which would leave the header
|
|
2651
|
+
// check expecting one track where two arrive, and tell the browser its sound
|
|
2652
|
+
// was lost.
|
|
2653
|
+
const audioFromAnotherFile =
|
|
2654
|
+
Number.isInteger(session.audioFileIndex) && session.audioFileIndex !== session.fileIndex;
|
|
2543
2655
|
return {
|
|
2544
2656
|
video: carriesVideo && Boolean(probed?.videoCodec),
|
|
2545
|
-
audio: carriesAudio && Boolean(probed?.audioCodec)
|
|
2657
|
+
audio: carriesAudio && (audioFromAnotherFile || Boolean(probed?.audioCodec))
|
|
2546
2658
|
};
|
|
2547
2659
|
}
|
|
2548
2660
|
|
|
@@ -4821,7 +4933,11 @@ export class HlsSessionManager {
|
|
|
4821
4933
|
const startSeconds = Number.isFinite(positionSecondsOverride)
|
|
4822
4934
|
? positionSecondsOverride
|
|
4823
4935
|
: this.runStartTimeFor(session, safeIndex);
|
|
4824
|
-
|
|
4936
|
+
// The start time of the file this run READS, which is the picture's own for
|
|
4937
|
+
// every session except one whose soundtrack is a separate file.
|
|
4938
|
+
const sourceStartTime = Number.isFinite(session.inputStartTime)
|
|
4939
|
+
? session.inputStartTime
|
|
4940
|
+
: (Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0);
|
|
4825
4941
|
// Cut where this session's grid says, whoever is producing the frames. The
|
|
4826
4942
|
// times are measured from the start of THIS run; the same list serves as
|
|
4827
4943
|
// the cut points and, when re-encoding, as the keyframes to force — one
|
|
@@ -4946,12 +5062,69 @@ export class HlsSessionManager {
|
|
|
4946
5062
|
const snappedKeyframe = Array.isArray(session.keyframeTimes) && session.keyframeTimes.length > 0
|
|
4947
5063
|
? nearestKeyframeAtOrBefore(session.keyframeTimes, seekSeconds)
|
|
4948
5064
|
: null;
|
|
5065
|
+
// A second input, and it exists for exactly one case: a browser that takes
|
|
5066
|
+
// its audio muxed into the picture, watching a release whose soundtrack is a
|
|
5067
|
+
// file of its own. An audio RENDITION reads that file as its only input and
|
|
5068
|
+
// has none of this — which is why the ordinary path, and every browser that
|
|
5069
|
+
// understands rendition groups, still runs on a single input.
|
|
5070
|
+
const audioInputUrl =
|
|
5071
|
+
typeof session.audioInputUrl === "string" && session.audioInputUrl.length > 0
|
|
5072
|
+
? session.audioInputUrl
|
|
5073
|
+
: "";
|
|
5074
|
+
// Where the picture's own start sits on the soundtrack file's timeline. Both
|
|
5075
|
+
// files begin at their own container start time, and those need not be the
|
|
5076
|
+
// same number; the difference is what keeps the two aligned.
|
|
5077
|
+
const audioTimelineShift = audioInputUrl
|
|
5078
|
+
? (Number.isFinite(session.audioFileStartTime) ? session.audioFileStartTime : 0) - sourceStartTime
|
|
5079
|
+
: 0;
|
|
5080
|
+
/**
|
|
5081
|
+
* Add the second input, if there is one, with its own seek.
|
|
5082
|
+
*
|
|
5083
|
+
* Called between the first `-i` and any OUTPUT option, because ffmpeg reads
|
|
5084
|
+
* these positionally: an option written after the last `-i` applies to the
|
|
5085
|
+
* output, and the residual seek below is exactly such an option. Getting the
|
|
5086
|
+
* order wrong would silently turn the audio file's seek into a trim of the
|
|
5087
|
+
* finished stream.
|
|
5088
|
+
*
|
|
5089
|
+
* @param {number} inputSeekSeconds - Where to start, on the PICTURE's
|
|
5090
|
+
* timeline. Translated to the soundtrack file's own here.
|
|
5091
|
+
*/
|
|
5092
|
+
const pushAudioInput = (inputSeekSeconds) => {
|
|
5093
|
+
if (!audioInputUrl) {
|
|
5094
|
+
return;
|
|
5095
|
+
}
|
|
5096
|
+
// `-itsoffset` states the soundtrack's timestamps on the picture's
|
|
5097
|
+
// timeline, so everything after this point — `-copyts`, the output offset,
|
|
5098
|
+
// the cut list — goes on treating the two as one timeline, unchanged.
|
|
5099
|
+
//
|
|
5100
|
+
// ONLY on the branch that keeps the source's own timestamps. Without
|
|
5101
|
+
// `-copyts` ffmpeg rebases each input from its own seek point, and both
|
|
5102
|
+
// inputs are seeked to the same instant just below — so the two are
|
|
5103
|
+
// already aligned and adding the offset would pull them apart by exactly
|
|
5104
|
+
// the amount it exists to remove.
|
|
5105
|
+
if (audioTimelineShift !== 0 && onKeyframeGridFor(session)) {
|
|
5106
|
+
args.push("-itsoffset", ffmpegSeconds(-audioTimelineShift));
|
|
5107
|
+
}
|
|
5108
|
+
const audioSeek = Math.max(0, inputSeekSeconds + audioTimelineShift);
|
|
5109
|
+
if (audioSeek > 0) {
|
|
5110
|
+
// No keyframe to snap to and none needed: every audio frame is a sync
|
|
5111
|
+
// point, so the seek can be accurate outright.
|
|
5112
|
+
args.push("-accurate_seek", "-ss", ffmpegSeconds(audioSeek));
|
|
5113
|
+
}
|
|
5114
|
+
args.push("-i", audioInputUrl);
|
|
5115
|
+
};
|
|
5116
|
+
|
|
4949
5117
|
if (snappedKeyframe !== null) {
|
|
4950
5118
|
const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
|
|
4951
5119
|
if (snappedKeyframe > 0) {
|
|
4952
5120
|
args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor(session, snappedKeyframe)));
|
|
4953
5121
|
}
|
|
4954
5122
|
args.push("-i", session.inputUrl);
|
|
5123
|
+
// The coarse landing, not the exact target: the residual below is discarded
|
|
5124
|
+
// from the OUTPUT and so takes the same slice off every stream. Seeking the
|
|
5125
|
+
// soundtrack to the exact target as well would take that slice twice and
|
|
5126
|
+
// leave the sound running ahead of the picture by it.
|
|
5127
|
+
pushAudioInput(snappedKeyframe);
|
|
4955
5128
|
if (residualSeconds > 0) {
|
|
4956
5129
|
args.push("-ss", ffmpegSeconds(residualSeconds));
|
|
4957
5130
|
}
|
|
@@ -4962,6 +5135,7 @@ export class HlsSessionManager {
|
|
|
4962
5135
|
args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
|
|
4963
5136
|
}
|
|
4964
5137
|
args.push("-i", session.inputUrl);
|
|
5138
|
+
pushAudioInput(seekSeconds);
|
|
4965
5139
|
}
|
|
4966
5140
|
// Which timeline the output is labelled on. An audio rendition has no
|
|
4967
5141
|
// picture of its own to follow, so it follows the grid it was given — the
|
|
@@ -4998,7 +5172,9 @@ export class HlsSessionManager {
|
|
|
4998
5172
|
// the player switching rendition rather than this proxy rebuilding the
|
|
4999
5173
|
// session. Cut on the same grid as the video it accompanies, which is
|
|
5000
5174
|
// what lets the two be played together.
|
|
5001
|
-
|
|
5175
|
+
// `0:` because a rendition's only input IS the file its track lives in —
|
|
5176
|
+
// the picture's own file, or the one beside it that carries this dub.
|
|
5177
|
+
args.push("-vn", "-map", `0:a:${session.audioSourceTrackIndex ?? session.audioTrackIndex ?? 0}?`, ...audioCodecArgs);
|
|
5002
5178
|
} else if (this.#servesAudioSeparately(session)) {
|
|
5003
5179
|
// The other half of the same arrangement: the picture alone, because its
|
|
5004
5180
|
// audio is published as a rendition and would otherwise play twice.
|
|
@@ -5008,8 +5184,12 @@ export class HlsSessionManager {
|
|
|
5008
5184
|
"-map",
|
|
5009
5185
|
"0:v:0?",
|
|
5010
5186
|
"-map",
|
|
5011
|
-
//
|
|
5012
|
-
|
|
5187
|
+
// The audio track the viewer chose: input 1 when their choice is a
|
|
5188
|
+
// soundtrack shipped as its own file, input 0 when it is one of the
|
|
5189
|
+
// picture's own. Type-relative within that input, which is what
|
|
5190
|
+
// `audioSourceTrackIndex` holds — the number the browser sent is flat
|
|
5191
|
+
// across both files and was resolved when the session was made.
|
|
5192
|
+
`${audioInputUrl ? 1 : 0}:a:${session.audioSourceTrackIndex ?? session.audioTrackIndex ?? 0}?`,
|
|
5013
5193
|
...videoCodecArgs,
|
|
5014
5194
|
...audioCodecArgs
|
|
5015
5195
|
);
|
|
@@ -5419,12 +5599,18 @@ export class HlsSessionManager {
|
|
|
5419
5599
|
*/
|
|
5420
5600
|
#describeTrackSelection(session) {
|
|
5421
5601
|
const wanted = [];
|
|
5602
|
+
// Named exactly as the command line names them, second input included: a
|
|
5603
|
+
// refusal whose message describes a different mapping than the one that was
|
|
5604
|
+
// refused is the reading that cost a wrong diagnosis before.
|
|
5605
|
+
const audioInput =
|
|
5606
|
+
typeof session.audioInputUrl === "string" && session.audioInputUrl.length > 0 ? 1 : 0;
|
|
5607
|
+
const audioTrack = session.audioSourceTrackIndex ?? session.audioTrackIndex ?? 0;
|
|
5422
5608
|
if (session.audioOnly === true) {
|
|
5423
|
-
wanted.push(`audio 0:a:${
|
|
5609
|
+
wanted.push(`audio 0:a:${audioTrack}`);
|
|
5424
5610
|
} else if (this.#servesAudioSeparately(session)) {
|
|
5425
5611
|
wanted.push("video 0:v:0");
|
|
5426
5612
|
} else {
|
|
5427
|
-
wanted.push("video 0:v:0", `audio
|
|
5613
|
+
wanted.push("video 0:v:0", `audio ${audioInput}:a:${audioTrack}`);
|
|
5428
5614
|
}
|
|
5429
5615
|
const counts = session.sourceStreamCounts;
|
|
5430
5616
|
const held = counts
|
|
@@ -8721,7 +8907,14 @@ export class HlsSessionManager {
|
|
|
8721
8907
|
containerFormat: base.containerFormat
|
|
8722
8908
|
}
|
|
8723
8909
|
: null,
|
|
8724
|
-
|
|
8910
|
+
// Hold the file this rendition will READ. For a soundtrack shipped beside
|
|
8911
|
+
// the picture that is a different file of the same torrent, and nothing
|
|
8912
|
+
// else claims it: the base holds the picture, and the disk sweep deletes
|
|
8913
|
+
// what nobody is holding — which is how a film being watched was deleted
|
|
8914
|
+
// on 2026-08-06.
|
|
8915
|
+
acquireSource: () => base.acquireSource?.(
|
|
8916
|
+
this.#resolveAudioSource(base.sourceKey, base.fileIndex, trackIndex).fileIndex
|
|
8917
|
+
)
|
|
8725
8918
|
});
|
|
8726
8919
|
if (!rendition) {
|
|
8727
8920
|
return null;
|
|
@@ -8733,6 +8926,82 @@ export class HlsSessionManager {
|
|
|
8733
8926
|
return rendition;
|
|
8734
8927
|
}
|
|
8735
8928
|
|
|
8929
|
+
/**
|
|
8930
|
+
* Where one numbered soundtrack actually is: which file of the torrent, and
|
|
8931
|
+
* which `0:a:N` inside it.
|
|
8932
|
+
*
|
|
8933
|
+
* The number is flat across the picture's own tracks and every soundtrack
|
|
8934
|
+
* shipped as a file beside it, so that the browser's menu, the
|
|
8935
|
+
* `audioTrackIndex` on a session request and the `a/<n>/` path a rendition is
|
|
8936
|
+
* published at all mean the same thing. This resolves it, once, from the
|
|
8937
|
+
* inventory the playback plan built — the very list the menu was drawn from,
|
|
8938
|
+
* so the two cannot disagree about what a number means.
|
|
8939
|
+
*
|
|
8940
|
+
* A number the inventory does not describe resolves to the picture's own file
|
|
8941
|
+
* at that index, which is exactly what every session did before soundtracks in
|
|
8942
|
+
* their own files existed: a plan cached by an older build carries no
|
|
8943
|
+
* inventory, and a session created against it must keep working.
|
|
8944
|
+
*
|
|
8945
|
+
* @param {string} sourceKey
|
|
8946
|
+
* @param {number} fileIndex - The PICTURE's file.
|
|
8947
|
+
* @param {number} flatIndex
|
|
8948
|
+
* @returns {{ fileIndex: number, sourceTrackIndex: number, isSidecar: boolean, name: string }}
|
|
8949
|
+
*/
|
|
8950
|
+
#resolveAudioSource(sourceKey, fileIndex, flatIndex) {
|
|
8951
|
+
const inventory = this.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
|
|
8952
|
+
const entry = Array.isArray(inventory)
|
|
8953
|
+
? inventory.find((candidate) => candidate?.index === flatIndex)
|
|
8954
|
+
: null;
|
|
8955
|
+
if (!entry || !Number.isInteger(entry.fileIndex) || !Number.isInteger(entry.sourceTrackIndex)) {
|
|
8956
|
+
return { fileIndex, sourceTrackIndex: flatIndex, isSidecar: false, name: "" };
|
|
8957
|
+
}
|
|
8958
|
+
return {
|
|
8959
|
+
fileIndex: entry.fileIndex,
|
|
8960
|
+
sourceTrackIndex: entry.sourceTrackIndex,
|
|
8961
|
+
isSidecar: entry.fileIndex !== fileIndex,
|
|
8962
|
+
name: typeof entry.fileName === "string" ? entry.fileName : ""
|
|
8963
|
+
};
|
|
8964
|
+
}
|
|
8965
|
+
|
|
8966
|
+
/**
|
|
8967
|
+
* Where a sidecar soundtrack's own timeline begins, in seconds.
|
|
8968
|
+
*
|
|
8969
|
+
* One short probe of that file, kept for as long as the process lives: a
|
|
8970
|
+
* container's start time is a property of the file and cannot change. It is
|
|
8971
|
+
* asked only when such a track is actually chosen, so a torrent whose extra
|
|
8972
|
+
* soundtracks nobody plays costs nothing for them.
|
|
8973
|
+
*
|
|
8974
|
+
* @param {string} sourceKey
|
|
8975
|
+
* @param {number} fileIndex - The SIDECAR's file.
|
|
8976
|
+
* @returns {Promise<number>}
|
|
8977
|
+
*/
|
|
8978
|
+
async #sidecarStartTimeSeconds(sourceKey, fileIndex) {
|
|
8979
|
+
if (!(this.sidecarStartTimes instanceof Map)) {
|
|
8980
|
+
this.sidecarStartTimes = new Map();
|
|
8981
|
+
}
|
|
8982
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
8983
|
+
const held = this.sidecarStartTimes.get(key);
|
|
8984
|
+
if (Number.isFinite(held)) {
|
|
8985
|
+
return held;
|
|
8986
|
+
}
|
|
8987
|
+
const url = new URL("/stream", `${this.localBaseUrl}/`);
|
|
8988
|
+
url.searchParams.set("sourceKey", sourceKey);
|
|
8989
|
+
url.searchParams.set("fileIndex", String(fileIndex));
|
|
8990
|
+
let startTime = 0;
|
|
8991
|
+
try {
|
|
8992
|
+
const info = await probeInputMediaInfo(this.ffmpegBin, url.toString(), { expectVideo: false });
|
|
8993
|
+
startTime = Number.isFinite(info?.startTime) ? info.startTime : 0;
|
|
8994
|
+
} catch (error) {
|
|
8995
|
+
logger.info(
|
|
8996
|
+
`transcode: the start time of soundtrack file ${fileIndex} could not be probed ` +
|
|
8997
|
+
`(${error instanceof Error ? error.message : String(error)}) — taken as 0`
|
|
8998
|
+
);
|
|
8999
|
+
startTime = 0;
|
|
9000
|
+
}
|
|
9001
|
+
this.sidecarStartTimes.set(key, startTime);
|
|
9002
|
+
return startTime;
|
|
9003
|
+
}
|
|
9004
|
+
|
|
8736
9005
|
/**
|
|
8737
9006
|
* The audio tracks of this session's file, as renditions for the master.
|
|
8738
9007
|
*
|
|
@@ -8748,20 +9017,40 @@ export class HlsSessionManager {
|
|
|
8748
9017
|
#audioRenditionsOf(session) {
|
|
8749
9018
|
const tracks = this.getCachedAudioTracks?.({
|
|
8750
9019
|
sourceKey: session.sourceKey,
|
|
9020
|
+
// The PICTURE's file, which is what `fileIndex` is on every session of a
|
|
9021
|
+
// family — a rendition is created with its base's, and only its
|
|
9022
|
+
// `audioFileIndex` points at the file its sound comes from. The inventory
|
|
9023
|
+
// is keyed on the picture and spans the soundtracks beside it.
|
|
8751
9024
|
fileIndex: session.fileIndex
|
|
8752
9025
|
}) ?? [];
|
|
8753
9026
|
if (!Array.isArray(tracks) || tracks.length === 0) {
|
|
8754
9027
|
return [];
|
|
8755
9028
|
}
|
|
8756
9029
|
const chosen = Number(session.audioTrackIndex) || 0;
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
9030
|
+
// One line per entry of the inventory, in its order and without omissions —
|
|
9031
|
+
// including a track the container marks unusable. The player addresses a
|
|
9032
|
+
// rendition by its POSITION in this list, and the browser addresses it by
|
|
9033
|
+
// the number the inventory gave it; leaving anything out would make those two
|
|
9034
|
+
// disagree from that point on. A track the file says not to offer is kept out
|
|
9035
|
+
// of the VIEWER's menu, which is the browser's own business and does not
|
|
9036
|
+
// touch the numbering.
|
|
9037
|
+
return tracks.map((entry, order) => {
|
|
9038
|
+
const index = Number.isInteger(entry?.index) ? entry.index : order;
|
|
9039
|
+
const language = typeof entry?.languageBcp47 === "string" && entry.languageBcp47.length > 0
|
|
9040
|
+
? entry.languageBcp47
|
|
9041
|
+
: (typeof entry?.language === "string" ? entry.language : "");
|
|
8760
9042
|
return {
|
|
8761
|
-
trackIndex:
|
|
8762
|
-
name:
|
|
9043
|
+
trackIndex: index,
|
|
9044
|
+
name: audioRenditionName(
|
|
9045
|
+
{ ...entry, index, folders: Array.isArray(entry?.folders) ? entry.folders : [] },
|
|
9046
|
+
tracks
|
|
9047
|
+
),
|
|
9048
|
+
// Only what the container itself states. What a folder name suggests
|
|
9049
|
+
// about a language is derived in the browser, where the language table
|
|
9050
|
+
// and the viewer's own locale already are; writing a guess into
|
|
9051
|
+
// `LANGUAGE` would put it in a playlist as though the file had said it.
|
|
8763
9052
|
language,
|
|
8764
|
-
isDefault:
|
|
9053
|
+
isDefault: index === chosen
|
|
8765
9054
|
};
|
|
8766
9055
|
});
|
|
8767
9056
|
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import { mergeContainerSubtitleFlags } from "./subtitle-defaults.js";
|
|
12
|
+
import { buildAudioInventory, mergeContainerAudioFlags } from "./audio-inventory.js";
|
|
13
|
+
import { countVideoFiles, matchSidecarFiles } from "./sidecar-files.js";
|
|
12
14
|
import {
|
|
13
15
|
parseFfmpegDurationSeconds,
|
|
14
16
|
parseFfmpegStartTimeSeconds,
|
|
@@ -28,6 +30,17 @@ const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
|
|
|
28
30
|
// the codec probe). ~16 MB ≈ the first segments of typical media.
|
|
29
31
|
const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* How long the plan waits for a file's own header before offering its
|
|
35
|
+
* soundtrack without what that header would have said.
|
|
36
|
+
*
|
|
37
|
+
* Not a measurement, and nothing is derived from it: it is the point past which
|
|
38
|
+
* holding the viewer costs more than the language and flags being waited for —
|
|
39
|
+
* which the folder name supplies anyway, from the torrent's file list, at no
|
|
40
|
+
* cost. The reading itself carries on in the worker and is kept there.
|
|
41
|
+
*/
|
|
42
|
+
const SIDECAR_HEADER_WAIT_MS = 3_000;
|
|
43
|
+
|
|
31
44
|
/** Subtitle codecs that can be converted to WebVTT (text-based). */
|
|
32
45
|
const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
|
|
33
46
|
|
|
@@ -345,6 +358,122 @@ export function createPlaybackPlanner({
|
|
|
345
358
|
return merged.tracks;
|
|
346
359
|
}
|
|
347
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Every soundtrack this file can be watched with, as one numbered list: its
|
|
363
|
+
* own tracks and the ones shipped as separate files beside it.
|
|
364
|
+
*
|
|
365
|
+
* Built here, in the plan, because the plan is what the viewer's menu is drawn
|
|
366
|
+
* from — so the offer is complete the moment a file is opened, with nothing
|
|
367
|
+
* arriving late and nothing measured while the viewer waits. It is also what
|
|
368
|
+
* the master playlist's rendition group is built from, so the number in the
|
|
369
|
+
* menu and the number in the `a/<n>/` address are the same number by
|
|
370
|
+
* construction rather than by agreement.
|
|
371
|
+
*
|
|
372
|
+
* @param {object} torrent
|
|
373
|
+
* @param {number} fileIndex
|
|
374
|
+
* @param {object[]} bannerAudioTracks - The probe's own audio streams.
|
|
375
|
+
* @returns {Promise<import("./audio-inventory.js").AudioInventoryEntry[]>}
|
|
376
|
+
*/
|
|
377
|
+
async function buildInventory(torrent, fileIndex, bannerAudioTracks) {
|
|
378
|
+
const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
|
|
379
|
+
/**
|
|
380
|
+
* Read a file's declared audio tracks, or give up quickly.
|
|
381
|
+
*
|
|
382
|
+
* The plan is on the path to the first frame, and reading a sidecar's header
|
|
383
|
+
* waits on the swarm: that file has usually had nothing downloaded when this
|
|
384
|
+
* runs, and a header that never arrives would hold the plan — and the
|
|
385
|
+
* viewer — for the whole of the read's own patience. What a timeout costs is
|
|
386
|
+
* small and deliberate: the track is still offered, still numbered and still
|
|
387
|
+
* playable, only without the language and flags its own header would have
|
|
388
|
+
* given. The language the viewer actually sees is read from the FOLDER the
|
|
389
|
+
* release put it in, which is in the torrent's file list and needs no bytes
|
|
390
|
+
* at all.
|
|
391
|
+
*
|
|
392
|
+
* @param {number} wantedFileIndex
|
|
393
|
+
* @param {string} label
|
|
394
|
+
* @returns {Promise<object[]>}
|
|
395
|
+
*/
|
|
396
|
+
const declaredAudioOf = async (wantedFileIndex, label) => {
|
|
397
|
+
if (typeof torrentPool?.getDeclaredAudioTracks !== "function") {
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
let timer = null;
|
|
401
|
+
try {
|
|
402
|
+
return await Promise.race([
|
|
403
|
+
torrentPool.getDeclaredAudioTracks(torrent, wantedFileIndex),
|
|
404
|
+
new Promise((resolve) => {
|
|
405
|
+
timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
|
|
406
|
+
timer.unref?.();
|
|
407
|
+
})
|
|
408
|
+
]).then((tracks) => {
|
|
409
|
+
if (tracks === null) {
|
|
410
|
+
logger.info(
|
|
411
|
+
`audio tracks: "${label}" did not answer within ` +
|
|
412
|
+
`${SIDECAR_HEADER_WAIT_MS / 1000}s — offered without what its header would say`
|
|
413
|
+
);
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
return Array.isArray(tracks) ? tracks : [];
|
|
417
|
+
});
|
|
418
|
+
} catch (error) {
|
|
419
|
+
logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
|
|
420
|
+
return [];
|
|
421
|
+
} finally {
|
|
422
|
+
if (timer !== null) {
|
|
423
|
+
clearTimeout(timer);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
// The picture's own tracks: ffmpeg numbers them, the container declares what
|
|
428
|
+
// they are. Both readings, lined up and checked — see `audio-inventory.js`.
|
|
429
|
+
let embedded = banner.map((track) => ({ ...track, declaresDefault: false }));
|
|
430
|
+
if (banner.length > 0) {
|
|
431
|
+
// The picture's head is already downloaded — the codec probe just read it
|
|
432
|
+
// — so this is a parse and not a wait, but it is bounded like the rest.
|
|
433
|
+
const declared = await declaredAudioOf(fileIndex, "the picture");
|
|
434
|
+
const merged = mergeContainerAudioFlags(banner, declared);
|
|
435
|
+
embedded = merged.tracks;
|
|
436
|
+
logger.info(
|
|
437
|
+
merged.aligned
|
|
438
|
+
? `audio tracks: the container describes all ${merged.tracks.length}` +
|
|
439
|
+
`${merged.tracks.some((track) => track.isCommentary) ? ", one of them commentary" : ""}` +
|
|
440
|
+
`${merged.tracks.some((track) => track.isVisualImpaired) ? ", one of them described" : ""}`
|
|
441
|
+
: `audio tracks: using the probe's own fields — ${merged.reason}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const sidecarFiles = matchSidecarFiles({
|
|
446
|
+
files: torrent?.files ?? [],
|
|
447
|
+
videoIndex: fileIndex,
|
|
448
|
+
torrentName: typeof torrent?.name === "string" ? torrent.name : "",
|
|
449
|
+
videoCount: countVideoFiles(torrent?.files ?? [])
|
|
450
|
+
});
|
|
451
|
+
// All of them at once. They are separate files with separate headers, and
|
|
452
|
+
// read one after another the waits add up on the path to the first frame.
|
|
453
|
+
const sidecars = await Promise.all(
|
|
454
|
+
sidecarFiles.audio.map(async (file) => ({
|
|
455
|
+
file,
|
|
456
|
+
// A bare elementary stream — `.ac3`, `.dts`, `.mp3` — has no table to
|
|
457
|
+
// read, so nothing is asked of the swarm for it at all.
|
|
458
|
+
tracks: file.declaresTracks ? await declaredAudioOf(file.fileIndex, file.name) : []
|
|
459
|
+
}))
|
|
460
|
+
);
|
|
461
|
+
const inventory = buildAudioInventory({ embedded, videoFileIndex: fileIndex, sidecars });
|
|
462
|
+
if (sidecars.length > 0) {
|
|
463
|
+
logger.info(
|
|
464
|
+
`audio tracks: ${sidecars.length} file(s) beside the picture carry sound — ` +
|
|
465
|
+
inventory
|
|
466
|
+
.filter((entry) => entry.kind === "sidecar")
|
|
467
|
+
.map((entry) =>
|
|
468
|
+
`a:${entry.index}=${entry.folders.join("/") || "."}/${entry.fileName}` +
|
|
469
|
+
`#${entry.sourceTrackIndex}${entry.codec ? `(${entry.codec})` : ""}`
|
|
470
|
+
)
|
|
471
|
+
.join(" ")
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return inventory;
|
|
475
|
+
}
|
|
476
|
+
|
|
348
477
|
function withHostTimings(plan) {
|
|
349
478
|
return {
|
|
350
479
|
...plan,
|
|
@@ -516,8 +645,10 @@ export function createPlaybackPlanner({
|
|
|
516
645
|
// (list of forced resolutions <= source). 0 when unknown.
|
|
517
646
|
videoWidth,
|
|
518
647
|
videoHeight,
|
|
519
|
-
// Full track inventory for the browser's audio/subtitle menus.
|
|
520
|
-
|
|
648
|
+
// Full track inventory for the browser's audio/subtitle menus. The audio
|
|
649
|
+
// half spans the picture's own tracks AND the soundtracks shipped as
|
|
650
|
+
// files beside it, under one numbering — see `buildInventory`.
|
|
651
|
+
audioTracks: await buildInventory(torrent, fileIndex, audioTracks ?? []),
|
|
521
652
|
subtitleTracks: await withContainerDefaults(torrent, fileIndex, subtitleTracks ?? []),
|
|
522
653
|
// Both host timings are filled in by `withHostTimings` on the way out,
|
|
523
654
|
// never here: read at build time they would be frozen into the cached
|