@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.
- package/CHANGELOG.md +756 -742
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +6 -0
- package/routes/transcode/audio-file/get.js +45 -0
- package/server.js +8 -1
- package/services/hls-session-manager.js +517 -55
- package/services/playback-planner.js +14 -0
- package/test/decode-cost.test.js +347 -401
- package/test/quality-variants.test.js +166 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
//
|
|
2940
|
-
|
|
2941
|
-
|
|
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
|
|
4173
|
-
//
|
|
4174
|
-
//
|
|
4175
|
-
//
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
}
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
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
|
|
4189
|
-
// host learns what this source costs, and the reading
|
|
4190
|
-
// from the rung the viewer has just switched to — so
|
|
4191
|
-
// the lesson would be the first to be dropped, and
|
|
4192
|
-
// this list: its next segment would 404 on a stream
|
|
4193
|
-
// its own encoder still running.
|
|
4194
|
-
|
|
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(
|
|
4201
|
-
sourceHeight: Math.round(Number(
|
|
4202
|
-
fps: Number(
|
|
4203
|
-
source:
|
|
4204
|
-
transcodeVideo:
|
|
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
|
-
|
|
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
|
|
4411
|
-
// happening. A
|
|
4412
|
-
//
|
|
4413
|
-
//
|
|
4414
|
-
//
|
|
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.
|