@torrent-tv/proxy 2.80.18 → 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 +22 -0
- package/docs/encode-architecture.md +51 -2
- 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 +37 -9
- package/services/encode/SegmentStore.js +284 -232
- package/services/encode/run-command.js +16 -1
- package/services/hls-session-manager.js +31 -128
- package/services/orchestrators/EncodeOrchestrator.js +52 -38
- 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/segment-formats/fmp4.js +54 -0
- package/services/segment-formats/mpegts.js +54 -0
- 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 -187
- 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 -195
- 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/produced-copy-choice.test.js +258 -358
- 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-serve-wiring.test.js +8 -9
- package/test/segment-store-eviction.test.js +232 -0
- package/test/segment-store.test.js +238 -216
- 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/encode/open-piece.js +0 -135
- package/services/piece-store/disk-tier.js +0 -151
- package/test/open-piece.test.js +0 -152
|
@@ -76,6 +76,9 @@ function findFragmentBounds(bytes) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const INIT_FILE_NAME = "init.mp4";
|
|
79
|
+
// What a piece is called while it is still being written. See
|
|
80
|
+
// `makingFileNameTemplate`.
|
|
81
|
+
const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.mp4$/;
|
|
79
82
|
const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
|
|
80
83
|
|
|
81
84
|
/**
|
|
@@ -140,6 +143,57 @@ export const fmp4Format = {
|
|
|
140
143
|
},
|
|
141
144
|
|
|
142
145
|
/** The output path template for the `segment` muxer. */
|
|
146
|
+
/**
|
|
147
|
+
* The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
|
|
148
|
+
*
|
|
149
|
+
* Not the name it is served under. A piece being written is not a piece, and
|
|
150
|
+
* under its final name it is indistinguishable from one — which is how half a
|
|
151
|
+
* segment came to be served: the proof of completeness was "the next file
|
|
152
|
+
* exists", true of one writer walking forward and false the moment two runs
|
|
153
|
+
* share an output, because the next file is then written by another process.
|
|
154
|
+
* Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
|
|
155
|
+
* 4 510 940, and the browser refused the whole one for the rest of the session.
|
|
156
|
+
*
|
|
157
|
+
* With a working name, the served name appears only when the encoder has said
|
|
158
|
+
* the piece is closed, and existence under it IS the proof — one rule, the
|
|
159
|
+
* same for every branch, and true whether or not our own process is alive.
|
|
160
|
+
*
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
makingFileNameTemplate(tag) {
|
|
164
|
+
return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.mp4`;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Which run is writing this working name, or null when the name is not one.
|
|
169
|
+
*
|
|
170
|
+
* The tag is what makes clearing up after a dead run possible without
|
|
171
|
+
* guessing: several runs write into one directory, so "the unfinished pieces"
|
|
172
|
+
* is only a well-formed question per run. It used to be answered by looking
|
|
173
|
+
* for the highest SERVED name inside the stretch the ended run was given and
|
|
174
|
+
* judging its bytes — a guess, and under the naming rule above it would remove
|
|
175
|
+
* a complete piece somebody else had closed.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} name
|
|
178
|
+
* @returns {string | null}
|
|
179
|
+
*/
|
|
180
|
+
makingTagOf(name) {
|
|
181
|
+
const match = MAKING_PATTERN.exec(String(name ?? "").trim());
|
|
182
|
+
return match ? match[1] : null;
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The name a piece just closed under a working name is served as, or null when
|
|
187
|
+
* the name is not one of ours.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} makingName
|
|
190
|
+
* @returns {string | null}
|
|
191
|
+
*/
|
|
192
|
+
servedNameOf(makingName) {
|
|
193
|
+
const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
|
|
194
|
+
return match ? this.segmentFileName(Number(match[2])) : null;
|
|
195
|
+
},
|
|
196
|
+
|
|
143
197
|
segmentFileNameTemplate() {
|
|
144
198
|
return "segment-%05d.mp4";
|
|
145
199
|
},
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* See {@link SegmentFormat} in `./index.js` for the interface contract.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
// What a piece is called while it is still being written. See
|
|
18
|
+
// `makingFileNameTemplate`.
|
|
19
|
+
const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.ts$/;
|
|
17
20
|
const SEGMENT_PATTERN = /^segment-(\d{5})\.ts$/;
|
|
18
21
|
|
|
19
22
|
/**
|
|
@@ -47,6 +50,57 @@ export const mpegtsFormat = {
|
|
|
47
50
|
},
|
|
48
51
|
|
|
49
52
|
/** The output path template for the `segment` muxer. */
|
|
53
|
+
/**
|
|
54
|
+
* The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
|
|
55
|
+
*
|
|
56
|
+
* Not the name it is served under. A piece being written is not a piece, and
|
|
57
|
+
* under its final name it is indistinguishable from one — which is how half a
|
|
58
|
+
* segment came to be served: the proof of completeness was "the next file
|
|
59
|
+
* exists", true of one writer walking forward and false the moment two runs
|
|
60
|
+
* share an output, because the next file is then written by another process.
|
|
61
|
+
* Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
|
|
62
|
+
* 4 510 940, and the browser refused the whole one for the rest of the session.
|
|
63
|
+
*
|
|
64
|
+
* With a working name, the served name appears only when the encoder has said
|
|
65
|
+
* the piece is closed, and existence under it IS the proof — one rule, the
|
|
66
|
+
* same for every branch, and true whether or not our own process is alive.
|
|
67
|
+
*
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
makingFileNameTemplate(tag) {
|
|
71
|
+
return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.ts`;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Which run is writing this working name, or null when the name is not one.
|
|
76
|
+
*
|
|
77
|
+
* The tag is what makes clearing up after a dead run possible without
|
|
78
|
+
* guessing: several runs write into one directory, so "the unfinished pieces"
|
|
79
|
+
* is only a well-formed question per run. It used to be answered by looking
|
|
80
|
+
* for the highest SERVED name inside the stretch the ended run was given and
|
|
81
|
+
* judging its bytes — a guess, and under the naming rule above it would remove
|
|
82
|
+
* a complete piece somebody else had closed.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} name
|
|
85
|
+
* @returns {string | null}
|
|
86
|
+
*/
|
|
87
|
+
makingTagOf(name) {
|
|
88
|
+
const match = MAKING_PATTERN.exec(String(name ?? "").trim());
|
|
89
|
+
return match ? match[1] : null;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The name a piece just closed under a working name is served as, or null when
|
|
94
|
+
* the name is not one of ours.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} makingName
|
|
97
|
+
* @returns {string | null}
|
|
98
|
+
*/
|
|
99
|
+
servedNameOf(makingName) {
|
|
100
|
+
const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
|
|
101
|
+
return match ? this.segmentFileName(Number(match[2])) : null;
|
|
102
|
+
},
|
|
103
|
+
|
|
50
104
|
segmentFileNameTemplate() {
|
|
51
105
|
return "segment-%05d.ts";
|
|
52
106
|
},
|
|
@@ -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
|
+
});
|