@torrent-tv/proxy 2.64.8 → 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.
@@ -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
- inputUrl.searchParams.set("fileIndex", String(fileIndex));
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, inputUrl.toString());
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
- const readWindowBytes = await this.#readWindowBytesFor(sourceKey, fileIndex, durationSeconds);
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
- try {
2377
- session.releaseSource = acquireSource();
2378
- } catch {
2379
- session.releaseSource = null;
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
- const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
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
- args.push("-vn", "-map", `0:a:${session.audioTrackIndex ?? 0}?`, ...audioCodecArgs);
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
- // Type-relative audio track chosen by the viewer (default 0).
5012
- `0:a:${session.audioTrackIndex ?? 0}?`,
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:${session.audioTrackIndex ?? 0}`);
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 0:a:${session.audioTrackIndex ?? 0}`);
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
- acquireSource: base.acquireSource
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
- 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 : "";
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: order,
8762
- name: title || language || `Track ${order + 1}`,
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: order === chosen
9053
+ isDefault: index === chosen
8765
9054
  };
8766
9055
  });
8767
9056
  }
@@ -101,6 +101,98 @@ export async function readAvailableMemory() {
101
101
  return null;
102
102
  }
103
103
 
104
+ /**
105
+ * Anonymous memory grouped by the SHAPE of the mappings holding it.
106
+ *
107
+ * The rollup says how much there is; this says what it looks like, and the
108
+ * three shapes it can take are three different diagnoses of the same number:
109
+ *
110
+ * - **one growing `[heap]`** — the allocator's break-managed arena. Freed
111
+ * blocks stay in it, and on musl there is no `malloc_trim` to ask for them
112
+ * back. Nothing above the allocator is holding anything.
113
+ * - **many large anonymous mappings** — one per big allocation, which is what
114
+ * a 4 MiB piece buffer is. If their count tracks the pieces the store says
115
+ * it holds, the memory is accounted for; if it keeps climbing while the
116
+ * store's count does not, the buffers are being kept alive by somebody.
117
+ * - **many medium ones** — the allocator's own per-thread arenas, taken and
118
+ * not returned.
119
+ *
120
+ * The field failure of 2026-08-31 is 700 MB that is none of the JavaScript
121
+ * heaps, none of the piece store, and none of ffmpeg. Which of the three
122
+ * shapes it has decides what to change, and no reading so far can tell them
123
+ * apart (roadmap item 2, step 4).
124
+ *
125
+ * @param {string} text - The contents of `/proc/self/smaps`.
126
+ * @returns {{ heapBytes: number, largeBytes: number, largeCount: number,
127
+ * largestBytes: number, smallBytes: number, smallCount: number,
128
+ * fileBytes: number }}
129
+ */
130
+ export function summariseMappings(text) {
131
+ const summary = {
132
+ heapBytes: 0,
133
+ largeBytes: 0,
134
+ largeCount: 0,
135
+ largestBytes: 0,
136
+ smallBytes: 0,
137
+ smallCount: 0,
138
+ fileBytes: 0
139
+ };
140
+ // A mapping is a header line followed by its fields; only `Rss` is wanted,
141
+ // because a mapping that is reserved and untouched costs no memory.
142
+ let pathName = null;
143
+ for (const line of String(text ?? "").split("\n")) {
144
+ const header = /^[0-9a-f]+-[0-9a-f]+ \S{4} [0-9a-f]+ \S+ \d+\s*(.*)$/.exec(line);
145
+ if (header) {
146
+ pathName = header[1].trim();
147
+ continue;
148
+ }
149
+ const rss = /^Rss:\s+(\d+)\s+kB$/.exec(line);
150
+ if (!rss || pathName === null) {
151
+ continue;
152
+ }
153
+ const bytes = Number(rss[1]) * 1024;
154
+ if (bytes === 0) {
155
+ continue;
156
+ }
157
+ if (pathName === "[heap]") {
158
+ summary.heapBytes += bytes;
159
+ } else if (pathName !== "" && !pathName.startsWith("[")) {
160
+ // Backed by a file: the executable, the libraries, anything mapped in.
161
+ // Counted so the anonymous figures can be checked against `rss`.
162
+ summary.fileBytes += bytes;
163
+ } else if (bytes >= LARGE_MAPPING_BYTES) {
164
+ summary.largeBytes += bytes;
165
+ summary.largeCount += 1;
166
+ summary.largestBytes = Math.max(summary.largestBytes, bytes);
167
+ } else {
168
+ summary.smallBytes += bytes;
169
+ summary.smallCount += 1;
170
+ }
171
+ }
172
+ return summary;
173
+ }
174
+
175
+ /**
176
+ * Where "large" begins. Two megabytes, so a 4 MiB piece buffer is always large
177
+ * and an allocator's ordinary arena is not.
178
+ */
179
+ const LARGE_MAPPING_BYTES = 2 * 1024 * 1024;
180
+
181
+ /**
182
+ * The mapping summary for this process, or null where /proc is not there.
183
+ *
184
+ * @returns {Promise<ReturnType<typeof summariseMappings> | null>}
185
+ */
186
+ export async function readMappingSummary() {
187
+ try {
188
+ return summariseMappings(await readFile("/proc/self/smaps", "utf8"));
189
+ } catch {
190
+ // silent-ok: not Linux, or the kernel does not publish it. The line leaves
191
+ // the term out rather than printing a worse one.
192
+ }
193
+ return null;
194
+ }
195
+
104
196
  /**
105
197
  * Available memory, falling back to what the runtime can offer.
106
198
  *
@@ -196,6 +288,7 @@ function megabytes(bytes) {
196
288
  * @param {number} [reading.availableBytes]
197
289
  * @param {boolean} [reading.availableMeasured]
198
290
  * @param {number | null} [reading.anonymousBytes]
291
+ * @param {ReturnType<typeof summariseMappings> | null} [reading.mappings]
199
292
  * @param {number | null} [reading.diskFreeBytes]
200
293
  * @param {{ name: string, residentBytes: number, committedBytes: number, spilledBytes: number, budgetBytes: number }[]} [reading.stores]
201
294
  * @returns {string}
@@ -207,6 +300,7 @@ export function describeMemory({
207
300
  availableBytes,
208
301
  availableMeasured,
209
302
  anonymousBytes = null,
303
+ mappings = null,
210
304
  diskFreeBytes = null,
211
305
  stores = []
212
306
  }) {
@@ -230,9 +324,16 @@ export function describeMemory({
230
324
  if (scope === "thread") {
231
325
  return `memory (${label || "thread"}): ${isolate}; ${storesPart}`;
232
326
  }
327
+ const shape = mappings === null
328
+ ? ""
329
+ : ` mappings=[heap ${megabytes(mappings.heapBytes)}, ` +
330
+ `${mappings.largeCount} anon ≥2MB = ${megabytes(mappings.largeBytes)} ` +
331
+ `(largest ${megabytes(mappings.largestBytes)}), ` +
332
+ `${mappings.smallCount} anon <2MB = ${megabytes(mappings.smallBytes)}, ` +
333
+ `files ${megabytes(mappings.fileBytes)}]`;
233
334
  return (
234
335
  `memory: rss=${megabytes(usage.rss)} ${isolate}` +
235
- `${anonymousBytes === null ? "" : ` anon=${megabytes(anonymousBytes)}`}; ` +
336
+ `${anonymousBytes === null ? "" : ` anon=${megabytes(anonymousBytes)}`}${shape}; ` +
236
337
  `${storesPart}; ` +
237
338
  `machine has ${megabytes(availableBytes ?? 0)} available` +
238
339
  `${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}` +
@@ -397,6 +498,10 @@ export function startMemoryReport({
397
498
  availableBytes: bytes,
398
499
  availableMeasured: measured,
399
500
  anonymousBytes,
501
+ // Read only when the line is written: `smaps` is one entry per
502
+ // mapping and a busy process has thousands, which is a different
503
+ // cost from the rollup's single line.
504
+ mappings: await readMappingSummary(),
400
505
  diskFreeBytes,
401
506
  stores
402
507
  }));