@torrent-tv/proxy 2.74.1 → 2.76.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +1504 -1453
  2. package/CLAUDE.md +19 -6
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +27 -6
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +9 -214
  18. package/services/container/AviContainer.js +266 -81
  19. package/services/container/Container.js +281 -80
  20. package/services/container/ContainerFactory.js +67 -0
  21. package/services/container/MatroskaContainer.js +327 -8
  22. package/services/container/Mp4Container.js +373 -27
  23. package/services/container/SubtitleFileContainer.js +0 -1
  24. package/services/controllers/SubtitleController.js +128 -128
  25. package/services/demand/index.js +7 -10
  26. package/services/download/registry.js +0 -14
  27. package/services/encode/CoverageMap.js +281 -0
  28. package/services/encode/EncodePlan.js +255 -0
  29. package/services/encode/EncodeRun.js +587 -0
  30. package/services/encode/Encoder.js +84 -0
  31. package/services/encode/NvencEncoder.js +45 -0
  32. package/services/encode/QsvEncoder.js +47 -0
  33. package/services/encode/SegmentDemand.js +0 -0
  34. package/services/encode/SegmentStore.js +529 -0
  35. package/services/encode/SoftwareEncoder.js +111 -0
  36. package/services/encode/V4l2m2mEncoder.js +53 -0
  37. package/services/encode/VaapiEncoder.js +53 -0
  38. package/services/encode/args.js +200 -0
  39. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  40. package/services/encode/index.js +9 -0
  41. package/services/encode/run-command.js +647 -0
  42. package/services/hls-session-manager.js +11073 -10711
  43. package/services/hwaccel.js +1688 -1992
  44. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  45. package/services/output/LiveOutputs.js +213 -0
  46. package/services/output/Output.js +94 -0
  47. package/services/output/OutputSpec.js +195 -0
  48. package/services/output/Timeline.js +220 -0
  49. package/services/output/index.js +1 -0
  50. package/services/output/ladder.js +26 -0
  51. package/services/playback-planner.js +806 -747
  52. package/services/produced-index.js +222 -300
  53. package/services/source/SourceFile.js +346 -0
  54. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  55. package/services/torrent/naming.js +619 -0
  56. package/services/torrent-worker/client.js +10 -0
  57. package/services/torrent-worker/container-tracks.js +71 -43
  58. package/services/torrent-worker/pool-adapter.js +370 -333
  59. package/services/torrent-worker/protocol.js +7 -0
  60. package/services/torrent-worker/subtitle-cues.js +549 -549
  61. package/services/torrent-worker/worker.js +18 -0
  62. package/services/tracks/AudioTrack.js +131 -40
  63. package/services/tracks/TextSubtitleTrack.js +287 -287
  64. package/services/tracks/index.js +15 -14
  65. package/services/viewer/Viewer.js +145 -0
  66. package/services/viewer/Viewers.js +124 -0
  67. package/test/audio-inventory.test.js +176 -177
  68. package/test/auto-quality-step.test.js +508 -506
  69. package/test/behind-head-repair.test.js +17 -7
  70. package/test/coverage-map.test.js +153 -0
  71. package/test/cut-times-timeline.test.js +6 -5
  72. package/test/cuts-follow-published-grid.test.js +4 -4
  73. package/test/decode-cost.test.js +31 -12
  74. package/test/encode-exit.test.js +1 -1
  75. package/test/encode-orchestrator.test.js +196 -0
  76. package/test/encode-plan.test.js +245 -0
  77. package/test/encode-run-state.test.js +2 -2
  78. package/test/encode-run.test.js +168 -0
  79. package/test/encoder-kinds.test.js +122 -0
  80. package/test/held-request-width.test.js +9 -3
  81. package/test/helpers/encode-run.js +128 -0
  82. package/test/keyframe-index-accuracy.test.js +19 -12
  83. package/test/keyframes-belong-to-the-file.test.js +132 -0
  84. package/test/matroska-cues-track.test.js +192 -192
  85. package/test/mp4-composition-times.test.js +0 -0
  86. package/test/orchestrator-wired.test.js +164 -0
  87. package/test/output-shape.test.js +68 -0
  88. package/test/output-spec.test.js +157 -0
  89. package/test/produced-copy-choice.test.js +58 -92
  90. package/test/produced-index.test.js +142 -188
  91. package/test/quality-variants.test.js +1079 -1075
  92. package/test/run-graph-drift.test.js +1 -1
  93. package/test/run-intervals.test.js +329 -0
  94. package/test/run-position-follows-published-grid.test.js +4 -4
  95. package/test/seek-landing.test.js +8 -8
  96. package/test/seek-target-not-superseded.test.js +21 -9
  97. package/test/segment-demand.test.js +82 -0
  98. package/test/segment-serve-wiring.test.js +47 -52
  99. package/test/segment-store.test.js +187 -0
  100. package/test/segments-are-shared.test.js +175 -0
  101. package/test/sidecar-naming.test.js +142 -0
  102. package/test/source-file.test.js +133 -0
  103. package/test/stale-request-after-seek.test.js +18 -12
  104. package/test/subtitle-language.test.js +252 -252
  105. package/test/timeline.test.js +95 -0
  106. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  107. package/test/torrent-naming.test.js +255 -0
  108. package/test/tracks-begin-together.test.js +44 -32
  109. package/test/two-viewers-one-picture.test.js +347 -0
  110. package/test/video-facts.test.js +102 -0
  111. package/test/viewer-outputs.test.js +273 -0
  112. package/test/viewer.test.js +91 -0
  113. package/utils/perf.js +1 -63
  114. package/services/container/index.js +0 -6
  115. package/services/container-index/avi.js +0 -167
  116. package/services/container-index/index.js +0 -118
  117. package/services/container-index/matroska.js +0 -336
  118. package/services/container-index/mp4.js +0 -358
  119. package/services/controllers/index.js +0 -2
  120. package/services/download/index.js +0 -8
  121. package/services/orchestrators/index.js +0 -2
  122. /package/services/{container-index → container}/ebml-reader.js +0 -0
  123. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  124. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -1,333 +1,370 @@
1
- /**
2
- * @file `TorrentPool`'s interface, served from the worker thread.
3
- *
4
- * The routes, the planner, the health report and the session manager all reach
5
- * for a torrent pool and use it the same handful of ways. Rather than rewrite
6
- * every one of them to thread a `sourceKey` through and await what used to be
7
- * immediate, this presents the shape they already expect and does the thread
8
- * hop behind it. Swapping the implementation is then a one-line change at
9
- * construction, and the call sites are untouched — which is what keeps a change
10
- * of this size reviewable.
11
- *
12
- * Two accommodations are needed, and both are deliberate:
13
- *
14
- * - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
15
- * nothing the caller inspects, so the command is dispatched and not awaited.
16
- * `acquireFile` hands back a release function exactly as before, which sends
17
- * its own command when called. Awaiting them would mean touching every call
18
- * site for no observable gain.
19
- * - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
20
- * thread, so the worker keys them. Callers that have one pass it; the rest
21
- * get one derived from the source itself, so the identity stays stable
22
- * across calls for the same torrent.
23
- */
24
-
25
- import { TorrentWorkerClient } from "./client.js";
26
- import { deriveSourceKey } from "../torrent-source-key.js";
27
-
28
- /**
29
- * A torrent pool whose work happens on another thread.
30
- *
31
- * See `protocol.js` for why: the torrent was taking ~85% of the main thread and
32
- * everything owed to a viewer queued behind it.
33
- */
34
- export class WorkerTorrentPool {
35
- #client;
36
- /** Stand-ins by source key, so repeat calls return the same object. */
37
- #torrents = new Map();
38
-
39
- /**
40
- * @param {{ maxDiskBytes?: number, memoryBytes?: number, stateDir?: string }} [options]
41
- */
42
- constructor(options = {}) {
43
- this.#client = new TorrentWorkerClient(options);
44
- }
45
-
46
- /**
47
- * Load (or join) a torrent and return a stand-in for it.
48
- *
49
- * @param {"magnet" | "torrent"} sourceType
50
- * @param {string} source
51
- * @returns {Promise<object>}
52
- */
53
- async getTorrent(sourceType, source) {
54
- const sourceKey = await deriveSourceKey(sourceType, source);
55
- const existing = this.#torrents.get(sourceKey);
56
- if (existing) {
57
- return existing;
58
- }
59
- const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
60
- this.#torrents.set(sourceKey, torrent);
61
- return torrent;
62
- }
63
-
64
- /**
65
- * Claim a file for reading; the returned function releases it.
66
- *
67
- * Synchronous by design — see the file header.
68
- *
69
- * @param {object} torrent - A stand-in from {@link getTorrent}.
70
- * @param {number} fileIndex
71
- * @returns {() => void}
72
- */
73
- acquireFile(torrent, fileIndex) {
74
- const sourceKey = torrent?.sourceKey;
75
- if (!sourceKey) {
76
- return () => undefined;
77
- }
78
- // Dispatched, not awaited — callers use the result immediately and inspect
79
- // nothing. But the release MUST NOT overtake it: both are ordinary messages
80
- // to the worker, and if release arrives first the reader count drops to zero
81
- // while a read is still running. The idle sweep then removes the torrent AND
82
- // its downloaded data out from under the encoder — field 2026-08-02:
83
- // "removed idle torrent ... and its store" mid-playback, after which every
84
- // read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
85
- // Chaining the release onto the acquire keeps them in order.
86
- const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
87
- let released = false;
88
- return () => {
89
- if (released) {
90
- return;
91
- }
92
- released = true;
93
- // Release the claim this call opened, not "the file" — waiting for the
94
- // acquire is also what tells us which claim that is.
95
- void acquired
96
- .then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
97
- .catch(() => undefined);
98
- };
99
- }
100
-
101
- /**
102
- * Live download figures for the progress display.
103
- *
104
- * @param {object} torrent
105
- * @param {number | null} [fileIndex]
106
- * @param {{ resumeAnchorByteStart?: number | null }} [options]
107
- * @returns {Promise<object | null>}
108
- */
109
- /**
110
- * Bytes every torrent here has moved.
111
- *
112
- * @returns {Promise<{ downloaded: number, uploaded: number }>}
113
- */
114
- async getTorrentTotals() {
115
- return this.#client.getTorrentTotals();
116
- }
117
-
118
- async getFileStats(torrent, fileIndex = null, options = {}) {
119
- const sourceKey = torrent?.sourceKey;
120
- if (!sourceKey) {
121
- return null;
122
- }
123
- return this.#client.getFileStats({
124
- sourceKey,
125
- fileIndex,
126
- resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
127
- });
128
- }
129
-
130
- /**
131
- * The text subtitle tracks a file carries, read from its own header.
132
- *
133
- * @param {object} torrent
134
- * @param {number} fileIndex
135
- * @returns {Promise<object[]>}
136
- */
137
- /**
138
- * Fetch one whole file using only the room the viewer's own reading leaves.
139
- *
140
- * For a soundtrack or subtitle file shipped beside the picture: small next to
141
- * the film, and having it on disk is what turns a later switch into a local
142
- * read instead of a wait on the swarm.
143
- *
144
- * @param {object} torrent
145
- * @param {number} fileIndex
146
- * @returns {Promise<boolean>} Whether a fill was started by this call.
147
- */
148
- async fillFileInBackground(torrent, fileIndex) {
149
- const sourceKey = torrent?.sourceKey;
150
- if (!sourceKey) {
151
- return false;
152
- }
153
- const answer = await this.#client.fillFile({ sourceKey, fileIndex });
154
- return answer?.started === true;
155
- }
156
-
157
- async getSubtitleTracks(torrent, fileIndex) {
158
- const sourceKey = torrent?.sourceKey;
159
- if (!sourceKey) {
160
- return [];
161
- }
162
- const answer = await this.#client.getSubtitleTracks({ sourceKey, fileIndex });
163
- return Array.isArray(answer?.tracks) ? answer.tracks : [];
164
- }
165
-
166
- /**
167
- * What the container itself declares about its subtitle tracks, in its own
168
- * order and including the picture-based ones — for lining up against
169
- * ffmpeg's own numbering.
170
- *
171
- * @param {object} torrent
172
- * @param {number} fileIndex
173
- * @returns {Promise<object[]>}
174
- */
175
- async getDeclaredSubtitleTracks(torrent, fileIndex) {
176
- const sourceKey = torrent?.sourceKey;
177
- if (!sourceKey) {
178
- return [];
179
- }
180
- const answer = await this.#client.getSubtitleTracks({ sourceKey, fileIndex });
181
- return Array.isArray(answer?.declared) ? answer.declared : [];
182
- }
183
-
184
- /**
185
- * Every track one file declares, read from its own header by the container
186
- * layer.
187
- *
188
- * The audio menu is built from ffmpeg's `-i` banner, which carries neither
189
- * `FlagOriginal`, `FlagCommentary`, `FlagVisualImpaired`, `FlagEnabled` nor
190
- * `LanguageBCP47` — so without this a director's commentary and the film
191
- * itself are indistinguishable in it. Also how a soundtrack shipped as its own
192
- * file is read: a `.mka` is Matroska and the same reader serves it.
193
- *
194
- * @param {object} torrent
195
- * @param {number} fileIndex
196
- * @returns {Promise<object[]>}
197
- */
198
- async getContainerTracks(torrent, fileIndex) {
199
- const sourceKey = torrent?.sourceKey;
200
- if (!sourceKey) {
201
- return [];
202
- }
203
- const answer = await this.#client.getContainerTracks({ sourceKey, fileIndex });
204
- return Array.isArray(answer?.tracks) ? answer.tracks : [];
205
- }
206
-
207
- /**
208
- * Start fetching the region a viewer is about to resume at. Named in seconds
209
- * here; the worker turns it into bytes, where the file's duration is readable.
210
- *
211
- * @param {object} torrent
212
- * @param {number} fileIndex
213
- * @param {number} positionSeconds
214
- * @returns {Promise<boolean>}
215
- */
216
- async warmResumePosition(torrent, fileIndex, positionSeconds) {
217
- const sourceKey = torrent?.sourceKey;
218
- if (!sourceKey) {
219
- return false;
220
- }
221
- const answer = await this.#client.warmResumePosition({ sourceKey, fileIndex, positionSeconds });
222
- return answer?.started === true;
223
- }
224
-
225
- /**
226
- * What one file declares about itself: format, duration, and where its own
227
- * timeline begins.
228
- *
229
- * @param {object} torrent
230
- * @param {number} fileIndex
231
- * @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
232
- */
233
- async getContainerMediaInfo(torrent, fileIndex) {
234
- const sourceKey = torrent?.sourceKey;
235
- if (!sourceKey) {
236
- return null;
237
- }
238
- const answer = await this.#client.getContainerMediaInfo({ sourceKey, fileIndex });
239
- return answer?.info ?? null;
240
- }
241
-
242
- /**
243
- * The audio tracks one file declares, in the order ffmpeg numbers them
244
- * `0:a:N`.
245
- *
246
- * @param {object} torrent
247
- * @param {number} fileIndex
248
- * @returns {Promise<object[]>}
249
- */
250
- async getDeclaredAudioTracks(torrent, fileIndex) {
251
- const tracks = await this.getContainerTracks(torrent, fileIndex);
252
- return tracks
253
- .filter((track) => track?.type === "audio")
254
- .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0));
255
- }
256
-
257
- /**
258
- * The cues of one subtitle track that the downloaded clusters already carry.
259
- *
260
- * @param {object} torrent
261
- * @param {number} fileIndex
262
- * @param {number} trackNumber
263
- * @returns {Promise<object | null>}
264
- */
265
- async getSubtitleCues(torrent, fileIndex, trackNumber) {
266
- const sourceKey = torrent?.sourceKey;
267
- if (!sourceKey) {
268
- return null;
269
- }
270
- return this.#client.getSubtitleCues({ sourceKey, fileIndex, trackNumber });
271
- }
272
-
273
- /**
274
- * Reorder piece selection around a read position.
275
- *
276
- * Synchronous by design see the file header.
277
- *
278
- * @param {object} torrent
279
- * @param {number} fileIndex
280
- * @param {number} byteStart
281
- * @param {number} [windowBytes]
282
- * @param {{ wholeFileRead?: boolean }} [options]
283
- * @returns {void}
284
- */
285
- prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes, options) {
286
- const sourceKey = torrent?.sourceKey;
287
- if (!sourceKey) {
288
- return;
289
- }
290
- void this.#client
291
- .prioritizeByteRange({
292
- sourceKey,
293
- fileIndex,
294
- byteStart,
295
- windowBytes,
296
- wholeFileRead: options?.wholeFileRead === true
297
- })
298
- .catch(() => undefined);
299
- }
300
-
301
- /**
302
- * Pre-fetch the head and tail the codec probe needs.
303
- *
304
- * Takes an options object, matching `TorrentPool.prefetchFileEdges` — this
305
- * adapter exists to present that same interface. It previously declared
306
- * positional parameters instead, so the planner's options object arrived as
307
- * `headBytes` and only worked because it was passed along far enough to be
308
- * destructured at the far end. Anyone calling it as documented got the
309
- * defaults instead of the sizes they asked for.
310
- *
311
- * @param {object} torrent
312
- * @param {number} fileIndex
313
- * @param {{ headBytes?: number, tailBytes?: number, timeoutMs?: number }} [options]
314
- * @returns {Promise<unknown>}
315
- */
316
- async prefetchFileEdges(torrent, fileIndex, options = {}) {
317
- const sourceKey = torrent?.sourceKey;
318
- if (!sourceKey) {
319
- return null;
320
- }
321
- return this.#client.prefetchFileEdges({ sourceKey, fileIndex, options });
322
- }
323
-
324
- /**
325
- * Shut the torrent client down and stop the thread.
326
- *
327
- * @returns {Promise<void>}
328
- */
329
- async destroyAll() {
330
- this.#torrents.clear();
331
- await this.#client.destroyAll();
332
- }
333
- }
1
+ /**
2
+ * @file `TorrentPool`'s interface, served from the worker thread.
3
+ *
4
+ * The routes, the planner, the health report and the session manager all reach
5
+ * for a torrent pool and use it the same handful of ways. Rather than rewrite
6
+ * every one of them to thread a `sourceKey` through and await what used to be
7
+ * immediate, this presents the shape they already expect and does the thread
8
+ * hop behind it. Swapping the implementation is then a one-line change at
9
+ * construction, and the call sites are untouched — which is what keeps a change
10
+ * of this size reviewable.
11
+ *
12
+ * Two accommodations are needed, and both are deliberate:
13
+ *
14
+ * - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
15
+ * nothing the caller inspects, so the command is dispatched and not awaited.
16
+ * `acquireFile` hands back a release function exactly as before, which sends
17
+ * its own command when called. Awaiting them would mean touching every call
18
+ * site for no observable gain.
19
+ * - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
20
+ * thread, so the worker keys them. Callers that have one pass it; the rest
21
+ * get one derived from the source itself, so the identity stays stable
22
+ * across calls for the same torrent.
23
+ */
24
+
25
+ import { TorrentWorkerClient } from "./client.js";
26
+ import { deriveSourceKey } from "../torrent-source-key.js";
27
+
28
+ /**
29
+ * A torrent pool whose work happens on another thread.
30
+ *
31
+ * See `protocol.js` for why: the torrent was taking ~85% of the main thread and
32
+ * everything owed to a viewer queued behind it.
33
+ */
34
+ export class WorkerTorrentPool {
35
+ #client;
36
+ /** Stand-ins by source key, so repeat calls return the same object. */
37
+ #torrents = new Map();
38
+
39
+ /**
40
+ * @param {{ maxDiskBytes?: number, memoryBytes?: number, stateDir?: string }} [options]
41
+ */
42
+ constructor(options = {}) {
43
+ this.#client = new TorrentWorkerClient(options);
44
+ }
45
+
46
+ /**
47
+ * Load (or join) a torrent and return a stand-in for it.
48
+ *
49
+ * @param {"magnet" | "torrent"} sourceType
50
+ * @param {string} source
51
+ * @returns {Promise<object>}
52
+ */
53
+ async getTorrent(sourceType, source) {
54
+ const sourceKey = await deriveSourceKey(sourceType, source);
55
+ const existing = this.#torrents.get(sourceKey);
56
+ if (existing) {
57
+ return existing;
58
+ }
59
+ const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
60
+ this.#torrents.set(sourceKey, torrent);
61
+ return torrent;
62
+ }
63
+
64
+ /**
65
+ * Claim a file for reading; the returned function releases it.
66
+ *
67
+ * Synchronous by design — see the file header.
68
+ *
69
+ * @param {object} torrent - A stand-in from {@link getTorrent}.
70
+ * @param {number} fileIndex
71
+ * @returns {() => void}
72
+ */
73
+ acquireFile(torrent, fileIndex) {
74
+ const sourceKey = torrent?.sourceKey;
75
+ if (!sourceKey) {
76
+ return () => undefined;
77
+ }
78
+ // Dispatched, not awaited — callers use the result immediately and inspect
79
+ // nothing. But the release MUST NOT overtake it: both are ordinary messages
80
+ // to the worker, and if release arrives first the reader count drops to zero
81
+ // while a read is still running. The idle sweep then removes the torrent AND
82
+ // its downloaded data out from under the encoder — field 2026-08-02:
83
+ // "removed idle torrent ... and its store" mid-playback, after which every
84
+ // read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
85
+ // Chaining the release onto the acquire keeps them in order.
86
+ const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
87
+ let released = false;
88
+ return () => {
89
+ if (released) {
90
+ return;
91
+ }
92
+ released = true;
93
+ // Release the claim this call opened, not "the file" — waiting for the
94
+ // acquire is also what tells us which claim that is.
95
+ void acquired
96
+ .then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
97
+ .catch(() => undefined);
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Live download figures for the progress display.
103
+ *
104
+ * @param {object} torrent
105
+ * @param {number | null} [fileIndex]
106
+ * @param {{ resumeAnchorByteStart?: number | null }} [options]
107
+ * @returns {Promise<object | null>}
108
+ */
109
+ /**
110
+ * Bytes every torrent here has moved.
111
+ *
112
+ * @returns {Promise<{ downloaded: number, uploaded: number }>}
113
+ */
114
+ async getTorrentTotals() {
115
+ return this.#client.getTorrentTotals();
116
+ }
117
+
118
+ async getFileStats(torrent, fileIndex = null, options = {}) {
119
+ const sourceKey = torrent?.sourceKey;
120
+ if (!sourceKey) {
121
+ return null;
122
+ }
123
+ return this.#client.getFileStats({
124
+ sourceKey,
125
+ fileIndex,
126
+ resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
127
+ });
128
+ }
129
+
130
+ /**
131
+ * The text subtitle tracks a file carries, read from its own header.
132
+ *
133
+ * @param {object} torrent
134
+ * @param {number} fileIndex
135
+ * @returns {Promise<object[]>}
136
+ */
137
+ /**
138
+ * Fetch one whole file using only the room the viewer's own reading leaves.
139
+ *
140
+ * For a soundtrack or subtitle file shipped beside the picture: small next to
141
+ * the film, and having it on disk is what turns a later switch into a local
142
+ * read instead of a wait on the swarm.
143
+ *
144
+ * @param {object} torrent
145
+ * @param {number} fileIndex
146
+ * @returns {Promise<boolean>} Whether a fill was started by this call.
147
+ */
148
+ async fillFileInBackground(torrent, fileIndex) {
149
+ const sourceKey = torrent?.sourceKey;
150
+ if (!sourceKey) {
151
+ return false;
152
+ }
153
+ const answer = await this.#client.fillFile({ sourceKey, fileIndex });
154
+ return answer?.started === true;
155
+ }
156
+
157
+ async getSubtitleTracks(torrent, fileIndex) {
158
+ const sourceKey = torrent?.sourceKey;
159
+ if (!sourceKey) {
160
+ return [];
161
+ }
162
+ const answer = await this.#client.getSubtitleTracks({ sourceKey, fileIndex });
163
+ return Array.isArray(answer?.tracks) ? answer.tracks : [];
164
+ }
165
+
166
+ /**
167
+ * What the container itself declares about its subtitle tracks, in its own
168
+ * order and including the picture-based ones — for lining up against
169
+ * ffmpeg's own numbering.
170
+ *
171
+ * @param {object} torrent
172
+ * @param {number} fileIndex
173
+ * @returns {Promise<object[]>}
174
+ */
175
+ async getDeclaredSubtitleTracks(torrent, fileIndex) {
176
+ const sourceKey = torrent?.sourceKey;
177
+ if (!sourceKey) {
178
+ return [];
179
+ }
180
+ const answer = await this.#client.getSubtitleTracks({ sourceKey, fileIndex });
181
+ return Array.isArray(answer?.declared) ? answer.declared : [];
182
+ }
183
+
184
+ /**
185
+ * Every track one file declares, read from its own header by the container
186
+ * layer.
187
+ *
188
+ * The audio menu is built from ffmpeg's `-i` banner, which carries neither
189
+ * `FlagOriginal`, `FlagCommentary`, `FlagVisualImpaired`, `FlagEnabled` nor
190
+ * `LanguageBCP47` — so without this a director's commentary and the film
191
+ * itself are indistinguishable in it. Also how a soundtrack shipped as its own
192
+ * file is read: a `.mka` is Matroska and the same reader serves it.
193
+ *
194
+ * @param {object} torrent
195
+ * @param {number} fileIndex
196
+ * @returns {Promise<object[]>}
197
+ */
198
+ async getContainerTracks(torrent, fileIndex) {
199
+ const sourceKey = torrent?.sourceKey;
200
+ if (!sourceKey) {
201
+ return [];
202
+ }
203
+ const answer = await this.#client.getContainerTracks({ sourceKey, fileIndex });
204
+ return Array.isArray(answer?.tracks) ? answer.tracks : [];
205
+ }
206
+
207
+ /**
208
+ * Start fetching the region a viewer is about to resume at. Named in seconds
209
+ * here; the worker turns it into bytes, where the file's duration is readable.
210
+ *
211
+ * @param {object} torrent
212
+ * @param {number} fileIndex
213
+ * @param {number} positionSeconds
214
+ * @returns {Promise<boolean>}
215
+ */
216
+ async warmResumePosition(torrent, fileIndex, positionSeconds) {
217
+ const sourceKey = torrent?.sourceKey;
218
+ if (!sourceKey) {
219
+ return false;
220
+ }
221
+ const answer = await this.#client.warmResumePosition({ sourceKey, fileIndex, positionSeconds });
222
+ return answer?.started === true;
223
+ }
224
+
225
+ /**
226
+ * What one file declares about itself: format, duration, and where its own
227
+ * timeline begins.
228
+ *
229
+ * @param {object} torrent
230
+ * @param {number} fileIndex
231
+ * @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
232
+ */
233
+ async getContainerMediaInfo(torrent, fileIndex) {
234
+ const sourceKey = torrent?.sourceKey;
235
+ if (!sourceKey) {
236
+ return null;
237
+ }
238
+ const answer = await this.#client.getContainerMediaInfo({ sourceKey, fileIndex });
239
+ return answer?.info ?? null;
240
+ }
241
+
242
+ /**
243
+ * Where one file's keyframes are, from the container's own table. Null when
244
+ * this file has no readable index — which is a final answer about the file,
245
+ * and the reason a copy of it has to be re-encoded instead.
246
+ *
247
+ * @param {object} torrent
248
+ * @param {number} fileIndex
249
+ * @returns {Promise<{ times: number[], tolerance: number } | null>}
250
+ */
251
+ async getContainerKeyframes(torrent, fileIndex) {
252
+ const sourceKey = torrent?.sourceKey;
253
+ if (!sourceKey) {
254
+ return null;
255
+ }
256
+ const answer = await this.#client.getContainerKeyframes({ sourceKey, fileIndex });
257
+ return answer?.index ?? null;
258
+ }
259
+
260
+ /**
261
+ * The audio tracks one file declares, in the order ffmpeg numbers them
262
+ * `0:a:N`.
263
+ *
264
+ * @param {object} torrent
265
+ * @param {number} fileIndex
266
+ * @returns {Promise<object[]>}
267
+ */
268
+ async getDeclaredAudioTracks(torrent, fileIndex) {
269
+ const tracks = await this.getContainerTracks(torrent, fileIndex);
270
+ return tracks
271
+ .filter((track) => track?.type === "audio")
272
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0));
273
+ }
274
+
275
+ /**
276
+ * The video track one file declares, or null where it declares none.
277
+ *
278
+ * One, because ffmpeg's `0:v:0` is what everything downstream is built on and
279
+ * a second video stream is a cover image far more often than a second film.
280
+ *
281
+ * @param {object} torrent
282
+ * @param {number} fileIndex
283
+ * @returns {Promise<object | null>}
284
+ */
285
+ async getDeclaredVideoTrack(torrent, fileIndex) {
286
+ const tracks = await this.getContainerTracks(torrent, fileIndex);
287
+ return (
288
+ tracks
289
+ .filter((track) => track?.type === "video")
290
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))[0] ?? null
291
+ );
292
+ }
293
+
294
+ /**
295
+ * The cues of one subtitle track that the downloaded clusters already carry.
296
+ *
297
+ * @param {object} torrent
298
+ * @param {number} fileIndex
299
+ * @param {number} trackNumber
300
+ * @returns {Promise<object | null>}
301
+ */
302
+ async getSubtitleCues(torrent, fileIndex, trackNumber) {
303
+ const sourceKey = torrent?.sourceKey;
304
+ if (!sourceKey) {
305
+ return null;
306
+ }
307
+ return this.#client.getSubtitleCues({ sourceKey, fileIndex, trackNumber });
308
+ }
309
+
310
+ /**
311
+ * Reorder piece selection around a read position.
312
+ *
313
+ * Synchronous by design see the file header.
314
+ *
315
+ * @param {object} torrent
316
+ * @param {number} fileIndex
317
+ * @param {number} byteStart
318
+ * @param {number} [windowBytes]
319
+ * @param {{ wholeFileRead?: boolean }} [options]
320
+ * @returns {void}
321
+ */
322
+ prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes, options) {
323
+ const sourceKey = torrent?.sourceKey;
324
+ if (!sourceKey) {
325
+ return;
326
+ }
327
+ void this.#client
328
+ .prioritizeByteRange({
329
+ sourceKey,
330
+ fileIndex,
331
+ byteStart,
332
+ windowBytes,
333
+ wholeFileRead: options?.wholeFileRead === true
334
+ })
335
+ .catch(() => undefined);
336
+ }
337
+
338
+ /**
339
+ * Pre-fetch the head and tail the codec probe needs.
340
+ *
341
+ * Takes an options object, matching `TorrentPool.prefetchFileEdges` — this
342
+ * adapter exists to present that same interface. It previously declared
343
+ * positional parameters instead, so the planner's options object arrived as
344
+ * `headBytes` and only worked because it was passed along far enough to be
345
+ * destructured at the far end. Anyone calling it as documented got the
346
+ * defaults instead of the sizes they asked for.
347
+ *
348
+ * @param {object} torrent
349
+ * @param {number} fileIndex
350
+ * @param {{ headBytes?: number, tailBytes?: number, timeoutMs?: number }} [options]
351
+ * @returns {Promise<unknown>}
352
+ */
353
+ async prefetchFileEdges(torrent, fileIndex, options = {}) {
354
+ const sourceKey = torrent?.sourceKey;
355
+ if (!sourceKey) {
356
+ return null;
357
+ }
358
+ return this.#client.prefetchFileEdges({ sourceKey, fileIndex, options });
359
+ }
360
+
361
+ /**
362
+ * Shut the torrent client down and stop the thread.
363
+ *
364
+ * @returns {Promise<void>}
365
+ */
366
+ async destroyAll() {
367
+ this.#torrents.clear();
368
+ await this.#client.destroyAll();
369
+ }
370
+ }