@torrent-tv/proxy 2.83.3 → 2.83.4

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.
@@ -208,8 +208,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
208
208
  return;
209
209
  }
210
210
 
211
- const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
212
-
213
211
  const range = parseRange(req.headers.range, file.length);
214
212
  // Prioritize the pieces at this read position so a seek (a request at a new
215
213
  // byte offset) downloads first instead of waiting behind the sequential
@@ -343,8 +341,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
343
341
  logger.warn(line);
344
342
  }
345
343
  reply.raw.destroy();
346
- } finally {
347
- releaseFile();
348
344
  }
349
345
  return;
350
346
  }
@@ -355,41 +351,11 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
355
351
 
356
352
  if (!range) {
357
353
  reply.header("Content-Length", String(file.length));
358
- const stream = file.createReadStream();
359
- bindRelease(stream, reply, releaseFile);
360
- return reply.send(stream);
354
+ return reply.send(file.createReadStream());
361
355
  }
362
356
 
363
357
  reply.code(206);
364
358
  reply.header("Content-Length", String(contentLength));
365
359
  reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
366
- const stream = file.createReadStream({ start, end });
367
- bindRelease(stream, reply, releaseFile);
368
- return reply.send(stream);
369
- }
370
-
371
- /**
372
- * Attach event listeners that release the file reference exactly once when
373
- * the stream or the underlying HTTP connection closes.
374
- *
375
- * @param {import("node:stream").Readable} stream
376
- * @param {import("fastify").FastifyReply} reply
377
- * @param {() => void} release
378
- * @returns {void}
379
- */
380
- function bindRelease(stream, reply, release) {
381
- let released = false;
382
- const releaseOnce = () => {
383
- if (released) {
384
- return;
385
- }
386
- released = true;
387
- release();
388
- };
389
-
390
- stream.on("close", releaseOnce);
391
- stream.on("end", releaseOnce);
392
- stream.on("error", releaseOnce);
393
- reply.raw.once("close", releaseOnce);
394
- reply.raw.once("finish", releaseOnce);
360
+ return reply.send(file.createReadStream({ start, end }));
395
361
  }
@@ -51,7 +51,6 @@ export class SubtitleController {
51
51
  if (!hasTrack) {
52
52
  const name = file.name ?? "";
53
53
  const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
54
- const release = this.torrentPool.acquireFile(torrent, fileIndex);
55
54
  try {
56
55
  const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
57
56
  const text = SubtitleFileContainer.decodeBytes(bytes);
@@ -65,8 +64,6 @@ export class SubtitleController {
65
64
  return { vtt, language: TextSubtitleTrack.detectLanguageFromVtt(vtt), headers: {} };
66
65
  } catch (e) {
67
66
  return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
68
- } finally {
69
- release();
70
67
  }
71
68
  }
72
69
 
@@ -1680,12 +1680,7 @@ export class HlsSessionManager {
1680
1680
  // keyframe times and which container they were read from. Present only for
1681
1681
  // a variant of a session cut at the source's keyframes, and it is what
1682
1682
  // makes the two interchangeable.
1683
- inheritedGrid = null,
1684
- // Called once for a session that is actually created, and expected to
1685
- // return a function that lets the source go. It is what keeps the torrent's
1686
- // data alive for as long as a viewer has a session on it — see
1687
- // disposeSession.
1688
- acquireSource = null
1683
+ inheritedGrid = null
1689
1684
  }) {
1690
1685
  if (!this.enabled) {
1691
1686
  const error = new Error("Audio transcoding is disabled on this proxy.");
@@ -2540,40 +2535,6 @@ export class HlsSessionManager {
2540
2535
  lastLoggedAt: 0
2541
2536
  }
2542
2537
  };
2543
- // Kept so a variant of this session can take its own hold on the same
2544
- // source: a variant is another encode of the same file and must keep the
2545
- // torrent's data alive exactly as this one does.
2546
- session.acquireSource = typeof acquireSource === "function" ? acquireSource : null;
2547
- if (typeof acquireSource === "function") {
2548
- // One claim per file this session READS. Almost always that is one file;
2549
- // a muxed session whose soundtrack ships beside the picture reads two, and
2550
- // holding only the picture would leave the sound to be swept off the disk
2551
- // from under a running encoder.
2552
- const claim = (heldFileIndex) => {
2553
- try {
2554
- const release = acquireSource(heldFileIndex);
2555
- return typeof release === "function" ? release : null;
2556
- } catch {
2557
- return null;
2558
- }
2559
- };
2560
- const releases = [claim(undefined)];
2561
- if (audioInputUrl) {
2562
- releases.push(claim(audioSource.fileIndex));
2563
- }
2564
- const held = releases.filter((release) => typeof release === "function");
2565
- session.releaseSource = held.length > 0
2566
- ? () => {
2567
- for (const release of held) {
2568
- try {
2569
- release();
2570
- } catch {
2571
- // Best effort — a session must always finish being disposed.
2572
- }
2573
- }
2574
- }
2575
- : null;
2576
- }
2577
2538
  // The viewer who asked for this session, so a browser that names itself
2578
2539
  // never has to have requested a segment first for its own soundtrack choice
2579
2540
  // to be known — nor for its own POSITION to be known, which is the same
@@ -7839,8 +7800,7 @@ export class HlsSessionManager {
7839
7800
  // creations (field 2026-08-17, corrections of 0.6-2.9 s).
7840
7801
  published: base.timeline.published
7841
7802
  }
7842
- : null,
7843
- acquireSource: base.acquireSource
7803
+ : null
7844
7804
  })
7845
7805
  .then(async (variant) => {
7846
7806
  // Making a session takes seconds — a probe and a keyframe index — and
@@ -8628,15 +8588,7 @@ export class HlsSessionManager {
8628
8588
  boundaries: base.timeline.boundaries,
8629
8589
  published: base.timeline.published
8630
8590
  }
8631
- : null,
8632
- // Hold the file this rendition will READ. For a soundtrack shipped beside
8633
- // the picture that is a different file of the same torrent, and nothing
8634
- // else claims it: the base holds the picture, and the disk sweep deletes
8635
- // what nobody is holding — which is how a film being watched was deleted
8636
- // on 2026-08-06.
8637
- acquireSource: () => base.acquireSource?.(
8638
- this.#resolveAudioSource(base.file.sourceKey, base.file.fileIndex, trackIndex).fileIndex
8639
- )
8591
+ : null
8640
8592
  });
8641
8593
  return rendition ?? null;
8642
8594
  }
@@ -10017,23 +9969,6 @@ export class HlsSessionManager {
10017
9969
  }
10018
9970
  }
10019
9971
 
10020
- // Let go of the source. While a session exists its torrent must survive
10021
- // both cleanups the pool runs, and until now neither knew about it: the
10022
- // only claim on a file is taken by a READ, and during a seek there is no
10023
- // read at all — the old encoder is dead and the new one has not started.
10024
- // Field 2026-08-06, that window met the thirty-second disk sweep and the
10025
- // film being watched was evicted mid-seek, six gigabytes deleted, after
10026
- // which the new encoder had nothing to read. The session's own thirty
10027
- // minutes governed the session, never the data under it.
10028
- if (typeof session.releaseSource === "function") {
10029
- try {
10030
- session.releaseSource();
10031
- } catch {
10032
- // Best effort — a session must always finish being disposed.
10033
- }
10034
- session.releaseSource = null;
10035
- }
10036
-
10037
9972
  if (session.inputRetryTimer) {
10038
9973
  clearTimeout(session.inputRetryTimer);
10039
9974
  session.inputRetryTimer = null;
@@ -10,7 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { logger } from "../utils/logger.js";
11
11
  import { Container } from "./container/Container.js";
12
12
  import { buildAudioInventory } from "./audio-inventory.js";
13
- import { countVideoFiles, matchSidecarFiles } from "./torrent/files.js";
13
+ import { contentsOf } from "./torrent/Contents.js";
14
14
  import {
15
15
  parseFfmpegDurationSeconds,
16
16
  parseFfmpegStartTimeSeconds,
@@ -339,22 +339,18 @@ export function createPlaybackPlanner({
339
339
  /**
340
340
  * The files beside this picture that belong to it, in three groups.
341
341
  *
342
- * One call site's worth of arguments, spelled once: the file list, the
343
- * torrent's own name (which WebTorrent prefixes to every path) and how many
344
- * pictures the torrent holds, which is what decides whether a sidecar with a
345
- * name in common with nothing can still belong to the only video there is.
342
+ * Asked of what the torrent says about itself, which works the grouping out
343
+ * once and keeps it. It used to be worked out here on every call, and again
344
+ * on the warm-up's own path over the same list, so one opened film paired the
345
+ * same files several times and neither side could be sure of the other's
346
+ * answer.
346
347
  *
347
348
  * @param {object} torrent
348
349
  * @param {number} fileIndex
349
350
  * @returns {{ audio: object[], subtitles: object[], images: object[] }}
350
351
  */
351
352
  function sidecarsOf(torrent, fileIndex) {
352
- return matchSidecarFiles({
353
- files: torrent?.files ?? [],
354
- videoIndex: fileIndex,
355
- torrentName: typeof torrent?.name === "string" ? torrent.name : "",
356
- videoCount: countVideoFiles(torrent?.files ?? [])
357
- });
353
+ return contentsOf(torrent ?? {}).sidecarsOf(fileIndex);
358
354
  }
359
355
 
360
356
  async function withContainerDefaults(torrent, fileIndex, subtitleTracks) {
@@ -630,7 +626,10 @@ export function createPlaybackPlanner({
630
626
  // codecs. A transient empty probe must NOT be cached: otherwise the wrong
631
627
  // plan (file treated as directly playable) sticks permanently for this
632
628
  // file, and an unsupported codec like xvid gets copied → black video.
633
- await torrentPool.prefetchFileEdges(torrent, fileIndex);
629
+ // Awaited in the literal sense: this answer cannot be given until the
630
+ // file has said what is in it, and a person is watching a loading screen
631
+ // for as long as that takes.
632
+ await torrentPool.prefetchFileEdges(torrent, fileIndex, { awaited: true });
634
633
  edgesReadyMs = Date.now() - planEntryMs;
635
634
  // The keyframe index reads the tail of the file, which the probe has just
636
635
  // waited for as well. Started here it overlaps the probe instead of
@@ -653,7 +652,7 @@ export function createPlaybackPlanner({
653
652
  ) {
654
653
  attempt += 1;
655
654
  await delay(Math.min(3_000, 500 + attempt * 250));
656
- await torrentPool.prefetchFileEdges(torrent, fileIndex);
655
+ await torrentPool.prefetchFileEdges(torrent, fileIndex, { awaited: true });
657
656
  probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
658
657
  }
659
658
  const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
@@ -22,7 +22,13 @@ export class PriorityOrchestrator {
22
22
  /** Where the map goes once it is built. @type {(published: object) => void} */
23
23
  #publish;
24
24
 
25
- /** The last map published per film and file, so an unchanged one is not resent. */
25
+ /**
26
+ * The last map published per film and file, so an unchanged one is not
27
+ * resent — and what it was about, so that a file everybody has left can be
28
+ * published as wanting nothing.
29
+ *
30
+ * @type {Map<string, { shape: string, sourceKey: string, fileIndex: number, durationSeconds: number }>}
31
+ */
26
32
  #last = new Map();
27
33
 
28
34
  /** The last map BUILT per film and file, for whoever reads instead of being
@@ -138,13 +144,33 @@ export class PriorityOrchestrator {
138
144
  // second by second, which is the same comparison over far fewer values.
139
145
  const zones = runsOf(map);
140
146
  const shape = JSON.stringify(zones);
141
- if (this.#last.get(key) !== shape) {
142
- this.#last.set(key, shape);
147
+ if (this.#last.get(key)?.shape !== shape) {
148
+ // The length is remembered with the shape, so that a file whose viewers
149
+ // have all gone can still be spoken for: what is published then is the
150
+ // same statement with nothing in it, and it has to name the file it is
151
+ // about.
152
+ this.#last.set(key, { shape, sourceKey, fileIndex, durationSeconds });
143
153
  this.#publish({ sourceKey, fileIndex, durationSeconds, zones });
144
154
  }
145
155
  return map;
146
156
  }
147
157
 
158
+ /**
159
+ * NOBODY WANTS ANYTHING OF THIS FILE ANY MORE — said, rather than left to be
160
+ * inferred from silence.
161
+ *
162
+ * The map used to be deleted from this class's memory and published nowhere,
163
+ * so what had been stated about that file on the swarm's behalf stood until
164
+ * the torrent itself was removed. Read from the register, a film nobody had
165
+ * watched for an hour was indistinguishable from one being watched now.
166
+ *
167
+ * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number }} what
168
+ * @returns {void}
169
+ */
170
+ #publishNothingFor({ sourceKey, fileIndex, durationSeconds }) {
171
+ this.#publish({ sourceKey, fileIndex, durationSeconds, zones: [] });
172
+ }
173
+
148
174
  /**
149
175
  * Build and publish the map for every file anybody is watching.
150
176
  *
@@ -222,8 +248,12 @@ export class PriorityOrchestrator {
222
248
  // for it and called from nowhere.
223
249
  for (const key of [...this.#maps.keys()]) {
224
250
  if (!byFile.has(key)) {
251
+ const last = this.#last.get(key);
225
252
  this.#maps.delete(key);
226
253
  this.#last.delete(key);
254
+ if (last) {
255
+ this.#publishNothingFor(last);
256
+ }
227
257
  }
228
258
  }
229
259
  for (const address of [...this.#byOutput.keys()]) {
@@ -232,9 +262,13 @@ export class PriorityOrchestrator {
232
262
  }
233
263
  }
234
264
  for (const one of byFile.values()) {
235
- // A file of unknown length cannot be divided into zones, and a file
236
- // nobody is watching has nothing to be urgent about.
237
- if (one.durationSeconds > 0 && one.viewers.length > 0) {
265
+ // A file of unknown length cannot be divided into zones at all. A file
266
+ // whose viewers have all gone CAN be spoken for, and must be: `build`
267
+ // with nobody watching produces a map with nothing in it, which is the
268
+ // truth and is what withdraws what was stated for them. Skipped, as it
269
+ // was, the last thing said about that file stood for as long as the
270
+ // torrent did.
271
+ if (one.durationSeconds > 0) {
238
272
  this.build(one);
239
273
  }
240
274
  }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * @file WHAT IS IN ONE TORRENT — its files, which of them carry a picture, and
3
+ * which files belong to which picture.
4
+ *
5
+ * The torrent's own statement about itself, held instead of recomputed. The
6
+ * pairing rules already lived beside this file, as functions; what did not
7
+ * exist was anywhere to keep their answer, so every caller worked it out again
8
+ * — the warm-up on one path and the playback plan on another, over the same
9
+ * list, several times per opened film.
10
+ *
11
+ * It is a class rather than a set of functions because the fact it holds passes
12
+ * the test this project uses for that: the composition is born with the file
13
+ * list, dies with the record of the torrent, and is addressed by one name.
14
+ *
15
+ * **What it must NOT hold**, and the boundary is the point:
16
+ *
17
+ * - nothing about a FILM. A poster, a title, a description are answers from a
18
+ * third party about an identity, and that identity comes from a file's own
19
+ * bytes rather than from the torrent's list of names. They are keyed by the
20
+ * picture's file, arrive late or not at all, and must never delay playback;
21
+ * - no reference to another layer. Not a container, whose life is a file's and
22
+ * which reads bytes; not a priority map, which is built from viewers and
23
+ * lives above this; not a viewer. What leaves here is plain values — indices
24
+ * into the torrent's own list — and whoever takes them holds nothing of this
25
+ * object.
26
+ *
27
+ * **What was measured and deliberately NOT built**, so that it is not proposed
28
+ * again as an oversight: telling a real episode from an extra. Over the 134
29
+ * torrents of the survey collection, the words that would say so (`sample`,
30
+ * `trailer`, `extra`, `bonus`, `preview`, `making`) matched four files, and all
31
+ * four were ordinary titles — "Making cash with her pussy" is an episode, not a
32
+ * making-of. Size says no more: the ratio of a video to the median of its own
33
+ * torrent runs continuously from 0.00 to 1.00 with no gap anywhere, because a
34
+ * collection of short clips is made of short clips. So there is nothing here to
35
+ * derive a rule from, and a rule invented anyway would decide the order in
36
+ * which a stranger's bandwidth is spent. Every picture is an item.
37
+ *
38
+ * Nothing here reads bytes, waits on the swarm or knows about ffmpeg. Like the
39
+ * functions it is built on, it is a function of the list of names.
40
+ */
41
+
42
+ import {
43
+ AUDIO_SIDECAR_EXTENSIONS,
44
+ IMAGE_SIDECAR_EXTENSIONS,
45
+ SUBTITLE_SIDECAR_EXTENSIONS,
46
+ VIDEO_FILE_EXTENSIONS,
47
+ extensionOf,
48
+ matchSidecarFiles,
49
+ splitTorrentPath
50
+ } from "./files.js";
51
+
52
+ /**
53
+ * One picture of a torrent, with the files that belong to it.
54
+ *
55
+ * @typedef {object} TorrentItem
56
+ * @property {number} fileIndex - Index of the picture in the torrent's own list.
57
+ * @property {string} name - Its file name, extension included.
58
+ * @property {string[]} folders - The folders above it, relative to the torrent root.
59
+ * @property {string} relativePath - Both of those as one path.
60
+ * @property {number} length - Bytes.
61
+ * @property {import("./files.js").SidecarFile[]} audio - Soundtracks beside it.
62
+ * @property {import("./files.js").SidecarFile[]} subtitles - Subtitle files beside it.
63
+ * @property {import("./files.js").SidecarFile[]} images - Contact sheets and covers.
64
+ */
65
+
66
+ /**
67
+ * One file of a torrent that belongs to no picture.
68
+ *
69
+ * @typedef {object} TorrentLeftover
70
+ * @property {number} fileIndex
71
+ * @property {string} name
72
+ * @property {string[]} folders
73
+ * @property {string} relativePath
74
+ * @property {number} length
75
+ */
76
+
77
+ /**
78
+ * Files in the order a person reads them: by folder, then by name, with runs of
79
+ * digits compared as numbers.
80
+ *
81
+ * A torrent's own order is whatever the tool that made it chose, and it is
82
+ * routinely by size — one measured release lists its episodes 08, 06, 07, 01,
83
+ * 02, 10. Nothing downstream depends on the position: every item carries the
84
+ * torrent's own index, and that is what a file is opened by.
85
+ *
86
+ * `numeric` is what makes 2 come before 10; comparing the strings would put
87
+ * "10" before "2".
88
+ *
89
+ * @param {{ relativePath: string }} left
90
+ * @param {{ relativePath: string }} right
91
+ * @returns {number}
92
+ */
93
+ function inReadingOrder(left, right) {
94
+ return left.relativePath.localeCompare(right.relativePath, undefined, {
95
+ numeric: true,
96
+ sensitivity: "base"
97
+ });
98
+ }
99
+
100
+ export class TorrentContents {
101
+ /** @type {TorrentItem[]} */
102
+ #items = [];
103
+
104
+ /** Which item a file belongs to, as its picture or as one of its parts. @type {Map<number, TorrentItem>} */
105
+ #itemByFile = new Map();
106
+
107
+ /** How many files carry a picture. @type {number} */
108
+ #videoCount = 0;
109
+
110
+ /** @type {TorrentLeftover[]} */
111
+ #leftovers = [];
112
+
113
+ /** Every file as this class reads it, in the torrent's own order. @type {object[]} */
114
+ #described = [];
115
+
116
+ /**
117
+ * @param {object} params
118
+ * @param {Array<{ path?: string, name?: string, length?: number }>} params.files -
119
+ * The torrent's own list, in its own order.
120
+ * @param {string} [params.name] - The torrent's name, which WebTorrent
121
+ * prefixes to every path in a multi-file torrent.
122
+ */
123
+ constructor({ files, name = "" }) {
124
+ const list = Array.isArray(files) ? files : [];
125
+ const described = list.map((file, fileIndex) => {
126
+ const { folders, name: fileName } = splitTorrentPath(file?.path ?? file?.name ?? "", name);
127
+ return {
128
+ fileIndex,
129
+ name: fileName,
130
+ folders,
131
+ relativePath: [...folders, fileName].join("/"),
132
+ length: Number.isFinite(file?.length) ? file.length : 0,
133
+ isVideo: VIDEO_FILE_EXTENSIONS.has(extensionOf(fileName))
134
+ };
135
+ });
136
+ this.#described = described;
137
+ this.#videoCount = described.filter((file) => file.isVideo).length;
138
+
139
+ const claimed = new Set();
140
+ for (const file of described.filter((one) => one.isVideo).sort(inReadingOrder)) {
141
+ const { audio, subtitles, images } = matchSidecarFiles({
142
+ files: list,
143
+ videoIndex: file.fileIndex,
144
+ torrentName: name,
145
+ videoCount: this.#videoCount
146
+ });
147
+ /** @type {TorrentItem} */
148
+ const item = {
149
+ fileIndex: file.fileIndex,
150
+ name: file.name,
151
+ folders: file.folders,
152
+ relativePath: file.relativePath,
153
+ length: file.length,
154
+ audio,
155
+ subtitles,
156
+ images
157
+ };
158
+ this.#items.push(item);
159
+ claimed.add(file.fileIndex);
160
+ this.#itemByFile.set(file.fileIndex, item);
161
+ for (const part of [...audio, ...subtitles, ...images]) {
162
+ claimed.add(part.fileIndex);
163
+ // A part paired with two pictures is answered for by the first that
164
+ // took it, which in this order is the earlier episode. Both items keep
165
+ // it in their own lists, because both genuinely offer it; this map
166
+ // answers the other question — which picture to fetch it beside — and
167
+ // there one answer is what stops it being fetched twice.
168
+ if (!this.#itemByFile.has(part.fileIndex)) {
169
+ this.#itemByFile.set(part.fileIndex, item);
170
+ }
171
+ }
172
+ }
173
+
174
+ this.#leftovers = described
175
+ .filter((file) => !claimed.has(file.fileIndex))
176
+ .sort(inReadingOrder)
177
+ .map((file) => ({
178
+ fileIndex: file.fileIndex,
179
+ name: file.name,
180
+ folders: file.folders,
181
+ relativePath: file.relativePath,
182
+ length: file.length
183
+ }));
184
+ }
185
+
186
+ /**
187
+ * Every picture of this torrent with what belongs to it, in reading order.
188
+ *
189
+ * @returns {TorrentItem[]}
190
+ */
191
+ get items() {
192
+ return this.#items;
193
+ }
194
+
195
+ /**
196
+ * How many files of this torrent carry a picture.
197
+ *
198
+ * @returns {number}
199
+ */
200
+ get videoCount() {
201
+ return this.#videoCount;
202
+ }
203
+
204
+ /**
205
+ * Files that belong to no picture: the release's own notes, a screenshot pack
206
+ * matched to nothing, anything a torrent carries beside its films.
207
+ *
208
+ * @returns {TorrentLeftover[]}
209
+ */
210
+ get leftovers() {
211
+ return this.#leftovers;
212
+ }
213
+
214
+ /**
215
+ * WHAT THIS FILE IS, by its name alone.
216
+ *
217
+ * The one place the question is answered for the whole product: the browser
218
+ * used to answer it again, twice — a list of extensions in its torrent parser
219
+ * and a second, shorter pair inside its picker — and the three had already
220
+ * diverged.
221
+ *
222
+ * @param {number} fileIndex
223
+ * @returns {"video" | "audio" | "subtitle" | "image" | "other"}
224
+ */
225
+ kindOf(fileIndex) {
226
+ const file = this.#described[fileIndex];
227
+ if (!file) {
228
+ return "other";
229
+ }
230
+ if (file.isVideo) {
231
+ return "video";
232
+ }
233
+ const extension = extensionOf(file.name);
234
+ if (AUDIO_SIDECAR_EXTENSIONS.has(extension)) {
235
+ return "audio";
236
+ }
237
+ if (SUBTITLE_SIDECAR_EXTENSIONS.has(extension)) {
238
+ return "subtitle";
239
+ }
240
+ if (IMAGE_SIDECAR_EXTENSIONS.has(extension)) {
241
+ return "image";
242
+ }
243
+ return "other";
244
+ }
245
+
246
+ /**
247
+ * Every file of the torrent, in the order a person reads them, with what each
248
+ * one is and where it sits.
249
+ *
250
+ * What the browser is given: it shows this list and asks nothing about the
251
+ * names itself.
252
+ *
253
+ * @returns {{ fileIndex: number, name: string, relativePath: string, length: number, kind: string }[]}
254
+ */
255
+ files() {
256
+ return [...this.#described]
257
+ .sort(inReadingOrder)
258
+ .map((file) => ({
259
+ fileIndex: file.fileIndex,
260
+ name: file.name,
261
+ relativePath: file.relativePath,
262
+ length: file.length,
263
+ kind: this.kindOf(file.fileIndex)
264
+ }));
265
+ }
266
+
267
+ /**
268
+ * The item this file belongs to — as its picture, or as one of its parts.
269
+ *
270
+ * @param {number} fileIndex
271
+ * @returns {TorrentItem | null}
272
+ */
273
+ itemOf(fileIndex) {
274
+ return this.#itemByFile.get(fileIndex) ?? null;
275
+ }
276
+
277
+ /**
278
+ * The files beside one picture that belong to it, in three groups.
279
+ *
280
+ * The shape the warm-up and the playback plan each worked out for themselves,
281
+ * answered from what was decided once, at construction.
282
+ *
283
+ * @param {number} fileIndex - The picture's index in the torrent.
284
+ * @returns {{ audio: import("./files.js").SidecarFile[], subtitles: import("./files.js").SidecarFile[], images: import("./files.js").SidecarFile[] }}
285
+ */
286
+ sidecarsOf(fileIndex) {
287
+ const item = this.#itemByFile.get(fileIndex);
288
+ if (!item || item.fileIndex !== fileIndex) {
289
+ return { audio: [], subtitles: [], images: [] };
290
+ }
291
+ return { audio: item.audio, subtitles: item.subtitles, images: item.images };
292
+ }
293
+ }
294
+
295
+ /** What has been worked out per torrent, and from how many files. @type {WeakMap<object, { contents: TorrentContents, fileCount: number }>} */
296
+ const byTorrent = new WeakMap();
297
+
298
+ /**
299
+ * What is in this torrent, worked out once.
300
+ *
301
+ * Found from the torrent itself, in the shape the piece store and the demand
302
+ * register already use, because the same instance is wanted by callers that
303
+ * have no way to hand it to one another.
304
+ *
305
+ * A torrent added from a magnet has no files until its metadata arrives, so the
306
+ * answer is rebuilt when the list changes size — which it does exactly once,
307
+ * from nothing to everything.
308
+ *
309
+ * @param {{ files?: unknown[], name?: string }} torrent
310
+ * @returns {TorrentContents}
311
+ */
312
+ export function contentsOf(torrent) {
313
+ const files = Array.isArray(torrent?.files) ? torrent.files : [];
314
+ const held = byTorrent.get(torrent);
315
+ if (held && held.fileCount === files.length) {
316
+ return held.contents;
317
+ }
318
+ const contents = new TorrentContents({
319
+ files,
320
+ name: typeof torrent?.name === "string" ? torrent.name : ""
321
+ });
322
+ byTorrent.set(torrent, { contents, fileCount: files.length });
323
+ return contents;
324
+ }
@@ -135,7 +135,14 @@ export const VIDEO_FILE_EXTENSIONS = new Set([
135
135
  ".m2p",
136
136
  ".mxf",
137
137
  ".rm",
138
- ".rmvb"
138
+ ".rmvb",
139
+ // A Video CD's MPEG-1 stream. It was offered by the browser and not counted
140
+ // here, which is the divergence that made this one list rather than two
141
+ // (measured 2026-09-12, and the only extension the two disagreed on). The
142
+ // union is the safe way to settle it: a file that is offered and turns out to
143
+ // be unplayable is one probe, while a file that is a film and is never
144
+ // offered cannot be reached at all.
145
+ ".dat"
139
146
  ]);
140
147
 
141
148
  /**