@torrent-tv/proxy 2.74.0 → 2.75.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.
@@ -1,411 +1,201 @@
1
- /**
2
- * The soundtracks a viewer may choose between, as ONE numbered list — the ones
3
- * muxed into the picture and the ones shipped as separate files beside it.
4
- *
5
- * Why one list. Everything downstream addresses a soundtrack by a single number:
6
- * the browser's menu, the `audioTrackIndex` on the session-create request, the
7
- * `a/<n>/` path a rendition is published at, and hls.js's own rendition order.
8
- * Giving a sidecar file its own numbering would mean a second vocabulary and a
9
- * translation at every boundary. Instead the number stays flat and this module
10
- * owns the only place that knows what it resolves to: which FILE the track lives
11
- * in, and which track it is inside that file.
12
- *
13
- * The list is built from two readings of the same file, and that is deliberate:
14
- *
15
- * - ffmpeg's `-i` banner, which is what `0:a:N` will select and therefore the
16
- * authority on NUMBERING;
17
- * - the container's own track table (`services/container/`), which is the only
18
- * authority on the FLAGS — `FlagOriginal`, `FlagCommentary`,
19
- * `FlagVisualImpaired`, `FlagEnabled` and `LanguageBCP47` do not appear in the
20
- * banner at all, so the audio menu could not tell a director's commentary from
21
- * the film itself.
22
- *
23
- * The two are lined up by position and the pairing is CHECKED, exactly as
24
- * `subtitle-defaults.js` checks its own: a length that differs, or one pair that
25
- * agrees on neither language nor title, means the two readings are not
26
- * describing the same thing in the same order — and then the container reading
27
- * is dropped whole rather than attributed to the wrong track. A wrong flag is
28
- * worse than a missing one, because `0:a:N` is what the encoder is given.
29
- */
30
-
31
- /**
32
- * Language codes that carry no information, so cannot confirm a pairing. Same
33
- * rule and same reasoning as `subtitle-defaults.js`: ffmpeg prints `und` for a
34
- * stream with no language, while Matroska's own default for `Language` is `eng`
35
- * — which is also a real answer, so it is compared like any other.
36
- */
37
- const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
38
-
39
- /**
40
- * @param {unknown} value
41
- * @returns {string}
42
- */
43
- function normalise(value) {
44
- return typeof value === "string" ? value.trim().toLowerCase() : "";
45
- }
46
-
47
- /**
48
- * Whether one banner stream and one container track can be the same track.
49
- *
50
- * Agreement on either the language or the title is enough; both sides saying
51
- * nothing is not agreement, but it is not disagreement either — a file may name
52
- * neither, and then this pair simply adds no support to the alignment.
53
- *
54
- * @param {{ language?: string, title?: string }} banner
55
- * @param {{ language?: string, name?: string }} container
56
- * @returns {boolean}
57
- */
58
- export function audioPairingHolds(banner, container) {
59
- const bannerLanguage = normalise(banner?.language);
60
- const containerLanguage = normalise(container?.language);
61
- if (
62
- !EMPTY_LANGUAGES.has(bannerLanguage) &&
63
- !EMPTY_LANGUAGES.has(containerLanguage) &&
64
- bannerLanguage === containerLanguage
65
- ) {
66
- return true;
67
- }
68
- const bannerTitle = normalise(banner?.title);
69
- const containerName = normalise(container?.name);
70
- if (bannerTitle.length > 0 && bannerTitle === containerName) {
71
- return true;
72
- }
73
- return (
74
- (EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
75
- (bannerTitle.length === 0 || containerName.length === 0)
76
- );
77
- }
78
-
79
- /**
80
- * The banner's audio streams, with what the container declares about each.
81
- *
82
- * @param {Array<{ index?: number, language?: string, title?: string, isDefault?: boolean, codec?: string }>} bannerTracks
83
- * @param {Array<object>} declared - `AudioTrack`s in container order.
84
- * @returns {{ tracks: object[], aligned: boolean, reason: string }}
85
- */
86
- export function mergeContainerAudioFlags(bannerTracks, declared) {
87
- const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
88
- const container = Array.isArray(declared) ? declared : [];
89
- const undecided = () => ({
90
- // Nothing of the container reading is used, flags included — attributing
91
- // them to the wrong track is the failure this guard exists to prevent.
92
- tracks: banner.map((track) => ({
93
- ...track,
94
- declaresDefault: false,
95
- isOriginal: false,
96
- isCommentary: false,
97
- isVisualImpaired: false,
98
- // Not "the container says this track is unusable": the container has not
99
- // been heard from. A track is offered unless it was read to say otherwise.
100
- isEnabled: true,
101
- languageBcp47: "",
102
- channels: null
103
- }))
104
- });
105
- if (banner.length === 0) {
106
- return { tracks: [], aligned: false, reason: "the probe found no audio stream" };
107
- }
108
- if (container.length === 0) {
109
- return { ...undecided(), aligned: false, reason: "the container declares no audio track" };
110
- }
111
- if (container.length !== banner.length) {
112
- return {
113
- ...undecided(),
114
- aligned: false,
115
- reason: `the container declares ${container.length} audio tracks and the probe found ${banner.length}`
116
- };
117
- }
118
- for (const [order, track] of banner.entries()) {
119
- if (!audioPairingHolds(track, container[order])) {
120
- return {
121
- ...undecided(),
122
- aligned: false,
123
- reason:
124
- `audio ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
125
- `in the probe and "${normalise(container[order]?.name) || "-"}"/` +
126
- `${normalise(container[order]?.language) || "-"} in the container`
127
- };
128
- }
129
- }
130
- return {
131
- tracks: banner.map((track, order) => ({
132
- ...track,
133
- // Read from the file itself (RFC 9559 §5.1.4.1). None of these four
134
- // reaches ffmpeg's banner, which is where every other field here is from.
135
- isOriginal: container[order].isOriginal === true,
136
- isCommentary: container[order].isCommentary === true,
137
- isVisualImpaired: container[order].isVisualImpaired === true,
138
- isEnabled: container[order].isEnabled !== false,
139
- // FlagDefault, and whether the file actually WROTE it. Matroska defaults
140
- // the flag to 1 and ffmpeg prints the applied default, so the banner
141
- // cannot tell "every track marked" from "the file has no opinion".
142
- isDefault: container[order].isDefault === true,
143
- declaresDefault: container[order].declaresDefault === true,
144
- languageBcp47:
145
- typeof container[order].languageBcp47 === "string" ? container[order].languageBcp47 : "",
146
- channels: Number.isFinite(container[order].channels) ? container[order].channels : null,
147
- title:
148
- typeof track?.title === "string" && track.title.length > 0
149
- ? track.title
150
- : (typeof container[order].name === "string" ? container[order].name : "")
151
- })),
152
- aligned: true,
153
- reason: ""
154
- };
155
- }
156
-
157
- /**
158
- * Codec identifiers as containers write them, against the name ffmpeg prints.
159
- *
160
- * Needed because the browser decides whether it can play a soundtrack from that
161
- * name, and for a sidecar file there is no ffmpeg banner to read it from — the
162
- * track came from the container's own table, where Matroska writes `A_AC3` and
163
- * MP4 writes `ac-3` for the thing ffmpeg calls `ac3`. Only what a soundtrack can
164
- * actually be is listed; an identifier not here is reported as it was written,
165
- * which the browser treats as one it does not know and therefore transcodes.
166
- */
167
- const CODEC_NAMES = new Map([
168
- ["A_AAC", "aac"],
169
- ["A_AC3", "ac3"],
170
- ["A_EAC3", "eac3"],
171
- ["A_DTS", "dts"],
172
- ["A_FLAC", "flac"],
173
- ["A_OPUS", "opus"],
174
- ["A_VORBIS", "vorbis"],
175
- ["A_TRUEHD", "truehd"],
176
- ["A_MPEG/L3", "mp3"],
177
- ["A_MPEG/L2", "mp2"],
178
- ["A_ALAC", "alac"],
179
- ["mp4a", "aac"],
180
- ["ac-3", "ac3"],
181
- ["ec-3", "eac3"],
182
- ["alac", "alac"],
183
- ["opus", "opus"],
184
- ["Opus", "opus"],
185
- ["fLaC", "flac"],
186
- ["flac", "flac"]
187
- ]);
188
-
189
- /**
190
- * Extensions of raw elementary streams, against the codec they carry.
191
- *
192
- * A bare `.ac3` has no track table to read, and its extension is the only thing
193
- * that states its codec — which for an elementary stream is exactly what the
194
- * extension means.
195
- */
196
- const CODEC_BY_EXTENSION = new Map([
197
- [".aac", "aac"],
198
- [".ac3", "ac3"],
199
- [".eac3", "eac3"],
200
- [".dts", "dts"],
201
- [".dtshd", "dts"],
202
- [".flac", "flac"],
203
- [".mp3", "mp3"],
204
- [".mp2", "mp2"],
205
- [".opus", "opus"],
206
- [".ogg", "vorbis"],
207
- [".oga", "vorbis"],
208
- [".wav", "pcm"],
209
- [".thd", "truehd"],
210
- [".mlp", "truehd"],
211
- [".m4a", "aac"]
212
- ]);
213
-
214
- /**
215
- * The ffmpeg-side codec name for one track, from whatever the reading gave.
216
- *
217
- * @param {{ codec?: string, codecId?: string }} track
218
- * @param {string} [extension] - The sidecar file's extension, when the track
219
- * came from a file with no readable table.
220
- * @returns {string}
221
- */
222
- export function codecNameOf(track, extension = "") {
223
- const fromBanner = typeof track?.codec === "string" ? track.codec.trim() : "";
224
- if (fromBanner.length > 0) {
225
- return fromBanner.toLowerCase();
226
- }
227
- const codecId = typeof track?.codecId === "string" ? track.codecId.trim() : "";
228
- if (codecId.length > 0) {
229
- const known = CODEC_NAMES.get(codecId);
230
- if (known) {
231
- return known;
232
- }
233
- // Matroska allows a suffix — `A_AAC/MPEG4/LC`, `A_PCM/INT/LIT` — so the
234
- // family is what the first two segments say.
235
- const family = codecId.split("/").slice(0, 2).join("/");
236
- const byFamily = CODEC_NAMES.get(family) ?? CODEC_NAMES.get(codecId.split("/")[0]);
237
- if (byFamily) {
238
- return byFamily;
239
- }
240
- if (codecId.startsWith("A_PCM")) {
241
- return "pcm";
242
- }
243
- return codecId.toLowerCase();
244
- }
245
- return CODEC_BY_EXTENSION.get(extension) ?? "";
246
- }
247
-
248
- /**
249
- * @typedef {object} AudioInventoryEntry
250
- * @property {number} index - The flat number everything downstream uses.
251
- * @property {number} fileIndex - The torrent file this track lives in.
252
- * @property {number} sourceTrackIndex - `0:a:N` WITHIN that file.
253
- * @property {"embedded" | "sidecar"} kind - Whether it is muxed into the picture
254
- * or ships as a file beside it. Not a type of track — a statement about where
255
- * the bytes are.
256
- * @property {string} codec
257
- * @property {string} language - As the container states it, or "" when it does
258
- * not. Never guessed here: what a folder name suggests is derived in the
259
- * browser, where the language table and the viewer's locale already live.
260
- * @property {string} languageBcp47
261
- * @property {string} title
262
- * @property {boolean} isDefault
263
- * @property {boolean} declaresDefault
264
- * @property {boolean} isOriginal
265
- * @property {boolean} isCommentary
266
- * @property {boolean} isVisualImpaired
267
- * @property {boolean} isEnabled
268
- * @property {number | null} channels
269
- * @property {string} fileName - For a sidecar: its own file name. "" otherwise.
270
- * @property {string[]} folders - For a sidecar: the folders above it, relative
271
- * to the torrent root. What the browser reads a language and a releaser from.
272
- */
273
-
274
- /**
275
- * One numbered list from the picture's own tracks and its sidecar files.
276
- *
277
- * Order is load-bearing: embedded tracks keep the numbers they have always had,
278
- * so a session created before this existed and one created after agree about
279
- * what `audioTrackIndex: 1` means, and sidecars are appended after them in
280
- * torrent-file order. Sidecar files are stable in that order for a given
281
- * torrent, so the numbering is stable for a given file.
282
- *
283
- * @param {object} params
284
- * @param {object[]} params.embedded - Merged banner+container tracks of the video.
285
- * @param {number} params.videoFileIndex
286
- * @param {Array<{ file: import("./sidecar-files.js").SidecarFile, tracks: object[] }>} params.sidecars
287
- * Each sidecar file with the audio tracks IT holds. A file whose container
288
- * could not be read contributes one track, which is what a bare elementary
289
- * stream is.
290
- * @returns {AudioInventoryEntry[]}
291
- */
292
- export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
293
- /** @type {AudioInventoryEntry[]} */
294
- const inventory = [];
295
- const add = (track, fileIndex, sourceTrackIndex, kind, file) => {
296
- inventory.push({
297
- index: inventory.length,
298
- fileIndex,
299
- sourceTrackIndex,
300
- kind,
301
- // The name the browser judges "can I play this?" by. For an embedded
302
- // track it is ffmpeg's own; for a sidecar it is translated from what the
303
- // container wrote, or from the extension when there was no table to read.
304
- codec: codecNameOf(track, file?.extension ?? ""),
305
- language: typeof track?.language === "string" ? track.language : "",
306
- languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
307
- title:
308
- typeof track?.title === "string" && track.title.length > 0
309
- ? track.title
310
- : (typeof track?.name === "string" ? track.name : ""),
311
- isDefault: track?.isDefault === true,
312
- declaresDefault: track?.declaresDefault === true,
313
- isOriginal: track?.isOriginal === true,
314
- isCommentary: track?.isCommentary === true,
315
- isVisualImpaired: track?.isVisualImpaired === true,
316
- isEnabled: track?.isEnabled !== false,
317
- channels: Number.isFinite(track?.channels) ? track.channels : null,
318
- fileName: kind === "sidecar" ? (file?.name ?? "") : "",
319
- folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : []
320
- });
321
- };
322
-
323
- for (const [order, track] of (Array.isArray(embedded) ? embedded : []).entries()) {
324
- add(track, videoFileIndex, order, "embedded", null);
325
- }
326
- for (const sidecar of Array.isArray(sidecars) ? sidecars : []) {
327
- const tracks = Array.isArray(sidecar?.tracks) && sidecar.tracks.length > 0
328
- ? sidecar.tracks
329
- // A file whose track table could not be read is one track: `.ac3`, `.dts`
330
- // and `.mp3` have no table to read, and a `.mka` whose head has not
331
- // arrived yet is better offered than hidden — ffmpeg will find its first
332
- // audio stream either way.
333
- : [{}];
334
- for (const [order, track] of tracks.entries()) {
335
- add(track, sidecar.file.fileIndex, order, "sidecar", sidecar.file);
336
- }
337
- }
338
- return inventory;
339
- }
340
-
341
- /**
342
- * Resolve the flat number back to the file and the track inside it.
343
- *
344
- * @param {AudioInventoryEntry[]} inventory
345
- * @param {number} index
346
- * @returns {AudioInventoryEntry | null}
347
- */
348
- export function resolveAudioIndex(inventory, index) {
349
- if (!Array.isArray(inventory) || !Number.isInteger(index) || index < 0) {
350
- return null;
351
- }
352
- return inventory.find((entry) => entry.index === index) ?? null;
353
- }
354
-
355
- /**
356
- * The name an `#EXT-X-MEDIA` line carries for one soundtrack.
357
- *
358
- * Deliberately plain, and deliberately NOT localised: this is the name inside a
359
- * playlist, and what the viewer reads in the menu is composed in the browser
360
- * from the same facts, where the language table and the viewer's own locale are.
361
- * The only requirements here are that it says something and that no two
362
- * renditions of one file share it — hls.js groups renditions by name.
363
- *
364
- * @param {AudioInventoryEntry} entry
365
- * @param {AudioInventoryEntry[]} inventory
366
- * @returns {string}
367
- */
368
- export function audioRenditionName(entry, inventory) {
369
- const parts = [];
370
- if (entry.title) {
371
- parts.push(entry.title);
372
- } else if (entry.languageBcp47 || entry.language) {
373
- parts.push(entry.languageBcp47 || entry.language);
374
- } else if (entry.folders.length > 0) {
375
- // The folder a dub sits in is usually the only thing naming it, and a
376
- // release names it for a reason: "Rus Sound", "Ukr Dub".
377
- parts.push(entry.folders[entry.folders.length - 1]);
378
- } else if (entry.fileName) {
379
- parts.push(entry.fileName);
380
- } else {
381
- parts.push(`Track ${entry.index + 1}`);
382
- }
383
- if (entry.isCommentary) {
384
- parts.push("commentary");
385
- } else if (entry.isVisualImpaired) {
386
- parts.push("described");
387
- }
388
- const name = parts.join(" · ");
389
- const clash = (Array.isArray(inventory) ? inventory : []).some(
390
- (other) => other.index !== entry.index && audioRenditionNameCore(other) === audioRenditionNameCore(entry)
391
- );
392
- return clash ? `${name} (${entry.index + 1})` : name;
393
- }
394
-
395
- /**
396
- * The part of a rendition name that a clash is judged on — the name without the
397
- * disambiguating number, so that adding the number cannot itself cause a clash.
398
- *
399
- * @param {AudioInventoryEntry} entry
400
- * @returns {string}
401
- */
402
- function audioRenditionNameCore(entry) {
403
- return (
404
- entry.title ||
405
- entry.languageBcp47 ||
406
- entry.language ||
407
- (entry.folders.length > 0 ? entry.folders[entry.folders.length - 1] : "") ||
408
- entry.fileName ||
409
- ""
410
- );
411
- }
1
+ /**
2
+ * The soundtracks a viewer may choose between, as ONE numbered list — the ones
3
+ * muxed into the picture and the ones shipped as separate files beside it.
4
+ *
5
+ * Why one list. Everything downstream addresses a soundtrack by a single number:
6
+ * the browser's menu, the `audioTrackIndex` on the session-create request, the
7
+ * `a/<n>/` path a rendition is published at, and hls.js's own rendition order.
8
+ * Giving a sidecar file its own numbering would mean a second vocabulary and a
9
+ * translation at every boundary. Instead the number stays flat and this module
10
+ * owns the only place that knows what it resolves to: which FILE the track lives
11
+ * in, and which track it is inside that file.
12
+ *
13
+ * The list is built from two readings of the same file, and that is deliberate:
14
+ *
15
+ * - ffmpeg's `-i` banner, which is what `0:a:N` will select and therefore the
16
+ * authority on NUMBERING;
17
+ * - the container's own track table (`services/container/`), which is the only
18
+ * authority on the FLAGS — `FlagOriginal`, `FlagCommentary`,
19
+ * `FlagVisualImpaired`, `FlagEnabled` and `LanguageBCP47` do not appear in the
20
+ * banner at all, so the audio menu could not tell a director's commentary from
21
+ * the film itself.
22
+ *
23
+ * The two are lined up by position and the pairing is CHECKED, exactly as
24
+ * `subtitle-defaults.js` checks its own: a length that differs, or one pair that
25
+ * agrees on neither language nor title, means the two readings are not
26
+ * describing the same thing in the same order — and then the container reading
27
+ * is dropped whole rather than attributed to the wrong track. A wrong flag is
28
+ * worse than a missing one, because `0:a:N` is what the encoder is given.
29
+ */
30
+
31
+
32
+ import { AudioTrack } from "./tracks/AudioTrack.js";
33
+
34
+
35
+
36
+
37
+
38
+ /**
39
+ * @typedef {object} AudioInventoryEntry
40
+ * @property {number} index - The flat number everything downstream uses.
41
+ * @property {number} fileIndex - The torrent file this track lives in.
42
+ * @property {number} sourceTrackIndex - `0:a:N` WITHIN that file.
43
+ * @property {"embedded" | "sidecar"} kind - Whether it is muxed into the picture
44
+ * or ships as a file beside it. Not a type of track — a statement about where
45
+ * the bytes are.
46
+ * @property {string} codec
47
+ * @property {string} language - As the container states it, or "" when it does
48
+ * not. Never guessed here: what a folder name suggests is derived in the
49
+ * browser, where the language table and the viewer's locale already live.
50
+ * @property {string} languageBcp47
51
+ * @property {string} title
52
+ * @property {boolean} isDefault
53
+ * @property {boolean} declaresDefault
54
+ * @property {boolean} isOriginal
55
+ * @property {boolean} isCommentary
56
+ * @property {boolean} isVisualImpaired
57
+ * @property {boolean} isEnabled
58
+ * @property {number | null} channels
59
+ * @property {string} fileName - For a sidecar: its own file name. "" otherwise.
60
+ * @property {string[]} folders - For a sidecar: the folders above it, relative
61
+ * to the torrent root. What the browser reads a language and a releaser from.
62
+ */
63
+
64
+ /**
65
+ * One numbered list from the picture's own tracks and its sidecar files.
66
+ *
67
+ * Order is load-bearing: embedded tracks keep the numbers they have always had,
68
+ * so a session created before this existed and one created after agree about
69
+ * what `audioTrackIndex: 1` means, and sidecars are appended after them in
70
+ * torrent-file order. Sidecar files are stable in that order for a given
71
+ * torrent, so the numbering is stable for a given file.
72
+ *
73
+ * @param {object} params
74
+ * @param {object[]} params.embedded - Merged banner+container tracks of the video.
75
+ * @param {number} params.videoFileIndex
76
+ * @param {Array<{ file: import("./sidecar-files.js").SidecarFile, tracks: object[] }>} params.sidecars
77
+ * Each sidecar file with the audio tracks IT holds. A file whose container
78
+ * could not be read contributes one track, which is what a bare elementary
79
+ * stream is.
80
+ * @returns {AudioInventoryEntry[]}
81
+ */
82
+ export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
83
+ /** @type {AudioInventoryEntry[]} */
84
+ const inventory = [];
85
+ const add = (track, fileIndex, sourceTrackIndex, kind, file) => {
86
+ inventory.push({
87
+ index: inventory.length,
88
+ fileIndex,
89
+ sourceTrackIndex,
90
+ kind,
91
+ // The name the browser judges "can I play this?" by. For an embedded
92
+ // track it is ffmpeg's own; for a sidecar it is translated from what the
93
+ // container wrote, or from the extension when there was no table to read.
94
+ codec: AudioTrack.codecNameOf(track, file?.extension ?? ""),
95
+ language: typeof track?.language === "string" ? track.language : "",
96
+ languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
97
+ title:
98
+ typeof track?.title === "string" && track.title.length > 0
99
+ ? track.title
100
+ : (typeof track?.name === "string" ? track.name : ""),
101
+ isDefault: track?.isDefault === true,
102
+ declaresDefault: track?.declaresDefault === true,
103
+ isOriginal: track?.isOriginal === true,
104
+ isCommentary: track?.isCommentary === true,
105
+ isVisualImpaired: track?.isVisualImpaired === true,
106
+ isEnabled: track?.isEnabled !== false,
107
+ channels: Number.isFinite(track?.channels) ? track.channels : null,
108
+ fileName: kind === "sidecar" ? (file?.name ?? "") : "",
109
+ folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : []
110
+ });
111
+ };
112
+
113
+ for (const [order, track] of (Array.isArray(embedded) ? embedded : []).entries()) {
114
+ add(track, videoFileIndex, order, "embedded", null);
115
+ }
116
+ for (const sidecar of Array.isArray(sidecars) ? sidecars : []) {
117
+ const tracks = Array.isArray(sidecar?.tracks) && sidecar.tracks.length > 0
118
+ ? sidecar.tracks
119
+ // A file whose track table could not be read is one track: `.ac3`, `.dts`
120
+ // and `.mp3` have no table to read, and a `.mka` whose head has not
121
+ // arrived yet is better offered than hidden — ffmpeg will find its first
122
+ // audio stream either way.
123
+ : [{}];
124
+ for (const [order, track] of tracks.entries()) {
125
+ add(track, sidecar.file.fileIndex, order, "sidecar", sidecar.file);
126
+ }
127
+ }
128
+ return inventory;
129
+ }
130
+
131
+ /**
132
+ * Resolve the flat number back to the file and the track inside it.
133
+ *
134
+ * @param {AudioInventoryEntry[]} inventory
135
+ * @param {number} index
136
+ * @returns {AudioInventoryEntry | null}
137
+ */
138
+ export function resolveAudioIndex(inventory, index) {
139
+ if (!Array.isArray(inventory) || !Number.isInteger(index) || index < 0) {
140
+ return null;
141
+ }
142
+ return inventory.find((entry) => entry.index === index) ?? null;
143
+ }
144
+
145
+ /**
146
+ * The name an `#EXT-X-MEDIA` line carries for one soundtrack.
147
+ *
148
+ * Deliberately plain, and deliberately NOT localised: this is the name inside a
149
+ * playlist, and what the viewer reads in the menu is composed in the browser
150
+ * from the same facts, where the language table and the viewer's own locale are.
151
+ * The only requirements here are that it says something and that no two
152
+ * renditions of one file share it — hls.js groups renditions by name.
153
+ *
154
+ * @param {AudioInventoryEntry} entry
155
+ * @param {AudioInventoryEntry[]} inventory
156
+ * @returns {string}
157
+ */
158
+ export function audioRenditionName(entry, inventory) {
159
+ const parts = [];
160
+ if (entry.title) {
161
+ parts.push(entry.title);
162
+ } else if (entry.languageBcp47 || entry.language) {
163
+ parts.push(entry.languageBcp47 || entry.language);
164
+ } else if (entry.folders.length > 0) {
165
+ // The folder a dub sits in is usually the only thing naming it, and a
166
+ // release names it for a reason: "Rus Sound", "Ukr Dub".
167
+ parts.push(entry.folders[entry.folders.length - 1]);
168
+ } else if (entry.fileName) {
169
+ parts.push(entry.fileName);
170
+ } else {
171
+ parts.push(`Track ${entry.index + 1}`);
172
+ }
173
+ if (entry.isCommentary) {
174
+ parts.push("commentary");
175
+ } else if (entry.isVisualImpaired) {
176
+ parts.push("described");
177
+ }
178
+ const name = parts.join(" · ");
179
+ const clash = (Array.isArray(inventory) ? inventory : []).some(
180
+ (other) => other.index !== entry.index && audioRenditionNameCore(other) === audioRenditionNameCore(entry)
181
+ );
182
+ return clash ? `${name} (${entry.index + 1})` : name;
183
+ }
184
+
185
+ /**
186
+ * The part of a rendition name that a clash is judged on — the name without the
187
+ * disambiguating number, so that adding the number cannot itself cause a clash.
188
+ *
189
+ * @param {AudioInventoryEntry} entry
190
+ * @returns {string}
191
+ */
192
+ function audioRenditionNameCore(entry) {
193
+ return (
194
+ entry.title ||
195
+ entry.languageBcp47 ||
196
+ entry.language ||
197
+ (entry.folders.length > 0 ? entry.folders[entry.folders.length - 1] : "") ||
198
+ entry.fileName ||
199
+ ""
200
+ );
201
+ }