@torrent-tv/proxy 2.64.9 → 2.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/docs/container-architecture.md +32 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +57 -2
- package/routes/api/transcode-sessions/post.js +11 -1
- package/server.js +24 -1
- package/services/audio-inventory.js +411 -0
- package/services/hls-session-manager.js +396 -24
- package/services/playback-planner.js +133 -2
- package/services/sidecar-files.js +352 -0
- package/services/torrent-worker/client.js +14 -0
- package/services/torrent-worker/container-tracks.js +243 -0
- package/services/torrent-worker/pool-adapter.js +38 -0
- package/services/torrent-worker/protocol.js +8 -0
- package/services/torrent-worker/worker.js +19 -0
- package/services/tracks/index.js +7 -1
- package/test/audio-inventory.test.js +177 -0
- package/test/sidecar-files.test.js +178 -0
- package/services/tracks/ExternalSubtitleFile.js +0 -27
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tracks a file declares, read from the file itself on the thread that owns
|
|
3
|
+
* the torrent.
|
|
4
|
+
*
|
|
5
|
+
* This exists because the container layer (`services/container/`) reads bytes,
|
|
6
|
+
* and the bytes live here: the torrent client runs on this worker thread, so the
|
|
7
|
+
* main thread cannot open a read stream on one of its files. The main thread
|
|
8
|
+
* asks over the channel (`Command.CONTAINER_TRACKS`) and gets plain objects
|
|
9
|
+
* back, because a class instance does not survive the boundary as a class.
|
|
10
|
+
*
|
|
11
|
+
* It reads whatever container the file is — the same `ContainerFactory` that
|
|
12
|
+
* serves the picture serves a `.mka` beside it, since a `.mka` IS Matroska and
|
|
13
|
+
* differs only in having no video track. That is the whole reason no new class
|
|
14
|
+
* was needed for a soundtrack shipped as its own file.
|
|
15
|
+
*
|
|
16
|
+
* Unlike the subtitle walk, the reads here DO wait on the swarm. A sidecar file
|
|
17
|
+
* has usually had nothing downloaded at all when this is first asked, and its
|
|
18
|
+
* header is a few hundred kilobytes; the alternative is offering the viewer a
|
|
19
|
+
* soundtrack with nothing known about it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { containerOrchestrator } from "../orchestrators/ContainerOrchestrator.js";
|
|
23
|
+
import { logger } from "../../utils/logger.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How long one range read may take before it is given up.
|
|
27
|
+
*
|
|
28
|
+
* Not a measurement and nothing is derived from it: it is the point past which a
|
|
29
|
+
* read of a file nobody is playing is presumed lost, so that one stream which
|
|
30
|
+
* never ends cannot hold the answer — and with it the viewer's audio menu — for
|
|
31
|
+
* the rest of the session.
|
|
32
|
+
*/
|
|
33
|
+
const READ_ABANDON_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
/** Bytes of the head a container's track table lives in. */
|
|
36
|
+
const HEAD_BYTES = 256 * 1024;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tracks already read, by `sourceKey:fileIndex`. Only a non-empty reading is
|
|
40
|
+
* kept: an empty one usually means the header has not arrived yet, and caching
|
|
41
|
+
* that would hide the file's tracks for the life of the process.
|
|
42
|
+
*
|
|
43
|
+
* @type {Map<string, object[]>}
|
|
44
|
+
*/
|
|
45
|
+
const byFile = new Map();
|
|
46
|
+
|
|
47
|
+
/** @type {Map<string, Promise<object[]>>} */
|
|
48
|
+
const inFlight = new Map();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read a byte range of a file, fetching what is missing.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} file
|
|
54
|
+
* @param {number} start
|
|
55
|
+
* @param {number} end - Inclusive.
|
|
56
|
+
* @returns {Promise<Buffer | null>}
|
|
57
|
+
*/
|
|
58
|
+
function readFetching(file, start, end) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let stream;
|
|
62
|
+
try {
|
|
63
|
+
stream = file.createReadStream({ start, end });
|
|
64
|
+
} catch {
|
|
65
|
+
resolve(null);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let settled = false;
|
|
69
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
70
|
+
let abandon = null;
|
|
71
|
+
const settle = (value) => {
|
|
72
|
+
if (settled) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
settled = true;
|
|
76
|
+
if (abandon !== null) {
|
|
77
|
+
clearTimeout(abandon);
|
|
78
|
+
}
|
|
79
|
+
if (value === null) {
|
|
80
|
+
stream.destroy?.();
|
|
81
|
+
}
|
|
82
|
+
resolve(value);
|
|
83
|
+
};
|
|
84
|
+
abandon = setTimeout(() => {
|
|
85
|
+
logger.info(
|
|
86
|
+
`container-tracks: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
|
|
87
|
+
`did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
|
|
88
|
+
);
|
|
89
|
+
settle(null);
|
|
90
|
+
}, READ_ABANDON_MS);
|
|
91
|
+
abandon.unref?.();
|
|
92
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
93
|
+
stream.on("end", () => settle(Buffer.concat(chunks)));
|
|
94
|
+
stream.on("error", () => settle(null));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One track as it crosses the thread boundary: the fields the inventory and the
|
|
100
|
+
* master playlist read, and nothing that would not survive being cloned.
|
|
101
|
+
*
|
|
102
|
+
* @param {import("../tracks/index.js").ContainerTrack} track
|
|
103
|
+
* @returns {object}
|
|
104
|
+
*/
|
|
105
|
+
function plainTrack(track) {
|
|
106
|
+
return {
|
|
107
|
+
type: track.type,
|
|
108
|
+
trackNumber: track.trackNumber,
|
|
109
|
+
declaredIndex: track.declaredIndex,
|
|
110
|
+
codecId: track.codecId,
|
|
111
|
+
language: track.resolvedLanguage(),
|
|
112
|
+
languageBcp47: track.languageBcp47,
|
|
113
|
+
name: track.name,
|
|
114
|
+
isEnabled: track.isEnabled,
|
|
115
|
+
isDefault: track.isDefault,
|
|
116
|
+
declaresDefault: track.declaresDefault,
|
|
117
|
+
// Audio-only, per RFC 9559 — absent on other types, and read as false there
|
|
118
|
+
// rather than being placed on the base track where they do not belong.
|
|
119
|
+
isOriginal: track.isOriginal === true,
|
|
120
|
+
isCommentary: track.isCommentary === true,
|
|
121
|
+
isVisualImpaired: track.isVisualImpaired === true,
|
|
122
|
+
channels: Number.isFinite(track.channels) ? track.channels : null,
|
|
123
|
+
samplingFrequency: Number.isFinite(track.samplingFrequency) ? track.samplingFrequency : null
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Every track one file declares, in container order.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} torrent
|
|
131
|
+
* @param {number} fileIndex
|
|
132
|
+
* @param {string} sourceKey
|
|
133
|
+
* @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
|
|
134
|
+
* @returns {Promise<object[]>}
|
|
135
|
+
*/
|
|
136
|
+
export async function containerTracksOf(torrent, fileIndex, sourceKey, options = {}) {
|
|
137
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
138
|
+
const held = byFile.get(key);
|
|
139
|
+
if (Array.isArray(held)) {
|
|
140
|
+
return held;
|
|
141
|
+
}
|
|
142
|
+
const running = inFlight.get(key);
|
|
143
|
+
if (running) {
|
|
144
|
+
return running;
|
|
145
|
+
}
|
|
146
|
+
const work = readTracks(torrent, fileIndex, sourceKey, options)
|
|
147
|
+
.then((tracks) => {
|
|
148
|
+
if (tracks.length > 0) {
|
|
149
|
+
byFile.set(key, tracks);
|
|
150
|
+
}
|
|
151
|
+
return tracks;
|
|
152
|
+
})
|
|
153
|
+
.finally(() => {
|
|
154
|
+
inFlight.delete(key);
|
|
155
|
+
});
|
|
156
|
+
inFlight.set(key, work);
|
|
157
|
+
return work;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {object} torrent
|
|
162
|
+
* @param {number} fileIndex
|
|
163
|
+
* @param {string} sourceKey
|
|
164
|
+
* @param {{ prefetchEdges?: () => Promise<unknown> }} options
|
|
165
|
+
* @returns {Promise<object[]>}
|
|
166
|
+
*/
|
|
167
|
+
async function readTracks(torrent, fileIndex, sourceKey, options) {
|
|
168
|
+
const file = torrent?.files?.[fileIndex];
|
|
169
|
+
if (!file || !Number.isFinite(file.length) || file.length <= 0) {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
if (typeof options.prefetchEdges === "function") {
|
|
173
|
+
try {
|
|
174
|
+
await options.prefetchEdges();
|
|
175
|
+
} catch {
|
|
176
|
+
// A prefetch that failed is not a reason to skip the read: the read
|
|
177
|
+
// fetches what it needs itself, only more slowly.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const readRange = async (start, end) =>
|
|
181
|
+
readFetching(file, start, Math.min(end, file.length - 1));
|
|
182
|
+
try {
|
|
183
|
+
const tracks = await containerOrchestrator.getTracks({
|
|
184
|
+
sourceKey,
|
|
185
|
+
fileIndex,
|
|
186
|
+
readRange,
|
|
187
|
+
fileSize: file.length,
|
|
188
|
+
label: String(file.name ?? "")
|
|
189
|
+
});
|
|
190
|
+
const plain = tracks.map(plainTrack);
|
|
191
|
+
if (plain.length > 0) {
|
|
192
|
+
logger.info(
|
|
193
|
+
`container-tracks: "${String(file.name).slice(0, 40)}" declares ` +
|
|
194
|
+
`${plain.filter((track) => track.type === "video").length} video, ` +
|
|
195
|
+
`${plain.filter((track) => track.type === "audio").length} audio, ` +
|
|
196
|
+
`${plain.filter((track) => track.type === "subtitle").length} subtitle track(s)`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return plain;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
logger.warn(
|
|
202
|
+
`container-tracks: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
|
|
203
|
+
);
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The audio tracks of one file, in the order ffmpeg numbers them `0:a:N`.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} torrent
|
|
212
|
+
* @param {number} fileIndex
|
|
213
|
+
* @param {string} sourceKey
|
|
214
|
+
* @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
|
|
215
|
+
* @returns {Promise<object[]>}
|
|
216
|
+
*/
|
|
217
|
+
export async function containerAudioTracksOf(torrent, fileIndex, sourceKey, options = {}) {
|
|
218
|
+
const tracks = await containerTracksOf(torrent, fileIndex, sourceKey, options);
|
|
219
|
+
return tracks
|
|
220
|
+
.filter((track) => track.type === "audio")
|
|
221
|
+
.sort((left, right) => left.declaredIndex - right.declaredIndex);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Bytes of a file's head worth fetching before its track table is read. */
|
|
225
|
+
export const CONTAINER_HEAD_BYTES = HEAD_BYTES;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Forget one file's tracks, or every file of a source.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} sourceKey
|
|
231
|
+
* @param {number} [fileIndex]
|
|
232
|
+
*/
|
|
233
|
+
export function forgetContainerTracks(sourceKey, fileIndex) {
|
|
234
|
+
if (fileIndex === undefined) {
|
|
235
|
+
for (const key of [...byFile.keys()]) {
|
|
236
|
+
if (key.startsWith(`${sourceKey}:`)) {
|
|
237
|
+
byFile.delete(key);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
byFile.delete(`${sourceKey}:${fileIndex}`);
|
|
243
|
+
}
|
|
@@ -161,6 +161,44 @@ export class WorkerTorrentPool {
|
|
|
161
161
|
return Array.isArray(answer?.declared) ? answer.declared : [];
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Every track one file declares, read from its own header by the container
|
|
166
|
+
* layer.
|
|
167
|
+
*
|
|
168
|
+
* The audio menu is built from ffmpeg's `-i` banner, which carries neither
|
|
169
|
+
* `FlagOriginal`, `FlagCommentary`, `FlagVisualImpaired`, `FlagEnabled` nor
|
|
170
|
+
* `LanguageBCP47` — so without this a director's commentary and the film
|
|
171
|
+
* itself are indistinguishable in it. Also how a soundtrack shipped as its own
|
|
172
|
+
* file is read: a `.mka` is Matroska and the same reader serves it.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} torrent
|
|
175
|
+
* @param {number} fileIndex
|
|
176
|
+
* @returns {Promise<object[]>}
|
|
177
|
+
*/
|
|
178
|
+
async getContainerTracks(torrent, fileIndex) {
|
|
179
|
+
const sourceKey = torrent?.sourceKey;
|
|
180
|
+
if (!sourceKey) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const answer = await this.#client.getContainerTracks({ sourceKey, fileIndex });
|
|
184
|
+
return Array.isArray(answer?.tracks) ? answer.tracks : [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The audio tracks one file declares, in the order ffmpeg numbers them
|
|
189
|
+
* `0:a:N`.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} torrent
|
|
192
|
+
* @param {number} fileIndex
|
|
193
|
+
* @returns {Promise<object[]>}
|
|
194
|
+
*/
|
|
195
|
+
async getDeclaredAudioTracks(torrent, fileIndex) {
|
|
196
|
+
const tracks = await this.getContainerTracks(torrent, fileIndex);
|
|
197
|
+
return tracks
|
|
198
|
+
.filter((track) => track?.type === "audio")
|
|
199
|
+
.sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0));
|
|
200
|
+
}
|
|
201
|
+
|
|
164
202
|
/**
|
|
165
203
|
* The cues of one subtitle track that the downloaded clusters already carry.
|
|
166
204
|
*
|
|
@@ -67,6 +67,14 @@ export const Command = {
|
|
|
67
67
|
PREFETCH_EDGES: "prefetch-edges",
|
|
68
68
|
/** The text subtitle tracks a file carries, for the viewer's menu. */
|
|
69
69
|
SUBTITLE_TRACKS: "subtitle-tracks",
|
|
70
|
+
/**
|
|
71
|
+
* Every track a file declares, read from its own header by the container
|
|
72
|
+
* layer. Answers what ffmpeg's `-i` banner cannot: FlagOriginal,
|
|
73
|
+
* FlagCommentary, FlagVisualImpaired, FlagEnabled and LanguageBCP47 appear
|
|
74
|
+
* nowhere in it. Asked of the picture AND of a soundtrack shipped as its own
|
|
75
|
+
* file beside it, which is the same question about a different file.
|
|
76
|
+
*/
|
|
77
|
+
CONTAINER_TRACKS: "container-tracks",
|
|
70
78
|
/** Cues of one subtitle track, from the clusters already downloaded. */
|
|
71
79
|
SUBTITLE_CUES: "subtitle-cues",
|
|
72
80
|
/** Shut the client down, optionally deleting downloaded data. */
|
|
@@ -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
|
+
});
|