@torrent-tv/proxy 2.64.8 → 2.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1305 -1285
- package/bin/cli.js +533 -520
- package/docs/container-architecture.md +32 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +57 -2
- package/routes/api/transcode-sessions/post.js +11 -1
- package/services/audio-inventory.js +411 -0
- package/services/hls-session-manager.js +313 -24
- package/services/memory-report.js +106 -1
- package/services/piece-store/shared-piece-store.js +47 -2
- package/services/playback-planner.js +133 -2
- package/services/sidecar-files.js +352 -0
- package/services/torrent-worker/client.js +47 -0
- package/services/torrent-worker/container-tracks.js +243 -0
- package/services/torrent-worker/pool-adapter.js +38 -0
- package/services/torrent-worker/protocol.js +8 -0
- package/services/torrent-worker/worker.js +784 -750
- package/services/tracks/index.js +7 -1
- package/test/audio-inventory.test.js +177 -0
- package/test/memory-budget.test.js +72 -1
- package/test/sidecar-files.test.js +178 -0
- package/services/tracks/ExternalSubtitleFile.js +0 -27
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
Domain → Application → Interface, per RFC 9559 (Matroska) and ISO/IEC 14496-12 (MP4).
|
|
4
4
|
|
|
5
|
+
## Two axes, and what is NOT a third
|
|
6
|
+
|
|
7
|
+
Everything here is placed on exactly two axes: **which container** a track lives
|
|
8
|
+
in, and **what kind of track** it is (video / audio / subtitle). A file lying
|
|
9
|
+
beside the video — a dub as `<name>.mka`, subtitles as `<name>.ass` — is not a
|
|
10
|
+
third kind of anything:
|
|
11
|
+
|
|
12
|
+
- a `.mka` **is** Matroska. `ContainerFactory` sniffs it, `MatroskaContainer`
|
|
13
|
+
reads its `TrackEntry` list, and out comes an `AudioTrack` with its language,
|
|
14
|
+
title and flags. It differs from the picture's own file only in having no
|
|
15
|
+
video track. The same holds for `.m4a` and `Mp4Container`;
|
|
16
|
+
- "external" is therefore not a TYPE. It is the answer to WHERE a track's bytes
|
|
17
|
+
are, and that is torrent knowledge — which this layer must not have
|
|
18
|
+
(`ContainerFactory`: no torrent knowledge; `ContainerOrchestrator`:
|
|
19
|
+
transport-agnostic). The pairing of a sidecar file with a picture lives in
|
|
20
|
+
`services/sidecar-files.js`, and the numbered list a viewer chooses from lives
|
|
21
|
+
in `services/audio-inventory.js`. Both are pure and both are application-layer.
|
|
22
|
+
|
|
23
|
+
A class called `ExternalSubtitleFile` used to sit in `tracks/` asserting the
|
|
24
|
+
opposite. It did not extend `ContainerTrack`, duplicated four of its fields, and
|
|
25
|
+
was imported by nothing; it was deleted rather than extended. Nothing replaced
|
|
26
|
+
it — a subtitle file beside the video is a `TextSubtitleTrack` whose bytes are
|
|
27
|
+
read from another file, and a raw `.srt` needs no `Container` subclass because
|
|
28
|
+
it has no track table and no index to read: the whole file is the payload, and
|
|
29
|
+
`SubtitleController` already reads it as such.
|
|
30
|
+
|
|
5
31
|
## Layers
|
|
6
32
|
|
|
7
33
|
```mermaid
|
|
@@ -26,8 +52,12 @@ flowchart TB
|
|
|
26
52
|
CF[ContainerFactory<br/>detect 16 bytes]
|
|
27
53
|
CO[ContainerOrchestrator<br/>cache + getTracks/getKeyframeIndex]
|
|
28
54
|
SO[SubtitleOrchestrator<br/>wrap subtitle-cues.js]
|
|
55
|
+
SF[sidecar-files.js<br/>which file goes with which]
|
|
56
|
+
AI[audio-inventory.js<br/>one flat numbering]
|
|
29
57
|
CF --> CO
|
|
30
58
|
CO --> SO
|
|
59
|
+
SF --> AI
|
|
60
|
+
CO --> AI
|
|
31
61
|
end
|
|
32
62
|
subgraph Interface
|
|
33
63
|
PC[PlaybackController]
|
|
@@ -50,6 +80,8 @@ flowchart TB
|
|
|
50
80
|
| `AudioTrack` | RFC9559 FlagOriginal 0x55AE, FlagCommentary 0x55AF, FlagVisualImpaired 0x55AC | `isOriginal/isCommentary/isVisualImpaired`, `channels/samplingFrequency` | FlagForced |
|
|
51
81
|
| `SubtitleTrack` | RFC9559 FlagForced 0x55AA (subtitle-only), FlagHearingImpaired 0x55AB | `isForced/isHearingImpaired`, `clusterPositions`/`samples` | Video dims |
|
|
52
82
|
| `TextSubtitleTrack` | `S_TEXT/UTF8, S_TEXT/ASS, tx3g, wvtt` | `toVtt()` convertible | Image tracks |
|
|
83
|
+
| `sidecar-files.js` | — (torrent naming) | which files of a torrent are one picture's sound and subtitles | what is inside them |
|
|
84
|
+
| `audio-inventory.js` | RFC9559 audio flags, merged against ffmpeg's numbering | one flat number per soundtrack → `(fileIndex, 0:a:N)` | display labels |
|
|
53
85
|
| `ImageSubtitleTrack` | `S_HDMV/PGS, S_VOBSUB, subp, clcp` | kept for `declaredIndex` alignment | Conversion |
|
|
54
86
|
| `MatroskaContainer` | RFC9559 SeekHead, Tracks, Cues, Clusters | single Tracks walk for all types, EBML via `ebml-reader.js` | HTTP |
|
|
55
87
|
| `Mp4Container` | ISO 14496-12 moov/trak/tkhd/mdhd/hdlr/elng/stbl | `alternate_group` grouping, packed language, `tx3g` forced bits | Torrent |
|
package/package.json
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { logger } from "../../../../utils/logger.js";
|
|
2
|
+
import {
|
|
3
|
+
countVideoFiles,
|
|
4
|
+
matchSidecarFiles,
|
|
5
|
+
TEXT_SUBTITLE_SIDECAR_EXTENSIONS
|
|
6
|
+
} from "../../../../services/sidecar-files.js";
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* Start fetching a source before anyone asks to play it.
|
|
@@ -74,10 +79,60 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
|
|
|
74
79
|
});
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
// The files that carry this episode's OTHER soundtracks and its subtitles.
|
|
83
|
+
//
|
|
84
|
+
// Warmed here for the same reason the picture is: none of it depends on the
|
|
85
|
+
// viewer, and every second of it that happens now is a second they do not
|
|
86
|
+
// spend waiting later. Without this the first thing to ask for a dub's header
|
|
87
|
+
// is the playback plan, on the path to the first frame, and the first thing to
|
|
88
|
+
// ask for a subtitle file is the browser once the film is already running —
|
|
89
|
+
// which is why a track the container marks default appears after the opening
|
|
90
|
+
// rather than during it.
|
|
91
|
+
//
|
|
92
|
+
// How much of each is fetched follows from what the file IS, not from a size
|
|
93
|
+
// anyone chose. A text subtitle file is smaller than one piece of this
|
|
94
|
+
// torrent, so its edges and the whole of it are the same pieces — fetch all of
|
|
95
|
+
// it. A soundtrack is tens of megabytes and only its header is needed to name
|
|
96
|
+
// and describe it, so it gets the head and the tail, exactly as the picture
|
|
97
|
+
// does. The rest of it is fetched when it is played, and nothing here spends
|
|
98
|
+
// the pool owner's bandwidth on a track nobody chose.
|
|
99
|
+
let sidecars = 0;
|
|
100
|
+
if (fileIndex !== null && Array.isArray(torrent.files)) {
|
|
101
|
+
const matched = matchSidecarFiles({
|
|
102
|
+
files: torrent.files,
|
|
103
|
+
videoIndex: fileIndex,
|
|
104
|
+
torrentName: typeof torrent.name === "string" ? torrent.name : "",
|
|
105
|
+
videoCount: countVideoFiles(torrent.files)
|
|
106
|
+
});
|
|
107
|
+
const warmOne = (file, options) => {
|
|
108
|
+
sidecars += 1;
|
|
109
|
+
// Not awaited, like the picture's own edges above: the point of this route
|
|
110
|
+
// is that the viewer goes on choosing while it happens.
|
|
111
|
+
Promise.resolve(torrentPool.prefetchFileEdges(torrent, file.fileIndex, options)).catch(
|
|
112
|
+
(error) => {
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
logger.warn(`warm ${sourceKey.slice(0, 8)}: "${file.name}" failed: ${message}`);
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
for (const file of matched.audio) {
|
|
119
|
+
warmOne(file, { tailBytes: 0 });
|
|
120
|
+
}
|
|
121
|
+
for (const file of matched.subtitles) {
|
|
122
|
+
warmOne(
|
|
123
|
+
file,
|
|
124
|
+
TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
|
|
125
|
+
? { headBytes: Math.max(1, file.length), tailBytes: 0 }
|
|
126
|
+
: { tailBytes: 0 }
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
77
131
|
logger.info(
|
|
78
132
|
`warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
|
|
79
|
-
(edges ? `, fetching the edges of file ${fileIndex}` : ", file not chosen yet")
|
|
133
|
+
(edges ? `, fetching the edges of file ${fileIndex}` : ", file not chosen yet") +
|
|
134
|
+
(sidecars > 0 ? ` and of ${sidecars} file(s) beside it` : "")
|
|
80
135
|
);
|
|
81
136
|
|
|
82
|
-
return reply.send({ started: true, swarm: true, edges });
|
|
137
|
+
return reply.send({ started: true, swarm: true, edges, sidecars });
|
|
83
138
|
}
|
|
@@ -113,7 +113,17 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
113
113
|
// only while they run, and a seek leaves a gap with no read at all — the
|
|
114
114
|
// disk sweep caught that gap on 2026-08-06 and deleted the film being
|
|
115
115
|
// watched.
|
|
116
|
-
|
|
116
|
+
// Takes the file to hold, because a session does not always read the file
|
|
117
|
+
// it was created for: a release whose dub ships as its own file gives that
|
|
118
|
+
// soundtrack a session of its own, reading a different index of the same
|
|
119
|
+
// torrent. Defaults to the picture, which is every other case.
|
|
120
|
+
acquireSource: (heldFileIndex = fileIndex) =>
|
|
121
|
+
holdSource({
|
|
122
|
+
sourceRegistry,
|
|
123
|
+
torrentPool,
|
|
124
|
+
sourceKey,
|
|
125
|
+
fileIndex: Number.isInteger(heldFileIndex) ? heldFileIndex : fileIndex
|
|
126
|
+
})
|
|
117
127
|
});
|
|
118
128
|
// The index of quality variants, when this session has more than one to
|
|
119
129
|
// offer. Its presence is what tells the browser it can change quality
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The soundtracks a viewer may choose between, as ONE numbered list — the ones
|
|
3
|
+
* muxed into the picture and the ones shipped as separate files beside it.
|
|
4
|
+
*
|
|
5
|
+
* Why one list. Everything downstream addresses a soundtrack by a single number:
|
|
6
|
+
* the browser's menu, the `audioTrackIndex` on the session-create request, the
|
|
7
|
+
* `a/<n>/` path a rendition is published at, and hls.js's own rendition order.
|
|
8
|
+
* Giving a sidecar file its own numbering would mean a second vocabulary and a
|
|
9
|
+
* translation at every boundary. Instead the number stays flat and this module
|
|
10
|
+
* owns the only place that knows what it resolves to: which FILE the track lives
|
|
11
|
+
* in, and which track it is inside that file.
|
|
12
|
+
*
|
|
13
|
+
* The list is built from two readings of the same file, and that is deliberate:
|
|
14
|
+
*
|
|
15
|
+
* - ffmpeg's `-i` banner, which is what `0:a:N` will select and therefore the
|
|
16
|
+
* authority on NUMBERING;
|
|
17
|
+
* - the container's own track table (`services/container/`), which is the only
|
|
18
|
+
* authority on the FLAGS — `FlagOriginal`, `FlagCommentary`,
|
|
19
|
+
* `FlagVisualImpaired`, `FlagEnabled` and `LanguageBCP47` do not appear in the
|
|
20
|
+
* banner at all, so the audio menu could not tell a director's commentary from
|
|
21
|
+
* the film itself.
|
|
22
|
+
*
|
|
23
|
+
* The two are lined up by position and the pairing is CHECKED, exactly as
|
|
24
|
+
* `subtitle-defaults.js` checks its own: a length that differs, or one pair that
|
|
25
|
+
* agrees on neither language nor title, means the two readings are not
|
|
26
|
+
* describing the same thing in the same order — and then the container reading
|
|
27
|
+
* is dropped whole rather than attributed to the wrong track. A wrong flag is
|
|
28
|
+
* worse than a missing one, because `0:a:N` is what the encoder is given.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Language codes that carry no information, so cannot confirm a pairing. Same
|
|
33
|
+
* rule and same reasoning as `subtitle-defaults.js`: ffmpeg prints `und` for a
|
|
34
|
+
* stream with no language, while Matroska's own default for `Language` is `eng`
|
|
35
|
+
* — which is also a real answer, so it is compared like any other.
|
|
36
|
+
*/
|
|
37
|
+
const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {unknown} value
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function normalise(value) {
|
|
44
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether one banner stream and one container track can be the same track.
|
|
49
|
+
*
|
|
50
|
+
* Agreement on either the language or the title is enough; both sides saying
|
|
51
|
+
* nothing is not agreement, but it is not disagreement either — a file may name
|
|
52
|
+
* neither, and then this pair simply adds no support to the alignment.
|
|
53
|
+
*
|
|
54
|
+
* @param {{ language?: string, title?: string }} banner
|
|
55
|
+
* @param {{ language?: string, name?: string }} container
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function audioPairingHolds(banner, container) {
|
|
59
|
+
const bannerLanguage = normalise(banner?.language);
|
|
60
|
+
const containerLanguage = normalise(container?.language);
|
|
61
|
+
if (
|
|
62
|
+
!EMPTY_LANGUAGES.has(bannerLanguage) &&
|
|
63
|
+
!EMPTY_LANGUAGES.has(containerLanguage) &&
|
|
64
|
+
bannerLanguage === containerLanguage
|
|
65
|
+
) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
const bannerTitle = normalise(banner?.title);
|
|
69
|
+
const containerName = normalise(container?.name);
|
|
70
|
+
if (bannerTitle.length > 0 && bannerTitle === containerName) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
(EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
|
|
75
|
+
(bannerTitle.length === 0 || containerName.length === 0)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The banner's audio streams, with what the container declares about each.
|
|
81
|
+
*
|
|
82
|
+
* @param {Array<{ index?: number, language?: string, title?: string, isDefault?: boolean, codec?: string }>} bannerTracks
|
|
83
|
+
* @param {Array<object>} declared - `AudioTrack`s in container order.
|
|
84
|
+
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
85
|
+
*/
|
|
86
|
+
export function mergeContainerAudioFlags(bannerTracks, declared) {
|
|
87
|
+
const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
|
|
88
|
+
const container = Array.isArray(declared) ? declared : [];
|
|
89
|
+
const undecided = () => ({
|
|
90
|
+
// Nothing of the container reading is used, flags included — attributing
|
|
91
|
+
// them to the wrong track is the failure this guard exists to prevent.
|
|
92
|
+
tracks: banner.map((track) => ({
|
|
93
|
+
...track,
|
|
94
|
+
declaresDefault: false,
|
|
95
|
+
isOriginal: false,
|
|
96
|
+
isCommentary: false,
|
|
97
|
+
isVisualImpaired: false,
|
|
98
|
+
// Not "the container says this track is unusable": the container has not
|
|
99
|
+
// been heard from. A track is offered unless it was read to say otherwise.
|
|
100
|
+
isEnabled: true,
|
|
101
|
+
languageBcp47: "",
|
|
102
|
+
channels: null
|
|
103
|
+
}))
|
|
104
|
+
});
|
|
105
|
+
if (banner.length === 0) {
|
|
106
|
+
return { tracks: [], aligned: false, reason: "the probe found no audio stream" };
|
|
107
|
+
}
|
|
108
|
+
if (container.length === 0) {
|
|
109
|
+
return { ...undecided(), aligned: false, reason: "the container declares no audio track" };
|
|
110
|
+
}
|
|
111
|
+
if (container.length !== banner.length) {
|
|
112
|
+
return {
|
|
113
|
+
...undecided(),
|
|
114
|
+
aligned: false,
|
|
115
|
+
reason: `the container declares ${container.length} audio tracks and the probe found ${banner.length}`
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
for (const [order, track] of banner.entries()) {
|
|
119
|
+
if (!audioPairingHolds(track, container[order])) {
|
|
120
|
+
return {
|
|
121
|
+
...undecided(),
|
|
122
|
+
aligned: false,
|
|
123
|
+
reason:
|
|
124
|
+
`audio ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
|
|
125
|
+
`in the probe and "${normalise(container[order]?.name) || "-"}"/` +
|
|
126
|
+
`${normalise(container[order]?.language) || "-"} in the container`
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
tracks: banner.map((track, order) => ({
|
|
132
|
+
...track,
|
|
133
|
+
// Read from the file itself (RFC 9559 §5.1.4.1). None of these four
|
|
134
|
+
// reaches ffmpeg's banner, which is where every other field here is from.
|
|
135
|
+
isOriginal: container[order].isOriginal === true,
|
|
136
|
+
isCommentary: container[order].isCommentary === true,
|
|
137
|
+
isVisualImpaired: container[order].isVisualImpaired === true,
|
|
138
|
+
isEnabled: container[order].isEnabled !== false,
|
|
139
|
+
// FlagDefault, and whether the file actually WROTE it. Matroska defaults
|
|
140
|
+
// the flag to 1 and ffmpeg prints the applied default, so the banner
|
|
141
|
+
// cannot tell "every track marked" from "the file has no opinion".
|
|
142
|
+
isDefault: container[order].isDefault === true,
|
|
143
|
+
declaresDefault: container[order].declaresDefault === true,
|
|
144
|
+
languageBcp47:
|
|
145
|
+
typeof container[order].languageBcp47 === "string" ? container[order].languageBcp47 : "",
|
|
146
|
+
channels: Number.isFinite(container[order].channels) ? container[order].channels : null,
|
|
147
|
+
title:
|
|
148
|
+
typeof track?.title === "string" && track.title.length > 0
|
|
149
|
+
? track.title
|
|
150
|
+
: (typeof container[order].name === "string" ? container[order].name : "")
|
|
151
|
+
})),
|
|
152
|
+
aligned: true,
|
|
153
|
+
reason: ""
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Codec identifiers as containers write them, against the name ffmpeg prints.
|
|
159
|
+
*
|
|
160
|
+
* Needed because the browser decides whether it can play a soundtrack from that
|
|
161
|
+
* name, and for a sidecar file there is no ffmpeg banner to read it from — the
|
|
162
|
+
* track came from the container's own table, where Matroska writes `A_AC3` and
|
|
163
|
+
* MP4 writes `ac-3` for the thing ffmpeg calls `ac3`. Only what a soundtrack can
|
|
164
|
+
* actually be is listed; an identifier not here is reported as it was written,
|
|
165
|
+
* which the browser treats as one it does not know and therefore transcodes.
|
|
166
|
+
*/
|
|
167
|
+
const CODEC_NAMES = new Map([
|
|
168
|
+
["A_AAC", "aac"],
|
|
169
|
+
["A_AC3", "ac3"],
|
|
170
|
+
["A_EAC3", "eac3"],
|
|
171
|
+
["A_DTS", "dts"],
|
|
172
|
+
["A_FLAC", "flac"],
|
|
173
|
+
["A_OPUS", "opus"],
|
|
174
|
+
["A_VORBIS", "vorbis"],
|
|
175
|
+
["A_TRUEHD", "truehd"],
|
|
176
|
+
["A_MPEG/L3", "mp3"],
|
|
177
|
+
["A_MPEG/L2", "mp2"],
|
|
178
|
+
["A_ALAC", "alac"],
|
|
179
|
+
["mp4a", "aac"],
|
|
180
|
+
["ac-3", "ac3"],
|
|
181
|
+
["ec-3", "eac3"],
|
|
182
|
+
["alac", "alac"],
|
|
183
|
+
["opus", "opus"],
|
|
184
|
+
["Opus", "opus"],
|
|
185
|
+
["fLaC", "flac"],
|
|
186
|
+
["flac", "flac"]
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Extensions of raw elementary streams, against the codec they carry.
|
|
191
|
+
*
|
|
192
|
+
* A bare `.ac3` has no track table to read, and its extension is the only thing
|
|
193
|
+
* that states its codec — which for an elementary stream is exactly what the
|
|
194
|
+
* extension means.
|
|
195
|
+
*/
|
|
196
|
+
const CODEC_BY_EXTENSION = new Map([
|
|
197
|
+
[".aac", "aac"],
|
|
198
|
+
[".ac3", "ac3"],
|
|
199
|
+
[".eac3", "eac3"],
|
|
200
|
+
[".dts", "dts"],
|
|
201
|
+
[".dtshd", "dts"],
|
|
202
|
+
[".flac", "flac"],
|
|
203
|
+
[".mp3", "mp3"],
|
|
204
|
+
[".mp2", "mp2"],
|
|
205
|
+
[".opus", "opus"],
|
|
206
|
+
[".ogg", "vorbis"],
|
|
207
|
+
[".oga", "vorbis"],
|
|
208
|
+
[".wav", "pcm"],
|
|
209
|
+
[".thd", "truehd"],
|
|
210
|
+
[".mlp", "truehd"],
|
|
211
|
+
[".m4a", "aac"]
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The ffmpeg-side codec name for one track, from whatever the reading gave.
|
|
216
|
+
*
|
|
217
|
+
* @param {{ codec?: string, codecId?: string }} track
|
|
218
|
+
* @param {string} [extension] - The sidecar file's extension, when the track
|
|
219
|
+
* came from a file with no readable table.
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
export function codecNameOf(track, extension = "") {
|
|
223
|
+
const fromBanner = typeof track?.codec === "string" ? track.codec.trim() : "";
|
|
224
|
+
if (fromBanner.length > 0) {
|
|
225
|
+
return fromBanner.toLowerCase();
|
|
226
|
+
}
|
|
227
|
+
const codecId = typeof track?.codecId === "string" ? track.codecId.trim() : "";
|
|
228
|
+
if (codecId.length > 0) {
|
|
229
|
+
const known = CODEC_NAMES.get(codecId);
|
|
230
|
+
if (known) {
|
|
231
|
+
return known;
|
|
232
|
+
}
|
|
233
|
+
// Matroska allows a suffix — `A_AAC/MPEG4/LC`, `A_PCM/INT/LIT` — so the
|
|
234
|
+
// family is what the first two segments say.
|
|
235
|
+
const family = codecId.split("/").slice(0, 2).join("/");
|
|
236
|
+
const byFamily = CODEC_NAMES.get(family) ?? CODEC_NAMES.get(codecId.split("/")[0]);
|
|
237
|
+
if (byFamily) {
|
|
238
|
+
return byFamily;
|
|
239
|
+
}
|
|
240
|
+
if (codecId.startsWith("A_PCM")) {
|
|
241
|
+
return "pcm";
|
|
242
|
+
}
|
|
243
|
+
return codecId.toLowerCase();
|
|
244
|
+
}
|
|
245
|
+
return CODEC_BY_EXTENSION.get(extension) ?? "";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @typedef {object} AudioInventoryEntry
|
|
250
|
+
* @property {number} index - The flat number everything downstream uses.
|
|
251
|
+
* @property {number} fileIndex - The torrent file this track lives in.
|
|
252
|
+
* @property {number} sourceTrackIndex - `0:a:N` WITHIN that file.
|
|
253
|
+
* @property {"embedded" | "sidecar"} kind - Whether it is muxed into the picture
|
|
254
|
+
* or ships as a file beside it. Not a type of track — a statement about where
|
|
255
|
+
* the bytes are.
|
|
256
|
+
* @property {string} codec
|
|
257
|
+
* @property {string} language - As the container states it, or "" when it does
|
|
258
|
+
* not. Never guessed here: what a folder name suggests is derived in the
|
|
259
|
+
* browser, where the language table and the viewer's locale already live.
|
|
260
|
+
* @property {string} languageBcp47
|
|
261
|
+
* @property {string} title
|
|
262
|
+
* @property {boolean} isDefault
|
|
263
|
+
* @property {boolean} declaresDefault
|
|
264
|
+
* @property {boolean} isOriginal
|
|
265
|
+
* @property {boolean} isCommentary
|
|
266
|
+
* @property {boolean} isVisualImpaired
|
|
267
|
+
* @property {boolean} isEnabled
|
|
268
|
+
* @property {number | null} channels
|
|
269
|
+
* @property {string} fileName - For a sidecar: its own file name. "" otherwise.
|
|
270
|
+
* @property {string[]} folders - For a sidecar: the folders above it, relative
|
|
271
|
+
* to the torrent root. What the browser reads a language and a releaser from.
|
|
272
|
+
*/
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* One numbered list from the picture's own tracks and its sidecar files.
|
|
276
|
+
*
|
|
277
|
+
* Order is load-bearing: embedded tracks keep the numbers they have always had,
|
|
278
|
+
* so a session created before this existed and one created after agree about
|
|
279
|
+
* what `audioTrackIndex: 1` means, and sidecars are appended after them in
|
|
280
|
+
* torrent-file order. Sidecar files are stable in that order for a given
|
|
281
|
+
* torrent, so the numbering is stable for a given file.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} params
|
|
284
|
+
* @param {object[]} params.embedded - Merged banner+container tracks of the video.
|
|
285
|
+
* @param {number} params.videoFileIndex
|
|
286
|
+
* @param {Array<{ file: import("./sidecar-files.js").SidecarFile, tracks: object[] }>} params.sidecars
|
|
287
|
+
* Each sidecar file with the audio tracks IT holds. A file whose container
|
|
288
|
+
* could not be read contributes one track, which is what a bare elementary
|
|
289
|
+
* stream is.
|
|
290
|
+
* @returns {AudioInventoryEntry[]}
|
|
291
|
+
*/
|
|
292
|
+
export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
|
|
293
|
+
/** @type {AudioInventoryEntry[]} */
|
|
294
|
+
const inventory = [];
|
|
295
|
+
const add = (track, fileIndex, sourceTrackIndex, kind, file) => {
|
|
296
|
+
inventory.push({
|
|
297
|
+
index: inventory.length,
|
|
298
|
+
fileIndex,
|
|
299
|
+
sourceTrackIndex,
|
|
300
|
+
kind,
|
|
301
|
+
// The name the browser judges "can I play this?" by. For an embedded
|
|
302
|
+
// track it is ffmpeg's own; for a sidecar it is translated from what the
|
|
303
|
+
// container wrote, or from the extension when there was no table to read.
|
|
304
|
+
codec: codecNameOf(track, file?.extension ?? ""),
|
|
305
|
+
language: typeof track?.language === "string" ? track.language : "",
|
|
306
|
+
languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
|
|
307
|
+
title:
|
|
308
|
+
typeof track?.title === "string" && track.title.length > 0
|
|
309
|
+
? track.title
|
|
310
|
+
: (typeof track?.name === "string" ? track.name : ""),
|
|
311
|
+
isDefault: track?.isDefault === true,
|
|
312
|
+
declaresDefault: track?.declaresDefault === true,
|
|
313
|
+
isOriginal: track?.isOriginal === true,
|
|
314
|
+
isCommentary: track?.isCommentary === true,
|
|
315
|
+
isVisualImpaired: track?.isVisualImpaired === true,
|
|
316
|
+
isEnabled: track?.isEnabled !== false,
|
|
317
|
+
channels: Number.isFinite(track?.channels) ? track.channels : null,
|
|
318
|
+
fileName: kind === "sidecar" ? (file?.name ?? "") : "",
|
|
319
|
+
folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : []
|
|
320
|
+
});
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
for (const [order, track] of (Array.isArray(embedded) ? embedded : []).entries()) {
|
|
324
|
+
add(track, videoFileIndex, order, "embedded", null);
|
|
325
|
+
}
|
|
326
|
+
for (const sidecar of Array.isArray(sidecars) ? sidecars : []) {
|
|
327
|
+
const tracks = Array.isArray(sidecar?.tracks) && sidecar.tracks.length > 0
|
|
328
|
+
? sidecar.tracks
|
|
329
|
+
// A file whose track table could not be read is one track: `.ac3`, `.dts`
|
|
330
|
+
// and `.mp3` have no table to read, and a `.mka` whose head has not
|
|
331
|
+
// arrived yet is better offered than hidden — ffmpeg will find its first
|
|
332
|
+
// audio stream either way.
|
|
333
|
+
: [{}];
|
|
334
|
+
for (const [order, track] of tracks.entries()) {
|
|
335
|
+
add(track, sidecar.file.fileIndex, order, "sidecar", sidecar.file);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return inventory;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Resolve the flat number back to the file and the track inside it.
|
|
343
|
+
*
|
|
344
|
+
* @param {AudioInventoryEntry[]} inventory
|
|
345
|
+
* @param {number} index
|
|
346
|
+
* @returns {AudioInventoryEntry | null}
|
|
347
|
+
*/
|
|
348
|
+
export function resolveAudioIndex(inventory, index) {
|
|
349
|
+
if (!Array.isArray(inventory) || !Number.isInteger(index) || index < 0) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
return inventory.find((entry) => entry.index === index) ?? null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The name an `#EXT-X-MEDIA` line carries for one soundtrack.
|
|
357
|
+
*
|
|
358
|
+
* Deliberately plain, and deliberately NOT localised: this is the name inside a
|
|
359
|
+
* playlist, and what the viewer reads in the menu is composed in the browser
|
|
360
|
+
* from the same facts, where the language table and the viewer's own locale are.
|
|
361
|
+
* The only requirements here are that it says something and that no two
|
|
362
|
+
* renditions of one file share it — hls.js groups renditions by name.
|
|
363
|
+
*
|
|
364
|
+
* @param {AudioInventoryEntry} entry
|
|
365
|
+
* @param {AudioInventoryEntry[]} inventory
|
|
366
|
+
* @returns {string}
|
|
367
|
+
*/
|
|
368
|
+
export function audioRenditionName(entry, inventory) {
|
|
369
|
+
const parts = [];
|
|
370
|
+
if (entry.title) {
|
|
371
|
+
parts.push(entry.title);
|
|
372
|
+
} else if (entry.languageBcp47 || entry.language) {
|
|
373
|
+
parts.push(entry.languageBcp47 || entry.language);
|
|
374
|
+
} else if (entry.folders.length > 0) {
|
|
375
|
+
// The folder a dub sits in is usually the only thing naming it, and a
|
|
376
|
+
// release names it for a reason: "Rus Sound", "Ukr Dub".
|
|
377
|
+
parts.push(entry.folders[entry.folders.length - 1]);
|
|
378
|
+
} else if (entry.fileName) {
|
|
379
|
+
parts.push(entry.fileName);
|
|
380
|
+
} else {
|
|
381
|
+
parts.push(`Track ${entry.index + 1}`);
|
|
382
|
+
}
|
|
383
|
+
if (entry.isCommentary) {
|
|
384
|
+
parts.push("commentary");
|
|
385
|
+
} else if (entry.isVisualImpaired) {
|
|
386
|
+
parts.push("described");
|
|
387
|
+
}
|
|
388
|
+
const name = parts.join(" · ");
|
|
389
|
+
const clash = (Array.isArray(inventory) ? inventory : []).some(
|
|
390
|
+
(other) => other.index !== entry.index && audioRenditionNameCore(other) === audioRenditionNameCore(entry)
|
|
391
|
+
);
|
|
392
|
+
return clash ? `${name} (${entry.index + 1})` : name;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The part of a rendition name that a clash is judged on — the name without the
|
|
397
|
+
* disambiguating number, so that adding the number cannot itself cause a clash.
|
|
398
|
+
*
|
|
399
|
+
* @param {AudioInventoryEntry} entry
|
|
400
|
+
* @returns {string}
|
|
401
|
+
*/
|
|
402
|
+
function audioRenditionNameCore(entry) {
|
|
403
|
+
return (
|
|
404
|
+
entry.title ||
|
|
405
|
+
entry.languageBcp47 ||
|
|
406
|
+
entry.language ||
|
|
407
|
+
(entry.folders.length > 0 ? entry.folders[entry.folders.length - 1] : "") ||
|
|
408
|
+
entry.fileName ||
|
|
409
|
+
""
|
|
410
|
+
);
|
|
411
|
+
}
|