@torrent-tv/proxy 2.75.0 → 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 (113) hide show
  1. package/CHANGELOG.md +1504 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  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 +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11073 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -0,0 +1,346 @@
1
+ /**
2
+ * @file One file of one torrent, as this proxy reads it.
3
+ *
4
+ * Which torrent, which file inside it, what it is called, what a probe of it
5
+ * said, and the address this proxy's own HTTP serves it at. Every one of those
6
+ * is a fact about the FILE: it is the same for the picture and for every
7
+ * quality step of it, the same for one viewer and for five, and it does not
8
+ * change while anybody is watching.
9
+ *
10
+ * They used to be nine fields copied onto every session, and the copying was
11
+ * visible in the code that read them back: twenty places in the session manager
12
+ * assembled `${sourceKey}:${fileIndex}` by hand, through five different
13
+ * spellings of the same two values, to key the caches that are keyed by a file —
14
+ * the host timings, the download stats, the decode cost, the cut table. A file
15
+ * that knows its own key removes all twenty.
16
+ *
17
+ * **What this is NOT.** It is not the container: a container answers for what
18
+ * the format states about itself and needs bytes to do it. It is not the track:
19
+ * a track is one stream inside a container. This is the file as a HANDLE — how
20
+ * it is named, addressed and identified — plus the facts a probe of it
21
+ * returned, which arrive together and are cheapest to keep where the handle is.
22
+ *
23
+ * Nothing here reaches outward, the address included: the proxy's own base URL
24
+ * is passed in, because where this proxy listens is not a property of a film.
25
+ */
26
+
27
+ /**
28
+ * What decoding this source costs per second of it, and which measurement of
29
+ * this host applies.
30
+ *
31
+ * The codec and bit depth travel with the rates because they decide WHICH
32
+ * measurement applies: the model is fitted per codec family, and a video that
33
+ * has to be re-encoded is by definition one the browser could not play — HEVC,
34
+ * 10-bit — which is exactly where H.264 constants are wrong.
35
+ *
36
+ * @param {{ width: number | null, height: number | null, fps: number | null, bitrateKbps: number | null, codec?: string | null, bitDepth?: number | null } | null} mediaInfo
37
+ * @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, codec: string, bitDepth: number | null } | null}
38
+ */
39
+ export function sourceDecodeCharacteristics(mediaInfo) {
40
+ const width = Number(mediaInfo?.width);
41
+ const height = Number(mediaInfo?.height);
42
+ const fps = Number(mediaInfo?.fps);
43
+ const kbps = Number(mediaInfo?.bitrateKbps);
44
+ if (!(width > 0) || !(height > 0) || !(fps > 0) || !(kbps > 0)) {
45
+ return null;
46
+ }
47
+ const depth = Number(mediaInfo?.bitDepth);
48
+ return {
49
+ megapixelsPerSecond: (width * height * fps) / 1e6,
50
+ megabitsPerSecond: kbps / 1000,
51
+ codec: typeof mediaInfo?.codec === "string" ? mediaInfo.codec : "",
52
+ bitDepth: Number.isFinite(depth) && depth > 0 ? depth : null
53
+ };
54
+ }
55
+
56
+ export class SourceFile {
57
+ /**
58
+ * @param {object} params
59
+ * @param {string} params.sourceKey - The registry's key for the torrent.
60
+ * @param {number} params.fileIndex - Zero-based index of the file in it.
61
+ * @param {string} [params.name] - The file's own name, for log lines.
62
+ */
63
+ constructor({ sourceKey, fileIndex, name = "" }) {
64
+ this.sourceKey = String(sourceKey ?? "");
65
+ this.fileIndex = Number.isFinite(fileIndex) ? Number(fileIndex) : 0;
66
+ this.givenName = typeof name === "string" ? name.trim() : "";
67
+ /**
68
+ * What a probe of this file said. Null until one has answered — a cold
69
+ * torrent has no header yet, and "not read" is not the same as "zero".
70
+ *
71
+ * @type {object | null}
72
+ */
73
+ this.media = null;
74
+ /**
75
+ * What this host answered when a quality step was asked for: the height
76
+ * REQUESTED against the height it turned out to produce.
77
+ *
78
+ * A file's own record, not a link between sessions. The realtime budget may
79
+ * land a request for 540p on a 240p encode, and once it has, that answer
80
+ * must not move — a player holding an init for one size cannot be sent
81
+ * another. It used to be kept as a map from a height to a SESSION ID on
82
+ * whichever session happened to be the picture, which tied two lifetimes
83
+ * together: disposing one had to reach back and clean the other's map, and
84
+ * a stale id was a thing that had to be noticed. Two numbers cannot go
85
+ * stale, and every session of one file answers alike because there is one
86
+ * file.
87
+ *
88
+ * @type {Map<number, number>}
89
+ */
90
+ this.stepHeights = new Map();
91
+ }
92
+
93
+ /**
94
+ * The key every cache about this file is keyed by.
95
+ *
96
+ * @returns {string}
97
+ */
98
+ get key() {
99
+ return `${this.sourceKey}:${this.fileIndex}`;
100
+ }
101
+
102
+ /**
103
+ * What to call this file in a log line. A file with no name is named by its
104
+ * index, so a line is never about a nameless thing.
105
+ *
106
+ * @returns {string}
107
+ */
108
+ get name() {
109
+ return this.givenName.length > 0 ? this.givenName : `file#${this.fileIndex}`;
110
+ }
111
+
112
+ /**
113
+ * Take in what a reading of this file returned.
114
+ *
115
+ * Readings are MERGED rather than replaced, because there is more than one
116
+ * reader and they answer about different things: ffmpeg's banner states the
117
+ * frame size, the frame rate and the bitrate, while the container's own
118
+ * header states where its timeline begins — and a sidecar soundtrack is read
119
+ * for that one field alone. Replacing would mean the second reader erasing
120
+ * what the first found.
121
+ *
122
+ * Called again whenever a fresher reading arrives: on a cold torrent the
123
+ * first answer can be missing the duration, and the second is what has it.
124
+ *
125
+ * @param {object | null} mediaInfo
126
+ * @returns {this}
127
+ */
128
+ learn(mediaInfo) {
129
+ if (mediaInfo && typeof mediaInfo === "object") {
130
+ this.media = { ...(this.media ?? {}), ...mediaInfo };
131
+ }
132
+ return this;
133
+ }
134
+
135
+ /** @returns {number | null} */
136
+ get width() {
137
+ const value = Number(this.media?.width);
138
+ return Number.isFinite(value) && value > 0 ? value : null;
139
+ }
140
+
141
+ /** @returns {number | null} */
142
+ get height() {
143
+ const value = Number(this.media?.height);
144
+ return Number.isFinite(value) && value > 0 ? value : null;
145
+ }
146
+
147
+ /** @returns {number | null} */
148
+ get fps() {
149
+ const value = Number(this.media?.fps);
150
+ return Number.isFinite(value) && value > 0 ? value : null;
151
+ }
152
+
153
+ /** @returns {boolean} */
154
+ get isHdr() {
155
+ return this.media?.isHdr === true;
156
+ }
157
+
158
+ /**
159
+ * Where this file's own timeline begins. Zero when nothing said otherwise —
160
+ * which is what a container that states no start time means.
161
+ *
162
+ * @returns {number}
163
+ */
164
+ get startTime() {
165
+ const value = Number(this.media?.startTime);
166
+ return Number.isFinite(value) ? value : 0;
167
+ }
168
+
169
+ /** @returns {number | null} */
170
+ get durationSeconds() {
171
+ const value = Number(this.media?.durationSeconds);
172
+ return Number.isFinite(value) && value > 0 ? value : null;
173
+ }
174
+
175
+ /** @returns {boolean} */
176
+ get hasDuration() {
177
+ return this.durationSeconds !== null;
178
+ }
179
+
180
+ /**
181
+ * How many streams of each kind ffmpeg's own banner said this file carries.
182
+ * The one place the file says what it has, and what tells a failed run whose
183
+ * fault it was: an audio index past the end of the list is ours, no streams
184
+ * at all is the file's.
185
+ *
186
+ * @returns {object | null}
187
+ */
188
+ get streamCounts() {
189
+ return this.media?.streamCounts ?? null;
190
+ }
191
+
192
+ /**
193
+ * Where this file's picture has its keyframes, in seconds, or null while
194
+ * nobody has read them.
195
+ *
196
+ * A fact of the file in both container formats and by their own
197
+ * specifications: Matroska's Cues name the track each entry belongs to, and
198
+ * MP4 keeps the sync-sample table inside the track. It used to live on the
199
+ * cut table, which is held per file AND grid — so a file cut two ways kept
200
+ * two copies of one immutable list, and the table's own header said it
201
+ * belonged to "the file and its grid", which is two things.
202
+ *
203
+ * @returns {number[] | null}
204
+ */
205
+ get keyframeTimes() {
206
+ return Array.isArray(this.media?.keyframeTimes) ? this.media.keyframeTimes : null;
207
+ }
208
+
209
+ /**
210
+ * How far a time in that table may be from the true keyframe. Zero means the
211
+ * table is exact, which is what a Cues element or a sync-sample table gives.
212
+ *
213
+ * @returns {number}
214
+ */
215
+ get keyframeTolerance() {
216
+ const value = Number(this.media?.keyframeTolerance);
217
+ return Number.isFinite(value) && value > 0 ? value : 0;
218
+ }
219
+
220
+ /**
221
+ * Which container answered about this file, so a log line can say what was
222
+ * read rather than what was guessed from the name.
223
+ *
224
+ * @returns {string}
225
+ */
226
+ get containerFormat() {
227
+ return typeof this.media?.containerFormat === "string" ? this.media.containerFormat : "";
228
+ }
229
+
230
+ /**
231
+ * What decoding this file costs per second, or null while its facts are
232
+ * incomplete.
233
+ *
234
+ * @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, codec: string, bitDepth: number | null } | null}
235
+ */
236
+ get decode() {
237
+ return sourceDecodeCharacteristics(this.media);
238
+ }
239
+
240
+ /**
241
+ * The address this proxy's own HTTP serves this file at.
242
+ *
243
+ * The base URL is a parameter because where this proxy listens is not a
244
+ * property of a film. `session` travels with it so the stream route can count
245
+ * the bytes it delivers against a session — that count is what tells a
246
+ * waiting browser the proxy is alive while nothing has been encoded yet,
247
+ * since the encoder's own progress cannot move before its first frame is
248
+ * decoded.
249
+ *
250
+ * @param {string} baseUrl - This proxy's own base, e.g. `http://127.0.0.1:9090`.
251
+ * @param {{ sessionId?: string }} [options]
252
+ * @returns {URL}
253
+ */
254
+ streamUrl(baseUrl, { sessionId = "" } = {}) {
255
+ const url = new URL("/stream", `${baseUrl}/`);
256
+ url.searchParams.set("sourceKey", this.sourceKey);
257
+ if (sessionId.length > 0) {
258
+ url.searchParams.set("session", sessionId);
259
+ }
260
+ url.searchParams.set("fileIndex", String(this.fileIndex));
261
+ return url;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * The source files this proxy holds, one per file.
267
+ *
268
+ * Keyed by the torrent and the index inside it, which is the only pair that
269
+ * identifies a file — and the same key every cache about a file already used,
270
+ * spelled once here instead of at twenty call sites.
271
+ */
272
+ export class SourceFiles {
273
+ /** @type {Map<string, SourceFile>} */
274
+ #byKey = new Map();
275
+
276
+ /**
277
+ * @param {string} sourceKey
278
+ * @param {number} fileIndex
279
+ * @returns {string}
280
+ */
281
+ static keyFor(sourceKey, fileIndex) {
282
+ return `${sourceKey}:${fileIndex}`;
283
+ }
284
+
285
+ /**
286
+ * The file with this key, made if it is not held yet.
287
+ *
288
+ * @param {string} sourceKey
289
+ * @param {number} fileIndex
290
+ * @param {string} [name]
291
+ * @returns {SourceFile}
292
+ */
293
+ get(sourceKey, fileIndex, name = "") {
294
+ const key = SourceFiles.keyFor(sourceKey, fileIndex);
295
+ let file = this.#byKey.get(key);
296
+ if (!file) {
297
+ file = new SourceFile({ sourceKey, fileIndex, name });
298
+ this.#byKey.set(key, file);
299
+ } else if (name.length > 0 && file.givenName.length === 0) {
300
+ // A name learned later is still this file's name. The first caller is
301
+ // sometimes a route that has an index and no name.
302
+ file.givenName = name.trim();
303
+ }
304
+ return file;
305
+ }
306
+
307
+ /**
308
+ * @param {string} key
309
+ * @returns {SourceFile | null}
310
+ */
311
+ peek(key) {
312
+ return this.#byKey.get(key) ?? null;
313
+ }
314
+
315
+ /**
316
+ * Drop every file nobody names any more.
317
+ *
318
+ * A file holds a handle and one probe result — a few dozen numbers — so it is
319
+ * kept for as long as anything refers to it and dropped in one sweep rather
320
+ * than reference-counted. What must NOT happen is dropping one while a
321
+ * session still points at it: the facts would be read again, and a cold
322
+ * torrent would answer differently the second time.
323
+ *
324
+ * Held by the OBJECT rather than by its key, the same way the timelines and
325
+ * the outputs are swept: what must survive is what a live session points at,
326
+ * and that is a reference, not a string that happens to match one.
327
+ *
328
+ * @param {Set<SourceFile>} filesInUse
329
+ * @returns {number} How many were dropped.
330
+ */
331
+ forgetUnused(filesInUse) {
332
+ let dropped = 0;
333
+ for (const [key, file] of [...this.#byKey.entries()]) {
334
+ if (!filesInUse.has(file)) {
335
+ this.#byKey.delete(key);
336
+ dropped += 1;
337
+ }
338
+ }
339
+ return dropped;
340
+ }
341
+
342
+ /** @returns {number} */
343
+ get size() {
344
+ return this.#byKey.size;
345
+ }
346
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Which files of a torrent belong to one video file as its sound or its
3
- * subtitles.
2
+ * @file Which files of a torrent belong to one video file as its sound, its
3
+ * subtitles or its pictures.
4
4
  *
5
5
  * Releases often ship a dub or a subtitle set as SEPARATE FILES beside the
6
6
  * picture — `Rus Sound/<name>.mka`, `Sub/[group]/<name>.ass` — and a viewer who
@@ -9,6 +9,12 @@
9
9
  * the container layer's business (`services/container/`), and what a viewer is
10
10
  * shown is composed in the browser, where the locale is known.
11
11
  *
12
+ * **Why it lives in `torrent/`.** A container knows only itself, and a track
13
+ * knows only its container; "the file next to this one" is a notion that exists
14
+ * only where there is a LIST of files, which is the torrent. So this is the
15
+ * torrent's own statement about itself, in the layer that holds them, and it is
16
+ * the reason the folder exists.
17
+ *
12
18
  * Nothing here reads bytes, waits on the swarm or knows about ffmpeg. It is a
13
19
  * function of the torrent's own list of names, which is why it can be tested
14
20
  * outright.
@@ -21,6 +27,8 @@
21
27
  * container declares and must stay free of torrent knowledge.
22
28
  */
23
29
 
30
+ import { nameFollows, sidecarNaming } from "./naming.js";
31
+
24
32
  /**
25
33
  * Containers and elementary streams that can carry a soundtrack on their own.
26
34
  *
@@ -69,6 +77,29 @@ export const SUBTITLE_SIDECAR_EXTENSIONS = new Set([
69
77
  ".txt"
70
78
  ]);
71
79
 
80
+ /**
81
+ * Still images shipped beside a video: a contact sheet of frames, a cover, a
82
+ * poster.
83
+ *
84
+ * Recognised for two things a viewer can see. A pack of a hundred videos is
85
+ * unusable as a list of release names, and many such packs ship one contact
86
+ * sheet per video — so the sheets can BE the picker. And a film with a cover
87
+ * beside it has something to show while its first frame is being made, which is
88
+ * otherwise a black rectangle.
89
+ *
90
+ * They are served by the byte-range route like anything else in the torrent;
91
+ * nothing here decodes them.
92
+ */
93
+ export const IMAGE_SIDECAR_EXTENSIONS = new Set([
94
+ ".jpg",
95
+ ".jpeg",
96
+ ".png",
97
+ ".webp",
98
+ ".gif",
99
+ ".bmp",
100
+ ".avif"
101
+ ]);
102
+
72
103
  /**
73
104
  * Containers that carry a picture.
74
105
  *
@@ -246,18 +277,67 @@ function hashTokensOf(text) {
246
277
  /**
247
278
  * Whether a sidecar and a video are the same release of the same episode.
248
279
  *
249
- * Two rules, both taken from what releases actually do, and neither of them
280
+ * Three rules, all taken from what releases actually do, and none of them
250
281
  * involving the folder — a dub lives in a folder of its own by construction, so
251
282
  * requiring the folders to match would reject every case this exists for:
252
283
  *
253
284
  * 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.
285
+ * 2. the sidecar's name CONTINUES the video's at a token boundary, which is what
286
+ * every surveyed player reads: `<video base>.<language>[.<flags>].<ext>`, the
287
+ * shape Plex, Jellyfin, Kodi, Bazarr and OpenSubtitles all produce;
288
+ * 3. they share a release hash, which anime releases carry.
289
+ *
290
+ * Rule 2 was missing here and present in the browser, as a plain "begins with",
291
+ * and the two were compared nowhere. Measured 2026-09-04 over the 115 torrents
292
+ * in `Dropbox/trn`: of 1249 video files the two answers agreed on 1239 and
293
+ * differed on 10, every one of the ten a `<base>.<language>.ass` name that the
294
+ * browser paired and this side did not. The consequence reached the viewer,
295
+ * because the proxy warms what IT paired while the browser offers what IT
296
+ * paired: a track offered but never warmed waits for its first piece off the
297
+ * swarm, 27.7 s in the field measurement of 2026-08-31.
298
+ *
299
+ * The boundary is what makes rule 2 safe, and a plain "begins with" is not the
300
+ * same rule: without it `Film.20.rus.srt` pairs with `Film.2.mkv` — measured,
301
+ * and the grammar then reports the leftover `0` as the track's title, which is
302
+ * the tell that the remainder is a fragment of another film's name.
255
303
  *
256
304
  * @param {string} sidecarName
257
305
  * @param {string} videoName
258
306
  * @returns {boolean}
259
307
  */
260
308
  export function namesPair(sidecarName, videoName) {
309
+ if (sameRelease(sidecarName, videoName)) {
310
+ return true;
311
+ }
312
+ return nameFollows(baseNameOf(sidecarName), baseNameOf(videoName));
313
+ }
314
+
315
+ /**
316
+ * The same question for an IMAGE, which has one shape of its own.
317
+ *
318
+ * A contact sheet is commonly named by the video's WHOLE name with the image
319
+ * extension appended — `Movie.mp4.jpg` beside `Movie.mp4` — verified on a pack
320
+ * of 106 videos with 106 sheets. That is not the base-name rule: the sheet's
321
+ * base name is `Movie.mp4` and the video's is `Movie`.
322
+ *
323
+ * @param {string} imageName
324
+ * @param {string} videoName
325
+ * @returns {boolean}
326
+ */
327
+ export function imageNamesPair(imageName, videoName) {
328
+ if (sameRelease(imageName, videoName)) {
329
+ return true;
330
+ }
331
+ const imageBase = baseNameOf(imageName).toLowerCase();
332
+ return imageBase.length > 0 && imageBase === String(videoName ?? "").toLowerCase();
333
+ }
334
+
335
+ /**
336
+ * @param {string} sidecarName
337
+ * @param {string} videoName
338
+ * @returns {boolean}
339
+ */
340
+ function sameRelease(sidecarName, videoName) {
261
341
  const sidecarBase = baseNameOf(sidecarName).toLowerCase();
262
342
  const videoBase = baseNameOf(videoName).toLowerCase();
263
343
  if (sidecarBase.length === 0 || videoBase.length === 0) {
@@ -304,13 +384,19 @@ export function namesPair(sidecarName, videoName) {
304
384
  * @param {number} [params.videoCount] - How many playable video files the
305
385
  * torrent holds. When 1, the relaxation above applies. Counted by the caller,
306
386
  * which is the side that knows what counts as playable.
307
- * @returns {{ audio: SidecarFile[], subtitles: SidecarFile[] }}
387
+ * @returns {{ audio: SidecarFile[], subtitles: SidecarFile[], images: SidecarFile[] }}
388
+ * Images are the third group because a contact sheet or a cover is paired by
389
+ * the same rule and found in the same one pass. They are matched more
390
+ * strictly than sound and subtitles: the one-video relaxation does NOT apply
391
+ * to them, since a torrent's stray screenshot is not this film's cover, and
392
+ * showing the wrong picture as a poster is a visible mistake where a missing
393
+ * one is merely nothing.
308
394
  */
309
395
  export function matchSidecarFiles({ files, videoIndex, torrentName = "", videoCount = 0 }) {
310
396
  const list = Array.isArray(files) ? files : [];
311
397
  const video = list[videoIndex];
312
398
  if (!video) {
313
- return { audio: [], subtitles: [] };
399
+ return { audio: [], subtitles: [], images: [] };
314
400
  }
315
401
  const videoPath = splitTorrentPath(video.path ?? video.name ?? "", torrentName);
316
402
  const onlyVideo = videoCount === 1;
@@ -318,6 +404,8 @@ export function matchSidecarFiles({ files, videoIndex, torrentName = "", videoCo
318
404
  const audio = [];
319
405
  /** @type {SidecarFile[]} */
320
406
  const subtitles = [];
407
+ /** @type {SidecarFile[]} */
408
+ const images = [];
321
409
 
322
410
  for (const [fileIndex, file] of list.entries()) {
323
411
  if (fileIndex === videoIndex) {
@@ -327,10 +415,11 @@ export function matchSidecarFiles({ files, videoIndex, torrentName = "", videoCo
327
415
  const extension = extensionOf(name);
328
416
  const isAudio = AUDIO_SIDECAR_EXTENSIONS.has(extension);
329
417
  const isSubtitle = !isAudio && SUBTITLE_SIDECAR_EXTENSIONS.has(extension);
330
- if (!isAudio && !isSubtitle) {
418
+ const isImage = !isAudio && !isSubtitle && IMAGE_SIDECAR_EXTENSIONS.has(extension);
419
+ if (!isAudio && !isSubtitle && !isImage) {
331
420
  continue;
332
421
  }
333
- if (!onlyVideo && !namesPair(name, videoPath.name)) {
422
+ if (isImage ? !imageNamesPair(name, videoPath.name) : !onlyVideo && !namesPair(name, videoPath.name)) {
334
423
  continue;
335
424
  }
336
425
  const entry = {
@@ -339,14 +428,21 @@ export function matchSidecarFiles({ files, videoIndex, torrentName = "", videoCo
339
428
  folders,
340
429
  extension,
341
430
  length: Number.isFinite(file?.length) ? file.length : 0,
342
- declaresTracks: declaresItsOwnTracks(extension)
431
+ declaresTracks: declaresItsOwnTracks(extension),
432
+ // What this file's own path says about the track in it: its language, the
433
+ // flags a releaser wrote, and who made it. Read HERE, by the same grammar
434
+ // that decided the pairing, so the two answers cannot disagree — which is
435
+ // what they did while the browser read the name and this side did not.
436
+ naming: sidecarNaming({ folders, fileName: name, videoName: videoPath.name })
343
437
  };
344
438
  if (isAudio) {
345
439
  audio.push(entry);
346
- } else {
440
+ } else if (isSubtitle) {
347
441
  subtitles.push(entry);
442
+ } else {
443
+ images.push(entry);
348
444
  }
349
445
  }
350
446
 
351
- return { audio, subtitles };
447
+ return { audio, subtitles, images };
352
448
  }