@torrent-tv/proxy 2.13.0 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -75,6 +75,47 @@ const MASTER_PLAYLIST_FILE_NAME = "master.m3u8";
75
75
  // directory level, so every relative name inside a variant's own playlist — its
76
76
  // segments and its init — resolves to that variant without any of them changing.
77
77
  const VARIANT_PATH_PREFIX = "v";
78
+ // Where an audio rendition lives, and the name the variants refer to it by. One
79
+ // directory level under the base session, exactly as a quality variant is, so
80
+ // every relative name inside its playlist resolves to it unchanged.
81
+ const AUDIO_PATH_PREFIX = "a";
82
+ const AUDIO_GROUP_ID = "aud";
83
+
84
+ /**
85
+ * Quote a value for an HLS attribute list. Only the quote itself can end the
86
+ * attribute early, and a track title comes from the file, so it is not ours to
87
+ * trust.
88
+ *
89
+ * @param {string} value
90
+ * @returns {string}
91
+ */
92
+ function escapeAttribute(value) {
93
+ // The quote would end the attribute early; a line break would end the LINE,
94
+ // splitting one `#EXT-X-MEDIA` into two and corrupting the master. Both come
95
+ // from the file's own metadata, which is not ours to trust.
96
+ return String(value ?? "").replace(/"/g, "'").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
97
+ }
98
+
99
+ // ISO 639-2 codes as ffmpeg reports them, against the RFC 5646 tags HLS asks
100
+ // for. Only the languages this serves in practice; anything else is passed
101
+ // through, which is what players other than iOS accept anyway.
102
+ const LANGUAGE_TAGS = new Map([
103
+ ["rus", "ru"], ["eng", "en"], ["ukr", "uk"], ["deu", "de"], ["ger", "de"],
104
+ ["fra", "fr"], ["fre", "fr"], ["spa", "es"], ["ita", "it"], ["jpn", "ja"],
105
+ ["kor", "ko"], ["zho", "zh"], ["chi", "zh"], ["pol", "pl"], ["por", "pt"],
106
+ ["tur", "tr"], ["ces", "cs"], ["cze", "cs"], ["nld", "nl"], ["dut", "nl"]
107
+ ]);
108
+
109
+ /**
110
+ * The RFC 5646 tag for a language ffmpeg named, or the name unchanged.
111
+ *
112
+ * @param {string} language
113
+ * @returns {string}
114
+ */
115
+ function languageTag(language) {
116
+ const code = String(language ?? "").toLowerCase();
117
+ return LANGUAGE_TAGS.get(code) ?? code;
118
+ }
78
119
  // The resolutions a viewer may choose between. Only rungs at or below the
79
120
  // source are offered: upscaling invents detail and costs the encoder more than
80
121
  // the source itself.
@@ -1155,6 +1196,7 @@ export class HlsSessionManager {
1155
1196
  getSourceStats = null,
1156
1197
  tonemapSupported = false,
1157
1198
  getCachedMediaInfo = null,
1199
+ getCachedAudioTracks = null,
1158
1200
  segmentFormatId = undefined,
1159
1201
  stateDir = ""
1160
1202
  }) {
@@ -1171,6 +1213,9 @@ export class HlsSessionManager {
1171
1213
  // Optional accessor for media info the playback planner already probed for
1172
1214
  // (sourceKey, fileIndex), so session create can skip its own ffmpeg scan.
1173
1215
  this.getCachedMediaInfo = typeof getCachedMediaInfo === "function" ? getCachedMediaInfo : null;
1216
+ // The file's audio tracks, for the master playlist's rendition group. Same
1217
+ // inventory the browser's audio menu is built from.
1218
+ this.getCachedAudioTracks = typeof getCachedAudioTracks === "function" ? getCachedAudioTracks : null;
1174
1219
  // Optional async accessor for a source's live download stats, used by the
1175
1220
  // realtime budget to tell a CPU limit from a download-starved input:
1176
1221
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
@@ -1254,6 +1299,20 @@ export class HlsSessionManager {
1254
1299
  startPositionSeconds = 0,
1255
1300
  audioTrackIndex = 0,
1256
1301
  manualQuality = false,
1302
+ // The caller takes its audio from a rendition group, so the picture is
1303
+ // encoded without it and each audio track is encoded once for the file
1304
+ // instead of once per rung. Off unless asked for: a browser that does not
1305
+ // know about renditions must still get its audio in the stream.
1306
+ audioRenditions = false,
1307
+ // This session IS one of those renditions: one audio track, no picture, cut
1308
+ // on the same grid as the video it accompanies.
1309
+ audioOnly = false,
1310
+ // The arrangement decided by the session this one belongs to — a variant or
1311
+ // a rendition of it. Every session of one master must agree about where the
1312
+ // audio is, and only the base is in a position to decide: a variant asked on
1313
+ // its own would answer about the rungs IT would be offered at, which is a
1314
+ // different list. Null means "decide it here", which is what a base does.
1315
+ inheritedAudioSeparate = null,
1257
1316
  segmentFormatId = "",
1258
1317
  // The cut grid of the session this one is a quality variant of: its
1259
1318
  // keyframe times and which container they were read from. Present only for
@@ -1308,6 +1367,11 @@ export class HlsSessionManager {
1308
1367
  String(normalizedTargetWidth),
1309
1368
  String(normalizedTargetHeight),
1310
1369
  forceManualQuality ? "q-manual" : "q-auto",
1370
+ // What the output CARRIES. A picture without its audio, a single audio
1371
+ // track without a picture, and the two muxed together are three different
1372
+ // encodes of the same file, and nothing about them is interchangeable —
1373
+ // so they cannot share a session, a directory or an encoder.
1374
+ audioOnly === true ? "audio-only" : (audioRenditions === true ? "video-only" : "muxed"),
1311
1375
  String(normalizedStartPosition),
1312
1376
  // Two viewers asking for different containers cannot share one ffmpeg.
1313
1377
  segmentFormat.id,
@@ -1446,7 +1510,7 @@ export class HlsSessionManager {
1446
1510
  if (inheritedGrid) {
1447
1511
  keyframeTimes = inheritedGrid.keyframeTimes;
1448
1512
  containerFormat = inheritedGrid.containerFormat ?? "";
1449
- } else if (hasDuration && !transcodeVideo) {
1513
+ } else if (hasDuration && !transcodeVideo && !audioOnly) {
1450
1514
  // Video-COPY path: keyframeTimes are REQUIRED to build correct segment
1451
1515
  // boundaries (the playlist itself), so this MUST block session creation —
1452
1516
  // an incorrect playlist is worse than a slower start. Short timeout: mp4
@@ -1515,10 +1579,17 @@ export class HlsSessionManager {
1515
1579
  // it produces every frame and may put keyframes where it likes — unless it
1516
1580
  // is a variant of a keyframe-cut session, in which case it must land on the
1517
1581
  // same times to be interchangeable with it.
1582
+ // An audio rendition carries no picture, so it has no keyframes of its own
1583
+ // to be cut at: it takes the grid of the video it accompanies, whatever that
1584
+ // is. Handed one, it uses it; handed none, the base is on the even grid and
1585
+ // so is this. Falling into the COPY branch instead — which is what
1586
+ // `transcodeVideo: false` means everywhere else — would put the audio of a
1587
+ // re-encoded stream on the source's keyframe times while the player was
1588
+ // told the even grid, and the two drift further apart with every segment.
1518
1589
  const useKeyframeGrid = hasDuration &&
1519
1590
  Array.isArray(keyframeTimes) &&
1520
1591
  keyframeTimes.length > 0 &&
1521
- (!transcodeVideo || inheritedGrid != null);
1592
+ (audioOnly ? inheritedGrid != null : (!transcodeVideo || inheritedGrid != null));
1522
1593
  // A rung takes the grid it was handed, rather than working one out again
1523
1594
  // from the same index. The two are not the same table: the one it is handed
1524
1595
  // has been CORRECTED wherever a produced segment showed the index to be
@@ -1614,6 +1685,12 @@ export class HlsSessionManager {
1614
1685
  transcodeVideo,
1615
1686
  transcodeAudio,
1616
1687
  audioTrackIndex: normalizedAudioTrack,
1688
+ // What this session's output carries. `audioOnly` is a rendition — one
1689
+ // audio track, no picture; `videoOnly` is a stream whose audio the viewer
1690
+ // takes from such a rendition. Neither is set on the ordinary muxed
1691
+ // session, which is what every browser gets until it says otherwise.
1692
+ audioOnly: audioOnly === true,
1693
+ audioRenditions: audioRenditions === true,
1617
1694
  outputFps,
1618
1695
  // Client-requested target box (the orientation-independent ceiling). Kept
1619
1696
  // for the session key and reference; the actual encode uses encodeWidth/
@@ -1750,6 +1827,23 @@ export class HlsSessionManager {
1750
1827
  }
1751
1828
  this.sessionsById.set(sessionId, session);
1752
1829
  this.sessionIdBySource.set(sourceMapKey, sessionId);
1830
+ // Settled ONCE, here, and never derived again. Whether the audio travels
1831
+ // separately decides the ffmpeg arguments, what the master says and whether
1832
+ // the rendition route answers at all, and those three must agree for the
1833
+ // whole life of the session — a session whose picture was encoded without
1834
+ // audio cannot start muxing it in at the next restart without either
1835
+ // playing it twice or refusing the append.
1836
+ //
1837
+ // It cannot be answered before this point (it asks what heights this
1838
+ // session will be offered at, which needs the record) and it must not be
1839
+ // asked after it, because the answer moves: the offered list is recomputed
1840
+ // as the host learns what this source costs, and crossing "two rungs" would
1841
+ // flip the arrangement under a stream that is playing.
1842
+ session.audioSeparate = inheritedAudioSeparate === null
1843
+ ? audioRenditions === true &&
1844
+ this.#variantHeights(session).length >= 2 &&
1845
+ this.#audioRenditionsOf(session).length > 0
1846
+ : inheritedAudioSeparate === true;
1753
1847
 
1754
1848
  logger.info(
1755
1849
  // Proxy version on the session-start line: a field report always includes
@@ -1873,9 +1967,17 @@ export class HlsSessionManager {
1873
1967
  sourceKey: session.sourceKey,
1874
1968
  fileIndex: session.fileIndex
1875
1969
  }) ?? null;
1970
+ // What the SOURCE has, narrowed to what this session's output carries. A
1971
+ // rendition maps only audio and a stream whose audio travels separately
1972
+ // maps only video, so answering from the source alone would tell the
1973
+ // browser about a track that is not in the stream, and would leave
1974
+ // `#initFromFirstSegment` waiting for a second track that no init will ever
1975
+ // declare — its warning about a short header would then fire on every one.
1976
+ const carriesVideo = session.audioOnly !== true;
1977
+ const carriesAudio = !this.#servesAudioSeparately(session);
1876
1978
  return {
1877
- video: Boolean(probed?.videoCodec),
1878
- audio: Boolean(probed?.audioCodec)
1979
+ video: carriesVideo && Boolean(probed?.videoCodec),
1980
+ audio: carriesAudio && Boolean(probed?.audioCodec)
1879
1981
  };
1880
1982
  }
1881
1983
 
@@ -2528,8 +2630,34 @@ export class HlsSessionManager {
2528
2630
  if (this.videoEncoder?.kind !== "software") {
2529
2631
  return;
2530
2632
  }
2633
+ // One tick at a time. It awaits torrent statistics per session now, so a
2634
+ // slow or stuck answer would otherwise let the next tick in behind it —
2635
+ // two passes over the same sessions, taking the same reading twice and
2636
+ // acting on the same speed twice.
2637
+ if (this.budgetTickRunning === true) {
2638
+ return;
2639
+ }
2640
+ this.budgetTickRunning = true;
2641
+ try {
2642
+ await this.#realtimeBudgetPass();
2643
+ } finally {
2644
+ this.budgetTickRunning = false;
2645
+ }
2646
+ }
2647
+
2648
+ /** One pass over the sessions. See `#enforceRealtimeBudget`. */
2649
+ async #realtimeBudgetPass() {
2531
2650
  const now = Date.now();
2532
2651
  for (const session of this.sessionsById.values()) {
2652
+ // What this file costs to decode is learned from EVERY encoding session,
2653
+ // before any of the budget's own conditions are consulted. Those exist to
2654
+ // decide whether to step the quality down, and they exclude most of what
2655
+ // is worth measuring: a rung already at the foot of its ladder has
2656
+ // nowhere to step, and a 240p variant IS its whole ladder — which is
2657
+ // exactly the rung the field measured at 0.95x on 2026-08-15, learning
2658
+ // nothing from three minutes of it because the loop had already skipped
2659
+ // the session as un-actionable.
2660
+ await this.#learnFromEncoder(session);
2533
2661
  if (
2534
2662
  !session ||
2535
2663
  session.state === "disposed" ||
@@ -2565,10 +2693,6 @@ export class HlsSessionManager {
2565
2693
  }
2566
2694
  if (speed >= BUDGET_SPEED_OK) {
2567
2695
  session.budgetSlowSince = 0; // recovered — reset the slow window
2568
- // A run keeping up with realtime cannot be badly starved of input, so
2569
- // what it reports is about this machine and this source. That is the
2570
- // reading worth learning from, and it needs no further check.
2571
- this.#learnDecodeCost(session, speed);
2572
2696
  continue;
2573
2697
  }
2574
2698
  if (speed >= BUDGET_SPEED_SLOW) {
@@ -2596,10 +2720,6 @@ export class HlsSessionManager {
2596
2720
  session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
2597
2721
  continue;
2598
2722
  }
2599
- // Sustained, and the encoder — not the torrent — is what is short. So the
2600
- // figure describes the machine on this source, and it is the case that
2601
- // matters most: a rung nobody can hold teaches exactly here.
2602
- this.#learnDecodeCost(session, speed);
2603
2723
  await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
2604
2724
  }
2605
2725
  }
@@ -2912,7 +3032,17 @@ export class HlsSessionManager {
2912
3032
  }
2913
3033
  args.push("-i", session.inputUrl);
2914
3034
  }
2915
- if (session.transcodeVideo) {
3035
+ // Which timeline the output is labelled on. An audio rendition has no
3036
+ // picture of its own to follow, so it follows the grid it was given — the
3037
+ // same one the video it plays with is on. Deciding by `transcodeVideo`, as
3038
+ // everything else here does, would put the audio of a re-encoded stream on
3039
+ // the copy branch: `-copyts` and a shift by the container's start time,
3040
+ // against a picture labelled from zero. The two would be offset by
3041
+ // `sourceStartTime` for the whole file.
3042
+ const onKeyframeGrid = session.audioOnly === true
3043
+ ? session.cutGrid === "keyframe"
3044
+ : !session.transcodeVideo;
3045
+ if (!onKeyframeGrid) {
2916
3046
  // Branch A (re-encode): fixed GOP makes keyframes land exactly on the
2917
3047
  // segment grid; relabel output onto the original timeline so segment N
2918
3048
  // carries PTS = N × segmentDuration.
@@ -2932,15 +3062,29 @@ export class HlsSessionManager {
2932
3062
  args.push("-output_ts_offset", ffmpegSeconds(-sourceStartTime));
2933
3063
  }
2934
3064
  }
2935
- args.push(
2936
- "-map",
2937
- "0:v:0?",
2938
- "-map",
2939
- // Type-relative audio track chosen by the viewer (default 0).
2940
- `0:a:${session.audioTrackIndex ?? 0}?`,
2941
- ...videoCodecArgs,
2942
- ...audioCodecArgs
2943
- );
3065
+ if (session.audioOnly === true) {
3066
+ // An audio RENDITION: one track, no picture. Published as its own
3067
+ // `#EXT-X-MEDIA` and shared by every video variant, so the track is
3068
+ // encoded once for the file instead of once per rung, and changing it is
3069
+ // the player switching rendition rather than this proxy rebuilding the
3070
+ // session. Cut on the same grid as the video it accompanies, which is
3071
+ // what lets the two be played together.
3072
+ args.push("-vn", "-map", `0:a:${session.audioTrackIndex ?? 0}?`, ...audioCodecArgs);
3073
+ } else if (this.#servesAudioSeparately(session)) {
3074
+ // The other half of the same arrangement: the picture alone, because its
3075
+ // audio is published as a rendition and would otherwise play twice.
3076
+ args.push("-an", "-map", "0:v:0?", ...videoCodecArgs);
3077
+ } else {
3078
+ args.push(
3079
+ "-map",
3080
+ "0:v:0?",
3081
+ "-map",
3082
+ // Type-relative audio track chosen by the viewer (default 0).
3083
+ `0:a:${session.audioTrackIndex ?? 0}?`,
3084
+ ...videoCodecArgs,
3085
+ ...audioCodecArgs
3086
+ );
3087
+ }
2944
3088
 
2945
3089
  // Where the cuts come from. On the copy path they are the source's own
2946
3090
  // keyframes, and until now they were only ever GUESSED: ffmpeg got a target
@@ -3426,6 +3570,17 @@ export class HlsSessionManager {
3426
3570
  if (head - index > BEHIND_HEAD_REPAIR_MAX_SEGMENTS) {
3427
3571
  return;
3428
3572
  }
3573
+ // An audio rendition is never repaired by moving it. Its run is placed
3574
+ // where the VIEWER is, from the session they are watching, so a request
3575
+ // behind that is not a run in the wrong place — it is the player probing.
3576
+ // Measured 2026-08-15: changing track at 159 s, hls.js asked for the new
3577
+ // rendition's segment #0, the repair obligingly took the encoder to the
3578
+ // start of the film, and the audio the viewer was waiting for arrived
3579
+ // 20.5 s later instead of at once. Left where it is, the right segment is
3580
+ // already being produced.
3581
+ if (session.audioOnly === true) {
3582
+ return;
3583
+ }
3429
3584
  // Nothing is encoding: a rung the viewer has switched away from is left
3430
3585
  // exactly so, and its held requests must not bring its encoder back.
3431
3586
  if (session.ffmpeg == null || hasChildExited(session.ffmpeg)) {
@@ -3513,6 +3668,19 @@ export class HlsSessionManager {
3513
3668
  // variants, so a seek it reports means the stream on screen.
3514
3669
  named.viewerPositionSeconds = positionSeconds;
3515
3670
  named.lastAccessedAt = Date.now();
3671
+ // The audio the viewer is listening to moves with them. It is a separate
3672
+ // encoder on a separate session that the browser cannot name, and nothing
3673
+ // else would ever reposition it: a request far AHEAD of its run is not
3674
+ // treated as a seek anywhere in this class, so after a forward jump the
3675
+ // audio would be held, refused, and left grinding forward from where it
3676
+ // was — the picture playing over silence for as long as the jump was.
3677
+ for (const renditionId of named.audioRenditionSessions?.values() ?? []) {
3678
+ const rendition = this.sessionsById.get(renditionId);
3679
+ if (rendition && rendition.state !== "disposed") {
3680
+ rendition.lastAccessedAt = Date.now();
3681
+ this.#seekSession(rendition, positionSeconds);
3682
+ }
3683
+ }
3516
3684
  return this.#seekSession(this.#activeVariant(named), positionSeconds);
3517
3685
  }
3518
3686
 
@@ -4048,6 +4216,32 @@ export class HlsSessionManager {
4048
4216
  );
4049
4217
  }
4050
4218
 
4219
+ /**
4220
+ * The session a family answers as: the base a rung belongs to, or the session
4221
+ * itself when it is not a rung.
4222
+ *
4223
+ * A rung knows only its own encode, so anything that is a property of the
4224
+ * FILE rather than of one encode of it — what the source is, whether its
4225
+ * video can be copied, which heights this host can serve it at — has to be
4226
+ * asked here. A live base is required: a rung whose base has been disposed
4227
+ * answers for itself rather than following a dead reference.
4228
+ *
4229
+ * @param {HlsSession} session
4230
+ * @returns {HlsSession}
4231
+ */
4232
+ #baseOf(session) {
4233
+ if (!(session?.variantBases instanceof Set)) {
4234
+ return session;
4235
+ }
4236
+ for (const baseId of session.variantBases) {
4237
+ const base = this.sessionsById.get(baseId);
4238
+ if (base && base !== session && base.state !== "disposed") {
4239
+ return base;
4240
+ }
4241
+ }
4242
+ return session;
4243
+ }
4244
+
4051
4245
  /**
4052
4246
  * Every session cut on one grid: a base and its quality rungs.
4053
4247
  *
@@ -4168,43 +4362,65 @@ export class HlsSessionManager {
4168
4362
  * @returns {number[]}
4169
4363
  */
4170
4364
  #variantHeights(session) {
4365
+ // Always answered by the family's BASE, whichever member is asking. A rung
4366
+ // is a session of its own, and it knows only its own encode: asked while
4367
+ // the viewer watches 240p, the 240p session priced the 1080p rung as a
4368
+ // re-encode — because ITS video is re-encoded — and refused it on a host
4369
+ // that was serving that very height by COPY minutes earlier. Field
4370
+ // 2026-08-15: `proxy now offers 360p 240p` seconds after the switch, and
4371
+ // the viewer could not go back. Only the base knows what the family can do
4372
+ // with the source.
4373
+ // Answered ON the base, never recursively: the family is one level deep by
4374
+ // construction, and a `variantBases` cycle would otherwise blow the stack on
4375
+ // the path that serves every playlist, init and segment.
4376
+ const owner = this.#baseOf(session);
4171
4377
  // Settled once per session, and re-settled when this file's own decode cost
4172
- // is measured or improves. Everything else is fixed for the session's life
4173
- // the source, the host's benchmarks, the height it settled on and this
4174
- // is asked on the path that serves every playlist, init and segment, where
4175
- // recomputing it meant repeating the refusal in the log every few seconds.
4176
- const observed = this.#observedDecodeCost.get(`${session.sourceKey}:${session.fileIndex}`) ?? null;
4177
- const version = observed?.version ?? 0;
4178
- if (Array.isArray(session.offeredHeightsCache) && session.offeredHeightsVersion === version) {
4179
- return session.offeredHeightsCache;
4180
- }
4181
- session.offeredHeightsVersion = version;
4182
- const heights = new Set(variantHeightsFor(Number(session.sourceHeight) || 0));
4183
- const own = this.variantHeightOf(session);
4378
+ // is measured or improves, or when the viewer moves to another rung — the
4379
+ // rung on screen is exempt from refusal, so it is an INPUT to this list and
4380
+ // belongs in what identifies a cached answer. Left out, the exemption
4381
+ // outlived the rung: a rung the host cannot hold went on being offered, and
4382
+ // went on passing every route guard, after the viewer had left it.
4383
+ // Everything else is fixed for the session's life.
4384
+ const observed = this.#observedDecodeCost.get(`${owner.sourceKey}:${owner.fileIndex}`) ?? null;
4385
+ const playing = this.variantHeightOf(this.#activeVariant(owner));
4386
+ const version = `${observed?.version ?? 0}:${playing}`;
4387
+ if (Array.isArray(owner.offeredHeightsCache) && owner.offeredHeightsVersion === version) {
4388
+ return owner.offeredHeightsCache;
4389
+ }
4390
+ const heights = new Set(variantHeightsFor(Number(owner.sourceHeight) || 0));
4391
+ const own = this.variantHeightOf(owner);
4184
4392
  if (own > 0) {
4185
4393
  heights.add(own);
4186
4394
  }
4187
4395
  const ordered = [...heights].sort((left, right) => right - left);
4188
- // The rung ON SCREEN is never withdrawn. The list is now recomputed as the
4189
- // host learns what this source costs, and the reading that teaches it comes
4190
- // from the rung the viewer has just switched to — so the rung that taught
4191
- // the lesson would be the first to be dropped, and every route guard reads
4192
- // this list: its next segment would 404 on a stream that is playing, with
4193
- // its own encoder still running. It leaves the offer when the viewer leaves
4194
- // it, not while they are watching it.
4195
- const playing = this.variantHeightOf(this.#activeVariant(session));
4196
- session.offeredHeightsCache = this.#sustainableHeights({
4396
+ // The rung ON SCREEN is never withdrawn while it is on screen. The list is
4397
+ // recomputed as the host learns what this source costs, and the reading
4398
+ // that teaches it comes from the rung the viewer has just switched to — so
4399
+ // the rung that taught the lesson would be the first to be dropped, and
4400
+ // every route guard reads this list: its next segment would 404 on a stream
4401
+ // that is playing, with its own encoder still running.
4402
+ const answer = this.#sustainableHeights({
4197
4403
  heights: ordered,
4198
4404
  ownHeight: own,
4199
4405
  playingHeight: playing,
4200
- sourceWidth: Number(session.sourceWidth) || 0,
4201
- sourceHeight: Math.round(Number(session.sourceHeight) || 0),
4202
- fps: Number(session.outputFps) || TRANSCODE_FPS,
4203
- source: session.sourceDecode ?? null,
4204
- transcodeVideo: session.transcodeVideo === true,
4406
+ sourceWidth: Number(owner.sourceWidth) || 0,
4407
+ sourceHeight: Math.round(Number(owner.sourceHeight) || 0),
4408
+ fps: Number(owner.outputFps) || TRANSCODE_FPS,
4409
+ source: owner.sourceDecode ?? null,
4410
+ transcodeVideo: owner.transcodeVideo === true,
4205
4411
  observedDecodeCostSec: observed?.costSec ?? null
4206
4412
  });
4207
- return session.offeredHeightsCache;
4413
+ if (owner !== session) {
4414
+ // An orphan: its base is gone, so this is the family's last word and
4415
+ // there is nobody to keep it for. Answering is right — the viewer is
4416
+ // still watching it — but caching it on a session whose flags are its
4417
+ // own encode's is how the wrong answer became the family's in the first
4418
+ // place.
4419
+ return answer;
4420
+ }
4421
+ owner.offeredHeightsVersion = version;
4422
+ owner.offeredHeightsCache = answer;
4423
+ return answer;
4208
4424
  }
4209
4425
 
4210
4426
  /**
@@ -4246,6 +4462,46 @@ export class HlsSessionManager {
4246
4462
  * @param {HlsSession} session
4247
4463
  * @param {number} speed - The `speed=` ffmpeg reports, as a multiple of realtime.
4248
4464
  */
4465
+ /**
4466
+ * Take a reading off an encoder that is running, if this one is worth having.
4467
+ *
4468
+ * Separate from the realtime budget, which asks a different question — should
4469
+ * the quality step down — and answers it only where it CAN step down. Most of
4470
+ * what is worth measuring is excluded by that: a rung at the foot of its
4471
+ * ladder, a variant whose ladder is one rung long, a base whose video is
4472
+ * copied. Measuring has no such preconditions.
4473
+ *
4474
+ * What it does refuse: a suspended encoder (ffmpeg reports a CUMULATIVE
4475
+ * speed, so a look-ahead pause is divided into it and the figure decays while
4476
+ * nothing is being encoded), a reading that has not moved since the last one
4477
+ * (the loop runs every 5 s and a stalled encoder would otherwise fill the
4478
+ * whole window with one frozen sample), and a run short of input, where what
4479
+ * is short is the torrent rather than the machine.
4480
+ *
4481
+ * @param {HlsSession} session
4482
+ */
4483
+ async #learnFromEncoder(session) {
4484
+ if (
4485
+ !session ||
4486
+ session.state === "disposed" ||
4487
+ session.state === "failed" ||
4488
+ !session.ffmpeg ||
4489
+ session.encoderPaused === true ||
4490
+ session.transcodeVideo !== true
4491
+ ) {
4492
+ return;
4493
+ }
4494
+ const speed = this.#parseSpeed(session.progress?.speed);
4495
+ if (speed === null || speed === session.lastLearnedSpeed) {
4496
+ return;
4497
+ }
4498
+ if (speed < BUDGET_SPEED_OK && await this.#classifyTranscodeBound(session) === "download") {
4499
+ return; // the torrent is what is short; this says nothing about the host
4500
+ }
4501
+ session.lastLearnedSpeed = speed;
4502
+ this.#learnDecodeCost(session, speed);
4503
+ }
4504
+
4249
4505
  #learnDecodeCost(session, speed) {
4250
4506
  if (session.transcodeVideo !== true || !(speed > 0)) {
4251
4507
  return; // a copied video decodes nothing, so it says nothing about decoding
@@ -4407,11 +4663,18 @@ export class HlsSessionManager {
4407
4663
  const dropped = [];
4408
4664
  for (const height of heights) {
4409
4665
  // The height an encoder is ALREADY producing, and the source's own height
4410
- // when the video is copied — neither has to be predicted, because it is
4411
- // happening. A source height that would have to be RE-ENCODED is a
4412
- // prediction like any other: on a session whose budget downshifted to
4413
- // 480p, the source's 1080p is neither copied nor being produced, and
4414
- // keeping it unpriced would offer exactly the kind of rung this refuses.
4666
+ // when the FAMILY serves it by copy — neither has to be predicted,
4667
+ // because it is happening. A copied rung costs no encoder at all, so no
4668
+ // measurement of this host can ever be a reason to withdraw it, and the
4669
+ // whole point of it is that it is where a viewer on a rung the machine
4670
+ // cannot hold goes back to. `transcodeVideo` here is the base's, not the
4671
+ // asking session's: a 240p rung re-encodes, and reading its own flag is
4672
+ // what withdrew a copied 1080p in the field on 2026-08-15.
4673
+ //
4674
+ // A source height that would have to be RE-ENCODED is a prediction like
4675
+ // any other: on a session whose budget downshifted to 480p, the source's
4676
+ // 1080p is neither copied nor being produced, and keeping it unpriced
4677
+ // would offer exactly the kind of rung this refuses.
4415
4678
  if (
4416
4679
  height === ownHeight ||
4417
4680
  height === playingHeight ||
@@ -4646,6 +4909,16 @@ export class HlsSessionManager {
4646
4909
  // variants could drift onto the same height and the choice would mean
4647
4910
  // nothing.
4648
4911
  manualQuality: true,
4912
+ // A rung of a session whose audio is published separately carries no
4913
+ // audio either — every rung of one master must agree about that, or
4914
+ // switching rung would start or stop a second copy of the same track.
4915
+ audioRenditions: base.audioRenditions === true,
4916
+ // Not re-decided here: asked on its own, a variant would answer about the
4917
+ // rungs IT would be offered at — a 540p rung of a copied 1080p source is
4918
+ // offered nothing but itself, so it would conclude "audio muxed" and
4919
+ // start carrying a second copy of a track the player is already fetching
4920
+ // from the rendition.
4921
+ inheritedAudioSeparate: base.audioSeparate === true,
4649
4922
  segmentFormatId: base.segmentFormat?.id ?? "",
4650
4923
  // Cut where the base is cut. Only for a base on the source's own keyframe
4651
4924
  // grid — a copy — where the variant has to land on those exact times to
@@ -4934,19 +5207,204 @@ export class HlsSessionManager {
4934
5207
  }
4935
5208
  const sourceWidth = Number(session.sourceWidth) || 0;
4936
5209
  const lines = ["#EXTM3U", `#EXT-X-VERSION:${session.segmentFormat.playlistVersion}`];
5210
+ // The audio tracks, published once for the whole file rather than muxed
5211
+ // into every rung. Two things follow from that: the same track is not
5212
+ // encoded once per rung on a host that struggles to encode it once, and
5213
+ // changing track becomes the player switching rendition instead of this
5214
+ // proxy rebuilding the session with another `audioTrackIndex`.
5215
+ //
5216
+ // Only for a session that asked for them. A browser that does not know
5217
+ // about renditions is served audio in its stream, as before, and gets no
5218
+ // `#EXT-X-MEDIA` lines to be confused by.
5219
+ const renditions = this.#servesAudioSeparately(session) ? this.#audioRenditionsOf(session) : [];
5220
+ const audioGroup = renditions.length > 0 ? AUDIO_GROUP_ID : "";
5221
+ for (const rendition of renditions) {
5222
+ lines.push(
5223
+ `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="${audioGroup}",NAME="${escapeAttribute(rendition.name)}"` +
5224
+ (rendition.language ? `,LANGUAGE="${escapeAttribute(languageTag(rendition.language))}"` : "") +
5225
+ `,AUTOSELECT=YES,DEFAULT=${rendition.isDefault ? "YES" : "NO"}` +
5226
+ `,URI="${AUDIO_PATH_PREFIX}/${rendition.trackIndex}/${PLAYLIST_FILE_NAME}"`
5227
+ );
5228
+ }
4937
5229
  for (const height of rungs) {
4938
5230
  const width = sourceHeight > 0 && sourceWidth > 0
4939
5231
  ? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
4940
5232
  : 0;
4941
5233
  lines.push(
4942
5234
  `#EXT-X-STREAM-INF:BANDWIDTH=${estimatedBitrateFor(height)}` +
4943
- (width > 0 ? `,RESOLUTION=${width}x${height}` : "")
5235
+ (width > 0 ? `,RESOLUTION=${width}x${height}` : "") +
5236
+ (audioGroup ? `,AUDIO="${audioGroup}"` : "")
4944
5237
  );
4945
5238
  lines.push(`${VARIANT_PATH_PREFIX}/${height}/${PLAYLIST_FILE_NAME}`);
4946
5239
  }
4947
5240
  return `${lines.join("\n")}\n`;
4948
5241
  }
4949
5242
 
5243
+ /**
5244
+ * Whether this session's audio is published separately rather than muxed into
5245
+ * its picture.
5246
+ *
5247
+ * Two things have to hold, and the second is why this is asked here rather
5248
+ * than settled when the session was made. The browser must understand
5249
+ * renditions — it says so when it creates the session, and one that does not
5250
+ * has to be sent audio in the stream. AND there must be a master playlist to
5251
+ * publish them in: a stream served as a single media playlist has nowhere to
5252
+ * carry an `#EXT-X-MEDIA` line, so taking the audio out of it would leave the
5253
+ * viewer with a picture and silence.
5254
+ *
5255
+ * @param {HlsSession} session
5256
+ * @returns {boolean}
5257
+ */
5258
+ #servesAudioSeparately(session) {
5259
+ return session.audioOnly !== true && session.audioSeparate === true;
5260
+ }
5261
+
5262
+ /**
5263
+ * One file of an audio rendition: its playlist, its init segment or one of
5264
+ * its segments.
5265
+ *
5266
+ * A rendition is an ordinary session underneath — same source, same file,
5267
+ * same cut grid, one audio track and no picture — created on the first
5268
+ * request for it, exactly as a quality variant is. What differs is that the
5269
+ * player fetches it ALONGSIDE a variant rather than instead of one, so both
5270
+ * encoders run: a rung and the audio it is played with.
5271
+ *
5272
+ * @param {string} baseSessionId
5273
+ * @param {number} trackIndex
5274
+ * @param {string} fileName
5275
+ * @returns {Promise<{ sessionId: string | null, error?: string }>}
5276
+ */
5277
+ async resolveAudioRenditionFile(baseSessionId, trackIndex, fileName) {
5278
+ if (!isSafeSessionId(baseSessionId) || !Number.isInteger(trackIndex) || trackIndex < 0) {
5279
+ return { sessionId: null };
5280
+ }
5281
+ const base = this.sessionsById.get(baseSessionId);
5282
+ if (!base || base.state === "disposed" || !this.#servesAudioSeparately(base)) {
5283
+ return { sessionId: null };
5284
+ }
5285
+ const isPlaylist = fileName === PLAYLIST_FILE_NAME;
5286
+ const isInit = base.segmentFormat.initFileName !== null && fileName === base.segmentFormat.initFileName;
5287
+ const isSegment = base.segmentFormat.isSegmentFileName(fileName);
5288
+ if (!isPlaylist && !isInit && !isSegment) {
5289
+ return { sessionId: null };
5290
+ }
5291
+ if (!this.#audioRenditionsOf(base).some((rendition) => rendition.trackIndex === trackIndex)) {
5292
+ return { sessionId: null };
5293
+ }
5294
+ // The playlist is answered from the base, for the same reason a variant's
5295
+ // is: every rendition of a file has the same boundaries and the same
5296
+ // duration — they are cut on one grid — and the player fetches the playlist
5297
+ // of tracks it may never select. Starting an encoder for each would put as
5298
+ // many encoders on the host as the file has languages.
5299
+ if (isPlaylist) {
5300
+ return { sessionId: base.id };
5301
+ }
5302
+ let rendition;
5303
+ try {
5304
+ rendition = await this.#resolveAudioRenditionSession(base, trackIndex);
5305
+ } catch (error) {
5306
+ const message = error instanceof Error ? error.message : String(error);
5307
+ logger.error(
5308
+ `transcode ${baseSessionId} could not prepare audio track ${trackIndex}: ${message}` +
5309
+ (error instanceof Error && error.stack ? `\n${error.stack}` : "")
5310
+ );
5311
+ return { sessionId: null, error: message };
5312
+ }
5313
+ return { sessionId: rendition?.id ?? null };
5314
+ }
5315
+
5316
+ /**
5317
+ * The session producing one audio track of this file, made on first request.
5318
+ *
5319
+ * @param {HlsSession} base
5320
+ * @param {number} trackIndex
5321
+ * @returns {Promise<HlsSession | null>}
5322
+ */
5323
+ async #resolveAudioRenditionSession(base, trackIndex) {
5324
+ const existingId = base.audioRenditionSessions?.get(trackIndex);
5325
+ const existing = existingId ? this.sessionsById.get(existingId) : null;
5326
+ if (existing && existing.state !== "disposed") {
5327
+ existing.lastAccessedAt = Date.now();
5328
+ return existing;
5329
+ }
5330
+ const rendition = await this.createOrGetSession({
5331
+ sourceKey: base.sourceKey,
5332
+ fileIndex: base.fileIndex,
5333
+ // No picture at all: the video flag says what to do with a video stream
5334
+ // this output does not carry.
5335
+ transcodeVideo: false,
5336
+ transcodeAudio: base.transcodeAudio,
5337
+ fileName: base.fileName,
5338
+ consumerId: variantConsumerId(base.id),
5339
+ audioTrackIndex: trackIndex,
5340
+ audioOnly: true,
5341
+ // Where the viewer is, so the rendition starts with the picture rather
5342
+ // than at the beginning of the file. Read the same way a quality variant
5343
+ // reads it: the base's own field is only written by a seek or by a
5344
+ // segment IT served, so on a resume-from-position open it is still unset
5345
+ // while the player is asking for segment #537 — and the audio would begin
5346
+ // at zero and never catch up, since nothing treats a far request as a
5347
+ // seek. The accessor falls back to the last segment actually requested.
5348
+ startPositionSeconds:
5349
+ Math.floor(this.#viewerPositionOf(this.#activeVariant(base)) / 10) * 10,
5350
+ segmentFormatId: base.segmentFormat.id,
5351
+ // Cut where the picture is cut. Two streams meant to be played together
5352
+ // have to be divided at the same times, and the grid is the base's — the
5353
+ // table as it stands now, corrections included. A base on the uniform
5354
+ // grid passes nothing: the rendition computes the same even grid from the
5355
+ // same duration.
5356
+ inheritedGrid: base.cutGrid === "keyframe"
5357
+ ? {
5358
+ boundaries: base.segmentBoundaries,
5359
+ keyframeTimes: base.keyframeTimes,
5360
+ containerFormat: base.containerFormat
5361
+ }
5362
+ : null,
5363
+ acquireSource: base.acquireSource
5364
+ });
5365
+ if (!rendition) {
5366
+ return null;
5367
+ }
5368
+ if (!(base.audioRenditionSessions instanceof Map)) {
5369
+ base.audioRenditionSessions = new Map();
5370
+ }
5371
+ base.audioRenditionSessions.set(trackIndex, rendition.id);
5372
+ return rendition;
5373
+ }
5374
+
5375
+ /**
5376
+ * The audio tracks of this session's file, as renditions for the master.
5377
+ *
5378
+ * Taken from the inventory the playback plan already probed — the same list
5379
+ * the browser's audio menu is built from — so nothing is probed again here.
5380
+ * The track the session was created with is the default one: it is what the
5381
+ * viewer chose (or the file's first track), and a master that defaulted to
5382
+ * something else would change the language on its own.
5383
+ *
5384
+ * @param {HlsSession} session
5385
+ * @returns {Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>}
5386
+ */
5387
+ #audioRenditionsOf(session) {
5388
+ const tracks = this.getCachedAudioTracks?.({
5389
+ sourceKey: session.sourceKey,
5390
+ fileIndex: session.fileIndex
5391
+ }) ?? [];
5392
+ if (!Array.isArray(tracks) || tracks.length === 0) {
5393
+ return [];
5394
+ }
5395
+ const chosen = Number(session.audioTrackIndex) || 0;
5396
+ return tracks.map((track, order) => {
5397
+ const language = typeof track?.language === "string" ? track.language : "";
5398
+ const title = typeof track?.title === "string" && track.title.length > 0 ? track.title : "";
5399
+ return {
5400
+ trackIndex: order,
5401
+ name: title || language || `Track ${order + 1}`,
5402
+ language,
5403
+ isDefault: order === chosen
5404
+ };
5405
+ });
5406
+ }
5407
+
4950
5408
  /**
4951
5409
  * How many times the viewer has moved since this session started.
4952
5410
  *
@@ -5416,6 +5874,34 @@ export class HlsSessionManager {
5416
5874
  // at this position (server-side seeking). The caller long-polls.
5417
5875
  if (!isPlaylist) {
5418
5876
  const requestedIndex = session.segmentFormat.segmentIndexFromName(fileName);
5877
+ // An audio rendition asked for something behind its run says so at once
5878
+ // instead of holding. Its run is placed where the viewer is and is not
5879
+ // moved from there, so the request can never be answered — and holding it
5880
+ // for the full window costs the player its own patience on the fragment
5881
+ // it needs NEXT. Measured 2026-08-15: on a track change at 159 s hls.js
5882
+ // asked for segment #0, and a held request plus a repaired encoder cost
5883
+ // 20.5 s of silence. Refused promptly, the player moves to the segment
5884
+ // that is genuinely being produced.
5885
+ if (
5886
+ session.audioOnly === true &&
5887
+ Number.isFinite(requestedIndex) &&
5888
+ requestedIndex < (session.encodeStartIndex ?? 0) &&
5889
+ session.ffmpeg != null &&
5890
+ // Not while a seek of its own is settling. A viewer seeking BACKWARDS
5891
+ // is reported to the base and forwarded here, but the run only moves
5892
+ // when the settle fires — until then `encodeStartIndex` still names the
5893
+ // old position, and every request for the new one is "behind" it. Those
5894
+ // are exactly the requests the viewer is waiting for, so they are held,
5895
+ // as they were before this refusal existed.
5896
+ session.seekTarget == null &&
5897
+ session.seekSettleTimer == null
5898
+ ) {
5899
+ logger.info(
5900
+ `transcode ${session.id} audio segment #${requestedIndex} is behind this rendition's run ` +
5901
+ `(#${session.encodeStartIndex}); it is not made and the run stays where the viewer is`
5902
+ );
5903
+ return { kind: "not-found" };
5904
+ }
5419
5905
  this.#ensureEncodingFor(
5420
5906
  session,
5421
5907
  requestedIndex,
@@ -5597,6 +6083,21 @@ export class HlsSessionManager {
5597
6083
  );
5598
6084
  }
5599
6085
  }
6086
+ // The audio renditions of this session, for the same reason and in the same
6087
+ // way. Nobody outside this class knows their ids — the browser holds one id
6088
+ // for the whole file — so nothing else could ever release them, and each
6089
+ // holds a consumer, a claim on the torrent, a directory and a live encoder.
6090
+ if (session.audioRenditionSessions instanceof Map) {
6091
+ const renditionIds = [...session.audioRenditionSessions.values()];
6092
+ session.audioRenditionSessions.clear();
6093
+ for (const renditionId of renditionIds) {
6094
+ await this.releaseSessionConsumer(
6095
+ renditionId,
6096
+ variantConsumerId(sessionId),
6097
+ "the session its audio belonged to ended"
6098
+ );
6099
+ }
6100
+ }
5600
6101
  // Disposed on its own (idle, or with its last family): it must stop being
5601
6102
  // offered, or the next request for that height would be answered with a
5602
6103
  // session that no longer exists.