@torrent-tv/proxy 2.64.9 → 2.66.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 CHANGED
@@ -1,3 +1,23 @@
1
+ ## 2.66.0
2
+
3
+ - **New**: A soundtrack that ships beside the picture is fetched WHOLE once the swarm has capacity to spare, so a later switch to it does not wait. It is a twentieth of the picture — 30 MB against 566 MB on the field torrent — and having it on disk is the difference between an instant switch and one that pays for its own first pieces. The moment it starts is not a guess about the swarm: it is when the encoder is already as far ahead of the viewer as `--lookahead` lets it get, which is the cushion figure the session already measures and prints. Once per file, and only for a soundtrack in a file of its own.
4
+ - **Chore**: That fetch is a bounded READ of the file's length, deliberately not `file.select()`. Selecting a whole file alongside the readers' own moving windows is the mistake `#syncSelections` in `torrent-pool.js` was written against — a claim covering everything always outranked the window, and a seek to 89.1 % of a 4.7 GB film waited 93 s while the swarm fetched 2.47 GB in file order. The comment there records it; this goes through the same path the edge warm-up uses.
5
+
6
+ ## 2.65.0
7
+
8
+ - **New**: A soundtrack that ships as its own file beside the picture is offered like any other. Releases commonly put a dub in `Rus Sound/<name>.mka` and subtitles in `Sub/[team]/<name>.ass`; until now the video played with its original sound and nothing said the rest of the release existed. The pairing is by name — equal base names, or a shared release hash — and a torrent holding exactly one picture takes every sidecar beside it, since there is nothing else they could belong to. A torrent with several episodes never relaxes that: a wrong pairing would put the sound of one episode over the picture of another, and nothing downstream could notice.
9
+ - **New**: No new container class was needed for it, and that is the point. A `.mka` IS Matroska: `ContainerFactory` sniffs it, `MatroskaContainer` reads its `TrackEntry` list, and out comes an `AudioTrack` with its language, title, channel count and flags — the same reader that serves the picture. "External" is not a kind of track; it is the answer to WHERE a track's bytes are, which is torrent knowledge and now lives in `services/sidecar-files.js` (which file goes with which) and `services/audio-inventory.js` (one numbered list), both pure and both application-layer. `tracks/ExternalSubtitleFile.js` asserted the opposite — it did not extend `ContainerTrack`, duplicated four of its fields and was imported by nothing — and is deleted rather than extended.
10
+ - **New**: The audio menu is built from the container's own track table where one can be read, with ffmpeg's `-i` banner as the fallback. `FlagOriginal` (0x55AE), `FlagCommentary` (0x55AF), `FlagVisualImpaired` (0x55AC), `FlagEnabled` (0xB9) and `LanguageBCP47` appear nowhere in that banner, so a director's commentary and the film itself were indistinguishable in the menu — roadmap item 58, points 1-3. The two readings are lined up by position and the pairing is CHECKED, exactly as `subtitle-defaults.js` checks its own: a differing count, or one pair agreeing on neither language nor title, drops the container reading whole rather than attributing a flag to the wrong track. `0:a:N` is what the encoder is handed, so a wrong flag is worse than a missing one.
11
+ - **New**: One flat number spans the picture's own tracks and the files beside it, and one place resolves it. The browser's menu, the `audioTrackIndex` of a session request and the `a/<n>/` address of a rendition all mean the same number; `#resolveAudioSource` turns it into a file index and a `0:a:N` inside that file. Embedded tracks keep the numbers they have always had, so a session created against a plan cached by an older build still means what it meant.
12
+ - **New**: A sidecar soundtrack chosen by a browser that understands rendition groups costs no second input at all — the rendition reads that file as its only input, and the player joins picture and sound, which is what a rendition group is for. A browser without them gets the two muxed by ffmpeg from two inputs, with the seek applied to each and `-itsoffset` stating the soundtrack's timestamps on the picture's timeline. Both files begin at their own container start time and those need not be equal; the difference is measured from the file rather than assumed to be zero, which is what keeps the sound from sitting at a fixed offset for the whole film.
13
+ - **Fix**: A session now holds a claim on every file it reads. A rendition reading a sidecar claimed only the picture, and the disk sweep deletes what nobody holds — the failure that deleted a film being watched on 2026-08-06, one file over.
14
+ - **Fix**: `declaredTracks` no longer answers from the picture's probe alone. A release that ships its dub separately often ships the picture with no sound at all, so the probe said there was no audio while the output plainly carried some — leaving the header check expecting one track where two arrive, and telling the browser its sound had been lost.
15
+ - **New**: `Command.CONTAINER_TRACKS` reads any file's own track table on the thread that owns the torrent, fetching its head first — a sidecar has usually had nothing downloaded when it is first asked about. The picture's tracks come back the same way, so there is one command rather than two.
16
+ - **New**: The warm-up that runs when a viewer picks a file now warms what lies beside it, for the same reason it warms the picture: none of it depends on the viewer, and every second of it spent now is a second they do not spend waiting later. How much of each follows from what the file IS, not from a size anyone chose — a text subtitle file is smaller than one piece of the torrent, so its edges and the whole of it are the same pieces and all of it is fetched; a soundtrack is tens of megabytes and only its header is needed to name it, so it gets head and tail like the picture. Without this the first thing to ask for a dub's header was the playback plan, on the path to the first frame.
17
+ - **Fix**: The plan waits at most 3 s for a sidecar's header instead of up to two minutes. Reading one goes through the worker, which prefetches the file's edges (60 s) and then reads them (60 s), and neither was bounded from the plan's side — the route's own `maxWaitMs` covers the codec probe loop and nothing after it. A sidecar is a file of the torrent nobody has asked for yet, and on this torrent 256 KB of header means one whole 8 MiB piece. What the timeout costs is small and deliberate: the track is still offered, still numbered and still playable, only without the language and flags its header would have given — and the language the viewer sees comes from the folder the release put it in, which is in the file list and costs no bytes at all.
18
+ - **Fix**: The media-info probe no longer spends its whole 8 s timeout on a file with no picture. It bails early when it has both a duration and a video stream line; a soundtrack file never prints the second, so it waited out the timeout every time — on the path where a viewer is changing track and the browser refuses a switch that is not ready in time. `Duration:` carries the start time being asked for, and it is the last thing such a file has to say about itself.
19
+ - **Fix**: A soundtrack session sizes its read window from the file it READS. Sized from the picture, whose byte rate is twenty times higher over the same duration, the window bought twenty times the seconds it was meant to represent and the piece store held it.
20
+
1
21
  ## 2.64.9
2
22
 
3
23
  - **New**: The memory line says the SHAPE of the anonymous memory, not only its size — `mappings=[heap 40MB, 47 anon >=2MB = 388MB (largest 64MB), 812 anon <2MB = 231MB, files 31MB]`, read per mapping from `/proc/self/smaps`. The 700 MB that grew in the field on 2026-08-31 is none of the JavaScript heaps, none of the piece store and none of ffmpeg, and the three shapes it could have are three different diagnoses: one growing `[heap]` is the allocator keeping what we free, with no `malloc_trim` on musl to ask for it back; many large anonymous mappings are piece buffers, and whether their count tracks what the store says it holds decides whether anything is retaining them; many medium ones are the allocator's own arenas. Mappings with no resident pages are left out — those are V8's address-space reservations, tens of gigabytes of them, and counting them would drown the reading.
@@ -2,6 +2,32 @@
2
2
 
3
3
  Domain → Application → Interface, per RFC 9559 (Matroska) and ISO/IEC 14496-12 (MP4).
4
4
 
5
+ ## Two axes, and what is NOT a third
6
+
7
+ Everything here is placed on exactly two axes: **which container** a track lives
8
+ in, and **what kind of track** it is (video / audio / subtitle). A file lying
9
+ beside the video — a dub as `<name>.mka`, subtitles as `<name>.ass` — is not a
10
+ third kind of anything:
11
+
12
+ - a `.mka` **is** Matroska. `ContainerFactory` sniffs it, `MatroskaContainer`
13
+ reads its `TrackEntry` list, and out comes an `AudioTrack` with its language,
14
+ title and flags. It differs from the picture's own file only in having no
15
+ video track. The same holds for `.m4a` and `Mp4Container`;
16
+ - "external" is therefore not a TYPE. It is the answer to WHERE a track's bytes
17
+ are, and that is torrent knowledge — which this layer must not have
18
+ (`ContainerFactory`: no torrent knowledge; `ContainerOrchestrator`:
19
+ transport-agnostic). The pairing of a sidecar file with a picture lives in
20
+ `services/sidecar-files.js`, and the numbered list a viewer chooses from lives
21
+ in `services/audio-inventory.js`. Both are pure and both are application-layer.
22
+
23
+ A class called `ExternalSubtitleFile` used to sit in `tracks/` asserting the
24
+ opposite. It did not extend `ContainerTrack`, duplicated four of its fields, and
25
+ was imported by nothing; it was deleted rather than extended. Nothing replaced
26
+ it — a subtitle file beside the video is a `TextSubtitleTrack` whose bytes are
27
+ read from another file, and a raw `.srt` needs no `Container` subclass because
28
+ it has no track table and no index to read: the whole file is the payload, and
29
+ `SubtitleController` already reads it as such.
30
+
5
31
  ## Layers
6
32
 
7
33
  ```mermaid
@@ -26,8 +52,12 @@ flowchart TB
26
52
  CF[ContainerFactory<br/>detect 16 bytes]
27
53
  CO[ContainerOrchestrator<br/>cache + getTracks/getKeyframeIndex]
28
54
  SO[SubtitleOrchestrator<br/>wrap subtitle-cues.js]
55
+ SF[sidecar-files.js<br/>which file goes with which]
56
+ AI[audio-inventory.js<br/>one flat numbering]
29
57
  CF --> CO
30
58
  CO --> SO
59
+ SF --> AI
60
+ CO --> AI
31
61
  end
32
62
  subgraph Interface
33
63
  PC[PlaybackController]
@@ -50,6 +80,8 @@ flowchart TB
50
80
  | `AudioTrack` | RFC9559 FlagOriginal 0x55AE, FlagCommentary 0x55AF, FlagVisualImpaired 0x55AC | `isOriginal/isCommentary/isVisualImpaired`, `channels/samplingFrequency` | FlagForced |
51
81
  | `SubtitleTrack` | RFC9559 FlagForced 0x55AA (subtitle-only), FlagHearingImpaired 0x55AB | `isForced/isHearingImpaired`, `clusterPositions`/`samples` | Video dims |
52
82
  | `TextSubtitleTrack` | `S_TEXT/UTF8, S_TEXT/ASS, tx3g, wvtt` | `toVtt()` convertible | Image tracks |
83
+ | `sidecar-files.js` | — (torrent naming) | which files of a torrent are one picture's sound and subtitles | what is inside them |
84
+ | `audio-inventory.js` | RFC9559 audio flags, merged against ffmpeg's numbering | one flat number per soundtrack → `(fileIndex, 0:a:N)` | display labels |
53
85
  | `ImageSubtitleTrack` | `S_HDMV/PGS, S_VOBSUB, subp, clcp` | kept for `declaredIndex` alignment | Conversion |
54
86
  | `MatroskaContainer` | RFC9559 SeekHead, Tracks, Cues, Clusters | single Tracks walk for all types, EBML via `ebml-reader.js` | HTTP |
55
87
  | `Mp4Container` | ISO 14496-12 moov/trak/tkhd/mdhd/hdlr/elng/stbl | `alternate_group` grouping, packed language, `tx3g` forced bits | Torrent |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.64.9",
3
+ "version": "2.66.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1,4 +1,9 @@
1
1
  import { logger } from "../../../../utils/logger.js";
2
+ import {
3
+ countVideoFiles,
4
+ matchSidecarFiles,
5
+ TEXT_SUBTITLE_SIDECAR_EXTENSIONS
6
+ } from "../../../../services/sidecar-files.js";
2
7
 
3
8
  /**
4
9
  * Start fetching a source before anyone asks to play it.
@@ -74,10 +79,60 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
74
79
  });
75
80
  }
76
81
 
82
+ // The files that carry this episode's OTHER soundtracks and its subtitles.
83
+ //
84
+ // Warmed here for the same reason the picture is: none of it depends on the
85
+ // viewer, and every second of it that happens now is a second they do not
86
+ // spend waiting later. Without this the first thing to ask for a dub's header
87
+ // is the playback plan, on the path to the first frame, and the first thing to
88
+ // ask for a subtitle file is the browser once the film is already running —
89
+ // which is why a track the container marks default appears after the opening
90
+ // rather than during it.
91
+ //
92
+ // How much of each is fetched follows from what the file IS, not from a size
93
+ // anyone chose. A text subtitle file is smaller than one piece of this
94
+ // torrent, so its edges and the whole of it are the same pieces — fetch all of
95
+ // it. A soundtrack is tens of megabytes and only its header is needed to name
96
+ // and describe it, so it gets the head and the tail, exactly as the picture
97
+ // does. The rest of it is fetched when it is played, and nothing here spends
98
+ // the pool owner's bandwidth on a track nobody chose.
99
+ let sidecars = 0;
100
+ if (fileIndex !== null && Array.isArray(torrent.files)) {
101
+ const matched = matchSidecarFiles({
102
+ files: torrent.files,
103
+ videoIndex: fileIndex,
104
+ torrentName: typeof torrent.name === "string" ? torrent.name : "",
105
+ videoCount: countVideoFiles(torrent.files)
106
+ });
107
+ const warmOne = (file, options) => {
108
+ sidecars += 1;
109
+ // Not awaited, like the picture's own edges above: the point of this route
110
+ // is that the viewer goes on choosing while it happens.
111
+ Promise.resolve(torrentPool.prefetchFileEdges(torrent, file.fileIndex, options)).catch(
112
+ (error) => {
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ logger.warn(`warm ${sourceKey.slice(0, 8)}: "${file.name}" failed: ${message}`);
115
+ }
116
+ );
117
+ };
118
+ for (const file of matched.audio) {
119
+ warmOne(file, { tailBytes: 0 });
120
+ }
121
+ for (const file of matched.subtitles) {
122
+ warmOne(
123
+ file,
124
+ TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
125
+ ? { headBytes: Math.max(1, file.length), tailBytes: 0 }
126
+ : { tailBytes: 0 }
127
+ );
128
+ }
129
+ }
130
+
77
131
  logger.info(
78
132
  `warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
79
- (edges ? `, fetching the edges of file ${fileIndex}` : ", file not chosen yet")
133
+ (edges ? `, fetching the edges of file ${fileIndex}` : ", file not chosen yet") +
134
+ (sidecars > 0 ? ` and of ${sidecars} file(s) beside it` : "")
80
135
  );
81
136
 
82
- return reply.send({ started: true, swarm: true, edges });
137
+ return reply.send({ started: true, swarm: true, edges, sidecars });
83
138
  }
@@ -113,7 +113,17 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
113
113
  // only while they run, and a seek leaves a gap with no read at all — the
114
114
  // disk sweep caught that gap on 2026-08-06 and deleted the film being
115
115
  // watched.
116
- acquireSource: () => holdSource({ sourceRegistry, torrentPool, sourceKey, fileIndex })
116
+ // Takes the file to hold, because a session does not always read the file
117
+ // it was created for: a release whose dub ships as its own file gives that
118
+ // soundtrack a session of its own, reading a different index of the same
119
+ // torrent. Defaults to the picture, which is every other case.
120
+ acquireSource: (heldFileIndex = fileIndex) =>
121
+ holdSource({
122
+ sourceRegistry,
123
+ torrentPool,
124
+ sourceKey,
125
+ fileIndex: Number.isInteger(heldFileIndex) ? heldFileIndex : fileIndex
126
+ })
117
127
  });
118
128
  // The index of quality variants, when this session has more than one to
119
129
  // offer. Its presence is what tells the browser it can change quality
package/server.js CHANGED
@@ -192,7 +192,30 @@ export async function startProxyServer({
192
192
  getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
193
193
  // The file's audio tracks, for the master playlist's rendition group. Already
194
194
  // probed for the browser's audio menu; read from there rather than probed again.
195
- getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params)
195
+ getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
196
+ // Pull one whole file onto the disk. Used for a soundtrack that ships beside
197
+ // the picture, once the encoder is as far ahead of the viewer as it is
198
+ // allowed to get — the one moment the swarm's capacity is demonstrably
199
+ // spare. A bounded read of the file's whole length, NOT `file.select()`:
200
+ // selecting a file alongside the readers' own windows is what made a seek
201
+ // wait 93 s while the swarm fetched 2.47 GB in file order (see
202
+ // `#syncSelections` in `torrent-pool.js`).
203
+ fetchWholeFile: async ({ sourceKey, fileIndex }) => {
204
+ const record = sourceRegistry.get(sourceKey);
205
+ if (!record) {
206
+ return;
207
+ }
208
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
209
+ const file = torrent?.files?.[fileIndex];
210
+ if (!file || !Number.isFinite(file.length) || file.length <= 0) {
211
+ return;
212
+ }
213
+ await torrentPool.prefetchFileEdges(torrent, fileIndex, {
214
+ headBytes: file.length,
215
+ tailBytes: 0,
216
+ timeoutMs: 600_000
217
+ });
218
+ }
196
219
  });
197
220
  const playbackPlanner = createPlaybackPlanner({
198
221
  ffmpegBin,
@@ -0,0 +1,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
+ * 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
+ }