@torrent-tv/proxy 2.89.6 → 2.89.8

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.
Files changed (92) hide show
  1. package/AGENTS.md +32 -12
  2. package/CHANGELOG.md +2340 -2330
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/biome.json +412 -412
  6. package/docs/browser-proxy-contract.md +155 -155
  7. package/docs/container-architecture.md +273 -273
  8. package/docs/encode-run-state.md +1 -1
  9. package/docs/playback-start-forecast.md +113 -98
  10. package/package.json +51 -50
  11. package/routes/api/sources/files/get.js +106 -106
  12. package/scripts/build-audio-samples.mjs +31 -31
  13. package/services/encode/EncodeRun.js +67 -15
  14. package/services/encode/EncodeRuns.js +19 -7
  15. package/services/encode/OutputOpening.js +784 -784
  16. package/services/encode/audio-calibration.js +67 -67
  17. package/services/encode/audio-work-rate.js +15 -15
  18. package/services/encode/encode-exit.js +2 -0
  19. package/services/encode/hwaccel.js +1894 -1894
  20. package/services/encode/output/index.js +24 -24
  21. package/services/encode/piece-completeness.js +60 -2
  22. package/services/encode/run-command.js +757 -755
  23. package/services/encode/segment-formats/index.js +1 -0
  24. package/services/encode/segment-formats/mp4-boxes.js +8 -1
  25. package/services/media/ContainerOrchestrator.js +202 -202
  26. package/services/media/KeyframeTables.js +356 -356
  27. package/services/media/SubtitleCues.js +592 -592
  28. package/services/media/audio-inventory.js +299 -299
  29. package/services/media/container/AviContainer.js +268 -268
  30. package/services/media/container/Container.js +632 -632
  31. package/services/media/container/ContainerFactory.js +38 -38
  32. package/services/media/container/MatroskaContainer.js +1031 -1031
  33. package/services/media/container/Mp4Container.js +1354 -1354
  34. package/services/media/container/SubtitleFileContainer.js +339 -339
  35. package/services/media/container/ebml-stream.js +174 -174
  36. package/services/media/container/matroska-clusters.js +1165 -1165
  37. package/services/media/container/unavailable.js +77 -77
  38. package/services/media/tracks/ContainerTrack.js +84 -84
  39. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  40. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  41. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  42. package/services/torrent/Contents.js +382 -382
  43. package/services/torrent/episode-naming.js +182 -182
  44. package/services/torrent/torrent-pool.js +2688 -2688
  45. package/services/torrent/worker/client.js +836 -836
  46. package/services/torrent/worker/held-bytes.js +231 -231
  47. package/services/torrent/worker/worker.js +1137 -1137
  48. package/services/transport/data-channel-handler.js +1373 -1373
  49. package/services/viewer/PriorityMap.js +421 -421
  50. package/test/audio-calibration.test.js +23 -23
  51. package/test/auto-quality-step.test.js +762 -762
  52. package/test/coalesce.test.js +39 -39
  53. package/test/coverage-follows-the-disk.test.js +5 -0
  54. package/test/decode-cost.test.js +475 -475
  55. package/test/encode-run.test.js +58 -0
  56. package/test/episode-naming.test.js +131 -131
  57. package/test/failed-encoder-still-serves-what-exists.test.js +1 -1
  58. package/test/fixtures/audio-frame-boundary.json +1 -1
  59. package/test/fixtures/refused-audio-20261002.json +10 -0
  60. package/test/fmp4-presentation-clock.test.js +4 -2
  61. package/test/format-keyed-outputs.test.js +78 -78
  62. package/test/health-metrics.test.js +11 -13
  63. package/test/held-read-ownership.test.js +77 -77
  64. package/test/helpers/encode-run.js +14 -6
  65. package/test/helpers/held-file.js +77 -77
  66. package/test/helpers/manager.js +62 -62
  67. package/test/helpers/matroska-file.js +296 -296
  68. package/test/keyframe-table-budget.test.js +123 -123
  69. package/test/matroska-cluster-discovery.test.js +323 -323
  70. package/test/one-output-one-session.test.js +273 -273
  71. package/test/output-speed.test.js +6 -1
  72. package/test/piece-completeness.test.js +37 -1
  73. package/test/quality-variants.test.js +1274 -1270
  74. package/test/rate-control-in-the-output.test.js +4 -3
  75. package/test/reads-not-yet-arrived.test.js +218 -218
  76. package/test/seek-frees-the-viewers-requests.test.js +1 -1
  77. package/test/segment-closed-on-both-branches.test.js +114 -114
  78. package/test/segment-judged-by-its-own-header.test.js +222 -222
  79. package/test/segment-serve-wiring.test.js +481 -481
  80. package/test/serving-existing-output.test.js +111 -111
  81. package/test/source-files-route.test.js +113 -113
  82. package/test/subtitle-cue-walk.test.js +385 -385
  83. package/test/subtitle-metadata.test.js +15 -15
  84. package/test/subtitle-plan-waits.test.js +125 -125
  85. package/test/subtitle-track-numbering.test.js +391 -391
  86. package/test/two-viewers-one-picture.test.js +508 -508
  87. package/test/viewer-requests-admission.test.js +92 -92
  88. package/test/viewer-starts-on-map.test.js +27 -27
  89. package/test/viewer-states.test.js +5 -2
  90. package/test/viewing-generations.test.js +390 -390
  91. package/test/whole-file-held-read.test.js +30 -30
  92. package/utils/coalesce.js +41 -41
@@ -1,299 +1,299 @@
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
- import { codecParametersComplete, codecParametersConflict, peakKbpsOf } from "./tracks/audio-bound.js";
34
-
35
-
36
-
37
-
38
-
39
- /**
40
- * @typedef {object} AudioInventoryEntry
41
- * @property {number} index - The flat number everything downstream uses.
42
- * @property {number} fileIndex - The torrent file this track lives in.
43
- * @property {number} sourceTrackIndex - `0:a:N` WITHIN that file.
44
- * @property {"embedded" | "sidecar"} kind - Whether it is muxed into the picture
45
- * or ships as a file beside it. Not a type of track — a statement about where
46
- * the bytes are.
47
- * @property {string} codec
48
- * @property {string} language - As the container states it, or "" when it does
49
- * not. Never guessed here: what a folder name suggests is derived in the
50
- * browser, where the language table and the viewer's locale already live.
51
- * @property {string} languageBcp47
52
- * @property {string} title
53
- * @property {boolean} isDefault
54
- * @property {boolean} declaresDefault
55
- * @property {boolean} isOriginal
56
- * @property {boolean} isCommentary
57
- * @property {boolean} isVisualImpaired
58
- * @property {boolean} isEnabled
59
- * @property {number | null} channels
60
- * @property {number | null} bitrateKbps - The rate the file states for this track; an average, not a bound.
61
- * @property {import("./tracks/audio-bound.js").CodecParameters | null} codecParameters -
62
- * The codec's configuration as the container states it, or null where no
63
- * track table was read.
64
- * @property {number | null} peakKbps - The most the codec, so configured, allows
65
- * this track to carry — a bound, not an average — or null where no confirmed
66
- * bound exists for that configuration (`tracks/audio-bound.js`).
67
- * @property {string} fileName - For a sidecar: its own file name. "" otherwise.
68
- * @property {string[]} folders - For a sidecar: the folders above it, relative
69
- * to the torrent root. What the browser reads a language and a releaser from.
70
- */
71
-
72
- /**
73
- * One numbered list from the picture's own tracks and its sidecar files.
74
- *
75
- * Order is load-bearing: embedded tracks keep the numbers they have always had,
76
- * so a session created before this existed and one created after agree about
77
- * what `audioTrackIndex: 1` means, and sidecars are appended after them in
78
- * torrent-file order. Sidecar files are stable in that order for a given
79
- * torrent, so the numbering is stable for a given file.
80
- *
81
- * @param {object} params
82
- * @param {object[]} params.embedded - Merged banner+container tracks of the video.
83
- * @param {number} params.videoFileIndex
84
- * @param {Array<{ file: import("../torrent/files.js").SidecarFile, tracks: object[] }>} params.sidecars
85
- * Each sidecar file with the audio tracks IT holds. A file whose container
86
- * could not be read contributes one track, which is what a bare elementary
87
- * stream is.
88
- * @returns {AudioInventoryEntry[]}
89
- */
90
- export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
91
- /** @type {AudioInventoryEntry[]} */
92
- const inventory = [];
93
- const add = (track, fileIndex, sourceTrackIndex, kind, file) => {
94
- inventory.push({
95
- index: inventory.length,
96
- fileIndex,
97
- sourceTrackIndex,
98
- kind,
99
- // The name the browser judges "can I play this?" by. For an embedded
100
- // track it is ffmpeg's own; for a sidecar it is translated from what the
101
- // container wrote, or from the extension when there was no table to read.
102
- codec: AudioTrack.codecNameOf(track, file?.extension ?? ""),
103
- language: typeof track?.language === "string" ? track.language : "",
104
- languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
105
- title:
106
- typeof track?.title === "string" && track.title.length > 0
107
- ? track.title
108
- : (typeof track?.name === "string" ? track.name : ""),
109
- isDefault: track?.isDefault === true,
110
- declaresDefault: track?.declaresDefault === true,
111
- isOriginal: track?.isOriginal === true,
112
- isCommentary: track?.isCommentary === true,
113
- isVisualImpaired: track?.isVisualImpaired === true,
114
- isEnabled: track?.isEnabled !== false,
115
- channels: Number.isFinite(track?.channels) ? track.channels : null,
116
- samplingFrequency: Number.isFinite(track?.samplingFrequency) ? track.samplingFrequency : null,
117
- // The rate the file STATES for this track, in kbit/s — an average, not a
118
- // bound — or null where nothing states one (a sidecar read from its
119
- // container table, a Matroska stream without statistics tags). What a
120
- // viewer's link is asked to carry for a copied soundtrack is this.
121
- bitrateKbps: Number.isFinite(track?.bitrateKbps) && track.bitrateKbps > 0 ? track.bitrateKbps : null,
122
- codecParameters: track?.codecParameters ?? null,
123
- peakKbps: Number.isFinite(track?.peakKbps) && track.peakKbps > 0 ? track.peakKbps : null,
124
- fileName: kind === "sidecar" ? (file?.name ?? "") : "",
125
- folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : [],
126
- // What the file's own path says, for a track that ships as its own file:
127
- // read here, by the grammar that also decided this file belongs to this
128
- // picture. The browser used to read the same path again to build the
129
- // label, which is one name read twice by two rules.
130
- naming: kind === "sidecar" ? (file?.naming ?? null) : null
131
- });
132
- };
133
-
134
- for (const [order, track] of (Array.isArray(embedded) ? embedded : []).entries()) {
135
- add(track, videoFileIndex, order, "embedded", null);
136
- }
137
- for (const sidecar of Array.isArray(sidecars) ? sidecars : []) {
138
- const tracks = Array.isArray(sidecar?.tracks) && sidecar.tracks.length > 0
139
- ? sidecar.tracks
140
- // A file whose track table could not be read is one track: `.ac3`, `.dts`
141
- // and `.mp3` have no table to read, and a `.mka` whose head has not
142
- // arrived yet is better offered than hidden — ffmpeg will find its first
143
- // audio stream either way.
144
- : [{}];
145
- for (const [order, track] of tracks.entries()) {
146
- add(track, sidecar.file.fileIndex, order, "sidecar", sidecar.file);
147
- }
148
- }
149
- return inventory;
150
- }
151
-
152
- /**
153
- * Resolve the flat number back to the file and the track inside it.
154
- *
155
- * @param {AudioInventoryEntry[]} inventory
156
- * @param {number} index
157
- * @returns {AudioInventoryEntry | null}
158
- */
159
- export function resolveAudioIndex(inventory, index) {
160
- if (!Array.isArray(inventory) || !Number.isInteger(index) || index < 0) {
161
- return null;
162
- }
163
- return inventory.find((entry) => entry.index === index) ?? null;
164
- }
165
-
166
- /**
167
- * Enrich the already-published first entry for a sidecar without changing the
168
- * inventory's length or any address used by a player.
169
- *
170
- * A late container read can reveal language, codec and flags after the HLS
171
- * master playlist has been loaded. Adding entries here would make the browser's
172
- * rendition order disagree with the playlist it already holds.
173
- *
174
- * @param {AudioInventoryEntry[]} inventory
175
- * @param {import("../torrent/files.js").SidecarFile} file
176
- * @param {object} declaredTrack
177
- * @returns {boolean} Whether an existing entry was updated.
178
- */
179
- export function enrichAudioInventoryFromSidecar(inventory, file, declaredTrack) {
180
- if (!Array.isArray(inventory) || !file || !declaredTrack) return false;
181
- const current = inventory.find(
182
- (entry) => entry.kind === "sidecar" && entry.fileIndex === file.fileIndex && entry.sourceTrackIndex === 0
183
- );
184
- if (!current) return false;
185
- const enriched = buildAudioInventory({
186
- embedded: [],
187
- videoFileIndex: -1,
188
- sidecars: [{ file, tracks: [declaredTrack] }]
189
- })[0];
190
- Object.assign(current, mergeInventoryEntry(current, enriched));
191
- return true;
192
- }
193
-
194
- /**
195
- * One entry of the inventory with what a later reading of the same track adds.
196
- *
197
- * Two rules, because the fields are of two kinds:
198
- *
199
- * 1. a single fact — a language, a title, a flag, a stated average rate — is
200
- * taken from the new reading where it states one, and otherwise the value
201
- * already known stands. A reading that says nothing about a field is not a
202
- * statement that the field is empty;
203
- * 2. the codec's configuration is one statement, and the bound is computed from
204
- * it, so its fields are never mixed between readings. A complete set that
205
- * agrees with what is known replaces it whole; an incomplete one that agrees
206
- * leaves it standing; any contradiction — a field both readings state,
207
- * stated differently — withdraws the configuration and the bound, even
208
- * when the new reading is complete, since two readings that disagree
209
- * establish neither. The next complete reading then stands on its own.
210
- *
211
- * Where the track IS — its number, its file, its position in that file and
212
- * whether it is a sidecar — is never changed: a player addresses it by them.
213
- *
214
- * @param {AudioInventoryEntry} current
215
- * @param {AudioInventoryEntry} next
216
- * @returns {AudioInventoryEntry}
217
- */
218
- export function mergeInventoryEntry(current, next) {
219
- const stated = (value) =>
220
- value !== null && value !== undefined && !(typeof value === "string" && value.length === 0);
221
- const merged = { ...current };
222
- for (const [field, value] of Object.entries(next)) {
223
- if (field === "codecParameters" || field === "peakKbps") continue;
224
- if (stated(value)) merged[field] = value;
225
- }
226
- const known = current.codecParameters ?? null;
227
- const read = next.codecParameters ?? null;
228
- if (codecParametersConflict(known, read)) {
229
- merged.codecParameters = null;
230
- } else if (codecParametersComplete(read)) {
231
- merged.codecParameters = read;
232
- } else {
233
- merged.codecParameters = known;
234
- }
235
- merged.peakKbps = peakKbpsOf(merged.codecParameters);
236
- merged.index = current.index;
237
- merged.fileIndex = current.fileIndex;
238
- merged.sourceTrackIndex = current.sourceTrackIndex;
239
- merged.kind = current.kind;
240
- return merged;
241
- }
242
-
243
- /**
244
- * The name an `#EXT-X-MEDIA` line carries for one soundtrack.
245
- *
246
- * Deliberately plain, and deliberately NOT localised: this is the name inside a
247
- * playlist, and what the viewer reads in the menu is composed in the browser
248
- * from the same facts, where the language table and the viewer's own locale are.
249
- * The only requirements here are that it says something and that no two
250
- * renditions of one file share it — hls.js groups renditions by name.
251
- *
252
- * @param {AudioInventoryEntry} entry
253
- * @param {AudioInventoryEntry[]} inventory
254
- * @returns {string}
255
- */
256
- export function audioRenditionName(entry, inventory) {
257
- const parts = [];
258
- if (entry.title) {
259
- parts.push(entry.title);
260
- } else if (entry.languageBcp47 || entry.language) {
261
- parts.push(entry.languageBcp47 || entry.language);
262
- } else if (entry.folders.length > 0) {
263
- // The folder a dub sits in is usually the only thing naming it, and a
264
- // release names it for a reason: "Rus Sound", "Ukr Dub".
265
- parts.push(entry.folders[entry.folders.length - 1]);
266
- } else if (entry.fileName) {
267
- parts.push(entry.fileName);
268
- } else {
269
- parts.push(`Track ${entry.index + 1}`);
270
- }
271
- if (entry.isCommentary) {
272
- parts.push("commentary");
273
- } else if (entry.isVisualImpaired) {
274
- parts.push("described");
275
- }
276
- const name = parts.join(" · ");
277
- const clash = (Array.isArray(inventory) ? inventory : []).some(
278
- (other) => other.index !== entry.index && audioRenditionNameCore(other) === audioRenditionNameCore(entry)
279
- );
280
- return clash ? `${name} (${entry.index + 1})` : name;
281
- }
282
-
283
- /**
284
- * The part of a rendition name that a clash is judged on — the name without the
285
- * disambiguating number, so that adding the number cannot itself cause a clash.
286
- *
287
- * @param {AudioInventoryEntry} entry
288
- * @returns {string}
289
- */
290
- function audioRenditionNameCore(entry) {
291
- return (
292
- entry.title ||
293
- entry.languageBcp47 ||
294
- entry.language ||
295
- (entry.folders.length > 0 ? entry.folders[entry.folders.length - 1] : "") ||
296
- entry.fileName ||
297
- ""
298
- );
299
- }
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
+ import { codecParametersComplete, codecParametersConflict, peakKbpsOf } from "./tracks/audio-bound.js";
34
+
35
+
36
+
37
+
38
+
39
+ /**
40
+ * @typedef {object} AudioInventoryEntry
41
+ * @property {number} index - The flat number everything downstream uses.
42
+ * @property {number} fileIndex - The torrent file this track lives in.
43
+ * @property {number} sourceTrackIndex - `0:a:N` WITHIN that file.
44
+ * @property {"embedded" | "sidecar"} kind - Whether it is muxed into the picture
45
+ * or ships as a file beside it. Not a type of track — a statement about where
46
+ * the bytes are.
47
+ * @property {string} codec
48
+ * @property {string} language - As the container states it, or "" when it does
49
+ * not. Never guessed here: what a folder name suggests is derived in the
50
+ * browser, where the language table and the viewer's locale already live.
51
+ * @property {string} languageBcp47
52
+ * @property {string} title
53
+ * @property {boolean} isDefault
54
+ * @property {boolean} declaresDefault
55
+ * @property {boolean} isOriginal
56
+ * @property {boolean} isCommentary
57
+ * @property {boolean} isVisualImpaired
58
+ * @property {boolean} isEnabled
59
+ * @property {number | null} channels
60
+ * @property {number | null} bitrateKbps - The rate the file states for this track; an average, not a bound.
61
+ * @property {import("./tracks/audio-bound.js").CodecParameters | null} codecParameters -
62
+ * The codec's configuration as the container states it, or null where no
63
+ * track table was read.
64
+ * @property {number | null} peakKbps - The most the codec, so configured, allows
65
+ * this track to carry — a bound, not an average — or null where no confirmed
66
+ * bound exists for that configuration (`tracks/audio-bound.js`).
67
+ * @property {string} fileName - For a sidecar: its own file name. "" otherwise.
68
+ * @property {string[]} folders - For a sidecar: the folders above it, relative
69
+ * to the torrent root. What the browser reads a language and a releaser from.
70
+ */
71
+
72
+ /**
73
+ * One numbered list from the picture's own tracks and its sidecar files.
74
+ *
75
+ * Order is load-bearing: embedded tracks keep the numbers they have always had,
76
+ * so a session created before this existed and one created after agree about
77
+ * what `audioTrackIndex: 1` means, and sidecars are appended after them in
78
+ * torrent-file order. Sidecar files are stable in that order for a given
79
+ * torrent, so the numbering is stable for a given file.
80
+ *
81
+ * @param {object} params
82
+ * @param {object[]} params.embedded - Merged banner+container tracks of the video.
83
+ * @param {number} params.videoFileIndex
84
+ * @param {Array<{ file: import("../torrent/files.js").SidecarFile, tracks: object[] }>} params.sidecars
85
+ * Each sidecar file with the audio tracks IT holds. A file whose container
86
+ * could not be read contributes one track, which is what a bare elementary
87
+ * stream is.
88
+ * @returns {AudioInventoryEntry[]}
89
+ */
90
+ export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
91
+ /** @type {AudioInventoryEntry[]} */
92
+ const inventory = [];
93
+ const add = (track, fileIndex, sourceTrackIndex, kind, file) => {
94
+ inventory.push({
95
+ index: inventory.length,
96
+ fileIndex,
97
+ sourceTrackIndex,
98
+ kind,
99
+ // The name the browser judges "can I play this?" by. For an embedded
100
+ // track it is ffmpeg's own; for a sidecar it is translated from what the
101
+ // container wrote, or from the extension when there was no table to read.
102
+ codec: AudioTrack.codecNameOf(track, file?.extension ?? ""),
103
+ language: typeof track?.language === "string" ? track.language : "",
104
+ languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
105
+ title:
106
+ typeof track?.title === "string" && track.title.length > 0
107
+ ? track.title
108
+ : (typeof track?.name === "string" ? track.name : ""),
109
+ isDefault: track?.isDefault === true,
110
+ declaresDefault: track?.declaresDefault === true,
111
+ isOriginal: track?.isOriginal === true,
112
+ isCommentary: track?.isCommentary === true,
113
+ isVisualImpaired: track?.isVisualImpaired === true,
114
+ isEnabled: track?.isEnabled !== false,
115
+ channels: Number.isFinite(track?.channels) ? track.channels : null,
116
+ samplingFrequency: Number.isFinite(track?.samplingFrequency) ? track.samplingFrequency : null,
117
+ // The rate the file STATES for this track, in kbit/s — an average, not a
118
+ // bound — or null where nothing states one (a sidecar read from its
119
+ // container table, a Matroska stream without statistics tags). What a
120
+ // viewer's link is asked to carry for a copied soundtrack is this.
121
+ bitrateKbps: Number.isFinite(track?.bitrateKbps) && track.bitrateKbps > 0 ? track.bitrateKbps : null,
122
+ codecParameters: track?.codecParameters ?? null,
123
+ peakKbps: Number.isFinite(track?.peakKbps) && track.peakKbps > 0 ? track.peakKbps : null,
124
+ fileName: kind === "sidecar" ? (file?.name ?? "") : "",
125
+ folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : [],
126
+ // What the file's own path says, for a track that ships as its own file:
127
+ // read here, by the grammar that also decided this file belongs to this
128
+ // picture. The browser used to read the same path again to build the
129
+ // label, which is one name read twice by two rules.
130
+ naming: kind === "sidecar" ? (file?.naming ?? null) : null
131
+ });
132
+ };
133
+
134
+ for (const [order, track] of (Array.isArray(embedded) ? embedded : []).entries()) {
135
+ add(track, videoFileIndex, order, "embedded", null);
136
+ }
137
+ for (const sidecar of Array.isArray(sidecars) ? sidecars : []) {
138
+ const tracks = Array.isArray(sidecar?.tracks) && sidecar.tracks.length > 0
139
+ ? sidecar.tracks
140
+ // A file whose track table could not be read is one track: `.ac3`, `.dts`
141
+ // and `.mp3` have no table to read, and a `.mka` whose head has not
142
+ // arrived yet is better offered than hidden — ffmpeg will find its first
143
+ // audio stream either way.
144
+ : [{}];
145
+ for (const [order, track] of tracks.entries()) {
146
+ add(track, sidecar.file.fileIndex, order, "sidecar", sidecar.file);
147
+ }
148
+ }
149
+ return inventory;
150
+ }
151
+
152
+ /**
153
+ * Resolve the flat number back to the file and the track inside it.
154
+ *
155
+ * @param {AudioInventoryEntry[]} inventory
156
+ * @param {number} index
157
+ * @returns {AudioInventoryEntry | null}
158
+ */
159
+ export function resolveAudioIndex(inventory, index) {
160
+ if (!Array.isArray(inventory) || !Number.isInteger(index) || index < 0) {
161
+ return null;
162
+ }
163
+ return inventory.find((entry) => entry.index === index) ?? null;
164
+ }
165
+
166
+ /**
167
+ * Enrich the already-published first entry for a sidecar without changing the
168
+ * inventory's length or any address used by a player.
169
+ *
170
+ * A late container read can reveal language, codec and flags after the HLS
171
+ * master playlist has been loaded. Adding entries here would make the browser's
172
+ * rendition order disagree with the playlist it already holds.
173
+ *
174
+ * @param {AudioInventoryEntry[]} inventory
175
+ * @param {import("../torrent/files.js").SidecarFile} file
176
+ * @param {object} declaredTrack
177
+ * @returns {boolean} Whether an existing entry was updated.
178
+ */
179
+ export function enrichAudioInventoryFromSidecar(inventory, file, declaredTrack) {
180
+ if (!Array.isArray(inventory) || !file || !declaredTrack) return false;
181
+ const current = inventory.find(
182
+ (entry) => entry.kind === "sidecar" && entry.fileIndex === file.fileIndex && entry.sourceTrackIndex === 0
183
+ );
184
+ if (!current) return false;
185
+ const enriched = buildAudioInventory({
186
+ embedded: [],
187
+ videoFileIndex: -1,
188
+ sidecars: [{ file, tracks: [declaredTrack] }]
189
+ })[0];
190
+ Object.assign(current, mergeInventoryEntry(current, enriched));
191
+ return true;
192
+ }
193
+
194
+ /**
195
+ * One entry of the inventory with what a later reading of the same track adds.
196
+ *
197
+ * Two rules, because the fields are of two kinds:
198
+ *
199
+ * 1. a single fact — a language, a title, a flag, a stated average rate — is
200
+ * taken from the new reading where it states one, and otherwise the value
201
+ * already known stands. A reading that says nothing about a field is not a
202
+ * statement that the field is empty;
203
+ * 2. the codec's configuration is one statement, and the bound is computed from
204
+ * it, so its fields are never mixed between readings. A complete set that
205
+ * agrees with what is known replaces it whole; an incomplete one that agrees
206
+ * leaves it standing; any contradiction — a field both readings state,
207
+ * stated differently — withdraws the configuration and the bound, even
208
+ * when the new reading is complete, since two readings that disagree
209
+ * establish neither. The next complete reading then stands on its own.
210
+ *
211
+ * Where the track IS — its number, its file, its position in that file and
212
+ * whether it is a sidecar — is never changed: a player addresses it by them.
213
+ *
214
+ * @param {AudioInventoryEntry} current
215
+ * @param {AudioInventoryEntry} next
216
+ * @returns {AudioInventoryEntry}
217
+ */
218
+ export function mergeInventoryEntry(current, next) {
219
+ const stated = (value) =>
220
+ value !== null && value !== undefined && !(typeof value === "string" && value.length === 0);
221
+ const merged = { ...current };
222
+ for (const [field, value] of Object.entries(next)) {
223
+ if (field === "codecParameters" || field === "peakKbps") continue;
224
+ if (stated(value)) merged[field] = value;
225
+ }
226
+ const known = current.codecParameters ?? null;
227
+ const read = next.codecParameters ?? null;
228
+ if (codecParametersConflict(known, read)) {
229
+ merged.codecParameters = null;
230
+ } else if (codecParametersComplete(read)) {
231
+ merged.codecParameters = read;
232
+ } else {
233
+ merged.codecParameters = known;
234
+ }
235
+ merged.peakKbps = peakKbpsOf(merged.codecParameters);
236
+ merged.index = current.index;
237
+ merged.fileIndex = current.fileIndex;
238
+ merged.sourceTrackIndex = current.sourceTrackIndex;
239
+ merged.kind = current.kind;
240
+ return merged;
241
+ }
242
+
243
+ /**
244
+ * The name an `#EXT-X-MEDIA` line carries for one soundtrack.
245
+ *
246
+ * Deliberately plain, and deliberately NOT localised: this is the name inside a
247
+ * playlist, and what the viewer reads in the menu is composed in the browser
248
+ * from the same facts, where the language table and the viewer's own locale are.
249
+ * The only requirements here are that it says something and that no two
250
+ * renditions of one file share it — hls.js groups renditions by name.
251
+ *
252
+ * @param {AudioInventoryEntry} entry
253
+ * @param {AudioInventoryEntry[]} inventory
254
+ * @returns {string}
255
+ */
256
+ export function audioRenditionName(entry, inventory) {
257
+ const parts = [];
258
+ if (entry.title) {
259
+ parts.push(entry.title);
260
+ } else if (entry.languageBcp47 || entry.language) {
261
+ parts.push(entry.languageBcp47 || entry.language);
262
+ } else if (entry.folders.length > 0) {
263
+ // The folder a dub sits in is usually the only thing naming it, and a
264
+ // release names it for a reason: "Rus Sound", "Ukr Dub".
265
+ parts.push(entry.folders[entry.folders.length - 1]);
266
+ } else if (entry.fileName) {
267
+ parts.push(entry.fileName);
268
+ } else {
269
+ parts.push(`Track ${entry.index + 1}`);
270
+ }
271
+ if (entry.isCommentary) {
272
+ parts.push("commentary");
273
+ } else if (entry.isVisualImpaired) {
274
+ parts.push("described");
275
+ }
276
+ const name = parts.join(" · ");
277
+ const clash = (Array.isArray(inventory) ? inventory : []).some(
278
+ (other) => other.index !== entry.index && audioRenditionNameCore(other) === audioRenditionNameCore(entry)
279
+ );
280
+ return clash ? `${name} (${entry.index + 1})` : name;
281
+ }
282
+
283
+ /**
284
+ * The part of a rendition name that a clash is judged on — the name without the
285
+ * disambiguating number, so that adding the number cannot itself cause a clash.
286
+ *
287
+ * @param {AudioInventoryEntry} entry
288
+ * @returns {string}
289
+ */
290
+ function audioRenditionNameCore(entry) {
291
+ return (
292
+ entry.title ||
293
+ entry.languageBcp47 ||
294
+ entry.language ||
295
+ (entry.folders.length > 0 ? entry.folders[entry.folders.length - 1] : "") ||
296
+ entry.fileName ||
297
+ ""
298
+ );
299
+ }