@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.
- package/CHANGELOG.md +1504 -1461
- package/CLAUDE.md +11 -1
- package/biome.json +182 -1
- package/docs/container-architecture.md +2 -1
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +206 -201
- package/services/container/AviContainer.js +1 -1
- package/services/container/Container.js +33 -1
- package/services/container/MatroskaContainer.js +1 -1
- package/services/container/Mp4Container.js +1 -1
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11073 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -775
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +18 -0
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /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
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
}
|