@torrent-tv/proxy 2.13.0 → 2.14.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.
@@ -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
@@ -3513,6 +3657,19 @@ export class HlsSessionManager {
3513
3657
  // variants, so a seek it reports means the stream on screen.
3514
3658
  named.viewerPositionSeconds = positionSeconds;
3515
3659
  named.lastAccessedAt = Date.now();
3660
+ // The audio the viewer is listening to moves with them. It is a separate
3661
+ // encoder on a separate session that the browser cannot name, and nothing
3662
+ // else would ever reposition it: a request far AHEAD of its run is not
3663
+ // treated as a seek anywhere in this class, so after a forward jump the
3664
+ // audio would be held, refused, and left grinding forward from where it
3665
+ // was — the picture playing over silence for as long as the jump was.
3666
+ for (const renditionId of named.audioRenditionSessions?.values() ?? []) {
3667
+ const rendition = this.sessionsById.get(renditionId);
3668
+ if (rendition && rendition.state !== "disposed") {
3669
+ rendition.lastAccessedAt = Date.now();
3670
+ this.#seekSession(rendition, positionSeconds);
3671
+ }
3672
+ }
3516
3673
  return this.#seekSession(this.#activeVariant(named), positionSeconds);
3517
3674
  }
3518
3675
 
@@ -4048,6 +4205,32 @@ export class HlsSessionManager {
4048
4205
  );
4049
4206
  }
4050
4207
 
4208
+ /**
4209
+ * The session a family answers as: the base a rung belongs to, or the session
4210
+ * itself when it is not a rung.
4211
+ *
4212
+ * A rung knows only its own encode, so anything that is a property of the
4213
+ * FILE rather than of one encode of it — what the source is, whether its
4214
+ * video can be copied, which heights this host can serve it at — has to be
4215
+ * asked here. A live base is required: a rung whose base has been disposed
4216
+ * answers for itself rather than following a dead reference.
4217
+ *
4218
+ * @param {HlsSession} session
4219
+ * @returns {HlsSession}
4220
+ */
4221
+ #baseOf(session) {
4222
+ if (!(session?.variantBases instanceof Set)) {
4223
+ return session;
4224
+ }
4225
+ for (const baseId of session.variantBases) {
4226
+ const base = this.sessionsById.get(baseId);
4227
+ if (base && base !== session && base.state !== "disposed") {
4228
+ return base;
4229
+ }
4230
+ }
4231
+ return session;
4232
+ }
4233
+
4051
4234
  /**
4052
4235
  * Every session cut on one grid: a base and its quality rungs.
4053
4236
  *
@@ -4168,43 +4351,65 @@ export class HlsSessionManager {
4168
4351
  * @returns {number[]}
4169
4352
  */
4170
4353
  #variantHeights(session) {
4354
+ // Always answered by the family's BASE, whichever member is asking. A rung
4355
+ // is a session of its own, and it knows only its own encode: asked while
4356
+ // the viewer watches 240p, the 240p session priced the 1080p rung as a
4357
+ // re-encode — because ITS video is re-encoded — and refused it on a host
4358
+ // that was serving that very height by COPY minutes earlier. Field
4359
+ // 2026-08-15: `proxy now offers 360p 240p` seconds after the switch, and
4360
+ // the viewer could not go back. Only the base knows what the family can do
4361
+ // with the source.
4362
+ // Answered ON the base, never recursively: the family is one level deep by
4363
+ // construction, and a `variantBases` cycle would otherwise blow the stack on
4364
+ // the path that serves every playlist, init and segment.
4365
+ const owner = this.#baseOf(session);
4171
4366
  // 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);
4367
+ // is measured or improves, or when the viewer moves to another rung — the
4368
+ // rung on screen is exempt from refusal, so it is an INPUT to this list and
4369
+ // belongs in what identifies a cached answer. Left out, the exemption
4370
+ // outlived the rung: a rung the host cannot hold went on being offered, and
4371
+ // went on passing every route guard, after the viewer had left it.
4372
+ // Everything else is fixed for the session's life.
4373
+ const observed = this.#observedDecodeCost.get(`${owner.sourceKey}:${owner.fileIndex}`) ?? null;
4374
+ const playing = this.variantHeightOf(this.#activeVariant(owner));
4375
+ const version = `${observed?.version ?? 0}:${playing}`;
4376
+ if (Array.isArray(owner.offeredHeightsCache) && owner.offeredHeightsVersion === version) {
4377
+ return owner.offeredHeightsCache;
4378
+ }
4379
+ const heights = new Set(variantHeightsFor(Number(owner.sourceHeight) || 0));
4380
+ const own = this.variantHeightOf(owner);
4184
4381
  if (own > 0) {
4185
4382
  heights.add(own);
4186
4383
  }
4187
4384
  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({
4385
+ // The rung ON SCREEN is never withdrawn while it is on screen. The list is
4386
+ // recomputed as the host learns what this source costs, and the reading
4387
+ // that teaches it comes from the rung the viewer has just switched to — so
4388
+ // the rung that taught the lesson would be the first to be dropped, and
4389
+ // every route guard reads this list: its next segment would 404 on a stream
4390
+ // that is playing, with its own encoder still running.
4391
+ const answer = this.#sustainableHeights({
4197
4392
  heights: ordered,
4198
4393
  ownHeight: own,
4199
4394
  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,
4395
+ sourceWidth: Number(owner.sourceWidth) || 0,
4396
+ sourceHeight: Math.round(Number(owner.sourceHeight) || 0),
4397
+ fps: Number(owner.outputFps) || TRANSCODE_FPS,
4398
+ source: owner.sourceDecode ?? null,
4399
+ transcodeVideo: owner.transcodeVideo === true,
4205
4400
  observedDecodeCostSec: observed?.costSec ?? null
4206
4401
  });
4207
- return session.offeredHeightsCache;
4402
+ if (owner !== session) {
4403
+ // An orphan: its base is gone, so this is the family's last word and
4404
+ // there is nobody to keep it for. Answering is right — the viewer is
4405
+ // still watching it — but caching it on a session whose flags are its
4406
+ // own encode's is how the wrong answer became the family's in the first
4407
+ // place.
4408
+ return answer;
4409
+ }
4410
+ owner.offeredHeightsVersion = version;
4411
+ owner.offeredHeightsCache = answer;
4412
+ return answer;
4208
4413
  }
4209
4414
 
4210
4415
  /**
@@ -4246,6 +4451,46 @@ export class HlsSessionManager {
4246
4451
  * @param {HlsSession} session
4247
4452
  * @param {number} speed - The `speed=` ffmpeg reports, as a multiple of realtime.
4248
4453
  */
4454
+ /**
4455
+ * Take a reading off an encoder that is running, if this one is worth having.
4456
+ *
4457
+ * Separate from the realtime budget, which asks a different question — should
4458
+ * the quality step down — and answers it only where it CAN step down. Most of
4459
+ * what is worth measuring is excluded by that: a rung at the foot of its
4460
+ * ladder, a variant whose ladder is one rung long, a base whose video is
4461
+ * copied. Measuring has no such preconditions.
4462
+ *
4463
+ * What it does refuse: a suspended encoder (ffmpeg reports a CUMULATIVE
4464
+ * speed, so a look-ahead pause is divided into it and the figure decays while
4465
+ * nothing is being encoded), a reading that has not moved since the last one
4466
+ * (the loop runs every 5 s and a stalled encoder would otherwise fill the
4467
+ * whole window with one frozen sample), and a run short of input, where what
4468
+ * is short is the torrent rather than the machine.
4469
+ *
4470
+ * @param {HlsSession} session
4471
+ */
4472
+ async #learnFromEncoder(session) {
4473
+ if (
4474
+ !session ||
4475
+ session.state === "disposed" ||
4476
+ session.state === "failed" ||
4477
+ !session.ffmpeg ||
4478
+ session.encoderPaused === true ||
4479
+ session.transcodeVideo !== true
4480
+ ) {
4481
+ return;
4482
+ }
4483
+ const speed = this.#parseSpeed(session.progress?.speed);
4484
+ if (speed === null || speed === session.lastLearnedSpeed) {
4485
+ return;
4486
+ }
4487
+ if (speed < BUDGET_SPEED_OK && await this.#classifyTranscodeBound(session) === "download") {
4488
+ return; // the torrent is what is short; this says nothing about the host
4489
+ }
4490
+ session.lastLearnedSpeed = speed;
4491
+ this.#learnDecodeCost(session, speed);
4492
+ }
4493
+
4249
4494
  #learnDecodeCost(session, speed) {
4250
4495
  if (session.transcodeVideo !== true || !(speed > 0)) {
4251
4496
  return; // a copied video decodes nothing, so it says nothing about decoding
@@ -4407,11 +4652,18 @@ export class HlsSessionManager {
4407
4652
  const dropped = [];
4408
4653
  for (const height of heights) {
4409
4654
  // 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.
4655
+ // when the FAMILY serves it by copy — neither has to be predicted,
4656
+ // because it is happening. A copied rung costs no encoder at all, so no
4657
+ // measurement of this host can ever be a reason to withdraw it, and the
4658
+ // whole point of it is that it is where a viewer on a rung the machine
4659
+ // cannot hold goes back to. `transcodeVideo` here is the base's, not the
4660
+ // asking session's: a 240p rung re-encodes, and reading its own flag is
4661
+ // what withdrew a copied 1080p in the field on 2026-08-15.
4662
+ //
4663
+ // A source height that would have to be RE-ENCODED is a prediction like
4664
+ // any other: on a session whose budget downshifted to 480p, the source's
4665
+ // 1080p is neither copied nor being produced, and keeping it unpriced
4666
+ // would offer exactly the kind of rung this refuses.
4415
4667
  if (
4416
4668
  height === ownHeight ||
4417
4669
  height === playingHeight ||
@@ -4646,6 +4898,16 @@ export class HlsSessionManager {
4646
4898
  // variants could drift onto the same height and the choice would mean
4647
4899
  // nothing.
4648
4900
  manualQuality: true,
4901
+ // A rung of a session whose audio is published separately carries no
4902
+ // audio either — every rung of one master must agree about that, or
4903
+ // switching rung would start or stop a second copy of the same track.
4904
+ audioRenditions: base.audioRenditions === true,
4905
+ // Not re-decided here: asked on its own, a variant would answer about the
4906
+ // rungs IT would be offered at — a 540p rung of a copied 1080p source is
4907
+ // offered nothing but itself, so it would conclude "audio muxed" and
4908
+ // start carrying a second copy of a track the player is already fetching
4909
+ // from the rendition.
4910
+ inheritedAudioSeparate: base.audioSeparate === true,
4649
4911
  segmentFormatId: base.segmentFormat?.id ?? "",
4650
4912
  // Cut where the base is cut. Only for a base on the source's own keyframe
4651
4913
  // grid — a copy — where the variant has to land on those exact times to
@@ -4934,19 +5196,204 @@ export class HlsSessionManager {
4934
5196
  }
4935
5197
  const sourceWidth = Number(session.sourceWidth) || 0;
4936
5198
  const lines = ["#EXTM3U", `#EXT-X-VERSION:${session.segmentFormat.playlistVersion}`];
5199
+ // The audio tracks, published once for the whole file rather than muxed
5200
+ // into every rung. Two things follow from that: the same track is not
5201
+ // encoded once per rung on a host that struggles to encode it once, and
5202
+ // changing track becomes the player switching rendition instead of this
5203
+ // proxy rebuilding the session with another `audioTrackIndex`.
5204
+ //
5205
+ // Only for a session that asked for them. A browser that does not know
5206
+ // about renditions is served audio in its stream, as before, and gets no
5207
+ // `#EXT-X-MEDIA` lines to be confused by.
5208
+ const renditions = this.#servesAudioSeparately(session) ? this.#audioRenditionsOf(session) : [];
5209
+ const audioGroup = renditions.length > 0 ? AUDIO_GROUP_ID : "";
5210
+ for (const rendition of renditions) {
5211
+ lines.push(
5212
+ `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="${audioGroup}",NAME="${escapeAttribute(rendition.name)}"` +
5213
+ (rendition.language ? `,LANGUAGE="${escapeAttribute(languageTag(rendition.language))}"` : "") +
5214
+ `,AUTOSELECT=YES,DEFAULT=${rendition.isDefault ? "YES" : "NO"}` +
5215
+ `,URI="${AUDIO_PATH_PREFIX}/${rendition.trackIndex}/${PLAYLIST_FILE_NAME}"`
5216
+ );
5217
+ }
4937
5218
  for (const height of rungs) {
4938
5219
  const width = sourceHeight > 0 && sourceWidth > 0
4939
5220
  ? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
4940
5221
  : 0;
4941
5222
  lines.push(
4942
5223
  `#EXT-X-STREAM-INF:BANDWIDTH=${estimatedBitrateFor(height)}` +
4943
- (width > 0 ? `,RESOLUTION=${width}x${height}` : "")
5224
+ (width > 0 ? `,RESOLUTION=${width}x${height}` : "") +
5225
+ (audioGroup ? `,AUDIO="${audioGroup}"` : "")
4944
5226
  );
4945
5227
  lines.push(`${VARIANT_PATH_PREFIX}/${height}/${PLAYLIST_FILE_NAME}`);
4946
5228
  }
4947
5229
  return `${lines.join("\n")}\n`;
4948
5230
  }
4949
5231
 
5232
+ /**
5233
+ * Whether this session's audio is published separately rather than muxed into
5234
+ * its picture.
5235
+ *
5236
+ * Two things have to hold, and the second is why this is asked here rather
5237
+ * than settled when the session was made. The browser must understand
5238
+ * renditions — it says so when it creates the session, and one that does not
5239
+ * has to be sent audio in the stream. AND there must be a master playlist to
5240
+ * publish them in: a stream served as a single media playlist has nowhere to
5241
+ * carry an `#EXT-X-MEDIA` line, so taking the audio out of it would leave the
5242
+ * viewer with a picture and silence.
5243
+ *
5244
+ * @param {HlsSession} session
5245
+ * @returns {boolean}
5246
+ */
5247
+ #servesAudioSeparately(session) {
5248
+ return session.audioOnly !== true && session.audioSeparate === true;
5249
+ }
5250
+
5251
+ /**
5252
+ * One file of an audio rendition: its playlist, its init segment or one of
5253
+ * its segments.
5254
+ *
5255
+ * A rendition is an ordinary session underneath — same source, same file,
5256
+ * same cut grid, one audio track and no picture — created on the first
5257
+ * request for it, exactly as a quality variant is. What differs is that the
5258
+ * player fetches it ALONGSIDE a variant rather than instead of one, so both
5259
+ * encoders run: a rung and the audio it is played with.
5260
+ *
5261
+ * @param {string} baseSessionId
5262
+ * @param {number} trackIndex
5263
+ * @param {string} fileName
5264
+ * @returns {Promise<{ sessionId: string | null, error?: string }>}
5265
+ */
5266
+ async resolveAudioRenditionFile(baseSessionId, trackIndex, fileName) {
5267
+ if (!isSafeSessionId(baseSessionId) || !Number.isInteger(trackIndex) || trackIndex < 0) {
5268
+ return { sessionId: null };
5269
+ }
5270
+ const base = this.sessionsById.get(baseSessionId);
5271
+ if (!base || base.state === "disposed" || !this.#servesAudioSeparately(base)) {
5272
+ return { sessionId: null };
5273
+ }
5274
+ const isPlaylist = fileName === PLAYLIST_FILE_NAME;
5275
+ const isInit = base.segmentFormat.initFileName !== null && fileName === base.segmentFormat.initFileName;
5276
+ const isSegment = base.segmentFormat.isSegmentFileName(fileName);
5277
+ if (!isPlaylist && !isInit && !isSegment) {
5278
+ return { sessionId: null };
5279
+ }
5280
+ if (!this.#audioRenditionsOf(base).some((rendition) => rendition.trackIndex === trackIndex)) {
5281
+ return { sessionId: null };
5282
+ }
5283
+ // The playlist is answered from the base, for the same reason a variant's
5284
+ // is: every rendition of a file has the same boundaries and the same
5285
+ // duration — they are cut on one grid — and the player fetches the playlist
5286
+ // of tracks it may never select. Starting an encoder for each would put as
5287
+ // many encoders on the host as the file has languages.
5288
+ if (isPlaylist) {
5289
+ return { sessionId: base.id };
5290
+ }
5291
+ let rendition;
5292
+ try {
5293
+ rendition = await this.#resolveAudioRenditionSession(base, trackIndex);
5294
+ } catch (error) {
5295
+ const message = error instanceof Error ? error.message : String(error);
5296
+ logger.error(
5297
+ `transcode ${baseSessionId} could not prepare audio track ${trackIndex}: ${message}` +
5298
+ (error instanceof Error && error.stack ? `\n${error.stack}` : "")
5299
+ );
5300
+ return { sessionId: null, error: message };
5301
+ }
5302
+ return { sessionId: rendition?.id ?? null };
5303
+ }
5304
+
5305
+ /**
5306
+ * The session producing one audio track of this file, made on first request.
5307
+ *
5308
+ * @param {HlsSession} base
5309
+ * @param {number} trackIndex
5310
+ * @returns {Promise<HlsSession | null>}
5311
+ */
5312
+ async #resolveAudioRenditionSession(base, trackIndex) {
5313
+ const existingId = base.audioRenditionSessions?.get(trackIndex);
5314
+ const existing = existingId ? this.sessionsById.get(existingId) : null;
5315
+ if (existing && existing.state !== "disposed") {
5316
+ existing.lastAccessedAt = Date.now();
5317
+ return existing;
5318
+ }
5319
+ const rendition = await this.createOrGetSession({
5320
+ sourceKey: base.sourceKey,
5321
+ fileIndex: base.fileIndex,
5322
+ // No picture at all: the video flag says what to do with a video stream
5323
+ // this output does not carry.
5324
+ transcodeVideo: false,
5325
+ transcodeAudio: base.transcodeAudio,
5326
+ fileName: base.fileName,
5327
+ consumerId: variantConsumerId(base.id),
5328
+ audioTrackIndex: trackIndex,
5329
+ audioOnly: true,
5330
+ // Where the viewer is, so the rendition starts with the picture rather
5331
+ // than at the beginning of the file. Read the same way a quality variant
5332
+ // reads it: the base's own field is only written by a seek or by a
5333
+ // segment IT served, so on a resume-from-position open it is still unset
5334
+ // while the player is asking for segment #537 — and the audio would begin
5335
+ // at zero and never catch up, since nothing treats a far request as a
5336
+ // seek. The accessor falls back to the last segment actually requested.
5337
+ startPositionSeconds:
5338
+ Math.floor(this.#viewerPositionOf(this.#activeVariant(base)) / 10) * 10,
5339
+ segmentFormatId: base.segmentFormat.id,
5340
+ // Cut where the picture is cut. Two streams meant to be played together
5341
+ // have to be divided at the same times, and the grid is the base's — the
5342
+ // table as it stands now, corrections included. A base on the uniform
5343
+ // grid passes nothing: the rendition computes the same even grid from the
5344
+ // same duration.
5345
+ inheritedGrid: base.cutGrid === "keyframe"
5346
+ ? {
5347
+ boundaries: base.segmentBoundaries,
5348
+ keyframeTimes: base.keyframeTimes,
5349
+ containerFormat: base.containerFormat
5350
+ }
5351
+ : null,
5352
+ acquireSource: base.acquireSource
5353
+ });
5354
+ if (!rendition) {
5355
+ return null;
5356
+ }
5357
+ if (!(base.audioRenditionSessions instanceof Map)) {
5358
+ base.audioRenditionSessions = new Map();
5359
+ }
5360
+ base.audioRenditionSessions.set(trackIndex, rendition.id);
5361
+ return rendition;
5362
+ }
5363
+
5364
+ /**
5365
+ * The audio tracks of this session's file, as renditions for the master.
5366
+ *
5367
+ * Taken from the inventory the playback plan already probed — the same list
5368
+ * the browser's audio menu is built from — so nothing is probed again here.
5369
+ * The track the session was created with is the default one: it is what the
5370
+ * viewer chose (or the file's first track), and a master that defaulted to
5371
+ * something else would change the language on its own.
5372
+ *
5373
+ * @param {HlsSession} session
5374
+ * @returns {Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>}
5375
+ */
5376
+ #audioRenditionsOf(session) {
5377
+ const tracks = this.getCachedAudioTracks?.({
5378
+ sourceKey: session.sourceKey,
5379
+ fileIndex: session.fileIndex
5380
+ }) ?? [];
5381
+ if (!Array.isArray(tracks) || tracks.length === 0) {
5382
+ return [];
5383
+ }
5384
+ const chosen = Number(session.audioTrackIndex) || 0;
5385
+ return tracks.map((track, order) => {
5386
+ const language = typeof track?.language === "string" ? track.language : "";
5387
+ const title = typeof track?.title === "string" && track.title.length > 0 ? track.title : "";
5388
+ return {
5389
+ trackIndex: order,
5390
+ name: title || language || `Track ${order + 1}`,
5391
+ language,
5392
+ isDefault: order === chosen
5393
+ };
5394
+ });
5395
+ }
5396
+
4950
5397
  /**
4951
5398
  * How many times the viewer has moved since this session started.
4952
5399
  *
@@ -5597,6 +6044,21 @@ export class HlsSessionManager {
5597
6044
  );
5598
6045
  }
5599
6046
  }
6047
+ // The audio renditions of this session, for the same reason and in the same
6048
+ // way. Nobody outside this class knows their ids — the browser holds one id
6049
+ // for the whole file — so nothing else could ever release them, and each
6050
+ // holds a consumer, a claim on the torrent, a directory and a live encoder.
6051
+ if (session.audioRenditionSessions instanceof Map) {
6052
+ const renditionIds = [...session.audioRenditionSessions.values()];
6053
+ session.audioRenditionSessions.clear();
6054
+ for (const renditionId of renditionIds) {
6055
+ await this.releaseSessionConsumer(
6056
+ renditionId,
6057
+ variantConsumerId(sessionId),
6058
+ "the session its audio belonged to ended"
6059
+ );
6060
+ }
6061
+ }
5600
6062
  // Disposed on its own (idle, or with its last family): it must stop being
5601
6063
  // offered, or the next request for that height would be answered with a
5602
6064
  // session that no longer exists.