@torrent-tv/proxy 2.64.9 → 2.66.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 +20 -0
- package/docs/container-architecture.md +32 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +57 -2
- package/routes/api/transcode-sessions/post.js +11 -1
- package/server.js +24 -1
- package/services/audio-inventory.js +411 -0
- package/services/hls-session-manager.js +396 -24
- package/services/playback-planner.js +133 -2
- package/services/sidecar-files.js +352 -0
- package/services/torrent-worker/client.js +14 -0
- package/services/torrent-worker/container-tracks.js +243 -0
- package/services/torrent-worker/pool-adapter.js +38 -0
- package/services/torrent-worker/protocol.js +8 -0
- package/services/torrent-worker/worker.js +19 -0
- package/services/tracks/index.js +7 -1
- package/test/audio-inventory.test.js +177 -0
- package/test/sidecar-files.test.js +178 -0
- package/services/tracks/ExternalSubtitleFile.js +0 -27
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import { mergeContainerSubtitleFlags } from "./subtitle-defaults.js";
|
|
12
|
+
import { buildAudioInventory, mergeContainerAudioFlags } from "./audio-inventory.js";
|
|
13
|
+
import { countVideoFiles, matchSidecarFiles } from "./sidecar-files.js";
|
|
12
14
|
import {
|
|
13
15
|
parseFfmpegDurationSeconds,
|
|
14
16
|
parseFfmpegStartTimeSeconds,
|
|
@@ -28,6 +30,17 @@ const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
|
|
|
28
30
|
// the codec probe). ~16 MB ≈ the first segments of typical media.
|
|
29
31
|
const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* How long the plan waits for a file's own header before offering its
|
|
35
|
+
* soundtrack without what that header would have said.
|
|
36
|
+
*
|
|
37
|
+
* Not a measurement, and nothing is derived from it: it is the point past which
|
|
38
|
+
* holding the viewer costs more than the language and flags being waited for —
|
|
39
|
+
* which the folder name supplies anyway, from the torrent's file list, at no
|
|
40
|
+
* cost. The reading itself carries on in the worker and is kept there.
|
|
41
|
+
*/
|
|
42
|
+
const SIDECAR_HEADER_WAIT_MS = 3_000;
|
|
43
|
+
|
|
31
44
|
/** Subtitle codecs that can be converted to WebVTT (text-based). */
|
|
32
45
|
const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
|
|
33
46
|
|
|
@@ -345,6 +358,122 @@ export function createPlaybackPlanner({
|
|
|
345
358
|
return merged.tracks;
|
|
346
359
|
}
|
|
347
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Every soundtrack this file can be watched with, as one numbered list: its
|
|
363
|
+
* own tracks and the ones shipped as separate files beside it.
|
|
364
|
+
*
|
|
365
|
+
* Built here, in the plan, because the plan is what the viewer's menu is drawn
|
|
366
|
+
* from — so the offer is complete the moment a file is opened, with nothing
|
|
367
|
+
* arriving late and nothing measured while the viewer waits. It is also what
|
|
368
|
+
* the master playlist's rendition group is built from, so the number in the
|
|
369
|
+
* menu and the number in the `a/<n>/` address are the same number by
|
|
370
|
+
* construction rather than by agreement.
|
|
371
|
+
*
|
|
372
|
+
* @param {object} torrent
|
|
373
|
+
* @param {number} fileIndex
|
|
374
|
+
* @param {object[]} bannerAudioTracks - The probe's own audio streams.
|
|
375
|
+
* @returns {Promise<import("./audio-inventory.js").AudioInventoryEntry[]>}
|
|
376
|
+
*/
|
|
377
|
+
async function buildInventory(torrent, fileIndex, bannerAudioTracks) {
|
|
378
|
+
const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
|
|
379
|
+
/**
|
|
380
|
+
* Read a file's declared audio tracks, or give up quickly.
|
|
381
|
+
*
|
|
382
|
+
* The plan is on the path to the first frame, and reading a sidecar's header
|
|
383
|
+
* waits on the swarm: that file has usually had nothing downloaded when this
|
|
384
|
+
* runs, and a header that never arrives would hold the plan — and the
|
|
385
|
+
* viewer — for the whole of the read's own patience. What a timeout costs is
|
|
386
|
+
* small and deliberate: the track is still offered, still numbered and still
|
|
387
|
+
* playable, only without the language and flags its own header would have
|
|
388
|
+
* given. The language the viewer actually sees is read from the FOLDER the
|
|
389
|
+
* release put it in, which is in the torrent's file list and needs no bytes
|
|
390
|
+
* at all.
|
|
391
|
+
*
|
|
392
|
+
* @param {number} wantedFileIndex
|
|
393
|
+
* @param {string} label
|
|
394
|
+
* @returns {Promise<object[]>}
|
|
395
|
+
*/
|
|
396
|
+
const declaredAudioOf = async (wantedFileIndex, label) => {
|
|
397
|
+
if (typeof torrentPool?.getDeclaredAudioTracks !== "function") {
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
let timer = null;
|
|
401
|
+
try {
|
|
402
|
+
return await Promise.race([
|
|
403
|
+
torrentPool.getDeclaredAudioTracks(torrent, wantedFileIndex),
|
|
404
|
+
new Promise((resolve) => {
|
|
405
|
+
timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
|
|
406
|
+
timer.unref?.();
|
|
407
|
+
})
|
|
408
|
+
]).then((tracks) => {
|
|
409
|
+
if (tracks === null) {
|
|
410
|
+
logger.info(
|
|
411
|
+
`audio tracks: "${label}" did not answer within ` +
|
|
412
|
+
`${SIDECAR_HEADER_WAIT_MS / 1000}s — offered without what its header would say`
|
|
413
|
+
);
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
return Array.isArray(tracks) ? tracks : [];
|
|
417
|
+
});
|
|
418
|
+
} catch (error) {
|
|
419
|
+
logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
|
|
420
|
+
return [];
|
|
421
|
+
} finally {
|
|
422
|
+
if (timer !== null) {
|
|
423
|
+
clearTimeout(timer);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
// The picture's own tracks: ffmpeg numbers them, the container declares what
|
|
428
|
+
// they are. Both readings, lined up and checked — see `audio-inventory.js`.
|
|
429
|
+
let embedded = banner.map((track) => ({ ...track, declaresDefault: false }));
|
|
430
|
+
if (banner.length > 0) {
|
|
431
|
+
// The picture's head is already downloaded — the codec probe just read it
|
|
432
|
+
// — so this is a parse and not a wait, but it is bounded like the rest.
|
|
433
|
+
const declared = await declaredAudioOf(fileIndex, "the picture");
|
|
434
|
+
const merged = mergeContainerAudioFlags(banner, declared);
|
|
435
|
+
embedded = merged.tracks;
|
|
436
|
+
logger.info(
|
|
437
|
+
merged.aligned
|
|
438
|
+
? `audio tracks: the container describes all ${merged.tracks.length}` +
|
|
439
|
+
`${merged.tracks.some((track) => track.isCommentary) ? ", one of them commentary" : ""}` +
|
|
440
|
+
`${merged.tracks.some((track) => track.isVisualImpaired) ? ", one of them described" : ""}`
|
|
441
|
+
: `audio tracks: using the probe's own fields — ${merged.reason}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const sidecarFiles = matchSidecarFiles({
|
|
446
|
+
files: torrent?.files ?? [],
|
|
447
|
+
videoIndex: fileIndex,
|
|
448
|
+
torrentName: typeof torrent?.name === "string" ? torrent.name : "",
|
|
449
|
+
videoCount: countVideoFiles(torrent?.files ?? [])
|
|
450
|
+
});
|
|
451
|
+
// All of them at once. They are separate files with separate headers, and
|
|
452
|
+
// read one after another the waits add up on the path to the first frame.
|
|
453
|
+
const sidecars = await Promise.all(
|
|
454
|
+
sidecarFiles.audio.map(async (file) => ({
|
|
455
|
+
file,
|
|
456
|
+
// A bare elementary stream — `.ac3`, `.dts`, `.mp3` — has no table to
|
|
457
|
+
// read, so nothing is asked of the swarm for it at all.
|
|
458
|
+
tracks: file.declaresTracks ? await declaredAudioOf(file.fileIndex, file.name) : []
|
|
459
|
+
}))
|
|
460
|
+
);
|
|
461
|
+
const inventory = buildAudioInventory({ embedded, videoFileIndex: fileIndex, sidecars });
|
|
462
|
+
if (sidecars.length > 0) {
|
|
463
|
+
logger.info(
|
|
464
|
+
`audio tracks: ${sidecars.length} file(s) beside the picture carry sound — ` +
|
|
465
|
+
inventory
|
|
466
|
+
.filter((entry) => entry.kind === "sidecar")
|
|
467
|
+
.map((entry) =>
|
|
468
|
+
`a:${entry.index}=${entry.folders.join("/") || "."}/${entry.fileName}` +
|
|
469
|
+
`#${entry.sourceTrackIndex}${entry.codec ? `(${entry.codec})` : ""}`
|
|
470
|
+
)
|
|
471
|
+
.join(" ")
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return inventory;
|
|
475
|
+
}
|
|
476
|
+
|
|
348
477
|
function withHostTimings(plan) {
|
|
349
478
|
return {
|
|
350
479
|
...plan,
|
|
@@ -516,8 +645,10 @@ export function createPlaybackPlanner({
|
|
|
516
645
|
// (list of forced resolutions <= source). 0 when unknown.
|
|
517
646
|
videoWidth,
|
|
518
647
|
videoHeight,
|
|
519
|
-
// Full track inventory for the browser's audio/subtitle menus.
|
|
520
|
-
|
|
648
|
+
// Full track inventory for the browser's audio/subtitle menus. The audio
|
|
649
|
+
// half spans the picture's own tracks AND the soundtracks shipped as
|
|
650
|
+
// files beside it, under one numbering — see `buildInventory`.
|
|
651
|
+
audioTracks: await buildInventory(torrent, fileIndex, audioTracks ?? []),
|
|
521
652
|
subtitleTracks: await withContainerDefaults(torrent, fileIndex, subtitleTracks ?? []),
|
|
522
653
|
// Both host timings are filled in by `withHostTimings` on the way out,
|
|
523
654
|
// never here: read at build time they would be frozen into the cached
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files of a torrent belong to one video file as its sound or its
|
|
3
|
+
* subtitles.
|
|
4
|
+
*
|
|
5
|
+
* Releases often ship a dub or a subtitle set as SEPARATE FILES beside the
|
|
6
|
+
* picture — `Rus Sound/<name>.mka`, `Sub/[group]/<name>.ass` — and a viewer who
|
|
7
|
+
* cannot reach them is watching the release without half of what it carries.
|
|
8
|
+
* This module answers only which file goes with which; what is inside a file is
|
|
9
|
+
* the container layer's business (`services/container/`), and what a viewer is
|
|
10
|
+
* shown is composed in the browser, where the locale is known.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here reads bytes, waits on the swarm or knows about ffmpeg. It is a
|
|
13
|
+
* function of the torrent's own list of names, which is why it can be tested
|
|
14
|
+
* outright.
|
|
15
|
+
*
|
|
16
|
+
* A note on the axes this fits into. A file beside the video is NOT a third kind
|
|
17
|
+
* of track: `<name>.mka` is a Matroska container holding an audio track, and
|
|
18
|
+
* `MatroskaContainer` reads it exactly as it reads the picture's own tracks. So
|
|
19
|
+
* "external" is not a type — it is only the answer to WHERE a track lives, and
|
|
20
|
+
* that answer belongs here rather than in `tracks/`, which describes what a
|
|
21
|
+
* container declares and must stay free of torrent knowledge.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Containers and elementary streams that can carry a soundtrack on their own.
|
|
26
|
+
*
|
|
27
|
+
* Two groups, and the difference matters to the caller: `.mka` and `.m4a` have a
|
|
28
|
+
* track table that `ContainerFactory` can read, so their language, title and
|
|
29
|
+
* flags are available; the rest are raw elementary streams that carry exactly
|
|
30
|
+
* one track and declare nothing about it.
|
|
31
|
+
*/
|
|
32
|
+
export const AUDIO_SIDECAR_EXTENSIONS = new Set([
|
|
33
|
+
".mka",
|
|
34
|
+
".m4a",
|
|
35
|
+
".aac",
|
|
36
|
+
".ac3",
|
|
37
|
+
".eac3",
|
|
38
|
+
".dts",
|
|
39
|
+
".dtshd",
|
|
40
|
+
".flac",
|
|
41
|
+
".mp3",
|
|
42
|
+
".mp2",
|
|
43
|
+
".ogg",
|
|
44
|
+
".oga",
|
|
45
|
+
".opus",
|
|
46
|
+
".wav",
|
|
47
|
+
".thd",
|
|
48
|
+
".mlp",
|
|
49
|
+
".wma"
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Subtitle files. Image-based ones (`.sup`, `.sub`, `.idx`) are listed because
|
|
54
|
+
* they exist and must be RECOGNISED — a file we cannot show is still not a
|
|
55
|
+
* soundtrack — but this module does not decide what is offerable; that is the
|
|
56
|
+
* subtitle track's own `isTextBased()`.
|
|
57
|
+
*/
|
|
58
|
+
export const SUBTITLE_SIDECAR_EXTENSIONS = new Set([
|
|
59
|
+
".srt",
|
|
60
|
+
".ass",
|
|
61
|
+
".ssa",
|
|
62
|
+
".vtt",
|
|
63
|
+
".webvtt",
|
|
64
|
+
".sub",
|
|
65
|
+
".sup",
|
|
66
|
+
".idx",
|
|
67
|
+
".ttml",
|
|
68
|
+
".smi",
|
|
69
|
+
".txt"
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Containers that carry a picture.
|
|
74
|
+
*
|
|
75
|
+
* Here to COUNT them, not to decide what plays: whether a file is playable is
|
|
76
|
+
* the browser's answer, made from what its own media stack accepts, and this
|
|
77
|
+
* proxy must not acquire a second opinion about it. The count is needed for one
|
|
78
|
+
* decision — whether a torrent holds exactly one picture, in which case a
|
|
79
|
+
* soundtrack beside it has nothing else it could belong to.
|
|
80
|
+
*/
|
|
81
|
+
export const VIDEO_FILE_EXTENSIONS = new Set([
|
|
82
|
+
".mp4",
|
|
83
|
+
".mkv",
|
|
84
|
+
".webm",
|
|
85
|
+
".mov",
|
|
86
|
+
".m4v",
|
|
87
|
+
".avi",
|
|
88
|
+
".mpg",
|
|
89
|
+
".mpeg",
|
|
90
|
+
".ts",
|
|
91
|
+
".m2ts",
|
|
92
|
+
".mts",
|
|
93
|
+
".wmv",
|
|
94
|
+
".asf",
|
|
95
|
+
".flv",
|
|
96
|
+
".f4v",
|
|
97
|
+
".ogv",
|
|
98
|
+
".ogm",
|
|
99
|
+
".3gp",
|
|
100
|
+
".3g2",
|
|
101
|
+
".divx",
|
|
102
|
+
".vob",
|
|
103
|
+
".m2v",
|
|
104
|
+
".m2p",
|
|
105
|
+
".mxf",
|
|
106
|
+
".rm",
|
|
107
|
+
".rmvb"
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How many files of a torrent carry a picture.
|
|
112
|
+
*
|
|
113
|
+
* @param {Array<{ path?: string, name?: string }>} files
|
|
114
|
+
* @returns {number}
|
|
115
|
+
*/
|
|
116
|
+
export function countVideoFiles(files) {
|
|
117
|
+
let count = 0;
|
|
118
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
119
|
+
const name = typeof file?.name === "string" && file.name.length > 0
|
|
120
|
+
? file.name
|
|
121
|
+
: String(file?.path ?? "");
|
|
122
|
+
if (VIDEO_FILE_EXTENSIONS.has(extensionOf(name))) {
|
|
123
|
+
count += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return count;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Subtitle formats that are text, and therefore small by construction — a whole
|
|
131
|
+
* episode of dialogue is tens of kilobytes.
|
|
132
|
+
*
|
|
133
|
+
* The distinction is used to decide how much of such a file to fetch ahead of
|
|
134
|
+
* the viewer: a text file is fetched WHOLE, because it is smaller than the
|
|
135
|
+
* torrent's own piece and reading its edges would cost the same pieces as
|
|
136
|
+
* reading all of it. An image-based one (`.sup`, `.sub`+`.idx`) is a picture per
|
|
137
|
+
* cue and runs to tens of megabytes, so it gets the same edges treatment as
|
|
138
|
+
* anything else.
|
|
139
|
+
*/
|
|
140
|
+
export const TEXT_SUBTITLE_SIDECAR_EXTENSIONS = new Set([
|
|
141
|
+
".srt",
|
|
142
|
+
".ass",
|
|
143
|
+
".ssa",
|
|
144
|
+
".vtt",
|
|
145
|
+
".webvtt",
|
|
146
|
+
".ttml",
|
|
147
|
+
".smi",
|
|
148
|
+
".txt"
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Extensions whose track table a `Container` subclass can read. Everything else
|
|
153
|
+
* in {@link AUDIO_SIDECAR_EXTENSIONS} is a bare stream: one track, no metadata.
|
|
154
|
+
*/
|
|
155
|
+
const CONTAINER_BACKED_AUDIO = new Set([".mka", ".m4a", ".mp4", ".mkv", ".webm"]);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The extension of a name, lowercased, including the dot. Empty when there is
|
|
159
|
+
* none.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} name
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
export function extensionOf(name) {
|
|
165
|
+
const text = typeof name === "string" ? name : "";
|
|
166
|
+
const dot = text.lastIndexOf(".");
|
|
167
|
+
// A leading dot is a hidden file, not an extension.
|
|
168
|
+
return dot > 0 ? text.slice(dot).toLowerCase() : "";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A name without its extension.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} name
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
export function baseNameOf(name) {
|
|
178
|
+
const text = typeof name === "string" ? name : "";
|
|
179
|
+
const dot = text.lastIndexOf(".");
|
|
180
|
+
return dot > 0 ? text.slice(0, dot) : text;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether a sidecar file of this extension has a readable track table.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} extension - Lowercased, with the dot.
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
export function declaresItsOwnTracks(extension) {
|
|
190
|
+
return CONTAINER_BACKED_AUDIO.has(extension);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Split a torrent file's path into the folders above it and its own name, with
|
|
195
|
+
* the torrent's own name removed from the front.
|
|
196
|
+
*
|
|
197
|
+
* WebTorrent prefixes every path in a multi-file torrent with the torrent name;
|
|
198
|
+
* the browser strips it again to show a playlist. Doing it once, here, means the
|
|
199
|
+
* folders travel to the browser already relative to the torrent root, so there
|
|
200
|
+
* is one stripping rule in the system rather than two that can disagree.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} path - `file.path` as WebTorrent reports it.
|
|
203
|
+
* @param {string} torrentName
|
|
204
|
+
* @returns {{ folders: string[], name: string }}
|
|
205
|
+
*/
|
|
206
|
+
export function splitTorrentPath(path, torrentName) {
|
|
207
|
+
const text = typeof path === "string" ? path.replace(/\\/g, "/") : "";
|
|
208
|
+
const prefix = typeof torrentName === "string" && torrentName.length > 0 ? `${torrentName}/` : "";
|
|
209
|
+
const relative = prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text;
|
|
210
|
+
const segments = relative.split("/").filter((segment) => segment.length > 0);
|
|
211
|
+
const name = segments.length > 0 ? segments[segments.length - 1] : "";
|
|
212
|
+
return { folders: segments.slice(0, -1), name };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Bracketed groups of a name, in order: `[HorribleSubs] X [1080p]` → both.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} text
|
|
219
|
+
* @returns {string[]}
|
|
220
|
+
*/
|
|
221
|
+
export function bracketTokensOf(text) {
|
|
222
|
+
const source = typeof text === "string" ? text : "";
|
|
223
|
+
const tokens = [];
|
|
224
|
+
for (const match of source.matchAll(/\[([^\]]+)\]/g)) {
|
|
225
|
+
const token = match[1].trim();
|
|
226
|
+
if (token.length > 0) {
|
|
227
|
+
tokens.push(token);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return tokens;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Bracketed groups that look like a release hash — `[78EFD746]`. Anime releases
|
|
235
|
+
* carry the CRC of the file, and two files sharing one are certainly a pair.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} text
|
|
238
|
+
* @returns {string[]}
|
|
239
|
+
*/
|
|
240
|
+
function hashTokensOf(text) {
|
|
241
|
+
return bracketTokensOf(text)
|
|
242
|
+
.filter((token) => /^[0-9a-f]{4,10}$/i.test(token))
|
|
243
|
+
.map((token) => token.toLowerCase());
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Whether a sidecar and a video are the same release of the same episode.
|
|
248
|
+
*
|
|
249
|
+
* Two rules, both taken from what releases actually do, and neither of them
|
|
250
|
+
* involving the folder — a dub lives in a folder of its own by construction, so
|
|
251
|
+
* requiring the folders to match would reject every case this exists for:
|
|
252
|
+
*
|
|
253
|
+
* 1. the base names are equal, which is the common shape (`X.mkv` / `X.mka`);
|
|
254
|
+
* 2. they share a release hash, which anime releases carry.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} sidecarName
|
|
257
|
+
* @param {string} videoName
|
|
258
|
+
* @returns {boolean}
|
|
259
|
+
*/
|
|
260
|
+
export function namesPair(sidecarName, videoName) {
|
|
261
|
+
const sidecarBase = baseNameOf(sidecarName).toLowerCase();
|
|
262
|
+
const videoBase = baseNameOf(videoName).toLowerCase();
|
|
263
|
+
if (sidecarBase.length === 0 || videoBase.length === 0) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
if (sidecarBase === videoBase) {
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
const videoHashes = new Set(hashTokensOf(videoBase));
|
|
270
|
+
if (videoHashes.size > 0) {
|
|
271
|
+
for (const token of hashTokensOf(sidecarBase)) {
|
|
272
|
+
if (videoHashes.has(token)) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @typedef {object} SidecarFile
|
|
282
|
+
* @property {number} fileIndex - Index in the torrent.
|
|
283
|
+
* @property {string} name - File name, extension included.
|
|
284
|
+
* @property {string[]} folders - Folders above it, relative to the torrent root.
|
|
285
|
+
* @property {string} extension - Lowercased, with the dot.
|
|
286
|
+
* @property {number} length - Bytes.
|
|
287
|
+
* @property {boolean} declaresTracks - Whether its own track table can be read.
|
|
288
|
+
*/
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The audio and subtitle files of a torrent that belong to one video file.
|
|
292
|
+
*
|
|
293
|
+
* The pairing is by name (see {@link namesPair}), with ONE relaxation: a torrent
|
|
294
|
+
* holding a single video file has nothing else its sidecars could belong to, so
|
|
295
|
+
* there every sidecar is taken. That covers the very common
|
|
296
|
+
* `Film.mkv` + `Rus.mka` shape, where the two names have nothing in common. It
|
|
297
|
+
* is deliberately not extended to a torrent with several videos, where a wrong
|
|
298
|
+
* pairing would put the sound of one episode over the picture of another.
|
|
299
|
+
*
|
|
300
|
+
* @param {object} params
|
|
301
|
+
* @param {Array<{ path?: string, name?: string, length?: number }>} params.files
|
|
302
|
+
* @param {number} params.videoIndex
|
|
303
|
+
* @param {string} [params.torrentName]
|
|
304
|
+
* @param {number} [params.videoCount] - How many playable video files the
|
|
305
|
+
* torrent holds. When 1, the relaxation above applies. Counted by the caller,
|
|
306
|
+
* which is the side that knows what counts as playable.
|
|
307
|
+
* @returns {{ audio: SidecarFile[], subtitles: SidecarFile[] }}
|
|
308
|
+
*/
|
|
309
|
+
export function matchSidecarFiles({ files, videoIndex, torrentName = "", videoCount = 0 }) {
|
|
310
|
+
const list = Array.isArray(files) ? files : [];
|
|
311
|
+
const video = list[videoIndex];
|
|
312
|
+
if (!video) {
|
|
313
|
+
return { audio: [], subtitles: [] };
|
|
314
|
+
}
|
|
315
|
+
const videoPath = splitTorrentPath(video.path ?? video.name ?? "", torrentName);
|
|
316
|
+
const onlyVideo = videoCount === 1;
|
|
317
|
+
/** @type {SidecarFile[]} */
|
|
318
|
+
const audio = [];
|
|
319
|
+
/** @type {SidecarFile[]} */
|
|
320
|
+
const subtitles = [];
|
|
321
|
+
|
|
322
|
+
for (const [fileIndex, file] of list.entries()) {
|
|
323
|
+
if (fileIndex === videoIndex) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const { folders, name } = splitTorrentPath(file?.path ?? file?.name ?? "", torrentName);
|
|
327
|
+
const extension = extensionOf(name);
|
|
328
|
+
const isAudio = AUDIO_SIDECAR_EXTENSIONS.has(extension);
|
|
329
|
+
const isSubtitle = !isAudio && SUBTITLE_SIDECAR_EXTENSIONS.has(extension);
|
|
330
|
+
if (!isAudio && !isSubtitle) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (!onlyVideo && !namesPair(name, videoPath.name)) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const entry = {
|
|
337
|
+
fileIndex,
|
|
338
|
+
name,
|
|
339
|
+
folders,
|
|
340
|
+
extension,
|
|
341
|
+
length: Number.isFinite(file?.length) ? file.length : 0,
|
|
342
|
+
declaresTracks: declaresItsOwnTracks(extension)
|
|
343
|
+
};
|
|
344
|
+
if (isAudio) {
|
|
345
|
+
audio.push(entry);
|
|
346
|
+
} else {
|
|
347
|
+
subtitles.push(entry);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { audio, subtitles };
|
|
352
|
+
}
|
|
@@ -325,6 +325,20 @@ export class TorrentWorkerClient {
|
|
|
325
325
|
return this.#caller.call(Command.SUBTITLE_TRACKS, { sourceKey, fileIndex });
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Every track one file declares, read from its own header.
|
|
330
|
+
*
|
|
331
|
+
* Asked of the picture for the flags ffmpeg's banner does not carry, and of a
|
|
332
|
+
* soundtrack shipped as its own file beside it — the same question about a
|
|
333
|
+
* different file, which is why there is one command rather than two.
|
|
334
|
+
*
|
|
335
|
+
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
336
|
+
* @returns {Promise<{ tracks: object[] }>}
|
|
337
|
+
*/
|
|
338
|
+
async getContainerTracks({ sourceKey, fileIndex }) {
|
|
339
|
+
return this.#caller.call(Command.CONTAINER_TRACKS, { sourceKey, fileIndex });
|
|
340
|
+
}
|
|
341
|
+
|
|
328
342
|
/**
|
|
329
343
|
* The cues of one subtitle track that can be read from what is downloaded.
|
|
330
344
|
*
|