@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## 2.66.0
|
|
2
|
+
|
|
3
|
+
- **New**: A soundtrack that ships beside the picture is fetched WHOLE once the swarm has capacity to spare, so a later switch to it does not wait. It is a twentieth of the picture — 30 MB against 566 MB on the field torrent — and having it on disk is the difference between an instant switch and one that pays for its own first pieces. The moment it starts is not a guess about the swarm: it is when the encoder is already as far ahead of the viewer as `--lookahead` lets it get, which is the cushion figure the session already measures and prints. Once per file, and only for a soundtrack in a file of its own.
|
|
4
|
+
- **Chore**: That fetch is a bounded READ of the file's length, deliberately not `file.select()`. Selecting a whole file alongside the readers' own moving windows is the mistake `#syncSelections` in `torrent-pool.js` was written against — a claim covering everything always outranked the window, and a seek to 89.1 % of a 4.7 GB film waited 93 s while the swarm fetched 2.47 GB in file order. The comment there records it; this goes through the same path the edge warm-up uses.
|
|
5
|
+
|
|
6
|
+
## 2.65.0
|
|
7
|
+
|
|
8
|
+
- **New**: A soundtrack that ships as its own file beside the picture is offered like any other. Releases commonly put a dub in `Rus Sound/<name>.mka` and subtitles in `Sub/[team]/<name>.ass`; until now the video played with its original sound and nothing said the rest of the release existed. The pairing is by name — equal base names, or a shared release hash — and a torrent holding exactly one picture takes every sidecar beside it, since there is nothing else they could belong to. A torrent with several episodes never relaxes that: a wrong pairing would put the sound of one episode over the picture of another, and nothing downstream could notice.
|
|
9
|
+
- **New**: No new container class was needed for it, and that is the point. A `.mka` IS Matroska: `ContainerFactory` sniffs it, `MatroskaContainer` reads its `TrackEntry` list, and out comes an `AudioTrack` with its language, title, channel count and flags — the same reader that serves the picture. "External" is not a kind of track; it is the answer to WHERE a track's bytes are, which is torrent knowledge and now lives in `services/sidecar-files.js` (which file goes with which) and `services/audio-inventory.js` (one numbered list), both pure and both application-layer. `tracks/ExternalSubtitleFile.js` asserted the opposite — it did not extend `ContainerTrack`, duplicated four of its fields and was imported by nothing — and is deleted rather than extended.
|
|
10
|
+
- **New**: The audio menu is built from the container's own track table where one can be read, with ffmpeg's `-i` banner as the fallback. `FlagOriginal` (0x55AE), `FlagCommentary` (0x55AF), `FlagVisualImpaired` (0x55AC), `FlagEnabled` (0xB9) and `LanguageBCP47` appear nowhere in that banner, so a director's commentary and the film itself were indistinguishable in the menu — roadmap item 58, points 1-3. The two readings are lined up by position and the pairing is CHECKED, exactly as `subtitle-defaults.js` checks its own: a differing count, or one pair agreeing on neither language nor title, drops the container reading whole rather than attributing a flag to the wrong track. `0:a:N` is what the encoder is handed, so a wrong flag is worse than a missing one.
|
|
11
|
+
- **New**: One flat number spans the picture's own tracks and the files beside it, and one place resolves it. The browser's menu, the `audioTrackIndex` of a session request and the `a/<n>/` address of a rendition all mean the same number; `#resolveAudioSource` turns it into a file index and a `0:a:N` inside that file. Embedded tracks keep the numbers they have always had, so a session created against a plan cached by an older build still means what it meant.
|
|
12
|
+
- **New**: A sidecar soundtrack chosen by a browser that understands rendition groups costs no second input at all — the rendition reads that file as its only input, and the player joins picture and sound, which is what a rendition group is for. A browser without them gets the two muxed by ffmpeg from two inputs, with the seek applied to each and `-itsoffset` stating the soundtrack's timestamps on the picture's timeline. Both files begin at their own container start time and those need not be equal; the difference is measured from the file rather than assumed to be zero, which is what keeps the sound from sitting at a fixed offset for the whole film.
|
|
13
|
+
- **Fix**: A session now holds a claim on every file it reads. A rendition reading a sidecar claimed only the picture, and the disk sweep deletes what nobody holds — the failure that deleted a film being watched on 2026-08-06, one file over.
|
|
14
|
+
- **Fix**: `declaredTracks` no longer answers from the picture's probe alone. A release that ships its dub separately often ships the picture with no sound at all, so the probe said there was no audio while the output plainly carried some — leaving the header check expecting one track where two arrive, and telling the browser its sound had been lost.
|
|
15
|
+
- **New**: `Command.CONTAINER_TRACKS` reads any file's own track table on the thread that owns the torrent, fetching its head first — a sidecar has usually had nothing downloaded when it is first asked about. The picture's tracks come back the same way, so there is one command rather than two.
|
|
16
|
+
- **New**: The warm-up that runs when a viewer picks a file now warms what lies beside it, for the same reason it warms the picture: none of it depends on the viewer, and every second of it spent now is a second they do not spend waiting later. How much of each follows from what the file IS, not from a size anyone chose — a text subtitle file is smaller than one piece of the torrent, so its edges and the whole of it are the same pieces and all of it is fetched; a soundtrack is tens of megabytes and only its header is needed to name it, so it gets head and tail like the picture. Without this the first thing to ask for a dub's header was the playback plan, on the path to the first frame.
|
|
17
|
+
- **Fix**: The plan waits at most 3 s for a sidecar's header instead of up to two minutes. Reading one goes through the worker, which prefetches the file's edges (60 s) and then reads them (60 s), and neither was bounded from the plan's side — the route's own `maxWaitMs` covers the codec probe loop and nothing after it. A sidecar is a file of the torrent nobody has asked for yet, and on this torrent 256 KB of header means one whole 8 MiB piece. What the timeout costs is small and deliberate: the track is still offered, still numbered and still playable, only without the language and flags its header would have given — and the language the viewer sees comes from the folder the release put it in, which is in the file list and costs no bytes at all.
|
|
18
|
+
- **Fix**: The media-info probe no longer spends its whole 8 s timeout on a file with no picture. It bails early when it has both a duration and a video stream line; a soundtrack file never prints the second, so it waited out the timeout every time — on the path where a viewer is changing track and the browser refuses a switch that is not ready in time. `Duration:` carries the start time being asked for, and it is the last thing such a file has to say about itself.
|
|
19
|
+
- **Fix**: A soundtrack session sizes its read window from the file it READS. Sized from the picture, whose byte rate is twenty times higher over the same duration, the window bought twenty times the seconds it was meant to represent and the piece store held it.
|
|
20
|
+
|
|
1
21
|
## 2.64.9
|
|
2
22
|
|
|
3
23
|
- **New**: The memory line says the SHAPE of the anonymous memory, not only its size — `mappings=[heap 40MB, 47 anon >=2MB = 388MB (largest 64MB), 812 anon <2MB = 231MB, files 31MB]`, read per mapping from `/proc/self/smaps`. The 700 MB that grew in the field on 2026-08-31 is none of the JavaScript heaps, none of the piece store and none of ffmpeg, and the three shapes it could have are three different diagnoses: one growing `[heap]` is the allocator keeping what we free, with no `malloc_trim` on musl to ask for it back; many large anonymous mappings are piece buffers, and whether their count tracks what the store says it holds decides whether anything is retaining them; many medium ones are the allocator's own arenas. Mappings with no resident pages are left out — those are V8's address-space reservations, tens of gigabytes of them, and counting them would drown the reading.
|
|
@@ -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
|
package/server.js
CHANGED
|
@@ -192,7 +192,30 @@ export async function startProxyServer({
|
|
|
192
192
|
getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
|
|
193
193
|
// The file's audio tracks, for the master playlist's rendition group. Already
|
|
194
194
|
// probed for the browser's audio menu; read from there rather than probed again.
|
|
195
|
-
getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params)
|
|
195
|
+
getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
|
|
196
|
+
// Pull one whole file onto the disk. Used for a soundtrack that ships beside
|
|
197
|
+
// the picture, once the encoder is as far ahead of the viewer as it is
|
|
198
|
+
// allowed to get — the one moment the swarm's capacity is demonstrably
|
|
199
|
+
// spare. A bounded read of the file's whole length, NOT `file.select()`:
|
|
200
|
+
// selecting a file alongside the readers' own windows is what made a seek
|
|
201
|
+
// wait 93 s while the swarm fetched 2.47 GB in file order (see
|
|
202
|
+
// `#syncSelections` in `torrent-pool.js`).
|
|
203
|
+
fetchWholeFile: async ({ sourceKey, fileIndex }) => {
|
|
204
|
+
const record = sourceRegistry.get(sourceKey);
|
|
205
|
+
if (!record) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
209
|
+
const file = torrent?.files?.[fileIndex];
|
|
210
|
+
if (!file || !Number.isFinite(file.length) || file.length <= 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await torrentPool.prefetchFileEdges(torrent, fileIndex, {
|
|
214
|
+
headBytes: file.length,
|
|
215
|
+
tailBytes: 0,
|
|
216
|
+
timeoutMs: 600_000
|
|
217
|
+
});
|
|
218
|
+
}
|
|
196
219
|
});
|
|
197
220
|
const playbackPlanner = createPlaybackPlanner({
|
|
198
221
|
ffmpegBin,
|
|
@@ -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
|
+
}
|