@torrent-tv/proxy 2.64.8 → 2.65.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 +1305 -1285
- package/bin/cli.js +533 -520
- 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/services/audio-inventory.js +411 -0
- package/services/hls-session-manager.js +313 -24
- package/services/memory-report.js +106 -1
- package/services/piece-store/shared-piece-store.js +47 -2
- package/services/playback-planner.js +133 -2
- package/services/sidecar-files.js +352 -0
- package/services/torrent-worker/client.js +47 -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 +784 -750
- package/services/tracks/index.js +7 -1
- package/test/audio-inventory.test.js +177 -0
- package/test/memory-budget.test.js +72 -1
- package/test/sidecar-files.test.js +178 -0
- package/services/tracks/ExternalSubtitleFile.js +0 -27
package/services/tracks/index.js
CHANGED
|
@@ -4,4 +4,10 @@ export { AudioTrack } from "./AudioTrack.js";
|
|
|
4
4
|
export { SubtitleTrack } from "./SubtitleTrack.js";
|
|
5
5
|
export { TextSubtitleTrack, TEXT_CODECS_MATROSKA, TEXT_FORMATS_MP4 } from "./TextSubtitleTrack.js";
|
|
6
6
|
export { ImageSubtitleTrack } from "./ImageSubtitleTrack.js";
|
|
7
|
-
|
|
7
|
+
// There is deliberately no class for a track that lives in a file of its own.
|
|
8
|
+
// `<name>.mka` is a Matroska container holding an `AudioTrack`, and
|
|
9
|
+
// `MatroskaContainer` reads it exactly as it reads the picture's — so "external"
|
|
10
|
+
// is not a KIND of track, only the answer to where a track's bytes are. That
|
|
11
|
+
// answer belongs to the application layer, which knows about torrents; this one
|
|
12
|
+
// describes what a container declares and must not. `ExternalSubtitleFile`,
|
|
13
|
+
// which asserted the opposite, was never used by anything and is gone.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file One numbered list of soundtracks, from two readings of the same file.
|
|
3
|
+
*
|
|
4
|
+
* Two things are being pinned here, and each has cost a real failure elsewhere
|
|
5
|
+
* in this project:
|
|
6
|
+
*
|
|
7
|
+
* 1. **The alignment guard.** The container reading is used ONLY when it can be
|
|
8
|
+
* lined up with ffmpeg's own numbering, because `0:a:N` is what the encoder
|
|
9
|
+
* is handed — a flag attributed to the wrong track is worse than a missing
|
|
10
|
+
* one. Same discipline as `subtitle-defaults.js`, for the same reason.
|
|
11
|
+
* 2. **The flat numbering.** The browser's menu, the `audioTrackIndex` on a
|
|
12
|
+
* session request and the `a/<n>/` address of a rendition are one number.
|
|
13
|
+
* Embedded tracks must keep the numbers they have always had, so that a
|
|
14
|
+
* session created against an older cached plan means the same thing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import {
|
|
20
|
+
audioPairingHolds,
|
|
21
|
+
audioRenditionName,
|
|
22
|
+
buildAudioInventory,
|
|
23
|
+
codecNameOf,
|
|
24
|
+
mergeContainerAudioFlags,
|
|
25
|
+
resolveAudioIndex
|
|
26
|
+
} from "../services/audio-inventory.js";
|
|
27
|
+
|
|
28
|
+
test("a pair agreeing on language is accepted", () => {
|
|
29
|
+
assert.equal(audioPairingHolds({ language: "jpn" }, { language: "jpn" }), true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a pair disagreeing on language is refused", () => {
|
|
33
|
+
assert.equal(audioPairingHolds({ language: "jpn" }, { language: "rus" }), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("two tracks that say nothing about themselves do not break the alignment", () => {
|
|
37
|
+
assert.equal(audioPairingHolds({ language: "und", title: "" }, { language: "", name: "" }), true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the container's own flags reach the merged track", () => {
|
|
41
|
+
const merged = mergeContainerAudioFlags(
|
|
42
|
+
[
|
|
43
|
+
{ index: 0, language: "eng", title: "", isDefault: true, codec: "aac" },
|
|
44
|
+
{ index: 1, language: "eng", title: "Director", isDefault: true, codec: "ac3" }
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
{ language: "eng", name: "", isOriginal: true, isDefault: true, declaresDefault: true, channels: 6 },
|
|
48
|
+
{ language: "eng", name: "Director", isCommentary: true, isDefault: false, declaresDefault: true, channels: 2 }
|
|
49
|
+
]
|
|
50
|
+
);
|
|
51
|
+
assert.equal(merged.aligned, true);
|
|
52
|
+
assert.equal(merged.tracks[0].isOriginal, true);
|
|
53
|
+
assert.equal(merged.tracks[0].channels, 6);
|
|
54
|
+
assert.equal(merged.tracks[1].isCommentary, true);
|
|
55
|
+
// Matroska defaults FlagDefault to 1 and ffmpeg prints the applied default, so
|
|
56
|
+
// the banner said both tracks were default. The container says otherwise.
|
|
57
|
+
assert.equal(merged.tracks[1].isDefault, false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a count that differs drops the container reading whole", () => {
|
|
61
|
+
const merged = mergeContainerAudioFlags(
|
|
62
|
+
[{ index: 0, language: "eng", isDefault: true }],
|
|
63
|
+
[{ language: "eng" }, { language: "rus" }]
|
|
64
|
+
);
|
|
65
|
+
assert.equal(merged.aligned, false);
|
|
66
|
+
assert.match(merged.reason, /declares 2 audio tracks and the probe found 1/);
|
|
67
|
+
// Nothing of it is used — not even the flags that happened to line up.
|
|
68
|
+
assert.equal(merged.tracks[0].isCommentary, false);
|
|
69
|
+
assert.equal(merged.tracks[0].declaresDefault, false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("one pair that agrees on neither language nor title drops it too", () => {
|
|
73
|
+
const merged = mergeContainerAudioFlags(
|
|
74
|
+
[{ index: 0, language: "jpn", title: "" }, { index: 1, language: "rus", title: "" }],
|
|
75
|
+
[{ language: "jpn", name: "" }, { language: "eng", name: "" }]
|
|
76
|
+
);
|
|
77
|
+
assert.equal(merged.aligned, false);
|
|
78
|
+
assert.match(merged.reason, /audio 1 is/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("embedded tracks keep the numbers they have always had, sidecars follow", () => {
|
|
82
|
+
const inventory = buildAudioInventory({
|
|
83
|
+
embedded: [
|
|
84
|
+
{ language: "jpn", codec: "aac", isDefault: true },
|
|
85
|
+
{ language: "eng", codec: "ac3", title: "Commentary", isCommentary: true }
|
|
86
|
+
],
|
|
87
|
+
videoFileIndex: 24,
|
|
88
|
+
sidecars: [
|
|
89
|
+
{
|
|
90
|
+
file: { fileIndex: 12, name: "ep.mka", folders: ["Rus Sound"], extension: ".mka" },
|
|
91
|
+
tracks: [{ language: "rus", codecId: "A_AC3", channels: 6 }]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
assert.deepEqual(inventory.map((entry) => entry.index), [0, 1, 2]);
|
|
97
|
+
assert.deepEqual(inventory.map((entry) => entry.fileIndex), [24, 24, 12]);
|
|
98
|
+
assert.deepEqual(inventory.map((entry) => entry.sourceTrackIndex), [0, 1, 0]);
|
|
99
|
+
assert.deepEqual(inventory.map((entry) => entry.kind), ["embedded", "embedded", "sidecar"]);
|
|
100
|
+
assert.equal(inventory[2].codec, "ac3");
|
|
101
|
+
assert.deepEqual(inventory[2].folders, ["Rus Sound"]);
|
|
102
|
+
assert.equal(inventory[2].fileName, "ep.mka");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a sidecar whose table could not be read is still offered, as one track", () => {
|
|
106
|
+
const inventory = buildAudioInventory({
|
|
107
|
+
embedded: [{ language: "eng", codec: "aac" }],
|
|
108
|
+
videoFileIndex: 0,
|
|
109
|
+
sidecars: [{ file: { fileIndex: 1, name: "dub.ac3", folders: [], extension: ".ac3" }, tracks: [] }]
|
|
110
|
+
});
|
|
111
|
+
assert.equal(inventory.length, 2);
|
|
112
|
+
assert.equal(inventory[1].sourceTrackIndex, 0);
|
|
113
|
+
// The extension of a bare elementary stream IS its codec.
|
|
114
|
+
assert.equal(inventory[1].codec, "ac3");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a sidecar carrying two tracks contributes both", () => {
|
|
118
|
+
const inventory = buildAudioInventory({
|
|
119
|
+
embedded: [],
|
|
120
|
+
videoFileIndex: 0,
|
|
121
|
+
sidecars: [
|
|
122
|
+
{
|
|
123
|
+
file: { fileIndex: 3, name: "dubs.mka", folders: [], extension: ".mka" },
|
|
124
|
+
tracks: [{ language: "rus" }, { language: "ukr" }]
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
});
|
|
128
|
+
assert.deepEqual(inventory.map((entry) => [entry.index, entry.sourceTrackIndex]), [[0, 0], [1, 1]]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("the flat number resolves back to a file and a track inside it", () => {
|
|
132
|
+
const inventory = buildAudioInventory({
|
|
133
|
+
embedded: [{ language: "jpn" }],
|
|
134
|
+
videoFileIndex: 24,
|
|
135
|
+
sidecars: [{ file: { fileIndex: 12, name: "ep.mka", folders: [], extension: ".mka" }, tracks: [{}] }]
|
|
136
|
+
});
|
|
137
|
+
assert.equal(resolveAudioIndex(inventory, 1).fileIndex, 12);
|
|
138
|
+
assert.equal(resolveAudioIndex(inventory, 1).sourceTrackIndex, 0);
|
|
139
|
+
assert.equal(resolveAudioIndex(inventory, 9), null);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("codec identifiers are translated to the names the browser judges by", () => {
|
|
143
|
+
assert.equal(codecNameOf({ codec: "AAC" }), "aac");
|
|
144
|
+
assert.equal(codecNameOf({ codecId: "A_AC3" }), "ac3");
|
|
145
|
+
assert.equal(codecNameOf({ codecId: "A_AAC/MPEG4/LC" }), "aac");
|
|
146
|
+
assert.equal(codecNameOf({ codecId: "A_PCM/INT/LIT" }), "pcm");
|
|
147
|
+
assert.equal(codecNameOf({ codecId: "ec-3" }), "eac3");
|
|
148
|
+
assert.equal(codecNameOf({}, ".dts"), "dts");
|
|
149
|
+
assert.equal(codecNameOf({}, ".unknown"), "");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a rendition is named by what the file says, and two never share a name", () => {
|
|
153
|
+
const inventory = buildAudioInventory({
|
|
154
|
+
embedded: [{ language: "jpn" }],
|
|
155
|
+
videoFileIndex: 0,
|
|
156
|
+
sidecars: [
|
|
157
|
+
{ file: { fileIndex: 1, name: "a.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] },
|
|
158
|
+
{ file: { fileIndex: 2, name: "b.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] }
|
|
159
|
+
]
|
|
160
|
+
});
|
|
161
|
+
assert.equal(audioRenditionName(inventory[0], inventory), "jpn");
|
|
162
|
+
// Both sidecars sit in the same folder and neither names itself, so the names
|
|
163
|
+
// would collide — and hls.js groups renditions by name.
|
|
164
|
+
assert.notEqual(
|
|
165
|
+
audioRenditionName(inventory[1], inventory),
|
|
166
|
+
audioRenditionName(inventory[2], inventory)
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a commentary says so in its rendition name", () => {
|
|
171
|
+
const inventory = buildAudioInventory({
|
|
172
|
+
embedded: [{ language: "eng" }, { language: "eng", title: "Director", isCommentary: true }],
|
|
173
|
+
videoFileIndex: 0,
|
|
174
|
+
sidecars: []
|
|
175
|
+
});
|
|
176
|
+
assert.match(audioRenditionName(inventory[1], inventory), /commentary/);
|
|
177
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
|
|
4
|
-
import { describeMemory, readingIsWorthWriting } from "../services/memory-report.js";
|
|
4
|
+
import { describeMemory, readingIsWorthWriting, summariseMappings } from "../services/memory-report.js";
|
|
5
5
|
import {
|
|
6
6
|
budgetForNewStore,
|
|
7
7
|
SharedPieceStore,
|
|
@@ -216,3 +216,74 @@ test("a reading is written when it moved, or when the silence has gone on long e
|
|
|
216
216
|
"no quiet interval means every reading is written, which is the process scope"
|
|
217
217
|
);
|
|
218
218
|
});
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The shape of the anonymous memory, not only its size. Three shapes, three
|
|
222
|
+
* different diagnoses of the same number — see `summariseMappings`.
|
|
223
|
+
*/
|
|
224
|
+
const SMAPS = [
|
|
225
|
+
"aaaad4000000-aaaad4c00000 r-xp 00000000 fe:01 1234 /usr/local/bin/node",
|
|
226
|
+
"Size: 12288 kB",
|
|
227
|
+
"Rss: 12000 kB",
|
|
228
|
+
"aaaae0000000-aaaae4000000 rw-p 00000000 00:00 0 [heap]",
|
|
229
|
+
"Size: 65536 kB",
|
|
230
|
+
"Rss: 40960 kB",
|
|
231
|
+
"ffff80000000-ffff80400000 rw-p 00000000 00:00 0 ",
|
|
232
|
+
"Size: 4096 kB",
|
|
233
|
+
"Rss: 4096 kB",
|
|
234
|
+
"ffff81000000-ffff81400000 rw-p 00000000 00:00 0 ",
|
|
235
|
+
"Size: 4096 kB",
|
|
236
|
+
"Rss: 4096 kB",
|
|
237
|
+
"ffff82000000-ffff82100000 rw-p 00000000 00:00 0 ",
|
|
238
|
+
"Size: 1024 kB",
|
|
239
|
+
"Rss: 512 kB",
|
|
240
|
+
"ffff83000000-ffff84000000 rw-p 00000000 00:00 0 ",
|
|
241
|
+
"Size: 16384 kB",
|
|
242
|
+
"Rss: 0 kB",
|
|
243
|
+
""
|
|
244
|
+
].join("\n");
|
|
245
|
+
|
|
246
|
+
test("the mappings are grouped by the shape that names the cause", () => {
|
|
247
|
+
const summary = summariseMappings(SMAPS);
|
|
248
|
+
assert.equal(summary.heapBytes, 40960 * 1024, "the allocator's break-managed arena stands alone");
|
|
249
|
+
assert.equal(summary.largeCount, 2, "two anonymous mappings of four megabytes");
|
|
250
|
+
assert.equal(summary.largeBytes, 8192 * 1024);
|
|
251
|
+
assert.equal(summary.largestBytes, 4096 * 1024);
|
|
252
|
+
assert.equal(summary.smallCount, 1);
|
|
253
|
+
assert.equal(summary.smallBytes, 512 * 1024);
|
|
254
|
+
assert.equal(summary.fileBytes, 12000 * 1024, "the executable is not a leak");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a mapping that is reserved but untouched costs nothing and is not counted", () => {
|
|
258
|
+
// The 16 MB mapping above has Rss 0: address space, not memory. Counting it
|
|
259
|
+
// would put tens of gigabytes of V8's reservations into the reading.
|
|
260
|
+
assert.equal(summariseMappings(SMAPS).largeCount, 2);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("no /proc, or nothing readable, is answered with zeroes rather than a throw", () => {
|
|
264
|
+
const empty = summariseMappings("");
|
|
265
|
+
assert.equal(empty.heapBytes, 0);
|
|
266
|
+
assert.equal(empty.largeCount, 0);
|
|
267
|
+
assert.deepEqual(summariseMappings(null), empty);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("the mapping shape is said in the line, and left out when it is not known", () => {
|
|
271
|
+
const withShape = describeMemory({
|
|
272
|
+
process: { rss: 900 * MEGABYTE, heapUsed: 27 * MEGABYTE, heapTotal: 30 * MEGABYTE, external: 6 * MEGABYTE, arrayBuffers: 2 * MEGABYTE },
|
|
273
|
+
availableBytes: 800 * MEGABYTE,
|
|
274
|
+
availableMeasured: true,
|
|
275
|
+
anonymousBytes: 870 * MEGABYTE,
|
|
276
|
+
mappings: summariseMappings(SMAPS),
|
|
277
|
+
stores: []
|
|
278
|
+
});
|
|
279
|
+
assert.match(withShape, /mappings=\[heap 40MB, 2 anon ≥2MB = 8MB \(largest 4MB\), 1 anon <2MB = 1MB, files 12MB\]/);
|
|
280
|
+
|
|
281
|
+
const withoutShape = describeMemory({
|
|
282
|
+
process: { rss: 900 * MEGABYTE, heapUsed: 27 * MEGABYTE, heapTotal: 30 * MEGABYTE, external: 6 * MEGABYTE, arrayBuffers: 2 * MEGABYTE },
|
|
283
|
+
availableBytes: 800 * MEGABYTE,
|
|
284
|
+
availableMeasured: true,
|
|
285
|
+
anonymousBytes: 870 * MEGABYTE,
|
|
286
|
+
stores: []
|
|
287
|
+
});
|
|
288
|
+
assert.ok(!withoutShape.includes("mappings="), "an unread breakdown is absent, not zeroed");
|
|
289
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Which files of a torrent are one video file's sound and subtitles.
|
|
3
|
+
*
|
|
4
|
+
* The case these were written against is a real torrent, and every name below
|
|
5
|
+
* is copied from it: `Drifters`, twelve episodes as `.mkv` in the root, twelve
|
|
6
|
+
* Russian soundtracks as `.mka` under `Rus Sound/`, twelve subtitle files under
|
|
7
|
+
* `Sub/[Stan WarHammer & Nesitach]/`, all sharing one base name per episode.
|
|
8
|
+
*
|
|
9
|
+
* The rule that matters most is the one about a torrent with SEVERAL videos: a
|
|
10
|
+
* sidecar is taken only when its name pairs with this episode's, because a
|
|
11
|
+
* wrong pairing would put the sound of episode 2 over the picture of episode 7
|
|
12
|
+
* and nothing downstream could notice.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import {
|
|
18
|
+
baseNameOf,
|
|
19
|
+
bracketTokensOf,
|
|
20
|
+
countVideoFiles,
|
|
21
|
+
extensionOf,
|
|
22
|
+
matchSidecarFiles,
|
|
23
|
+
namesPair,
|
|
24
|
+
splitTorrentPath
|
|
25
|
+
} from "../services/sidecar-files.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The Drifters torrent, as WebTorrent reports it: every path prefixed with the
|
|
29
|
+
* torrent's own name.
|
|
30
|
+
*
|
|
31
|
+
* @param {number} episodes
|
|
32
|
+
* @returns {Array<{ path: string, name: string, length: number }>}
|
|
33
|
+
*/
|
|
34
|
+
function driftersFiles(episodes = 3) {
|
|
35
|
+
const files = [];
|
|
36
|
+
const push = (relative, length) => {
|
|
37
|
+
const name = relative.slice(relative.lastIndexOf("/") + 1);
|
|
38
|
+
files.push({ path: `Drifters/${relative}`, name, length });
|
|
39
|
+
};
|
|
40
|
+
for (let episode = 1; episode <= episodes; episode += 1) {
|
|
41
|
+
const stem = `[HorribleSubs] Drifters - ${String(episode).padStart(2, "0")} [1080p]`;
|
|
42
|
+
push(`Sub/[Stan WarHammer & Nesitach]/${stem}.ass`, 29_000);
|
|
43
|
+
push(`Rus Sound/${stem}.mka`, 30_000_000);
|
|
44
|
+
push(`${stem}.mkv`, 566_000_000);
|
|
45
|
+
}
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("a path is split into folders and a name, with the torrent's own name removed", () => {
|
|
50
|
+
const split = splitTorrentPath("Drifters/Rus Sound/[HorribleSubs] Drifters - 02 [1080p].mka", "Drifters");
|
|
51
|
+
assert.deepEqual(split.folders, ["Rus Sound"]);
|
|
52
|
+
assert.equal(split.name, "[HorribleSubs] Drifters - 02 [1080p].mka");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a path that does not begin with the torrent name is left alone", () => {
|
|
56
|
+
const split = splitTorrentPath("Sub/x.ass", "Drifters");
|
|
57
|
+
assert.deepEqual(split.folders, ["Sub"]);
|
|
58
|
+
assert.equal(split.name, "x.ass");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("extension and base name", () => {
|
|
62
|
+
assert.equal(extensionOf("[HorribleSubs] Drifters - 02 [1080p].mka"), ".mka");
|
|
63
|
+
assert.equal(extensionOf("no-extension"), "");
|
|
64
|
+
assert.equal(extensionOf(".hidden"), "");
|
|
65
|
+
assert.equal(baseNameOf("a.b.mkv"), "a.b");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("bracketed groups are read in order", () => {
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
bracketTokensOf("[HorribleSubs] Drifters - 02 [1080p]"),
|
|
71
|
+
["HorribleSubs", "1080p"]
|
|
72
|
+
);
|
|
73
|
+
assert.deepEqual(bracketTokensOf("nothing here"), []);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("equal base names pair, whatever folder each sits in", () => {
|
|
77
|
+
assert.equal(
|
|
78
|
+
namesPair("[HorribleSubs] Drifters - 02 [1080p].mka", "[HorribleSubs] Drifters - 02 [1080p].mkv"),
|
|
79
|
+
true
|
|
80
|
+
);
|
|
81
|
+
assert.equal(
|
|
82
|
+
namesPair("[HorribleSubs] Drifters - 03 [1080p].mka", "[HorribleSubs] Drifters - 02 [1080p].mkv"),
|
|
83
|
+
false
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a shared release hash pairs two differently named files", () => {
|
|
88
|
+
assert.equal(namesPair("Ep01_rus [A1B2C3D4].mka", "[Group] Ep 01 [A1B2C3D4].mkv"), true);
|
|
89
|
+
assert.equal(namesPair("Ep01_rus [11112222].mka", "[Group] Ep 01 [A1B2C3D4].mkv"), false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("the sound and the subtitles of THIS episode are found, and no other's", () => {
|
|
93
|
+
const files = driftersFiles(3);
|
|
94
|
+
// Episode 2's picture: index 5 in the list built above.
|
|
95
|
+
const videoIndex = files.findIndex((file) => file.name === "[HorribleSubs] Drifters - 02 [1080p].mkv");
|
|
96
|
+
const matched = matchSidecarFiles({
|
|
97
|
+
files,
|
|
98
|
+
videoIndex,
|
|
99
|
+
torrentName: "Drifters",
|
|
100
|
+
videoCount: countVideoFiles(files)
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assert.equal(matched.audio.length, 1);
|
|
104
|
+
assert.equal(matched.audio[0].name, "[HorribleSubs] Drifters - 02 [1080p].mka");
|
|
105
|
+
assert.deepEqual(matched.audio[0].folders, ["Rus Sound"]);
|
|
106
|
+
assert.equal(matched.audio[0].extension, ".mka");
|
|
107
|
+
// A `.mka` is Matroska, so its own track table can be read — which is the
|
|
108
|
+
// whole reason no new container class was needed for a separate soundtrack.
|
|
109
|
+
assert.equal(matched.audio[0].declaresTracks, true);
|
|
110
|
+
|
|
111
|
+
assert.equal(matched.subtitles.length, 1);
|
|
112
|
+
assert.equal(matched.subtitles[0].name, "[HorribleSubs] Drifters - 02 [1080p].ass");
|
|
113
|
+
assert.deepEqual(matched.subtitles[0].folders, ["Sub", "[Stan WarHammer & Nesitach]"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the picture itself is never its own sidecar", () => {
|
|
117
|
+
const files = driftersFiles(1);
|
|
118
|
+
const videoIndex = files.findIndex((file) => file.name.endsWith(".mkv"));
|
|
119
|
+
const matched = matchSidecarFiles({ files, videoIndex, torrentName: "Drifters", videoCount: 1 });
|
|
120
|
+
assert.equal(matched.audio.some((entry) => entry.fileIndex === videoIndex), false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a torrent with one picture takes every sidecar, however it is named", () => {
|
|
124
|
+
const files = [
|
|
125
|
+
{ path: "Film/Film.2019.1080p.mkv", name: "Film.2019.1080p.mkv", length: 5_000_000_000 },
|
|
126
|
+
{ path: "Film/Rus.mka", name: "Rus.mka", length: 300_000_000 },
|
|
127
|
+
{ path: "Film/subs/forced.srt", name: "forced.srt", length: 4_000 }
|
|
128
|
+
];
|
|
129
|
+
const matched = matchSidecarFiles({
|
|
130
|
+
files,
|
|
131
|
+
videoIndex: 0,
|
|
132
|
+
torrentName: "Film",
|
|
133
|
+
videoCount: countVideoFiles(files)
|
|
134
|
+
});
|
|
135
|
+
assert.deepEqual(matched.audio.map((entry) => entry.name), ["Rus.mka"]);
|
|
136
|
+
assert.deepEqual(matched.subtitles.map((entry) => entry.name), ["forced.srt"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a torrent with several pictures does NOT take a sidecar that names none of them", () => {
|
|
140
|
+
const files = [
|
|
141
|
+
{ path: "Pack/Ep01.mkv", name: "Ep01.mkv", length: 100 },
|
|
142
|
+
{ path: "Pack/Ep02.mkv", name: "Ep02.mkv", length: 100 },
|
|
143
|
+
{ path: "Pack/Sound/Something Else.mka", name: "Something Else.mka", length: 100 }
|
|
144
|
+
];
|
|
145
|
+
const matched = matchSidecarFiles({
|
|
146
|
+
files,
|
|
147
|
+
videoIndex: 0,
|
|
148
|
+
torrentName: "Pack",
|
|
149
|
+
videoCount: countVideoFiles(files)
|
|
150
|
+
});
|
|
151
|
+
assert.deepEqual(matched.audio, []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("files of no interest are ignored", () => {
|
|
155
|
+
const files = [
|
|
156
|
+
{ path: "X/film.mkv", name: "film.mkv", length: 100 },
|
|
157
|
+
{ path: "X/film.nfo", name: "film.nfo", length: 10 },
|
|
158
|
+
{ path: "X/cover.jpg", name: "cover.jpg", length: 10 }
|
|
159
|
+
];
|
|
160
|
+
const matched = matchSidecarFiles({ files, videoIndex: 0, torrentName: "X", videoCount: 1 });
|
|
161
|
+
assert.deepEqual(matched.audio, []);
|
|
162
|
+
assert.deepEqual(matched.subtitles, []);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a raw elementary stream is recognised but declares no tracks of its own", () => {
|
|
166
|
+
const files = [
|
|
167
|
+
{ path: "X/film.mkv", name: "film.mkv", length: 100 },
|
|
168
|
+
{ path: "X/film.ac3", name: "film.ac3", length: 100 }
|
|
169
|
+
];
|
|
170
|
+
const matched = matchSidecarFiles({ files, videoIndex: 0, torrentName: "X", videoCount: 1 });
|
|
171
|
+
assert.equal(matched.audio.length, 1);
|
|
172
|
+
assert.equal(matched.audio[0].declaresTracks, false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("pictures are counted, and nothing else is", () => {
|
|
176
|
+
assert.equal(countVideoFiles(driftersFiles(12)), 12);
|
|
177
|
+
assert.equal(countVideoFiles([{ name: "a.mka" }, { name: "b.srt" }]), 0);
|
|
178
|
+
});
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file External subtitle file — not part of the media container, but shares TextSubtitleTrack API.
|
|
3
|
-
*
|
|
4
|
-
* A standalone .srt/.ass/.ssa/.vtt file beside the video in the same torrent.
|
|
5
|
-
* Has no container flags; language comes from filename suffix or franc detection.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export class ExternalSubtitleFile {
|
|
9
|
-
/**
|
|
10
|
-
* @param {object} params
|
|
11
|
-
* @param {string} params.fileName
|
|
12
|
-
* @param {number} params.fileIndex - Torrent file index.
|
|
13
|
-
* @param {string} params.extension - ".srt" etc. lowercased.
|
|
14
|
-
* @param {string} params.language - Hint from filename or "".
|
|
15
|
-
*/
|
|
16
|
-
constructor({ fileName, fileIndex, extension, language = "" }) {
|
|
17
|
-
this.fileName = fileName;
|
|
18
|
-
this.fileIndex = fileIndex;
|
|
19
|
-
this.extension = extension;
|
|
20
|
-
this.language = language;
|
|
21
|
-
this.type = "external-subtitle";
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
isTextBased() {
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
}
|