@torrent-tv/proxy 2.64.9 → 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 +15 -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/services/audio-inventory.js +411 -0
- package/services/hls-session-manager.js +313 -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
|
@@ -28,6 +28,7 @@ import { createSendStream } from "./channel.js";
|
|
|
28
28
|
import { createFileClaims } from "./file-claims.js";
|
|
29
29
|
import { readFragments, supplyFiguresFor } from "./piece-reader.js";
|
|
30
30
|
import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
|
|
31
|
+
import { CONTAINER_HEAD_BYTES, containerTracksOf } from "./container-tracks.js";
|
|
31
32
|
import { Command, Event } from "./protocol.js";
|
|
32
33
|
import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../memory-report.js";
|
|
33
34
|
|
|
@@ -370,6 +371,24 @@ async function runCommand(command, params, id) {
|
|
|
370
371
|
};
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
case Command.CONTAINER_TRACKS: {
|
|
375
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
376
|
+
return {
|
|
377
|
+
tracks: await containerTracksOf(torrent, params.fileIndex, params.sourceKey, {
|
|
378
|
+
// The header of a file nobody is playing has usually not arrived at
|
|
379
|
+
// all — a sidecar soundtrack is asked about before anyone has chosen
|
|
380
|
+
// it. Its head is a few hundred kilobytes, and without them there is
|
|
381
|
+
// nothing to read.
|
|
382
|
+
prefetchEdges: () =>
|
|
383
|
+
pool.prefetchFileEdges(torrent, params.fileIndex, {
|
|
384
|
+
headBytes: CONTAINER_HEAD_BYTES,
|
|
385
|
+
tailBytes: 0,
|
|
386
|
+
timeoutMs: 60_000
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
373
392
|
case Command.SUBTITLE_CUES: {
|
|
374
393
|
const torrent = await requireTorrent(params.sourceKey);
|
|
375
394
|
const held = await cuesHeldFor(torrent, params.fileIndex, params.sourceKey, params.trackNumber);
|
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
|
+
});
|
|
@@ -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
|
-
}
|