@torrent-tv/proxy 2.64.9 → 2.66.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.
@@ -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");
@@ -1619,6 +1626,7 @@ export class HlsSessionManager {
1619
1626
  tonemapSupported = false,
1620
1627
  getCachedMediaInfo = null,
1621
1628
  getCachedAudioTracks = null,
1629
+ fetchWholeFile = null,
1622
1630
  segmentFormatId = undefined,
1623
1631
  stateDir = "",
1624
1632
  getTorrentTotals}) {
@@ -1638,6 +1646,12 @@ export class HlsSessionManager {
1638
1646
  // The file's audio tracks, for the master playlist's rendition group. Same
1639
1647
  // inventory the browser's audio menu is built from.
1640
1648
  this.getCachedAudioTracks = typeof getCachedAudioTracks === "function" ? getCachedAudioTracks : null;
1649
+ // Fetch one whole file of a source, as a bounded read rather than a
1650
+ // selection. Used to pull a soundtrack that ships beside the picture onto
1651
+ // the disk while the swarm has capacity to spare — see
1652
+ // `#fetchSpareSoundtracks`. Optional: a proxy wired without it simply reads
1653
+ // such a soundtrack when it is played.
1654
+ this.fetchWholeFile = typeof fetchWholeFile === "function" ? fetchWholeFile : null;
1641
1655
  // Optional async accessor for a source's live download stats, used by the
1642
1656
  // realtime budget to tell a CPU limit from a download-starved input:
1643
1657
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
@@ -1792,6 +1806,13 @@ export class HlsSessionManager {
1792
1806
  : 0;
1793
1807
  const normalizedAudioTrack =
1794
1808
  Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
1809
+ // Which FILE the chosen soundtrack lives in, and which track it is inside
1810
+ // that file. A release often ships its dub as a file of its own beside the
1811
+ // picture, and the number that travels between the browser, this route and
1812
+ // the `a/<n>/` address is flat across both — see `audio-inventory.js`. This
1813
+ // is the one place that resolves it, so nothing downstream carries two
1814
+ // vocabularies.
1815
+ const audioSource = this.#resolveAudioSource(sourceKey, fileIndex, normalizedAudioTrack);
1795
1816
  const forceManualQuality = manualQuality === true && transcodeVideo;
1796
1817
  const sourceMapKey = [
1797
1818
  sourceKey,
@@ -1852,7 +1873,23 @@ export class HlsSessionManager {
1852
1873
  const sessionDir = createSessionDirPath(sessionId);
1853
1874
  const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
1854
1875
  inputUrl.searchParams.set("sourceKey", sourceKey);
1855
- inputUrl.searchParams.set("fileIndex", String(fileIndex));
1876
+ // A soundtrack shipped as its own file is encoded FROM that file, and an
1877
+ // audio rendition carries nothing else — so it reads the sidecar directly and
1878
+ // needs no second input at all. The muxed case, where a browser takes its
1879
+ // audio inside the picture's own stream, is the one that reads two files; its
1880
+ // second input is `audioInputUrl` below.
1881
+ const readsSidecarAlone = audioOnly === true && audioSource.isSidecar;
1882
+ inputUrl.searchParams.set(
1883
+ "fileIndex",
1884
+ String(readsSidecarAlone ? audioSource.fileIndex : fileIndex)
1885
+ );
1886
+ // The second input, for a muxed session whose sound comes from another file.
1887
+ let audioInputUrl = null;
1888
+ if (!readsSidecarAlone && audioSource.isSidecar) {
1889
+ audioInputUrl = new URL("/stream", `${this.localBaseUrl}/`);
1890
+ audioInputUrl.searchParams.set("sourceKey", sourceKey);
1891
+ audioInputUrl.searchParams.set("fileIndex", String(audioSource.fileIndex));
1892
+ }
1856
1893
 
1857
1894
  // Media info (duration/resolution/fps/startTime/HDR) up front, so we can
1858
1895
  // serve a complete VOD playlist (#EXT-X-ENDLIST) with the correct total
@@ -1871,15 +1908,40 @@ export class HlsSessionManager {
1871
1908
  cachedMediaInfo.width > 0 &&
1872
1909
  Number.isFinite(cachedMediaInfo.height) &&
1873
1910
  cachedMediaInfo.height > 0;
1911
+ // Always the PICTURE's, even when this session reads a soundtrack from
1912
+ // another file: the timeline, the duration and the cut grid are the
1913
+ // picture's, and a rendition exists to be played WITH it. Only where the
1914
+ // sidecar's own timeline begins is read from the sidecar, just below.
1915
+ const pictureUrl = readsSidecarAlone
1916
+ ? (() => {
1917
+ const url = new URL("/stream", `${this.localBaseUrl}/`);
1918
+ url.searchParams.set("sourceKey", sourceKey);
1919
+ url.searchParams.set("fileIndex", String(fileIndex));
1920
+ return url;
1921
+ })()
1922
+ : inputUrl;
1874
1923
  const mediaInfo = cachedUsable
1875
1924
  ? cachedMediaInfo
1876
- : await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
1925
+ : await probeInputMediaInfo(this.ffmpegBin, pictureUrl.toString());
1877
1926
  const mediaInfoMs = Date.now() - mediaInfoStartMs;
1878
1927
  const mediaInfoSource = cachedUsable ? "cached" : "probed";
1879
1928
  const durationSeconds = mediaInfo.durationSeconds;
1880
1929
  const sourceWidth = mediaInfo.width;
1881
1930
  const sourceHeight = mediaInfo.height;
1882
1931
  const sourceStartTime = Number.isFinite(mediaInfo.startTime) ? mediaInfo.startTime : 0;
1932
+ // Where the timeline of the file this session actually READS begins.
1933
+ //
1934
+ // For every session until now that was the picture's own file, so one figure
1935
+ // served both purposes. A soundtrack shipped separately has a start time of
1936
+ // its own, and it is the one that must be subtracted when the output is
1937
+ // relabelled onto a zero-based timeline: subtract the picture's instead and
1938
+ // the sound sits at a fixed offset from it for the whole film. Measured from
1939
+ // the file rather than assumed to be zero, because assuming it is exactly
1940
+ // the fault being avoided.
1941
+ const audioFileStartTime = audioSource.isSidecar
1942
+ ? await this.#sidecarStartTimeSeconds(sourceKey, audioSource.fileIndex)
1943
+ : sourceStartTime;
1944
+ const inputStartTime = readsSidecarAlone ? audioFileStartTime : sourceStartTime;
1883
1945
  // Tone-map an HDR source to SDR only when re-encoding video on the software
1884
1946
  // path and this ffmpeg has the filters. Hardware encoders keep their own
1885
1947
  // (untone-mapped) path for now; when unavailable, HDR falls back to a plain
@@ -1900,7 +1962,17 @@ export class HlsSessionManager {
1900
1962
  // the file's own average byte rate, which is size ÷ duration; the size
1901
1963
  // comes from the same stats call the realtime budget uses. Best effort —
1902
1964
  // without it the reader keeps its own byte default.
1903
- const readWindowBytes = await this.#readWindowBytesFor(sourceKey, fileIndex, durationSeconds);
1965
+ //
1966
+ // Sized for the file being READ, which for a soundtrack shipped separately
1967
+ // is that file: it is a twentieth of the picture's size over the same
1968
+ // duration, so the picture's byte rate would buy a window twenty times
1969
+ // wider than the seconds it is meant to represent, and the piece store
1970
+ // would hold it.
1971
+ const readWindowBytes = await this.#readWindowBytesFor(
1972
+ sourceKey,
1973
+ readsSidecarAlone ? audioSource.fileIndex : fileIndex,
1974
+ durationSeconds
1975
+ );
1904
1976
  if (readWindowBytes > 0) {
1905
1977
  inputUrl.searchParams.set("windowBytes", String(readWindowBytes));
1906
1978
  // This read, and only this read, follows the viewer. The codec probe and
@@ -2169,6 +2241,22 @@ export class HlsSessionManager {
2169
2241
  transcodeVideo,
2170
2242
  transcodeAudio,
2171
2243
  audioTrackIndex: normalizedAudioTrack,
2244
+ // Where that soundtrack actually is. The number above is flat across the
2245
+ // picture's own tracks and the files beside it, and these two are what it
2246
+ // resolves to: which file, and which `0:a:N` inside that file. Equal to
2247
+ // `fileIndex` and to the flat number for an ordinary embedded track, which
2248
+ // is what every session was before soundtracks in their own files existed.
2249
+ audioFileIndex: audioSource.fileIndex,
2250
+ audioSourceTrackIndex: audioSource.sourceTrackIndex,
2251
+ // The second input, present only for a muxed session whose sound is in
2252
+ // another file. An audio rendition reads its sidecar as its only input, so
2253
+ // it has none.
2254
+ audioInputUrl: audioInputUrl ? audioInputUrl.toString() : "",
2255
+ // Where the timeline of the file being READ begins, against the picture's
2256
+ // own `sourceStartTime` below. The two differ only when the sound comes
2257
+ // from a separate file.
2258
+ inputStartTime,
2259
+ audioFileStartTime,
2172
2260
  // What this session's output carries. `audioOnly` is a rendition — one
2173
2261
  // audio track, no picture; `videoOnly` is a stream whose audio the viewer
2174
2262
  // takes from such a rendition. Neither is set on the ordinary muxed
@@ -2373,11 +2461,34 @@ export class HlsSessionManager {
2373
2461
  // torrent's data alive exactly as this one does.
2374
2462
  session.acquireSource = typeof acquireSource === "function" ? acquireSource : null;
2375
2463
  if (typeof acquireSource === "function") {
2376
- try {
2377
- session.releaseSource = acquireSource();
2378
- } catch {
2379
- session.releaseSource = null;
2380
- }
2464
+ // One claim per file this session READS. Almost always that is one file;
2465
+ // a muxed session whose soundtrack ships beside the picture reads two, and
2466
+ // holding only the picture would leave the sound to be swept off the disk
2467
+ // from under a running encoder.
2468
+ const claim = (heldFileIndex) => {
2469
+ try {
2470
+ const release = acquireSource(heldFileIndex);
2471
+ return typeof release === "function" ? release : null;
2472
+ } catch {
2473
+ return null;
2474
+ }
2475
+ };
2476
+ const releases = [claim(undefined)];
2477
+ if (audioInputUrl) {
2478
+ releases.push(claim(audioSource.fileIndex));
2479
+ }
2480
+ const held = releases.filter((release) => typeof release === "function");
2481
+ session.releaseSource = held.length > 0
2482
+ ? () => {
2483
+ for (const release of held) {
2484
+ try {
2485
+ release();
2486
+ } catch {
2487
+ // Best effort — a session must always finish being disposed.
2488
+ }
2489
+ }
2490
+ }
2491
+ : null;
2381
2492
  }
2382
2493
  this.sessionsById.set(sessionId, session);
2383
2494
  this.sessionIdBySource.set(sourceMapKey, sessionId);
@@ -2540,9 +2651,17 @@ export class HlsSessionManager {
2540
2651
  // declare — its warning about a short header would then fire on every one.
2541
2652
  const carriesVideo = session.audioOnly !== true;
2542
2653
  const carriesAudio = !this.#servesAudioSeparately(session);
2654
+ // The soundtrack of this session may not be in the file that was probed. A
2655
+ // release that ships its dub as a separate file often ships the picture with
2656
+ // no sound of its own at all, and then the picture's probe says there is no
2657
+ // audio while the output plainly carries some — which would leave the header
2658
+ // check expecting one track where two arrive, and tell the browser its sound
2659
+ // was lost.
2660
+ const audioFromAnotherFile =
2661
+ Number.isInteger(session.audioFileIndex) && session.audioFileIndex !== session.fileIndex;
2543
2662
  return {
2544
2663
  video: carriesVideo && Boolean(probed?.videoCodec),
2545
- audio: carriesAudio && Boolean(probed?.audioCodec)
2664
+ audio: carriesAudio && (audioFromAnotherFile || Boolean(probed?.audioCodec))
2546
2665
  };
2547
2666
  }
2548
2667
 
@@ -3476,6 +3595,82 @@ export class HlsSessionManager {
3476
3595
  `${viewers} viewer(s) holding up to ` +
3477
3596
  `${deepestBuffer === null ? "?" : deepestBuffer.toFixed(1)}s`
3478
3597
  );
3598
+ this.#fetchSpareSoundtracks(session, aheadOfPicture);
3599
+ }
3600
+
3601
+ /**
3602
+ * Fetch the soundtracks that ship beside this picture, whole, while the swarm
3603
+ * has capacity to spare.
3604
+ *
3605
+ * WHY IT WAITS FOR THE CUSHION. A soundtrack nobody has chosen is worth having
3606
+ * on disk — it is a twentieth of the picture (30 MB against 566 MB on the
3607
+ * field torrent) and having it makes every later switch instant instead of
3608
+ * paying for its first pieces. But fetching it takes swarm capacity from the
3609
+ * picture, and there is exactly one moment when that capacity is demonstrably
3610
+ * spare: when the encoder is already as far ahead of the viewer as it is
3611
+ * allowed to get. That is not a guess about the swarm — it is the measurement
3612
+ * the line above just printed.
3613
+ *
3614
+ * WHY IT IS A READ AND NOT A SELECTION. `file.select()` claims every piece of
3615
+ * a file at once, and `#syncSelections` in `torrent-pool.js` records what that
3616
+ * cost when it was done alongside the readers' own windows: a claim covering
3617
+ * everything always outranked the window, and a seek to 89.1% of a 4.7 GB film
3618
+ * waited 93 s while the swarm fetched 2.47 GB in file order. So this goes
3619
+ * through the same bounded read the edge warm-up uses, which claims a moving
3620
+ * window like any other reader and gives it back when it ends.
3621
+ *
3622
+ * Once per file, and only for a soundtrack in a file of its own — the
3623
+ * picture's own tracks are already in the bytes being played.
3624
+ *
3625
+ * @param {HlsSession} session
3626
+ * @param {number} aheadOfPicture - Seconds of film ready ahead of the viewer.
3627
+ * @returns {void}
3628
+ */
3629
+ #fetchSpareSoundtracks(session, aheadOfPicture) {
3630
+ if (typeof this.fetchWholeFile !== "function") {
3631
+ return;
3632
+ }
3633
+ // The encoder is held at this distance and no further, so reaching it is the
3634
+ // signal that nothing more is being asked of the swarm on the picture's
3635
+ // behalf.
3636
+ if (!(aheadOfPicture >= this.lookaheadSeconds)) {
3637
+ return;
3638
+ }
3639
+ const inventory = this.getCachedAudioTracks?.({
3640
+ sourceKey: session.sourceKey,
3641
+ fileIndex: session.fileIndex
3642
+ }) ?? [];
3643
+ if (!(this.spareSoundtracksFetched instanceof Set)) {
3644
+ this.spareSoundtracksFetched = new Set();
3645
+ }
3646
+ const wanted = new Set(
3647
+ inventory
3648
+ .filter((entry) => entry?.kind === "sidecar" && Number.isInteger(entry.fileIndex))
3649
+ .map((entry) => entry.fileIndex)
3650
+ );
3651
+ for (const fileIndex of wanted) {
3652
+ const key = `${session.sourceKey}:${fileIndex}`;
3653
+ if (this.spareSoundtracksFetched.has(key)) {
3654
+ continue;
3655
+ }
3656
+ this.spareSoundtracksFetched.add(key);
3657
+ logger.info(
3658
+ `transcode ${session.id.slice(0, 8)} the picture is ${Math.round(aheadOfPicture)}s ahead of ` +
3659
+ `the viewer, so file ${fileIndex} — a soundtrack beside it — is fetched whole now; ` +
3660
+ "a switch to it will not wait for the swarm"
3661
+ );
3662
+ // Not awaited: nothing depends on it finishing, and a failure costs only
3663
+ // that the switch pays for its own pieces, as it did before this existed.
3664
+ Promise.resolve(this.fetchWholeFile({ sourceKey: session.sourceKey, fileIndex })).catch(
3665
+ (error) => {
3666
+ logger.info(
3667
+ `transcode: fetching soundtrack file ${fileIndex} whole failed ` +
3668
+ `(${error instanceof Error ? error.message : String(error)}) — ` +
3669
+ "it will be read when it is played"
3670
+ );
3671
+ }
3672
+ );
3673
+ }
3479
3674
  }
3480
3675
 
3481
3676
  /**
@@ -4821,7 +5016,11 @@ export class HlsSessionManager {
4821
5016
  const startSeconds = Number.isFinite(positionSecondsOverride)
4822
5017
  ? positionSecondsOverride
4823
5018
  : this.runStartTimeFor(session, safeIndex);
4824
- const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
5019
+ // The start time of the file this run READS, which is the picture's own for
5020
+ // every session except one whose soundtrack is a separate file.
5021
+ const sourceStartTime = Number.isFinite(session.inputStartTime)
5022
+ ? session.inputStartTime
5023
+ : (Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0);
4825
5024
  // Cut where this session's grid says, whoever is producing the frames. The
4826
5025
  // times are measured from the start of THIS run; the same list serves as
4827
5026
  // the cut points and, when re-encoding, as the keyframes to force — one
@@ -4946,12 +5145,69 @@ export class HlsSessionManager {
4946
5145
  const snappedKeyframe = Array.isArray(session.keyframeTimes) && session.keyframeTimes.length > 0
4947
5146
  ? nearestKeyframeAtOrBefore(session.keyframeTimes, seekSeconds)
4948
5147
  : null;
5148
+ // A second input, and it exists for exactly one case: a browser that takes
5149
+ // its audio muxed into the picture, watching a release whose soundtrack is a
5150
+ // file of its own. An audio RENDITION reads that file as its only input and
5151
+ // has none of this — which is why the ordinary path, and every browser that
5152
+ // understands rendition groups, still runs on a single input.
5153
+ const audioInputUrl =
5154
+ typeof session.audioInputUrl === "string" && session.audioInputUrl.length > 0
5155
+ ? session.audioInputUrl
5156
+ : "";
5157
+ // Where the picture's own start sits on the soundtrack file's timeline. Both
5158
+ // files begin at their own container start time, and those need not be the
5159
+ // same number; the difference is what keeps the two aligned.
5160
+ const audioTimelineShift = audioInputUrl
5161
+ ? (Number.isFinite(session.audioFileStartTime) ? session.audioFileStartTime : 0) - sourceStartTime
5162
+ : 0;
5163
+ /**
5164
+ * Add the second input, if there is one, with its own seek.
5165
+ *
5166
+ * Called between the first `-i` and any OUTPUT option, because ffmpeg reads
5167
+ * these positionally: an option written after the last `-i` applies to the
5168
+ * output, and the residual seek below is exactly such an option. Getting the
5169
+ * order wrong would silently turn the audio file's seek into a trim of the
5170
+ * finished stream.
5171
+ *
5172
+ * @param {number} inputSeekSeconds - Where to start, on the PICTURE's
5173
+ * timeline. Translated to the soundtrack file's own here.
5174
+ */
5175
+ const pushAudioInput = (inputSeekSeconds) => {
5176
+ if (!audioInputUrl) {
5177
+ return;
5178
+ }
5179
+ // `-itsoffset` states the soundtrack's timestamps on the picture's
5180
+ // timeline, so everything after this point — `-copyts`, the output offset,
5181
+ // the cut list — goes on treating the two as one timeline, unchanged.
5182
+ //
5183
+ // ONLY on the branch that keeps the source's own timestamps. Without
5184
+ // `-copyts` ffmpeg rebases each input from its own seek point, and both
5185
+ // inputs are seeked to the same instant just below — so the two are
5186
+ // already aligned and adding the offset would pull them apart by exactly
5187
+ // the amount it exists to remove.
5188
+ if (audioTimelineShift !== 0 && onKeyframeGridFor(session)) {
5189
+ args.push("-itsoffset", ffmpegSeconds(-audioTimelineShift));
5190
+ }
5191
+ const audioSeek = Math.max(0, inputSeekSeconds + audioTimelineShift);
5192
+ if (audioSeek > 0) {
5193
+ // No keyframe to snap to and none needed: every audio frame is a sync
5194
+ // point, so the seek can be accurate outright.
5195
+ args.push("-accurate_seek", "-ss", ffmpegSeconds(audioSeek));
5196
+ }
5197
+ args.push("-i", audioInputUrl);
5198
+ };
5199
+
4949
5200
  if (snappedKeyframe !== null) {
4950
5201
  const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
4951
5202
  if (snappedKeyframe > 0) {
4952
5203
  args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor(session, snappedKeyframe)));
4953
5204
  }
4954
5205
  args.push("-i", session.inputUrl);
5206
+ // The coarse landing, not the exact target: the residual below is discarded
5207
+ // from the OUTPUT and so takes the same slice off every stream. Seeking the
5208
+ // soundtrack to the exact target as well would take that slice twice and
5209
+ // leave the sound running ahead of the picture by it.
5210
+ pushAudioInput(snappedKeyframe);
4955
5211
  if (residualSeconds > 0) {
4956
5212
  args.push("-ss", ffmpegSeconds(residualSeconds));
4957
5213
  }
@@ -4962,6 +5218,7 @@ export class HlsSessionManager {
4962
5218
  args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
4963
5219
  }
4964
5220
  args.push("-i", session.inputUrl);
5221
+ pushAudioInput(seekSeconds);
4965
5222
  }
4966
5223
  // Which timeline the output is labelled on. An audio rendition has no
4967
5224
  // picture of its own to follow, so it follows the grid it was given — the
@@ -4998,7 +5255,9 @@ export class HlsSessionManager {
4998
5255
  // the player switching rendition rather than this proxy rebuilding the
4999
5256
  // session. Cut on the same grid as the video it accompanies, which is
5000
5257
  // what lets the two be played together.
5001
- args.push("-vn", "-map", `0:a:${session.audioTrackIndex ?? 0}?`, ...audioCodecArgs);
5258
+ // `0:` because a rendition's only input IS the file its track lives in —
5259
+ // the picture's own file, or the one beside it that carries this dub.
5260
+ args.push("-vn", "-map", `0:a:${session.audioSourceTrackIndex ?? session.audioTrackIndex ?? 0}?`, ...audioCodecArgs);
5002
5261
  } else if (this.#servesAudioSeparately(session)) {
5003
5262
  // The other half of the same arrangement: the picture alone, because its
5004
5263
  // audio is published as a rendition and would otherwise play twice.
@@ -5008,8 +5267,12 @@ export class HlsSessionManager {
5008
5267
  "-map",
5009
5268
  "0:v:0?",
5010
5269
  "-map",
5011
- // Type-relative audio track chosen by the viewer (default 0).
5012
- `0:a:${session.audioTrackIndex ?? 0}?`,
5270
+ // The audio track the viewer chose: input 1 when their choice is a
5271
+ // soundtrack shipped as its own file, input 0 when it is one of the
5272
+ // picture's own. Type-relative within that input, which is what
5273
+ // `audioSourceTrackIndex` holds — the number the browser sent is flat
5274
+ // across both files and was resolved when the session was made.
5275
+ `${audioInputUrl ? 1 : 0}:a:${session.audioSourceTrackIndex ?? session.audioTrackIndex ?? 0}?`,
5013
5276
  ...videoCodecArgs,
5014
5277
  ...audioCodecArgs
5015
5278
  );
@@ -5419,12 +5682,18 @@ export class HlsSessionManager {
5419
5682
  */
5420
5683
  #describeTrackSelection(session) {
5421
5684
  const wanted = [];
5685
+ // Named exactly as the command line names them, second input included: a
5686
+ // refusal whose message describes a different mapping than the one that was
5687
+ // refused is the reading that cost a wrong diagnosis before.
5688
+ const audioInput =
5689
+ typeof session.audioInputUrl === "string" && session.audioInputUrl.length > 0 ? 1 : 0;
5690
+ const audioTrack = session.audioSourceTrackIndex ?? session.audioTrackIndex ?? 0;
5422
5691
  if (session.audioOnly === true) {
5423
- wanted.push(`audio 0:a:${session.audioTrackIndex ?? 0}`);
5692
+ wanted.push(`audio 0:a:${audioTrack}`);
5424
5693
  } else if (this.#servesAudioSeparately(session)) {
5425
5694
  wanted.push("video 0:v:0");
5426
5695
  } else {
5427
- wanted.push("video 0:v:0", `audio 0:a:${session.audioTrackIndex ?? 0}`);
5696
+ wanted.push("video 0:v:0", `audio ${audioInput}:a:${audioTrack}`);
5428
5697
  }
5429
5698
  const counts = session.sourceStreamCounts;
5430
5699
  const held = counts
@@ -8721,7 +8990,14 @@ export class HlsSessionManager {
8721
8990
  containerFormat: base.containerFormat
8722
8991
  }
8723
8992
  : null,
8724
- acquireSource: base.acquireSource
8993
+ // Hold the file this rendition will READ. For a soundtrack shipped beside
8994
+ // the picture that is a different file of the same torrent, and nothing
8995
+ // else claims it: the base holds the picture, and the disk sweep deletes
8996
+ // what nobody is holding — which is how a film being watched was deleted
8997
+ // on 2026-08-06.
8998
+ acquireSource: () => base.acquireSource?.(
8999
+ this.#resolveAudioSource(base.sourceKey, base.fileIndex, trackIndex).fileIndex
9000
+ )
8725
9001
  });
8726
9002
  if (!rendition) {
8727
9003
  return null;
@@ -8733,6 +9009,82 @@ export class HlsSessionManager {
8733
9009
  return rendition;
8734
9010
  }
8735
9011
 
9012
+ /**
9013
+ * Where one numbered soundtrack actually is: which file of the torrent, and
9014
+ * which `0:a:N` inside it.
9015
+ *
9016
+ * The number is flat across the picture's own tracks and every soundtrack
9017
+ * shipped as a file beside it, so that the browser's menu, the
9018
+ * `audioTrackIndex` on a session request and the `a/<n>/` path a rendition is
9019
+ * published at all mean the same thing. This resolves it, once, from the
9020
+ * inventory the playback plan built — the very list the menu was drawn from,
9021
+ * so the two cannot disagree about what a number means.
9022
+ *
9023
+ * A number the inventory does not describe resolves to the picture's own file
9024
+ * at that index, which is exactly what every session did before soundtracks in
9025
+ * their own files existed: a plan cached by an older build carries no
9026
+ * inventory, and a session created against it must keep working.
9027
+ *
9028
+ * @param {string} sourceKey
9029
+ * @param {number} fileIndex - The PICTURE's file.
9030
+ * @param {number} flatIndex
9031
+ * @returns {{ fileIndex: number, sourceTrackIndex: number, isSidecar: boolean, name: string }}
9032
+ */
9033
+ #resolveAudioSource(sourceKey, fileIndex, flatIndex) {
9034
+ const inventory = this.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
9035
+ const entry = Array.isArray(inventory)
9036
+ ? inventory.find((candidate) => candidate?.index === flatIndex)
9037
+ : null;
9038
+ if (!entry || !Number.isInteger(entry.fileIndex) || !Number.isInteger(entry.sourceTrackIndex)) {
9039
+ return { fileIndex, sourceTrackIndex: flatIndex, isSidecar: false, name: "" };
9040
+ }
9041
+ return {
9042
+ fileIndex: entry.fileIndex,
9043
+ sourceTrackIndex: entry.sourceTrackIndex,
9044
+ isSidecar: entry.fileIndex !== fileIndex,
9045
+ name: typeof entry.fileName === "string" ? entry.fileName : ""
9046
+ };
9047
+ }
9048
+
9049
+ /**
9050
+ * Where a sidecar soundtrack's own timeline begins, in seconds.
9051
+ *
9052
+ * One short probe of that file, kept for as long as the process lives: a
9053
+ * container's start time is a property of the file and cannot change. It is
9054
+ * asked only when such a track is actually chosen, so a torrent whose extra
9055
+ * soundtracks nobody plays costs nothing for them.
9056
+ *
9057
+ * @param {string} sourceKey
9058
+ * @param {number} fileIndex - The SIDECAR's file.
9059
+ * @returns {Promise<number>}
9060
+ */
9061
+ async #sidecarStartTimeSeconds(sourceKey, fileIndex) {
9062
+ if (!(this.sidecarStartTimes instanceof Map)) {
9063
+ this.sidecarStartTimes = new Map();
9064
+ }
9065
+ const key = `${sourceKey}:${fileIndex}`;
9066
+ const held = this.sidecarStartTimes.get(key);
9067
+ if (Number.isFinite(held)) {
9068
+ return held;
9069
+ }
9070
+ const url = new URL("/stream", `${this.localBaseUrl}/`);
9071
+ url.searchParams.set("sourceKey", sourceKey);
9072
+ url.searchParams.set("fileIndex", String(fileIndex));
9073
+ let startTime = 0;
9074
+ try {
9075
+ const info = await probeInputMediaInfo(this.ffmpegBin, url.toString(), { expectVideo: false });
9076
+ startTime = Number.isFinite(info?.startTime) ? info.startTime : 0;
9077
+ } catch (error) {
9078
+ logger.info(
9079
+ `transcode: the start time of soundtrack file ${fileIndex} could not be probed ` +
9080
+ `(${error instanceof Error ? error.message : String(error)}) — taken as 0`
9081
+ );
9082
+ startTime = 0;
9083
+ }
9084
+ this.sidecarStartTimes.set(key, startTime);
9085
+ return startTime;
9086
+ }
9087
+
8736
9088
  /**
8737
9089
  * The audio tracks of this session's file, as renditions for the master.
8738
9090
  *
@@ -8748,20 +9100,40 @@ export class HlsSessionManager {
8748
9100
  #audioRenditionsOf(session) {
8749
9101
  const tracks = this.getCachedAudioTracks?.({
8750
9102
  sourceKey: session.sourceKey,
9103
+ // The PICTURE's file, which is what `fileIndex` is on every session of a
9104
+ // family — a rendition is created with its base's, and only its
9105
+ // `audioFileIndex` points at the file its sound comes from. The inventory
9106
+ // is keyed on the picture and spans the soundtracks beside it.
8751
9107
  fileIndex: session.fileIndex
8752
9108
  }) ?? [];
8753
9109
  if (!Array.isArray(tracks) || tracks.length === 0) {
8754
9110
  return [];
8755
9111
  }
8756
9112
  const chosen = Number(session.audioTrackIndex) || 0;
8757
- return tracks.map((track, order) => {
8758
- const language = typeof track?.language === "string" ? track.language : "";
8759
- const title = typeof track?.title === "string" && track.title.length > 0 ? track.title : "";
9113
+ // One line per entry of the inventory, in its order and without omissions —
9114
+ // including a track the container marks unusable. The player addresses a
9115
+ // rendition by its POSITION in this list, and the browser addresses it by
9116
+ // the number the inventory gave it; leaving anything out would make those two
9117
+ // disagree from that point on. A track the file says not to offer is kept out
9118
+ // of the VIEWER's menu, which is the browser's own business and does not
9119
+ // touch the numbering.
9120
+ return tracks.map((entry, order) => {
9121
+ const index = Number.isInteger(entry?.index) ? entry.index : order;
9122
+ const language = typeof entry?.languageBcp47 === "string" && entry.languageBcp47.length > 0
9123
+ ? entry.languageBcp47
9124
+ : (typeof entry?.language === "string" ? entry.language : "");
8760
9125
  return {
8761
- trackIndex: order,
8762
- name: title || language || `Track ${order + 1}`,
9126
+ trackIndex: index,
9127
+ name: audioRenditionName(
9128
+ { ...entry, index, folders: Array.isArray(entry?.folders) ? entry.folders : [] },
9129
+ tracks
9130
+ ),
9131
+ // Only what the container itself states. What a folder name suggests
9132
+ // about a language is derived in the browser, where the language table
9133
+ // and the viewer's own locale already are; writing a guess into
9134
+ // `LANGUAGE` would put it in a playlist as though the file had said it.
8763
9135
  language,
8764
- isDefault: order === chosen
9136
+ isDefault: index === chosen
8765
9137
  };
8766
9138
  });
8767
9139
  }