@torrent-tv/proxy 2.80.19 → 2.81.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 +12 -0
- package/package.json +1 -1
- package/research/double-spawn-2026-09-10.md +171 -0
- package/services/disk/DiskSpace.js +146 -0
- package/services/disk/wire.js +60 -0
- package/services/encode/EncodeRun.js +25 -3
- package/services/encode/SegmentStore.js +137 -9
- package/services/hls-session-manager.js +26 -45
- package/services/orchestrators/EncodeOrchestrator.js +4 -1
- package/services/piece-store/allowance.js +107 -0
- package/services/piece-store/piece-disk-store.js +365 -0
- package/services/piece-store/shared-piece-store.js +1549 -1535
- package/services/torrent-worker/client.js +32 -0
- package/services/torrent-worker/pool-adapter.js +15 -0
- package/services/torrent-worker/protocol.js +9 -0
- package/services/torrent-worker/worker.js +8 -1
- package/services/viewer/positions.js +48 -0
- package/test/audio-inventory.test.js +176 -176
- package/test/auto-quality-step.test.js +514 -514
- package/test/concurrent-cost.test.js +138 -138
- package/test/coverage-follows-the-disk.test.js +191 -191
- package/test/coverage-map.test.js +195 -195
- package/test/declared-tracks.test.js +35 -35
- package/test/disk-space.test.js +138 -0
- package/test/encode-orchestrator.test.js +0 -3
- package/test/encode-run.test.js +5 -12
- package/test/held-request-width.test.js +155 -155
- package/test/helpers/encode-run.js +2 -2
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +281 -220
- package/test/orchestrator-wired.test.js +199 -199
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +267 -0
- package/test/piece-reader.test.js +4 -4
- package/test/piece-store-eviction.test.js +17 -17
- package/test/piece-store-reservations.test.js +20 -1
- package/test/piece-store-slow-disk.test.js +16 -1
- package/test/read-window.test.js +6 -6
- package/test/run-intervals.test.js +100 -100
- package/test/seek-landing.test.js +109 -109
- package/test/segment-store-eviction.test.js +232 -0
- package/test/segments-are-shared.test.js +1 -1
- package/test/shared-piece-store.test.js +12 -12
- package/test/sidecar-naming.test.js +142 -142
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-walk.test.js +369 -369
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-track-numbering.test.js +370 -370
- package/test/tail-duplication.test.js +167 -167
- package/test/tracks-begin-together.test.js +195 -195
- package/test/two-viewers-one-picture.test.js +374 -374
- package/test/video-facts.test.js +102 -102
- package/test/wedge-certainty.test.js +131 -131
- package/services/piece-store/disk-tier.js +0 -151
|
@@ -676,6 +676,38 @@ export class TorrentWorkerClient {
|
|
|
676
676
|
*
|
|
677
677
|
* @returns {Promise<void>}
|
|
678
678
|
*/
|
|
679
|
+
/**
|
|
680
|
+
* What the spilled pieces weigh, as of the last revision. Zero until one has
|
|
681
|
+
* happened, which is what "we have not asked yet" means.
|
|
682
|
+
*
|
|
683
|
+
* @type {number}
|
|
684
|
+
*/
|
|
685
|
+
spilledBytes = 0;
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Say how much disk the spilled pieces on this thread may take between them.
|
|
689
|
+
*
|
|
690
|
+
* Best effort and never awaited by anything that matters: a share that does
|
|
691
|
+
* not arrive leaves the ceiling where it was, and the next revision is a
|
|
692
|
+
* minute away.
|
|
693
|
+
*
|
|
694
|
+
* @param {number} bytes
|
|
695
|
+
* @returns {Promise<void>}
|
|
696
|
+
*/
|
|
697
|
+
async allowSpillBytes(bytes) {
|
|
698
|
+
try {
|
|
699
|
+
const revised = await this.#caller.call(Command.SPILL_ALLOWANCE, { bytes });
|
|
700
|
+
// The reply says what those stores actually hold, which is what the owner
|
|
701
|
+
// of the disk needs for the next division. One exchange, both directions.
|
|
702
|
+
this.spilledBytes = Array.isArray(revised)
|
|
703
|
+
? revised.reduce((sum, store) => sum + (Number(store?.bytes) || 0), 0)
|
|
704
|
+
: this.spilledBytes;
|
|
705
|
+
} catch {
|
|
706
|
+
// The thread is gone or busy; the next revision says it again.
|
|
707
|
+
}
|
|
708
|
+
return this.spilledBytes;
|
|
709
|
+
}
|
|
710
|
+
|
|
679
711
|
async destroyAll() {
|
|
680
712
|
this.#stopping = true;
|
|
681
713
|
try {
|
|
@@ -70,6 +70,21 @@ export class WorkerTorrentPool {
|
|
|
70
70
|
* @param {number} fileIndex
|
|
71
71
|
* @returns {() => void}
|
|
72
72
|
*/
|
|
73
|
+
/** What the spilled pieces weigh on the torrent thread, as last revised. */
|
|
74
|
+
get spilledBytes() {
|
|
75
|
+
return this.#client.spilledBytes;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Say how much disk those spilled pieces may take between them.
|
|
80
|
+
*
|
|
81
|
+
* @param {number} bytes
|
|
82
|
+
* @returns {Promise<number>} What they hold now.
|
|
83
|
+
*/
|
|
84
|
+
allowSpillBytes(bytes) {
|
|
85
|
+
return this.#client.allowSpillBytes(bytes);
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
acquireFile(torrent, fileIndex) {
|
|
74
89
|
const sourceKey = torrent?.sourceKey;
|
|
75
90
|
if (!sourceKey) {
|
|
@@ -115,6 +115,15 @@ export const Command = {
|
|
|
115
115
|
/** Cues of one subtitle track, from the clusters already downloaded. */
|
|
116
116
|
SUBTITLE_CUES: "subtitle-cues",
|
|
117
117
|
/** Shut the client down, optionally deleting downloaded data. */
|
|
118
|
+
/**
|
|
119
|
+
* How much disk the spilled pieces may take between them.
|
|
120
|
+
*
|
|
121
|
+
* Sent from the main thread, because the disk has one owner and this store is
|
|
122
|
+
* not its only user: the segments an encoder produces are on the same disk,
|
|
123
|
+
* and a ceiling one of two users sets for itself is not a ceiling. The worker
|
|
124
|
+
* divides its share between the stores it holds.
|
|
125
|
+
*/
|
|
126
|
+
SPILL_ALLOWANCE: "spill-allowance",
|
|
118
127
|
DESTROY_ALL: "destroy-all"
|
|
119
128
|
};
|
|
120
129
|
|
|
@@ -65,7 +65,7 @@ forwardLogsTo((_level, message) => {
|
|
|
65
65
|
// the hook above had a chance to register. Verified the hard way: with a static
|
|
66
66
|
// import the process still aborted, and the stack named the genuine polyfill.
|
|
67
67
|
const { TorrentPool, resolveDhtBootstrap } = await import("../torrent-pool.js");
|
|
68
|
-
const { collectStoreStats, machineReserveBytes, pieceBufferCollection, reviseStoreBudgets } =
|
|
68
|
+
const { collectStoreStats, machineReserveBytes, pieceBufferCollection, reviseSpillBudgets, reviseStoreBudgets } =
|
|
69
69
|
await import("../piece-store/shared-piece-store.js");
|
|
70
70
|
|
|
71
71
|
// Resolved before the client exists, because the client builds its DHT in its
|
|
@@ -580,6 +580,13 @@ async function runCommand(command, params, id) {
|
|
|
580
580
|
return true;
|
|
581
581
|
}
|
|
582
582
|
|
|
583
|
+
case Command.SPILL_ALLOWANCE: {
|
|
584
|
+
// The disk has one owner and it is on the main thread, where the segments
|
|
585
|
+
// are. What arrives is this thread’s whole share; the stores divide it
|
|
586
|
+
// between themselves.
|
|
587
|
+
return reviseSpillBudgets(Number(params.bytes));
|
|
588
|
+
}
|
|
589
|
+
|
|
583
590
|
case Command.DESTROY_ALL: {
|
|
584
591
|
fileClaims.closeAll();
|
|
585
592
|
torrentsByKey.clear();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Where the viewers of one output stand.
|
|
3
|
+
*
|
|
4
|
+
* Asked by whatever has to give disk back: what lies behind every viewer has
|
|
5
|
+
* been played and will not be wanted again unless somebody seeks back, and what
|
|
6
|
+
* lies ahead of the furthest will be wanted eventually. So an answer of "nobody"
|
|
7
|
+
* is the strongest statement there is — everything that output holds is worth
|
|
8
|
+
* less than anything anybody is on their way to.
|
|
9
|
+
*
|
|
10
|
+
* Pure: it is handed the sessions, the clock and a way to turn seconds into a
|
|
11
|
+
* segment number, and it holds none of them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { viewersOf } from "./Viewer.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} params
|
|
18
|
+
* @param {Iterable<{ outputKey?: string }>} params.sessions - Every live session.
|
|
19
|
+
* @param {string} params.outputKey - The output being asked about.
|
|
20
|
+
* @param {(session: object, seconds: number) => number} params.segmentAt - Which
|
|
21
|
+
* segment of THAT session's timeline a moment of film falls in.
|
|
22
|
+
* @param {number} params.now
|
|
23
|
+
* @param {number} params.staleAfterMs - Silence longer than any a watching
|
|
24
|
+
* viewer can produce.
|
|
25
|
+
* @returns {number[]} A segment number per present viewer, unsorted.
|
|
26
|
+
*/
|
|
27
|
+
export function viewerSegmentsOn({ sessions, outputKey, segmentAt, now, staleAfterMs }) {
|
|
28
|
+
const at = [];
|
|
29
|
+
for (const session of sessions) {
|
|
30
|
+
if (session.outputKey !== outputKey) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const viewer of viewersOf(session).values()) {
|
|
34
|
+
if (!viewer.isPresent(now, staleAfterMs)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const seconds = viewer.positionSeconds();
|
|
38
|
+
if (!Number.isFinite(seconds)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const index = segmentAt(session, /** @type {number} */ (seconds));
|
|
42
|
+
if (Number.isInteger(index) && index >= 0) {
|
|
43
|
+
at.push(index);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return at;
|
|
48
|
+
}
|
|
@@ -1,176 +1,176 @@
|
|
|
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 { Container } from "../services/container/Container.js";
|
|
19
|
-
import { AudioTrack } from "../services/tracks/AudioTrack.js";
|
|
20
|
-
import assert from "node:assert/strict";
|
|
21
|
-
import {
|
|
22
|
-
audioRenditionName,
|
|
23
|
-
buildAudioInventory,
|
|
24
|
-
resolveAudioIndex
|
|
25
|
-
} from "../services/audio-inventory.js";
|
|
26
|
-
|
|
27
|
-
test("a pair agreeing on language is accepted", () => {
|
|
28
|
-
assert.equal(Container.pairingHolds({ language: "jpn" }, { language: "jpn" }), true);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("a pair disagreeing on language is refused", () => {
|
|
32
|
-
assert.equal(Container.pairingHolds({ language: "jpn" }, { language: "rus" }), false);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("two tracks that say nothing about themselves do not break the alignment", () => {
|
|
36
|
-
assert.equal(Container.pairingHolds({ language: "und", title: "" }, { language: "", name: "" }), true);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("the container's own flags reach the merged track", () => {
|
|
40
|
-
const merged = Container.mergeAudioFlags(
|
|
41
|
-
[
|
|
42
|
-
{ index: 0, language: "eng", title: "", isDefault: true, codec: "aac" },
|
|
43
|
-
{ index: 1, language: "eng", title: "Director", isDefault: true, codec: "ac3" }
|
|
44
|
-
],
|
|
45
|
-
[
|
|
46
|
-
{ language: "eng", name: "", isOriginal: true, isDefault: true, declaresDefault: true, channels: 6 },
|
|
47
|
-
{ language: "eng", name: "Director", isCommentary: true, isDefault: false, declaresDefault: true, channels: 2 }
|
|
48
|
-
]
|
|
49
|
-
);
|
|
50
|
-
assert.equal(merged.aligned, true);
|
|
51
|
-
assert.equal(merged.tracks[0].isOriginal, true);
|
|
52
|
-
assert.equal(merged.tracks[0].channels, 6);
|
|
53
|
-
assert.equal(merged.tracks[1].isCommentary, true);
|
|
54
|
-
// Matroska defaults FlagDefault to 1 and ffmpeg prints the applied default, so
|
|
55
|
-
// the banner said both tracks were default. The container says otherwise.
|
|
56
|
-
assert.equal(merged.tracks[1].isDefault, false);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("a count that differs drops the container reading whole", () => {
|
|
60
|
-
const merged = Container.mergeAudioFlags(
|
|
61
|
-
[{ index: 0, language: "eng", isDefault: true }],
|
|
62
|
-
[{ language: "eng" }, { language: "rus" }]
|
|
63
|
-
);
|
|
64
|
-
assert.equal(merged.aligned, false);
|
|
65
|
-
assert.match(merged.reason, /declares 2 audio tracks and the probe found 1/);
|
|
66
|
-
// Nothing of it is used — not even the flags that happened to line up.
|
|
67
|
-
assert.equal(merged.tracks[0].isCommentary, false);
|
|
68
|
-
assert.equal(merged.tracks[0].declaresDefault, false);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("one pair that agrees on neither language nor title drops it too", () => {
|
|
72
|
-
const merged = Container.mergeAudioFlags(
|
|
73
|
-
[{ index: 0, language: "jpn", title: "" }, { index: 1, language: "rus", title: "" }],
|
|
74
|
-
[{ language: "jpn", name: "" }, { language: "eng", name: "" }]
|
|
75
|
-
);
|
|
76
|
-
assert.equal(merged.aligned, false);
|
|
77
|
-
assert.match(merged.reason, /audio 1 is/);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("embedded tracks keep the numbers they have always had, sidecars follow", () => {
|
|
81
|
-
const inventory = buildAudioInventory({
|
|
82
|
-
embedded: [
|
|
83
|
-
{ language: "jpn", codec: "aac", isDefault: true },
|
|
84
|
-
{ language: "eng", codec: "ac3", title: "Commentary", isCommentary: true }
|
|
85
|
-
],
|
|
86
|
-
videoFileIndex: 24,
|
|
87
|
-
sidecars: [
|
|
88
|
-
{
|
|
89
|
-
file: { fileIndex: 12, name: "ep.mka", folders: ["Rus Sound"], extension: ".mka" },
|
|
90
|
-
tracks: [{ language: "rus", codecId: "A_AC3", channels: 6 }]
|
|
91
|
-
}
|
|
92
|
-
]
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
assert.deepEqual(inventory.map((entry) => entry.index), [0, 1, 2]);
|
|
96
|
-
assert.deepEqual(inventory.map((entry) => entry.fileIndex), [24, 24, 12]);
|
|
97
|
-
assert.deepEqual(inventory.map((entry) => entry.sourceTrackIndex), [0, 1, 0]);
|
|
98
|
-
assert.deepEqual(inventory.map((entry) => entry.kind), ["embedded", "embedded", "sidecar"]);
|
|
99
|
-
assert.equal(inventory[2].codec, "ac3");
|
|
100
|
-
assert.deepEqual(inventory[2].folders, ["Rus Sound"]);
|
|
101
|
-
assert.equal(inventory[2].fileName, "ep.mka");
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("a sidecar whose table could not be read is still offered, as one track", () => {
|
|
105
|
-
const inventory = buildAudioInventory({
|
|
106
|
-
embedded: [{ language: "eng", codec: "aac" }],
|
|
107
|
-
videoFileIndex: 0,
|
|
108
|
-
sidecars: [{ file: { fileIndex: 1, name: "dub.ac3", folders: [], extension: ".ac3" }, tracks: [] }]
|
|
109
|
-
});
|
|
110
|
-
assert.equal(inventory.length, 2);
|
|
111
|
-
assert.equal(inventory[1].sourceTrackIndex, 0);
|
|
112
|
-
// The extension of a bare elementary stream IS its codec.
|
|
113
|
-
assert.equal(inventory[1].codec, "ac3");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("a sidecar carrying two tracks contributes both", () => {
|
|
117
|
-
const inventory = buildAudioInventory({
|
|
118
|
-
embedded: [],
|
|
119
|
-
videoFileIndex: 0,
|
|
120
|
-
sidecars: [
|
|
121
|
-
{
|
|
122
|
-
file: { fileIndex: 3, name: "dubs.mka", folders: [], extension: ".mka" },
|
|
123
|
-
tracks: [{ language: "rus" }, { language: "ukr" }]
|
|
124
|
-
}
|
|
125
|
-
]
|
|
126
|
-
});
|
|
127
|
-
assert.deepEqual(inventory.map((entry) => [entry.index, entry.sourceTrackIndex]), [[0, 0], [1, 1]]);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("the flat number resolves back to a file and a track inside it", () => {
|
|
131
|
-
const inventory = buildAudioInventory({
|
|
132
|
-
embedded: [{ language: "jpn" }],
|
|
133
|
-
videoFileIndex: 24,
|
|
134
|
-
sidecars: [{ file: { fileIndex: 12, name: "ep.mka", folders: [], extension: ".mka" }, tracks: [{}] }]
|
|
135
|
-
});
|
|
136
|
-
assert.equal(resolveAudioIndex(inventory, 1).fileIndex, 12);
|
|
137
|
-
assert.equal(resolveAudioIndex(inventory, 1).sourceTrackIndex, 0);
|
|
138
|
-
assert.equal(resolveAudioIndex(inventory, 9), null);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("codec identifiers are translated to the names the browser judges by", () => {
|
|
142
|
-
assert.equal(AudioTrack.codecNameOf({ codec: "AAC" }), "aac");
|
|
143
|
-
assert.equal(AudioTrack.codecNameOf({ codecId: "A_AC3" }), "ac3");
|
|
144
|
-
assert.equal(AudioTrack.codecNameOf({ codecId: "A_AAC/MPEG4/LC" }), "aac");
|
|
145
|
-
assert.equal(AudioTrack.codecNameOf({ codecId: "A_PCM/INT/LIT" }), "pcm");
|
|
146
|
-
assert.equal(AudioTrack.codecNameOf({ codecId: "ec-3" }), "eac3");
|
|
147
|
-
assert.equal(AudioTrack.codecNameOf({}, ".dts"), "dts");
|
|
148
|
-
assert.equal(AudioTrack.codecNameOf({}, ".unknown"), "");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("a rendition is named by what the file says, and two never share a name", () => {
|
|
152
|
-
const inventory = buildAudioInventory({
|
|
153
|
-
embedded: [{ language: "jpn" }],
|
|
154
|
-
videoFileIndex: 0,
|
|
155
|
-
sidecars: [
|
|
156
|
-
{ file: { fileIndex: 1, name: "a.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] },
|
|
157
|
-
{ file: { fileIndex: 2, name: "b.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] }
|
|
158
|
-
]
|
|
159
|
-
});
|
|
160
|
-
assert.equal(audioRenditionName(inventory[0], inventory), "jpn");
|
|
161
|
-
// Both sidecars sit in the same folder and neither names itself, so the names
|
|
162
|
-
// would collide — and hls.js groups renditions by name.
|
|
163
|
-
assert.notEqual(
|
|
164
|
-
audioRenditionName(inventory[1], inventory),
|
|
165
|
-
audioRenditionName(inventory[2], inventory)
|
|
166
|
-
);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("a commentary says so in its rendition name", () => {
|
|
170
|
-
const inventory = buildAudioInventory({
|
|
171
|
-
embedded: [{ language: "eng" }, { language: "eng", title: "Director", isCommentary: true }],
|
|
172
|
-
videoFileIndex: 0,
|
|
173
|
-
sidecars: []
|
|
174
|
-
});
|
|
175
|
-
assert.match(audioRenditionName(inventory[1], inventory), /commentary/);
|
|
176
|
-
});
|
|
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 { Container } from "../services/container/Container.js";
|
|
19
|
+
import { AudioTrack } from "../services/tracks/AudioTrack.js";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import {
|
|
22
|
+
audioRenditionName,
|
|
23
|
+
buildAudioInventory,
|
|
24
|
+
resolveAudioIndex
|
|
25
|
+
} from "../services/audio-inventory.js";
|
|
26
|
+
|
|
27
|
+
test("a pair agreeing on language is accepted", () => {
|
|
28
|
+
assert.equal(Container.pairingHolds({ language: "jpn" }, { language: "jpn" }), true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a pair disagreeing on language is refused", () => {
|
|
32
|
+
assert.equal(Container.pairingHolds({ language: "jpn" }, { language: "rus" }), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("two tracks that say nothing about themselves do not break the alignment", () => {
|
|
36
|
+
assert.equal(Container.pairingHolds({ language: "und", title: "" }, { language: "", name: "" }), true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("the container's own flags reach the merged track", () => {
|
|
40
|
+
const merged = Container.mergeAudioFlags(
|
|
41
|
+
[
|
|
42
|
+
{ index: 0, language: "eng", title: "", isDefault: true, codec: "aac" },
|
|
43
|
+
{ index: 1, language: "eng", title: "Director", isDefault: true, codec: "ac3" }
|
|
44
|
+
],
|
|
45
|
+
[
|
|
46
|
+
{ language: "eng", name: "", isOriginal: true, isDefault: true, declaresDefault: true, channels: 6 },
|
|
47
|
+
{ language: "eng", name: "Director", isCommentary: true, isDefault: false, declaresDefault: true, channels: 2 }
|
|
48
|
+
]
|
|
49
|
+
);
|
|
50
|
+
assert.equal(merged.aligned, true);
|
|
51
|
+
assert.equal(merged.tracks[0].isOriginal, true);
|
|
52
|
+
assert.equal(merged.tracks[0].channels, 6);
|
|
53
|
+
assert.equal(merged.tracks[1].isCommentary, true);
|
|
54
|
+
// Matroska defaults FlagDefault to 1 and ffmpeg prints the applied default, so
|
|
55
|
+
// the banner said both tracks were default. The container says otherwise.
|
|
56
|
+
assert.equal(merged.tracks[1].isDefault, false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a count that differs drops the container reading whole", () => {
|
|
60
|
+
const merged = Container.mergeAudioFlags(
|
|
61
|
+
[{ index: 0, language: "eng", isDefault: true }],
|
|
62
|
+
[{ language: "eng" }, { language: "rus" }]
|
|
63
|
+
);
|
|
64
|
+
assert.equal(merged.aligned, false);
|
|
65
|
+
assert.match(merged.reason, /declares 2 audio tracks and the probe found 1/);
|
|
66
|
+
// Nothing of it is used — not even the flags that happened to line up.
|
|
67
|
+
assert.equal(merged.tracks[0].isCommentary, false);
|
|
68
|
+
assert.equal(merged.tracks[0].declaresDefault, false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("one pair that agrees on neither language nor title drops it too", () => {
|
|
72
|
+
const merged = Container.mergeAudioFlags(
|
|
73
|
+
[{ index: 0, language: "jpn", title: "" }, { index: 1, language: "rus", title: "" }],
|
|
74
|
+
[{ language: "jpn", name: "" }, { language: "eng", name: "" }]
|
|
75
|
+
);
|
|
76
|
+
assert.equal(merged.aligned, false);
|
|
77
|
+
assert.match(merged.reason, /audio 1 is/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("embedded tracks keep the numbers they have always had, sidecars follow", () => {
|
|
81
|
+
const inventory = buildAudioInventory({
|
|
82
|
+
embedded: [
|
|
83
|
+
{ language: "jpn", codec: "aac", isDefault: true },
|
|
84
|
+
{ language: "eng", codec: "ac3", title: "Commentary", isCommentary: true }
|
|
85
|
+
],
|
|
86
|
+
videoFileIndex: 24,
|
|
87
|
+
sidecars: [
|
|
88
|
+
{
|
|
89
|
+
file: { fileIndex: 12, name: "ep.mka", folders: ["Rus Sound"], extension: ".mka" },
|
|
90
|
+
tracks: [{ language: "rus", codecId: "A_AC3", channels: 6 }]
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(inventory.map((entry) => entry.index), [0, 1, 2]);
|
|
96
|
+
assert.deepEqual(inventory.map((entry) => entry.fileIndex), [24, 24, 12]);
|
|
97
|
+
assert.deepEqual(inventory.map((entry) => entry.sourceTrackIndex), [0, 1, 0]);
|
|
98
|
+
assert.deepEqual(inventory.map((entry) => entry.kind), ["embedded", "embedded", "sidecar"]);
|
|
99
|
+
assert.equal(inventory[2].codec, "ac3");
|
|
100
|
+
assert.deepEqual(inventory[2].folders, ["Rus Sound"]);
|
|
101
|
+
assert.equal(inventory[2].fileName, "ep.mka");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a sidecar whose table could not be read is still offered, as one track", () => {
|
|
105
|
+
const inventory = buildAudioInventory({
|
|
106
|
+
embedded: [{ language: "eng", codec: "aac" }],
|
|
107
|
+
videoFileIndex: 0,
|
|
108
|
+
sidecars: [{ file: { fileIndex: 1, name: "dub.ac3", folders: [], extension: ".ac3" }, tracks: [] }]
|
|
109
|
+
});
|
|
110
|
+
assert.equal(inventory.length, 2);
|
|
111
|
+
assert.equal(inventory[1].sourceTrackIndex, 0);
|
|
112
|
+
// The extension of a bare elementary stream IS its codec.
|
|
113
|
+
assert.equal(inventory[1].codec, "ac3");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a sidecar carrying two tracks contributes both", () => {
|
|
117
|
+
const inventory = buildAudioInventory({
|
|
118
|
+
embedded: [],
|
|
119
|
+
videoFileIndex: 0,
|
|
120
|
+
sidecars: [
|
|
121
|
+
{
|
|
122
|
+
file: { fileIndex: 3, name: "dubs.mka", folders: [], extension: ".mka" },
|
|
123
|
+
tracks: [{ language: "rus" }, { language: "ukr" }]
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
});
|
|
127
|
+
assert.deepEqual(inventory.map((entry) => [entry.index, entry.sourceTrackIndex]), [[0, 0], [1, 1]]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the flat number resolves back to a file and a track inside it", () => {
|
|
131
|
+
const inventory = buildAudioInventory({
|
|
132
|
+
embedded: [{ language: "jpn" }],
|
|
133
|
+
videoFileIndex: 24,
|
|
134
|
+
sidecars: [{ file: { fileIndex: 12, name: "ep.mka", folders: [], extension: ".mka" }, tracks: [{}] }]
|
|
135
|
+
});
|
|
136
|
+
assert.equal(resolveAudioIndex(inventory, 1).fileIndex, 12);
|
|
137
|
+
assert.equal(resolveAudioIndex(inventory, 1).sourceTrackIndex, 0);
|
|
138
|
+
assert.equal(resolveAudioIndex(inventory, 9), null);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("codec identifiers are translated to the names the browser judges by", () => {
|
|
142
|
+
assert.equal(AudioTrack.codecNameOf({ codec: "AAC" }), "aac");
|
|
143
|
+
assert.equal(AudioTrack.codecNameOf({ codecId: "A_AC3" }), "ac3");
|
|
144
|
+
assert.equal(AudioTrack.codecNameOf({ codecId: "A_AAC/MPEG4/LC" }), "aac");
|
|
145
|
+
assert.equal(AudioTrack.codecNameOf({ codecId: "A_PCM/INT/LIT" }), "pcm");
|
|
146
|
+
assert.equal(AudioTrack.codecNameOf({ codecId: "ec-3" }), "eac3");
|
|
147
|
+
assert.equal(AudioTrack.codecNameOf({}, ".dts"), "dts");
|
|
148
|
+
assert.equal(AudioTrack.codecNameOf({}, ".unknown"), "");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a rendition is named by what the file says, and two never share a name", () => {
|
|
152
|
+
const inventory = buildAudioInventory({
|
|
153
|
+
embedded: [{ language: "jpn" }],
|
|
154
|
+
videoFileIndex: 0,
|
|
155
|
+
sidecars: [
|
|
156
|
+
{ file: { fileIndex: 1, name: "a.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] },
|
|
157
|
+
{ file: { fileIndex: 2, name: "b.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] }
|
|
158
|
+
]
|
|
159
|
+
});
|
|
160
|
+
assert.equal(audioRenditionName(inventory[0], inventory), "jpn");
|
|
161
|
+
// Both sidecars sit in the same folder and neither names itself, so the names
|
|
162
|
+
// would collide — and hls.js groups renditions by name.
|
|
163
|
+
assert.notEqual(
|
|
164
|
+
audioRenditionName(inventory[1], inventory),
|
|
165
|
+
audioRenditionName(inventory[2], inventory)
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a commentary says so in its rendition name", () => {
|
|
170
|
+
const inventory = buildAudioInventory({
|
|
171
|
+
embedded: [{ language: "eng" }, { language: "eng", title: "Director", isCommentary: true }],
|
|
172
|
+
videoFileIndex: 0,
|
|
173
|
+
sidecars: []
|
|
174
|
+
});
|
|
175
|
+
assert.match(audioRenditionName(inventory[1], inventory), /commentary/);
|
|
176
|
+
});
|