@torrent-tv/proxy 2.64.9 → 2.65.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.
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Which files of a torrent belong to one video file as its sound or its
3
+ * subtitles.
4
+ *
5
+ * Releases often ship a dub or a subtitle set as SEPARATE FILES beside the
6
+ * picture — `Rus Sound/<name>.mka`, `Sub/[group]/<name>.ass` — and a viewer who
7
+ * cannot reach them is watching the release without half of what it carries.
8
+ * This module answers only which file goes with which; what is inside a file is
9
+ * the container layer's business (`services/container/`), and what a viewer is
10
+ * shown is composed in the browser, where the locale is known.
11
+ *
12
+ * Nothing here reads bytes, waits on the swarm or knows about ffmpeg. It is a
13
+ * function of the torrent's own list of names, which is why it can be tested
14
+ * outright.
15
+ *
16
+ * A note on the axes this fits into. A file beside the video is NOT a third kind
17
+ * of track: `<name>.mka` is a Matroska container holding an audio track, and
18
+ * `MatroskaContainer` reads it exactly as it reads the picture's own tracks. So
19
+ * "external" is not a type — it is only the answer to WHERE a track lives, and
20
+ * that answer belongs here rather than in `tracks/`, which describes what a
21
+ * container declares and must stay free of torrent knowledge.
22
+ */
23
+
24
+ /**
25
+ * Containers and elementary streams that can carry a soundtrack on their own.
26
+ *
27
+ * Two groups, and the difference matters to the caller: `.mka` and `.m4a` have a
28
+ * track table that `ContainerFactory` can read, so their language, title and
29
+ * flags are available; the rest are raw elementary streams that carry exactly
30
+ * one track and declare nothing about it.
31
+ */
32
+ export const AUDIO_SIDECAR_EXTENSIONS = new Set([
33
+ ".mka",
34
+ ".m4a",
35
+ ".aac",
36
+ ".ac3",
37
+ ".eac3",
38
+ ".dts",
39
+ ".dtshd",
40
+ ".flac",
41
+ ".mp3",
42
+ ".mp2",
43
+ ".ogg",
44
+ ".oga",
45
+ ".opus",
46
+ ".wav",
47
+ ".thd",
48
+ ".mlp",
49
+ ".wma"
50
+ ]);
51
+
52
+ /**
53
+ * Subtitle files. Image-based ones (`.sup`, `.sub`, `.idx`) are listed because
54
+ * they exist and must be RECOGNISED — a file we cannot show is still not a
55
+ * soundtrack — but this module does not decide what is offerable; that is the
56
+ * subtitle track's own `isTextBased()`.
57
+ */
58
+ export const SUBTITLE_SIDECAR_EXTENSIONS = new Set([
59
+ ".srt",
60
+ ".ass",
61
+ ".ssa",
62
+ ".vtt",
63
+ ".webvtt",
64
+ ".sub",
65
+ ".sup",
66
+ ".idx",
67
+ ".ttml",
68
+ ".smi",
69
+ ".txt"
70
+ ]);
71
+
72
+ /**
73
+ * Containers that carry a picture.
74
+ *
75
+ * Here to COUNT them, not to decide what plays: whether a file is playable is
76
+ * the browser's answer, made from what its own media stack accepts, and this
77
+ * proxy must not acquire a second opinion about it. The count is needed for one
78
+ * decision — whether a torrent holds exactly one picture, in which case a
79
+ * soundtrack beside it has nothing else it could belong to.
80
+ */
81
+ export const VIDEO_FILE_EXTENSIONS = new Set([
82
+ ".mp4",
83
+ ".mkv",
84
+ ".webm",
85
+ ".mov",
86
+ ".m4v",
87
+ ".avi",
88
+ ".mpg",
89
+ ".mpeg",
90
+ ".ts",
91
+ ".m2ts",
92
+ ".mts",
93
+ ".wmv",
94
+ ".asf",
95
+ ".flv",
96
+ ".f4v",
97
+ ".ogv",
98
+ ".ogm",
99
+ ".3gp",
100
+ ".3g2",
101
+ ".divx",
102
+ ".vob",
103
+ ".m2v",
104
+ ".m2p",
105
+ ".mxf",
106
+ ".rm",
107
+ ".rmvb"
108
+ ]);
109
+
110
+ /**
111
+ * How many files of a torrent carry a picture.
112
+ *
113
+ * @param {Array<{ path?: string, name?: string }>} files
114
+ * @returns {number}
115
+ */
116
+ export function countVideoFiles(files) {
117
+ let count = 0;
118
+ for (const file of Array.isArray(files) ? files : []) {
119
+ const name = typeof file?.name === "string" && file.name.length > 0
120
+ ? file.name
121
+ : String(file?.path ?? "");
122
+ if (VIDEO_FILE_EXTENSIONS.has(extensionOf(name))) {
123
+ count += 1;
124
+ }
125
+ }
126
+ return count;
127
+ }
128
+
129
+ /**
130
+ * Subtitle formats that are text, and therefore small by construction — a whole
131
+ * episode of dialogue is tens of kilobytes.
132
+ *
133
+ * The distinction is used to decide how much of such a file to fetch ahead of
134
+ * the viewer: a text file is fetched WHOLE, because it is smaller than the
135
+ * torrent's own piece and reading its edges would cost the same pieces as
136
+ * reading all of it. An image-based one (`.sup`, `.sub`+`.idx`) is a picture per
137
+ * cue and runs to tens of megabytes, so it gets the same edges treatment as
138
+ * anything else.
139
+ */
140
+ export const TEXT_SUBTITLE_SIDECAR_EXTENSIONS = new Set([
141
+ ".srt",
142
+ ".ass",
143
+ ".ssa",
144
+ ".vtt",
145
+ ".webvtt",
146
+ ".ttml",
147
+ ".smi",
148
+ ".txt"
149
+ ]);
150
+
151
+ /**
152
+ * Extensions whose track table a `Container` subclass can read. Everything else
153
+ * in {@link AUDIO_SIDECAR_EXTENSIONS} is a bare stream: one track, no metadata.
154
+ */
155
+ const CONTAINER_BACKED_AUDIO = new Set([".mka", ".m4a", ".mp4", ".mkv", ".webm"]);
156
+
157
+ /**
158
+ * The extension of a name, lowercased, including the dot. Empty when there is
159
+ * none.
160
+ *
161
+ * @param {string} name
162
+ * @returns {string}
163
+ */
164
+ export function extensionOf(name) {
165
+ const text = typeof name === "string" ? name : "";
166
+ const dot = text.lastIndexOf(".");
167
+ // A leading dot is a hidden file, not an extension.
168
+ return dot > 0 ? text.slice(dot).toLowerCase() : "";
169
+ }
170
+
171
+ /**
172
+ * A name without its extension.
173
+ *
174
+ * @param {string} name
175
+ * @returns {string}
176
+ */
177
+ export function baseNameOf(name) {
178
+ const text = typeof name === "string" ? name : "";
179
+ const dot = text.lastIndexOf(".");
180
+ return dot > 0 ? text.slice(0, dot) : text;
181
+ }
182
+
183
+ /**
184
+ * Whether a sidecar file of this extension has a readable track table.
185
+ *
186
+ * @param {string} extension - Lowercased, with the dot.
187
+ * @returns {boolean}
188
+ */
189
+ export function declaresItsOwnTracks(extension) {
190
+ return CONTAINER_BACKED_AUDIO.has(extension);
191
+ }
192
+
193
+ /**
194
+ * Split a torrent file's path into the folders above it and its own name, with
195
+ * the torrent's own name removed from the front.
196
+ *
197
+ * WebTorrent prefixes every path in a multi-file torrent with the torrent name;
198
+ * the browser strips it again to show a playlist. Doing it once, here, means the
199
+ * folders travel to the browser already relative to the torrent root, so there
200
+ * is one stripping rule in the system rather than two that can disagree.
201
+ *
202
+ * @param {string} path - `file.path` as WebTorrent reports it.
203
+ * @param {string} torrentName
204
+ * @returns {{ folders: string[], name: string }}
205
+ */
206
+ export function splitTorrentPath(path, torrentName) {
207
+ const text = typeof path === "string" ? path.replace(/\\/g, "/") : "";
208
+ const prefix = typeof torrentName === "string" && torrentName.length > 0 ? `${torrentName}/` : "";
209
+ const relative = prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text;
210
+ const segments = relative.split("/").filter((segment) => segment.length > 0);
211
+ const name = segments.length > 0 ? segments[segments.length - 1] : "";
212
+ return { folders: segments.slice(0, -1), name };
213
+ }
214
+
215
+ /**
216
+ * Bracketed groups of a name, in order: `[HorribleSubs] X [1080p]` → both.
217
+ *
218
+ * @param {string} text
219
+ * @returns {string[]}
220
+ */
221
+ export function bracketTokensOf(text) {
222
+ const source = typeof text === "string" ? text : "";
223
+ const tokens = [];
224
+ for (const match of source.matchAll(/\[([^\]]+)\]/g)) {
225
+ const token = match[1].trim();
226
+ if (token.length > 0) {
227
+ tokens.push(token);
228
+ }
229
+ }
230
+ return tokens;
231
+ }
232
+
233
+ /**
234
+ * Bracketed groups that look like a release hash — `[78EFD746]`. Anime releases
235
+ * carry the CRC of the file, and two files sharing one are certainly a pair.
236
+ *
237
+ * @param {string} text
238
+ * @returns {string[]}
239
+ */
240
+ function hashTokensOf(text) {
241
+ return bracketTokensOf(text)
242
+ .filter((token) => /^[0-9a-f]{4,10}$/i.test(token))
243
+ .map((token) => token.toLowerCase());
244
+ }
245
+
246
+ /**
247
+ * Whether a sidecar and a video are the same release of the same episode.
248
+ *
249
+ * Two rules, both taken from what releases actually do, and neither of them
250
+ * involving the folder — a dub lives in a folder of its own by construction, so
251
+ * requiring the folders to match would reject every case this exists for:
252
+ *
253
+ * 1. the base names are equal, which is the common shape (`X.mkv` / `X.mka`);
254
+ * 2. they share a release hash, which anime releases carry.
255
+ *
256
+ * @param {string} sidecarName
257
+ * @param {string} videoName
258
+ * @returns {boolean}
259
+ */
260
+ export function namesPair(sidecarName, videoName) {
261
+ const sidecarBase = baseNameOf(sidecarName).toLowerCase();
262
+ const videoBase = baseNameOf(videoName).toLowerCase();
263
+ if (sidecarBase.length === 0 || videoBase.length === 0) {
264
+ return false;
265
+ }
266
+ if (sidecarBase === videoBase) {
267
+ return true;
268
+ }
269
+ const videoHashes = new Set(hashTokensOf(videoBase));
270
+ if (videoHashes.size > 0) {
271
+ for (const token of hashTokensOf(sidecarBase)) {
272
+ if (videoHashes.has(token)) {
273
+ return true;
274
+ }
275
+ }
276
+ }
277
+ return false;
278
+ }
279
+
280
+ /**
281
+ * @typedef {object} SidecarFile
282
+ * @property {number} fileIndex - Index in the torrent.
283
+ * @property {string} name - File name, extension included.
284
+ * @property {string[]} folders - Folders above it, relative to the torrent root.
285
+ * @property {string} extension - Lowercased, with the dot.
286
+ * @property {number} length - Bytes.
287
+ * @property {boolean} declaresTracks - Whether its own track table can be read.
288
+ */
289
+
290
+ /**
291
+ * The audio and subtitle files of a torrent that belong to one video file.
292
+ *
293
+ * The pairing is by name (see {@link namesPair}), with ONE relaxation: a torrent
294
+ * holding a single video file has nothing else its sidecars could belong to, so
295
+ * there every sidecar is taken. That covers the very common
296
+ * `Film.mkv` + `Rus.mka` shape, where the two names have nothing in common. It
297
+ * is deliberately not extended to a torrent with several videos, where a wrong
298
+ * pairing would put the sound of one episode over the picture of another.
299
+ *
300
+ * @param {object} params
301
+ * @param {Array<{ path?: string, name?: string, length?: number }>} params.files
302
+ * @param {number} params.videoIndex
303
+ * @param {string} [params.torrentName]
304
+ * @param {number} [params.videoCount] - How many playable video files the
305
+ * torrent holds. When 1, the relaxation above applies. Counted by the caller,
306
+ * which is the side that knows what counts as playable.
307
+ * @returns {{ audio: SidecarFile[], subtitles: SidecarFile[] }}
308
+ */
309
+ export function matchSidecarFiles({ files, videoIndex, torrentName = "", videoCount = 0 }) {
310
+ const list = Array.isArray(files) ? files : [];
311
+ const video = list[videoIndex];
312
+ if (!video) {
313
+ return { audio: [], subtitles: [] };
314
+ }
315
+ const videoPath = splitTorrentPath(video.path ?? video.name ?? "", torrentName);
316
+ const onlyVideo = videoCount === 1;
317
+ /** @type {SidecarFile[]} */
318
+ const audio = [];
319
+ /** @type {SidecarFile[]} */
320
+ const subtitles = [];
321
+
322
+ for (const [fileIndex, file] of list.entries()) {
323
+ if (fileIndex === videoIndex) {
324
+ continue;
325
+ }
326
+ const { folders, name } = splitTorrentPath(file?.path ?? file?.name ?? "", torrentName);
327
+ const extension = extensionOf(name);
328
+ const isAudio = AUDIO_SIDECAR_EXTENSIONS.has(extension);
329
+ const isSubtitle = !isAudio && SUBTITLE_SIDECAR_EXTENSIONS.has(extension);
330
+ if (!isAudio && !isSubtitle) {
331
+ continue;
332
+ }
333
+ if (!onlyVideo && !namesPair(name, videoPath.name)) {
334
+ continue;
335
+ }
336
+ const entry = {
337
+ fileIndex,
338
+ name,
339
+ folders,
340
+ extension,
341
+ length: Number.isFinite(file?.length) ? file.length : 0,
342
+ declaresTracks: declaresItsOwnTracks(extension)
343
+ };
344
+ if (isAudio) {
345
+ audio.push(entry);
346
+ } else {
347
+ subtitles.push(entry);
348
+ }
349
+ }
350
+
351
+ return { audio, subtitles };
352
+ }
@@ -325,6 +325,20 @@ export class TorrentWorkerClient {
325
325
  return this.#caller.call(Command.SUBTITLE_TRACKS, { sourceKey, fileIndex });
326
326
  }
327
327
 
328
+ /**
329
+ * Every track one file declares, read from its own header.
330
+ *
331
+ * Asked of the picture for the flags ffmpeg's banner does not carry, and of a
332
+ * soundtrack shipped as its own file beside it — the same question about a
333
+ * different file, which is why there is one command rather than two.
334
+ *
335
+ * @param {{ sourceKey: string, fileIndex: number }} params
336
+ * @returns {Promise<{ tracks: object[] }>}
337
+ */
338
+ async getContainerTracks({ sourceKey, fileIndex }) {
339
+ return this.#caller.call(Command.CONTAINER_TRACKS, { sourceKey, fileIndex });
340
+ }
341
+
328
342
  /**
329
343
  * The cues of one subtitle track that can be read from what is downloaded.
330
344
  *
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The tracks a file declares, read from the file itself on the thread that owns
3
+ * the torrent.
4
+ *
5
+ * This exists because the container layer (`services/container/`) reads bytes,
6
+ * and the bytes live here: the torrent client runs on this worker thread, so the
7
+ * main thread cannot open a read stream on one of its files. The main thread
8
+ * asks over the channel (`Command.CONTAINER_TRACKS`) and gets plain objects
9
+ * back, because a class instance does not survive the boundary as a class.
10
+ *
11
+ * It reads whatever container the file is — the same `ContainerFactory` that
12
+ * serves the picture serves a `.mka` beside it, since a `.mka` IS Matroska and
13
+ * differs only in having no video track. That is the whole reason no new class
14
+ * was needed for a soundtrack shipped as its own file.
15
+ *
16
+ * Unlike the subtitle walk, the reads here DO wait on the swarm. A sidecar file
17
+ * has usually had nothing downloaded at all when this is first asked, and its
18
+ * header is a few hundred kilobytes; the alternative is offering the viewer a
19
+ * soundtrack with nothing known about it.
20
+ */
21
+
22
+ import { containerOrchestrator } from "../orchestrators/ContainerOrchestrator.js";
23
+ import { logger } from "../../utils/logger.js";
24
+
25
+ /**
26
+ * How long one range read may take before it is given up.
27
+ *
28
+ * Not a measurement and nothing is derived from it: it is the point past which a
29
+ * read of a file nobody is playing is presumed lost, so that one stream which
30
+ * never ends cannot hold the answer — and with it the viewer's audio menu — for
31
+ * the rest of the session.
32
+ */
33
+ const READ_ABANDON_MS = 60_000;
34
+
35
+ /** Bytes of the head a container's track table lives in. */
36
+ const HEAD_BYTES = 256 * 1024;
37
+
38
+ /**
39
+ * Tracks already read, by `sourceKey:fileIndex`. Only a non-empty reading is
40
+ * kept: an empty one usually means the header has not arrived yet, and caching
41
+ * that would hide the file's tracks for the life of the process.
42
+ *
43
+ * @type {Map<string, object[]>}
44
+ */
45
+ const byFile = new Map();
46
+
47
+ /** @type {Map<string, Promise<object[]>>} */
48
+ const inFlight = new Map();
49
+
50
+ /**
51
+ * Read a byte range of a file, fetching what is missing.
52
+ *
53
+ * @param {object} file
54
+ * @param {number} start
55
+ * @param {number} end - Inclusive.
56
+ * @returns {Promise<Buffer | null>}
57
+ */
58
+ function readFetching(file, start, end) {
59
+ return new Promise((resolve) => {
60
+ const chunks = [];
61
+ let stream;
62
+ try {
63
+ stream = file.createReadStream({ start, end });
64
+ } catch {
65
+ resolve(null);
66
+ return;
67
+ }
68
+ let settled = false;
69
+ /** @type {ReturnType<typeof setTimeout> | null} */
70
+ let abandon = null;
71
+ const settle = (value) => {
72
+ if (settled) {
73
+ return;
74
+ }
75
+ settled = true;
76
+ if (abandon !== null) {
77
+ clearTimeout(abandon);
78
+ }
79
+ if (value === null) {
80
+ stream.destroy?.();
81
+ }
82
+ resolve(value);
83
+ };
84
+ abandon = setTimeout(() => {
85
+ logger.info(
86
+ `container-tracks: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
87
+ `did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
88
+ );
89
+ settle(null);
90
+ }, READ_ABANDON_MS);
91
+ abandon.unref?.();
92
+ stream.on("data", (chunk) => chunks.push(chunk));
93
+ stream.on("end", () => settle(Buffer.concat(chunks)));
94
+ stream.on("error", () => settle(null));
95
+ });
96
+ }
97
+
98
+ /**
99
+ * One track as it crosses the thread boundary: the fields the inventory and the
100
+ * master playlist read, and nothing that would not survive being cloned.
101
+ *
102
+ * @param {import("../tracks/index.js").ContainerTrack} track
103
+ * @returns {object}
104
+ */
105
+ function plainTrack(track) {
106
+ return {
107
+ type: track.type,
108
+ trackNumber: track.trackNumber,
109
+ declaredIndex: track.declaredIndex,
110
+ codecId: track.codecId,
111
+ language: track.resolvedLanguage(),
112
+ languageBcp47: track.languageBcp47,
113
+ name: track.name,
114
+ isEnabled: track.isEnabled,
115
+ isDefault: track.isDefault,
116
+ declaresDefault: track.declaresDefault,
117
+ // Audio-only, per RFC 9559 — absent on other types, and read as false there
118
+ // rather than being placed on the base track where they do not belong.
119
+ isOriginal: track.isOriginal === true,
120
+ isCommentary: track.isCommentary === true,
121
+ isVisualImpaired: track.isVisualImpaired === true,
122
+ channels: Number.isFinite(track.channels) ? track.channels : null,
123
+ samplingFrequency: Number.isFinite(track.samplingFrequency) ? track.samplingFrequency : null
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Every track one file declares, in container order.
129
+ *
130
+ * @param {object} torrent
131
+ * @param {number} fileIndex
132
+ * @param {string} sourceKey
133
+ * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
134
+ * @returns {Promise<object[]>}
135
+ */
136
+ export async function containerTracksOf(torrent, fileIndex, sourceKey, options = {}) {
137
+ const key = `${sourceKey}:${fileIndex}`;
138
+ const held = byFile.get(key);
139
+ if (Array.isArray(held)) {
140
+ return held;
141
+ }
142
+ const running = inFlight.get(key);
143
+ if (running) {
144
+ return running;
145
+ }
146
+ const work = readTracks(torrent, fileIndex, sourceKey, options)
147
+ .then((tracks) => {
148
+ if (tracks.length > 0) {
149
+ byFile.set(key, tracks);
150
+ }
151
+ return tracks;
152
+ })
153
+ .finally(() => {
154
+ inFlight.delete(key);
155
+ });
156
+ inFlight.set(key, work);
157
+ return work;
158
+ }
159
+
160
+ /**
161
+ * @param {object} torrent
162
+ * @param {number} fileIndex
163
+ * @param {string} sourceKey
164
+ * @param {{ prefetchEdges?: () => Promise<unknown> }} options
165
+ * @returns {Promise<object[]>}
166
+ */
167
+ async function readTracks(torrent, fileIndex, sourceKey, options) {
168
+ const file = torrent?.files?.[fileIndex];
169
+ if (!file || !Number.isFinite(file.length) || file.length <= 0) {
170
+ return [];
171
+ }
172
+ if (typeof options.prefetchEdges === "function") {
173
+ try {
174
+ await options.prefetchEdges();
175
+ } catch {
176
+ // A prefetch that failed is not a reason to skip the read: the read
177
+ // fetches what it needs itself, only more slowly.
178
+ }
179
+ }
180
+ const readRange = async (start, end) =>
181
+ readFetching(file, start, Math.min(end, file.length - 1));
182
+ try {
183
+ const tracks = await containerOrchestrator.getTracks({
184
+ sourceKey,
185
+ fileIndex,
186
+ readRange,
187
+ fileSize: file.length,
188
+ label: String(file.name ?? "")
189
+ });
190
+ const plain = tracks.map(plainTrack);
191
+ if (plain.length > 0) {
192
+ logger.info(
193
+ `container-tracks: "${String(file.name).slice(0, 40)}" declares ` +
194
+ `${plain.filter((track) => track.type === "video").length} video, ` +
195
+ `${plain.filter((track) => track.type === "audio").length} audio, ` +
196
+ `${plain.filter((track) => track.type === "subtitle").length} subtitle track(s)`
197
+ );
198
+ }
199
+ return plain;
200
+ } catch (error) {
201
+ logger.warn(
202
+ `container-tracks: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
203
+ );
204
+ return [];
205
+ }
206
+ }
207
+
208
+ /**
209
+ * The audio tracks of one file, in the order ffmpeg numbers them `0:a:N`.
210
+ *
211
+ * @param {object} torrent
212
+ * @param {number} fileIndex
213
+ * @param {string} sourceKey
214
+ * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
215
+ * @returns {Promise<object[]>}
216
+ */
217
+ export async function containerAudioTracksOf(torrent, fileIndex, sourceKey, options = {}) {
218
+ const tracks = await containerTracksOf(torrent, fileIndex, sourceKey, options);
219
+ return tracks
220
+ .filter((track) => track.type === "audio")
221
+ .sort((left, right) => left.declaredIndex - right.declaredIndex);
222
+ }
223
+
224
+ /** Bytes of a file's head worth fetching before its track table is read. */
225
+ export const CONTAINER_HEAD_BYTES = HEAD_BYTES;
226
+
227
+ /**
228
+ * Forget one file's tracks, or every file of a source.
229
+ *
230
+ * @param {string} sourceKey
231
+ * @param {number} [fileIndex]
232
+ */
233
+ export function forgetContainerTracks(sourceKey, fileIndex) {
234
+ if (fileIndex === undefined) {
235
+ for (const key of [...byFile.keys()]) {
236
+ if (key.startsWith(`${sourceKey}:`)) {
237
+ byFile.delete(key);
238
+ }
239
+ }
240
+ return;
241
+ }
242
+ byFile.delete(`${sourceKey}:${fileIndex}`);
243
+ }
@@ -161,6 +161,44 @@ export class WorkerTorrentPool {
161
161
  return Array.isArray(answer?.declared) ? answer.declared : [];
162
162
  }
163
163
 
164
+ /**
165
+ * Every track one file declares, read from its own header by the container
166
+ * layer.
167
+ *
168
+ * The audio menu is built from ffmpeg's `-i` banner, which carries neither
169
+ * `FlagOriginal`, `FlagCommentary`, `FlagVisualImpaired`, `FlagEnabled` nor
170
+ * `LanguageBCP47` — so without this a director's commentary and the film
171
+ * itself are indistinguishable in it. Also how a soundtrack shipped as its own
172
+ * file is read: a `.mka` is Matroska and the same reader serves it.
173
+ *
174
+ * @param {object} torrent
175
+ * @param {number} fileIndex
176
+ * @returns {Promise<object[]>}
177
+ */
178
+ async getContainerTracks(torrent, fileIndex) {
179
+ const sourceKey = torrent?.sourceKey;
180
+ if (!sourceKey) {
181
+ return [];
182
+ }
183
+ const answer = await this.#client.getContainerTracks({ sourceKey, fileIndex });
184
+ return Array.isArray(answer?.tracks) ? answer.tracks : [];
185
+ }
186
+
187
+ /**
188
+ * The audio tracks one file declares, in the order ffmpeg numbers them
189
+ * `0:a:N`.
190
+ *
191
+ * @param {object} torrent
192
+ * @param {number} fileIndex
193
+ * @returns {Promise<object[]>}
194
+ */
195
+ async getDeclaredAudioTracks(torrent, fileIndex) {
196
+ const tracks = await this.getContainerTracks(torrent, fileIndex);
197
+ return tracks
198
+ .filter((track) => track?.type === "audio")
199
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0));
200
+ }
201
+
164
202
  /**
165
203
  * The cues of one subtitle track that the downloaded clusters already carry.
166
204
  *
@@ -67,6 +67,14 @@ export const Command = {
67
67
  PREFETCH_EDGES: "prefetch-edges",
68
68
  /** The text subtitle tracks a file carries, for the viewer's menu. */
69
69
  SUBTITLE_TRACKS: "subtitle-tracks",
70
+ /**
71
+ * Every track a file declares, read from its own header by the container
72
+ * layer. Answers what ffmpeg's `-i` banner cannot: FlagOriginal,
73
+ * FlagCommentary, FlagVisualImpaired, FlagEnabled and LanguageBCP47 appear
74
+ * nowhere in it. Asked of the picture AND of a soundtrack shipped as its own
75
+ * file beside it, which is the same question about a different file.
76
+ */
77
+ CONTAINER_TRACKS: "container-tracks",
70
78
  /** Cues of one subtitle track, from the clusters already downloaded. */
71
79
  SUBTITLE_CUES: "subtitle-cues",
72
80
  /** Shut the client down, optionally deleting downloaded data. */