@torrent-tv/proxy 2.64.8 → 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.
@@ -107,6 +107,38 @@ function availableMemorySync() {
107
107
  return os.freemem();
108
108
  }
109
109
 
110
+ /**
111
+ * How many piece buffers this thread has let go of, and how many the collector
112
+ * has actually taken back.
113
+ *
114
+ * The one reading that separates the two explanations of the 700 MB nobody can
115
+ * account for (roadmap item 2, field 2026-08-31): if the two numbers track each
116
+ * other, this code is holding nothing and whatever grows is below us, in the
117
+ * allocator — on musl there is no `malloc_trim` and no way to ask. If the gap
118
+ * widens, a reference of ours outlives the piece, and then a heap snapshot can
119
+ * name the holder.
120
+ *
121
+ * What it does NOT prove: a `SharedArrayBuffer`'s memory is shared between the
122
+ * isolates, so this thread's handle going means only that THIS thread let go.
123
+ * The main thread counts its own (`torrent-worker/client.js`), and the pair is
124
+ * what answers the question.
125
+ */
126
+ const released = { count: 0, collected: 0 };
127
+ const collector = typeof FinalizationRegistry === "function"
128
+ ? new FinalizationRegistry(() => {
129
+ released.collected += 1;
130
+ })
131
+ : null;
132
+
133
+ /**
134
+ * What the collector has taken back against what was let go.
135
+ *
136
+ * @returns {{ released: number, collected: number }}
137
+ */
138
+ export function pieceBufferCollection() {
139
+ return { released: released.count, collected: released.collected };
140
+ }
141
+
110
142
  const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
111
143
  const MIN_RESIDENT_PIECES = 2;
112
144
  /**
@@ -501,7 +533,7 @@ export class SharedPieceStore {
501
533
  this.#counters.fromMemory += 1;
502
534
  return already;
503
535
  }
504
- const target = new SharedArrayBuffer(this.#lengthOf(index));
536
+ const target = this.#watchForCollection(new SharedArrayBuffer(this.#lengthOf(index)));
505
537
  await this.#disk.read(index, Buffer.from(target));
506
538
  this.#registerPiece(index, target);
507
539
  this.#counters.fromDisk += 1;
@@ -512,6 +544,19 @@ export class SharedPieceStore {
512
544
  }
513
545
  }
514
546
 
547
+ /**
548
+ * Count this buffer as one this thread will have to let go of, and notice
549
+ * when the collector takes it. See {@link pieceBufferCollection}.
550
+ *
551
+ * @param {SharedArrayBuffer} buffer
552
+ * @returns {SharedArrayBuffer} The same buffer.
553
+ */
554
+ #watchForCollection(buffer) {
555
+ released.count += 1;
556
+ collector?.register(buffer, null);
557
+ return buffer;
558
+ }
559
+
515
560
  /**
516
561
  * A fresh buffer holding this piece's bytes.
517
562
  *
@@ -521,7 +566,7 @@ export class SharedPieceStore {
521
566
  */
522
567
  #copyIntoNewBuffer(index, bytes) {
523
568
  const length = this.#lengthOf(index);
524
- const sab = new SharedArrayBuffer(length);
569
+ const sab = this.#watchForCollection(new SharedArrayBuffer(length));
525
570
  const view = Buffer.from(sab);
526
571
  if (bytes.copy) {
527
572
  bytes.copy(view, 0, 0, length);
@@ -9,6 +9,8 @@
9
9
  import { spawn } from "node:child_process";
10
10
  import { logger } from "../utils/logger.js";
11
11
  import { mergeContainerSubtitleFlags } from "./subtitle-defaults.js";
12
+ import { buildAudioInventory, mergeContainerAudioFlags } from "./audio-inventory.js";
13
+ import { countVideoFiles, matchSidecarFiles } from "./sidecar-files.js";
12
14
  import {
13
15
  parseFfmpegDurationSeconds,
14
16
  parseFfmpegStartTimeSeconds,
@@ -28,6 +30,17 @@ const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
28
30
  // the codec probe). ~16 MB ≈ the first segments of typical media.
29
31
  const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
30
32
 
33
+ /**
34
+ * How long the plan waits for a file's own header before offering its
35
+ * soundtrack without what that header would have said.
36
+ *
37
+ * Not a measurement, and nothing is derived from it: it is the point past which
38
+ * holding the viewer costs more than the language and flags being waited for —
39
+ * which the folder name supplies anyway, from the torrent's file list, at no
40
+ * cost. The reading itself carries on in the worker and is kept there.
41
+ */
42
+ const SIDECAR_HEADER_WAIT_MS = 3_000;
43
+
31
44
  /** Subtitle codecs that can be converted to WebVTT (text-based). */
32
45
  const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
33
46
 
@@ -345,6 +358,122 @@ export function createPlaybackPlanner({
345
358
  return merged.tracks;
346
359
  }
347
360
 
361
+ /**
362
+ * Every soundtrack this file can be watched with, as one numbered list: its
363
+ * own tracks and the ones shipped as separate files beside it.
364
+ *
365
+ * Built here, in the plan, because the plan is what the viewer's menu is drawn
366
+ * from — so the offer is complete the moment a file is opened, with nothing
367
+ * arriving late and nothing measured while the viewer waits. It is also what
368
+ * the master playlist's rendition group is built from, so the number in the
369
+ * menu and the number in the `a/<n>/` address are the same number by
370
+ * construction rather than by agreement.
371
+ *
372
+ * @param {object} torrent
373
+ * @param {number} fileIndex
374
+ * @param {object[]} bannerAudioTracks - The probe's own audio streams.
375
+ * @returns {Promise<import("./audio-inventory.js").AudioInventoryEntry[]>}
376
+ */
377
+ async function buildInventory(torrent, fileIndex, bannerAudioTracks) {
378
+ const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
379
+ /**
380
+ * Read a file's declared audio tracks, or give up quickly.
381
+ *
382
+ * The plan is on the path to the first frame, and reading a sidecar's header
383
+ * waits on the swarm: that file has usually had nothing downloaded when this
384
+ * runs, and a header that never arrives would hold the plan — and the
385
+ * viewer — for the whole of the read's own patience. What a timeout costs is
386
+ * small and deliberate: the track is still offered, still numbered and still
387
+ * playable, only without the language and flags its own header would have
388
+ * given. The language the viewer actually sees is read from the FOLDER the
389
+ * release put it in, which is in the torrent's file list and needs no bytes
390
+ * at all.
391
+ *
392
+ * @param {number} wantedFileIndex
393
+ * @param {string} label
394
+ * @returns {Promise<object[]>}
395
+ */
396
+ const declaredAudioOf = async (wantedFileIndex, label) => {
397
+ if (typeof torrentPool?.getDeclaredAudioTracks !== "function") {
398
+ return [];
399
+ }
400
+ let timer = null;
401
+ try {
402
+ return await Promise.race([
403
+ torrentPool.getDeclaredAudioTracks(torrent, wantedFileIndex),
404
+ new Promise((resolve) => {
405
+ timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
406
+ timer.unref?.();
407
+ })
408
+ ]).then((tracks) => {
409
+ if (tracks === null) {
410
+ logger.info(
411
+ `audio tracks: "${label}" did not answer within ` +
412
+ `${SIDECAR_HEADER_WAIT_MS / 1000}s — offered without what its header would say`
413
+ );
414
+ return [];
415
+ }
416
+ return Array.isArray(tracks) ? tracks : [];
417
+ });
418
+ } catch (error) {
419
+ logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
420
+ return [];
421
+ } finally {
422
+ if (timer !== null) {
423
+ clearTimeout(timer);
424
+ }
425
+ }
426
+ };
427
+ // The picture's own tracks: ffmpeg numbers them, the container declares what
428
+ // they are. Both readings, lined up and checked — see `audio-inventory.js`.
429
+ let embedded = banner.map((track) => ({ ...track, declaresDefault: false }));
430
+ if (banner.length > 0) {
431
+ // The picture's head is already downloaded — the codec probe just read it
432
+ // — so this is a parse and not a wait, but it is bounded like the rest.
433
+ const declared = await declaredAudioOf(fileIndex, "the picture");
434
+ const merged = mergeContainerAudioFlags(banner, declared);
435
+ embedded = merged.tracks;
436
+ logger.info(
437
+ merged.aligned
438
+ ? `audio tracks: the container describes all ${merged.tracks.length}` +
439
+ `${merged.tracks.some((track) => track.isCommentary) ? ", one of them commentary" : ""}` +
440
+ `${merged.tracks.some((track) => track.isVisualImpaired) ? ", one of them described" : ""}`
441
+ : `audio tracks: using the probe's own fields — ${merged.reason}`
442
+ );
443
+ }
444
+
445
+ const sidecarFiles = matchSidecarFiles({
446
+ files: torrent?.files ?? [],
447
+ videoIndex: fileIndex,
448
+ torrentName: typeof torrent?.name === "string" ? torrent.name : "",
449
+ videoCount: countVideoFiles(torrent?.files ?? [])
450
+ });
451
+ // All of them at once. They are separate files with separate headers, and
452
+ // read one after another the waits add up on the path to the first frame.
453
+ const sidecars = await Promise.all(
454
+ sidecarFiles.audio.map(async (file) => ({
455
+ file,
456
+ // A bare elementary stream — `.ac3`, `.dts`, `.mp3` — has no table to
457
+ // read, so nothing is asked of the swarm for it at all.
458
+ tracks: file.declaresTracks ? await declaredAudioOf(file.fileIndex, file.name) : []
459
+ }))
460
+ );
461
+ const inventory = buildAudioInventory({ embedded, videoFileIndex: fileIndex, sidecars });
462
+ if (sidecars.length > 0) {
463
+ logger.info(
464
+ `audio tracks: ${sidecars.length} file(s) beside the picture carry sound — ` +
465
+ inventory
466
+ .filter((entry) => entry.kind === "sidecar")
467
+ .map((entry) =>
468
+ `a:${entry.index}=${entry.folders.join("/") || "."}/${entry.fileName}` +
469
+ `#${entry.sourceTrackIndex}${entry.codec ? `(${entry.codec})` : ""}`
470
+ )
471
+ .join(" ")
472
+ );
473
+ }
474
+ return inventory;
475
+ }
476
+
348
477
  function withHostTimings(plan) {
349
478
  return {
350
479
  ...plan,
@@ -516,8 +645,10 @@ export function createPlaybackPlanner({
516
645
  // (list of forced resolutions <= source). 0 when unknown.
517
646
  videoWidth,
518
647
  videoHeight,
519
- // Full track inventory for the browser's audio/subtitle menus.
520
- audioTracks: audioTracks ?? [],
648
+ // Full track inventory for the browser's audio/subtitle menus. The audio
649
+ // half spans the picture's own tracks AND the soundtracks shipped as
650
+ // files beside it, under one numbering — see `buildInventory`.
651
+ audioTracks: await buildInventory(torrent, fileIndex, audioTracks ?? []),
521
652
  subtitleTracks: await withContainerDefaults(torrent, fileIndex, subtitleTracks ?? []),
522
653
  // Both host timings are filled in by `withHostTimings` on the way out,
523
654
  // never here: read at build time they would be frozen into the cached
@@ -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
+ }
@@ -41,6 +41,37 @@ const WORKER_EXIT_GRACE_MS = 5_000;
41
41
  * queued behind that work, so reading a finished 10 MB file took 12-23 s where
42
42
  * handing it to the channel took 125 ms.
43
43
  */
44
+ /**
45
+ * Piece buffers this thread has been handed, and how many the collector has
46
+ * taken back.
47
+ *
48
+ * A `SharedArrayBuffer`'s memory belongs to neither isolate: it lives until
49
+ * BOTH have let go. So the worker's own count answers only half the question,
50
+ * and this is the other half — 700 MB grew in the field on 2026-08-31 with the
51
+ * store's own memory falling, and the two candidates are "a reference of ours
52
+ * outlives the piece" and "the allocator keeps what we free". These two
53
+ * counters separate them (roadmap item 2).
54
+ *
55
+ * A fragment arrives as a fresh handle onto the same shared memory every time,
56
+ * so what is counted is handles, not pieces. That is the right quantity anyway:
57
+ * one handle retained keeps the whole piece alive.
58
+ */
59
+ const fragmentBuffers = { seen: 0, collected: 0 };
60
+ const fragmentCollector = typeof FinalizationRegistry === "function"
61
+ ? new FinalizationRegistry(() => {
62
+ fragmentBuffers.collected += 1;
63
+ })
64
+ : null;
65
+
66
+ /**
67
+ * What the collector has taken back against what this thread was handed.
68
+ *
69
+ * @returns {{ seen: number, collected: number }}
70
+ */
71
+ export function fragmentBufferCollection() {
72
+ return { seen: fragmentBuffers.seen, collected: fragmentBuffers.collected };
73
+ }
74
+
44
75
  export class TorrentWorkerClient {
45
76
  #worker;
46
77
  /**
@@ -138,6 +169,8 @@ export class TorrentWorkerClient {
138
169
  );
139
170
  }
140
171
  this.#lastPieceByRead.set(message.id, message.pieceIndex);
172
+ fragmentBuffers.seen += 1;
173
+ fragmentCollector?.register(buffer, null);
141
174
  const view = new Uint8Array(buffer, message.offset, message.length);
142
175
 
143
176
  const reader = this.#fragmentReaders.get(message.id);
@@ -292,6 +325,20 @@ export class TorrentWorkerClient {
292
325
  return this.#caller.call(Command.SUBTITLE_TRACKS, { sourceKey, fileIndex });
293
326
  }
294
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
+
295
342
  /**
296
343
  * The cues of one subtitle track that can be read from what is downloaded.
297
344
  *