@torrent-tv/proxy 2.75.0 → 2.76.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 +1504 -1461
- package/CLAUDE.md +11 -1
- package/biome.json +182 -1
- package/docs/container-architecture.md +2 -1
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +206 -201
- package/services/container/AviContainer.js +1 -1
- package/services/container/Container.js +33 -1
- package/services/container/MatroskaContainer.js +1 -1
- package/services/container/Mp4Container.js +1 -1
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11073 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -775
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +18 -0
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
|
@@ -25,7 +25,17 @@ export async function handleTranscodeAudioFileGet(req, reply, { hlsSessionManage
|
|
|
25
25
|
const trackIndex = Number(req.params.trackIndex);
|
|
26
26
|
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Which viewer is asking. One picture serves everyone watching it, and the
|
|
29
|
+
// soundtrack each of them chose is their own: without this, a segment request
|
|
30
|
+
// from one viewer would be read as everybody moving to that track, and the
|
|
31
|
+
// other viewer's encoder would be stopped once per segment.
|
|
32
|
+
const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
|
|
33
|
+
const resolved = await hlsSessionManager.resolveAudioRenditionFile(
|
|
34
|
+
baseSessionId,
|
|
35
|
+
trackIndex,
|
|
36
|
+
fileName,
|
|
37
|
+
consumerId
|
|
38
|
+
);
|
|
29
39
|
if (resolved.error) {
|
|
30
40
|
// Retryable, like every other not-ready answer on this path: a 500 for a
|
|
31
41
|
// rendition playlist would end the stream over something the next attempt
|
|
@@ -36,9 +36,19 @@ export async function handleTranscodeAudioWarmGet(req, reply, { hlsSessionManage
|
|
|
36
36
|
return reply.code(400).send({ error: "A track index and a non-negative position are required." });
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Whose track change this is. Another viewer of the same picture may be
|
|
40
|
+
// listening to something else, and preparing a track must not be read as
|
|
41
|
+
// everybody moving to it.
|
|
42
|
+
const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
|
|
43
|
+
|
|
39
44
|
let prepared;
|
|
40
45
|
try {
|
|
41
|
-
prepared = await hlsSessionManager.prepareAudioTrack(
|
|
46
|
+
prepared = await hlsSessionManager.prepareAudioTrack(
|
|
47
|
+
baseSessionId,
|
|
48
|
+
trackIndex,
|
|
49
|
+
positionSeconds,
|
|
50
|
+
consumerId
|
|
51
|
+
);
|
|
42
52
|
} catch (error) {
|
|
43
53
|
const message = error instanceof Error ? error.message : String(error);
|
|
44
54
|
reply.header("Retry-After", "1");
|
|
@@ -117,7 +117,7 @@ export async function serveSessionFile(req, reply, { hlsSessionManager, sessionI
|
|
|
117
117
|
// where the viewer was.
|
|
118
118
|
logger.info(
|
|
119
119
|
`[hold] ${fileName} refused: the viewer is at ` +
|
|
120
|
-
`${hlsSessionManager.viewerPositionOf(sessionId).toFixed(1)}s and this is not the segment there`
|
|
120
|
+
`${hlsSessionManager.viewerPositionOf(sessionId, consumerId).toFixed(1)}s and this is not the segment there`
|
|
121
121
|
);
|
|
122
122
|
reply.header("Retry-After", "0");
|
|
123
123
|
return reply.code(503).send({ error: "Superseded by a seek." });
|
|
@@ -26,7 +26,16 @@ export async function handleTranscodeVariantFileGet(req, reply, { hlsSessionMana
|
|
|
26
26
|
const height = Number(req.params.height);
|
|
27
27
|
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Which viewer is asking. Two viewers of one picture can be on two rungs, and
|
|
30
|
+
// a segment request is what says which rung a viewer is watching — read as
|
|
31
|
+
// the session's own, one of them would take the other off their step.
|
|
32
|
+
const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
|
|
33
|
+
const resolved = await hlsSessionManager.resolveVariantFile(
|
|
34
|
+
baseSessionId,
|
|
35
|
+
height,
|
|
36
|
+
fileName,
|
|
37
|
+
consumerId
|
|
38
|
+
);
|
|
30
39
|
if (resolved.error) {
|
|
31
40
|
// Preparing the variant failed — a probe, a keyframe index, an input that
|
|
32
41
|
// is not there yet. Retryable, like every other not-ready answer on this
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
mayRestart,
|
|
29
29
|
processCanBeSignalled,
|
|
30
30
|
wireState
|
|
31
|
-
} from "../services/encode-run-state.js";
|
|
31
|
+
} from "../services/encode/encode-run-state.js";
|
|
32
32
|
|
|
33
33
|
/** Where the rendered document lives. */
|
|
34
34
|
export const GRAPH_DOC_PATH = path.join(
|
|
@@ -97,7 +97,7 @@ export function renderRunGraphMarkdown() {
|
|
|
97
97
|
const lines = [];
|
|
98
98
|
|
|
99
99
|
lines.push(
|
|
100
|
-
"<!-- GENERATED from services/encode-run-state.js by scripts/render-run-graph.js.",
|
|
100
|
+
"<!-- GENERATED from services/encode/encode-run-state.js by scripts/render-run-graph.js.",
|
|
101
101
|
" Do not edit by hand: change the table and run `npm run graph`. -->",
|
|
102
102
|
"",
|
|
103
103
|
"# The encoder run — states and transitions",
|
package/server.js
CHANGED
|
@@ -210,6 +210,23 @@ export async function startProxyServer({
|
|
|
210
210
|
return null;
|
|
211
211
|
}
|
|
212
212
|
},
|
|
213
|
+
// Where the file's keyframes are, read by the same container that answered
|
|
214
|
+
// the two above. It used to be read by the session itself over this proxy's
|
|
215
|
+
// own HTTP, once per session — so two viewers opening one film read the
|
|
216
|
+
// same table twice, and each of them could get a different answer about
|
|
217
|
+
// whether the picture can be copied at all.
|
|
218
|
+
getContainerKeyframes: async ({ sourceKey, fileIndex }) => {
|
|
219
|
+
const record = sourceRegistry.get(sourceKey);
|
|
220
|
+
if (!record || typeof torrentPool.getContainerKeyframes !== "function") {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
225
|
+
return await torrentPool.getContainerKeyframes(torrent, fileIndex);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
213
230
|
// Pull one whole file onto the disk. Used for a soundtrack that ships beside
|
|
214
231
|
// the picture, once the encoder is as far ahead of the viewer as it is
|
|
215
232
|
// allowed to get — the one moment the swarm's capacity is demonstrably
|
|
@@ -232,6 +249,14 @@ export async function startProxyServer({
|
|
|
232
249
|
await torrentPool.fillFileInBackground?.(torrent, fileIndex);
|
|
233
250
|
}
|
|
234
251
|
});
|
|
252
|
+
// What the last life of this process left on the disk. The kernel kills this
|
|
253
|
+
// one often enough for that to be an ordinary state rather than an odd one —
|
|
254
|
+
// twice in a single viewing on 2026-09-02 — and when it does, no exit handler
|
|
255
|
+
// runs and nothing is cleared up. So this is both the cleanup and the only
|
|
256
|
+
// record that those encoders ended at all: it says what it found before it
|
|
257
|
+
// decides anything, keeps the segments whose closure is proven, and removes
|
|
258
|
+
// the one piece per output that was being written when the process died.
|
|
259
|
+
hlsSessionManager.adoptSegmentsLeftBehind();
|
|
235
260
|
const playbackPlanner = createPlaybackPlanner({
|
|
236
261
|
ffmpegBin,
|
|
237
262
|
transcodeAudioEnabled: transcodeAudio,
|
|
@@ -1,201 +1,206 @@
|
|
|
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
|
-
import { AudioTrack } from "./tracks/AudioTrack.js";
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @typedef {object} AudioInventoryEntry
|
|
40
|
-
* @property {number} index - The flat number everything downstream uses.
|
|
41
|
-
* @property {number} fileIndex - The torrent file this track lives in.
|
|
42
|
-
* @property {number} sourceTrackIndex - `0:a:N` WITHIN that file.
|
|
43
|
-
* @property {"embedded" | "sidecar"} kind - Whether it is muxed into the picture
|
|
44
|
-
* or ships as a file beside it. Not a type of track — a statement about where
|
|
45
|
-
* the bytes are.
|
|
46
|
-
* @property {string} codec
|
|
47
|
-
* @property {string} language - As the container states it, or "" when it does
|
|
48
|
-
* not. Never guessed here: what a folder name suggests is derived in the
|
|
49
|
-
* browser, where the language table and the viewer's locale already live.
|
|
50
|
-
* @property {string} languageBcp47
|
|
51
|
-
* @property {string} title
|
|
52
|
-
* @property {boolean} isDefault
|
|
53
|
-
* @property {boolean} declaresDefault
|
|
54
|
-
* @property {boolean} isOriginal
|
|
55
|
-
* @property {boolean} isCommentary
|
|
56
|
-
* @property {boolean} isVisualImpaired
|
|
57
|
-
* @property {boolean} isEnabled
|
|
58
|
-
* @property {number | null} channels
|
|
59
|
-
* @property {string} fileName - For a sidecar: its own file name. "" otherwise.
|
|
60
|
-
* @property {string[]} folders - For a sidecar: the folders above it, relative
|
|
61
|
-
* to the torrent root. What the browser reads a language and a releaser from.
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* One numbered list from the picture's own tracks and its sidecar files.
|
|
66
|
-
*
|
|
67
|
-
* Order is load-bearing: embedded tracks keep the numbers they have always had,
|
|
68
|
-
* so a session created before this existed and one created after agree about
|
|
69
|
-
* what `audioTrackIndex: 1` means, and sidecars are appended after them in
|
|
70
|
-
* torrent-file order. Sidecar files are stable in that order for a given
|
|
71
|
-
* torrent, so the numbering is stable for a given file.
|
|
72
|
-
*
|
|
73
|
-
* @param {object} params
|
|
74
|
-
* @param {object[]} params.embedded - Merged banner+container tracks of the video.
|
|
75
|
-
* @param {number} params.videoFileIndex
|
|
76
|
-
* @param {Array<{ file: import("./
|
|
77
|
-
* Each sidecar file with the audio tracks IT holds. A file whose container
|
|
78
|
-
* could not be read contributes one track, which is what a bare elementary
|
|
79
|
-
* stream is.
|
|
80
|
-
* @returns {AudioInventoryEntry[]}
|
|
81
|
-
*/
|
|
82
|
-
export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
|
|
83
|
-
/** @type {AudioInventoryEntry[]} */
|
|
84
|
-
const inventory = [];
|
|
85
|
-
const add = (track, fileIndex, sourceTrackIndex, kind, file) => {
|
|
86
|
-
inventory.push({
|
|
87
|
-
index: inventory.length,
|
|
88
|
-
fileIndex,
|
|
89
|
-
sourceTrackIndex,
|
|
90
|
-
kind,
|
|
91
|
-
// The name the browser judges "can I play this?" by. For an embedded
|
|
92
|
-
// track it is ffmpeg's own; for a sidecar it is translated from what the
|
|
93
|
-
// container wrote, or from the extension when there was no table to read.
|
|
94
|
-
codec: AudioTrack.codecNameOf(track, file?.extension ?? ""),
|
|
95
|
-
language: typeof track?.language === "string" ? track.language : "",
|
|
96
|
-
languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
|
|
97
|
-
title:
|
|
98
|
-
typeof track?.title === "string" && track.title.length > 0
|
|
99
|
-
? track.title
|
|
100
|
-
: (typeof track?.name === "string" ? track.name : ""),
|
|
101
|
-
isDefault: track?.isDefault === true,
|
|
102
|
-
declaresDefault: track?.declaresDefault === true,
|
|
103
|
-
isOriginal: track?.isOriginal === true,
|
|
104
|
-
isCommentary: track?.isCommentary === true,
|
|
105
|
-
isVisualImpaired: track?.isVisualImpaired === true,
|
|
106
|
-
isEnabled: track?.isEnabled !== false,
|
|
107
|
-
channels: Number.isFinite(track?.channels) ? track.channels : null,
|
|
108
|
-
fileName: kind === "sidecar" ? (file?.name ?? "") : "",
|
|
109
|
-
folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : []
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* The
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (entry.
|
|
174
|
-
parts.push(
|
|
175
|
-
} else
|
|
176
|
-
parts.push(
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
import { AudioTrack } from "./tracks/AudioTrack.js";
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} AudioInventoryEntry
|
|
40
|
+
* @property {number} index - The flat number everything downstream uses.
|
|
41
|
+
* @property {number} fileIndex - The torrent file this track lives in.
|
|
42
|
+
* @property {number} sourceTrackIndex - `0:a:N` WITHIN that file.
|
|
43
|
+
* @property {"embedded" | "sidecar"} kind - Whether it is muxed into the picture
|
|
44
|
+
* or ships as a file beside it. Not a type of track — a statement about where
|
|
45
|
+
* the bytes are.
|
|
46
|
+
* @property {string} codec
|
|
47
|
+
* @property {string} language - As the container states it, or "" when it does
|
|
48
|
+
* not. Never guessed here: what a folder name suggests is derived in the
|
|
49
|
+
* browser, where the language table and the viewer's locale already live.
|
|
50
|
+
* @property {string} languageBcp47
|
|
51
|
+
* @property {string} title
|
|
52
|
+
* @property {boolean} isDefault
|
|
53
|
+
* @property {boolean} declaresDefault
|
|
54
|
+
* @property {boolean} isOriginal
|
|
55
|
+
* @property {boolean} isCommentary
|
|
56
|
+
* @property {boolean} isVisualImpaired
|
|
57
|
+
* @property {boolean} isEnabled
|
|
58
|
+
* @property {number | null} channels
|
|
59
|
+
* @property {string} fileName - For a sidecar: its own file name. "" otherwise.
|
|
60
|
+
* @property {string[]} folders - For a sidecar: the folders above it, relative
|
|
61
|
+
* to the torrent root. What the browser reads a language and a releaser from.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One numbered list from the picture's own tracks and its sidecar files.
|
|
66
|
+
*
|
|
67
|
+
* Order is load-bearing: embedded tracks keep the numbers they have always had,
|
|
68
|
+
* so a session created before this existed and one created after agree about
|
|
69
|
+
* what `audioTrackIndex: 1` means, and sidecars are appended after them in
|
|
70
|
+
* torrent-file order. Sidecar files are stable in that order for a given
|
|
71
|
+
* torrent, so the numbering is stable for a given file.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} params
|
|
74
|
+
* @param {object[]} params.embedded - Merged banner+container tracks of the video.
|
|
75
|
+
* @param {number} params.videoFileIndex
|
|
76
|
+
* @param {Array<{ file: import("./torrent/files.js").SidecarFile, tracks: object[] }>} params.sidecars
|
|
77
|
+
* Each sidecar file with the audio tracks IT holds. A file whose container
|
|
78
|
+
* could not be read contributes one track, which is what a bare elementary
|
|
79
|
+
* stream is.
|
|
80
|
+
* @returns {AudioInventoryEntry[]}
|
|
81
|
+
*/
|
|
82
|
+
export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
|
|
83
|
+
/** @type {AudioInventoryEntry[]} */
|
|
84
|
+
const inventory = [];
|
|
85
|
+
const add = (track, fileIndex, sourceTrackIndex, kind, file) => {
|
|
86
|
+
inventory.push({
|
|
87
|
+
index: inventory.length,
|
|
88
|
+
fileIndex,
|
|
89
|
+
sourceTrackIndex,
|
|
90
|
+
kind,
|
|
91
|
+
// The name the browser judges "can I play this?" by. For an embedded
|
|
92
|
+
// track it is ffmpeg's own; for a sidecar it is translated from what the
|
|
93
|
+
// container wrote, or from the extension when there was no table to read.
|
|
94
|
+
codec: AudioTrack.codecNameOf(track, file?.extension ?? ""),
|
|
95
|
+
language: typeof track?.language === "string" ? track.language : "",
|
|
96
|
+
languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
|
|
97
|
+
title:
|
|
98
|
+
typeof track?.title === "string" && track.title.length > 0
|
|
99
|
+
? track.title
|
|
100
|
+
: (typeof track?.name === "string" ? track.name : ""),
|
|
101
|
+
isDefault: track?.isDefault === true,
|
|
102
|
+
declaresDefault: track?.declaresDefault === true,
|
|
103
|
+
isOriginal: track?.isOriginal === true,
|
|
104
|
+
isCommentary: track?.isCommentary === true,
|
|
105
|
+
isVisualImpaired: track?.isVisualImpaired === true,
|
|
106
|
+
isEnabled: track?.isEnabled !== false,
|
|
107
|
+
channels: Number.isFinite(track?.channels) ? track.channels : null,
|
|
108
|
+
fileName: kind === "sidecar" ? (file?.name ?? "") : "",
|
|
109
|
+
folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : [],
|
|
110
|
+
// What the file's own path says, for a track that ships as its own file:
|
|
111
|
+
// read here, by the grammar that also decided this file belongs to this
|
|
112
|
+
// picture. The browser used to read the same path again to build the
|
|
113
|
+
// label, which is one name read twice by two rules.
|
|
114
|
+
naming: kind === "sidecar" ? (file?.naming ?? null) : null
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
for (const [order, track] of (Array.isArray(embedded) ? embedded : []).entries()) {
|
|
119
|
+
add(track, videoFileIndex, order, "embedded", null);
|
|
120
|
+
}
|
|
121
|
+
for (const sidecar of Array.isArray(sidecars) ? sidecars : []) {
|
|
122
|
+
const tracks = Array.isArray(sidecar?.tracks) && sidecar.tracks.length > 0
|
|
123
|
+
? sidecar.tracks
|
|
124
|
+
// A file whose track table could not be read is one track: `.ac3`, `.dts`
|
|
125
|
+
// and `.mp3` have no table to read, and a `.mka` whose head has not
|
|
126
|
+
// arrived yet is better offered than hidden — ffmpeg will find its first
|
|
127
|
+
// audio stream either way.
|
|
128
|
+
: [{}];
|
|
129
|
+
for (const [order, track] of tracks.entries()) {
|
|
130
|
+
add(track, sidecar.file.fileIndex, order, "sidecar", sidecar.file);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return inventory;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the flat number back to the file and the track inside it.
|
|
138
|
+
*
|
|
139
|
+
* @param {AudioInventoryEntry[]} inventory
|
|
140
|
+
* @param {number} index
|
|
141
|
+
* @returns {AudioInventoryEntry | null}
|
|
142
|
+
*/
|
|
143
|
+
export function resolveAudioIndex(inventory, index) {
|
|
144
|
+
if (!Array.isArray(inventory) || !Number.isInteger(index) || index < 0) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return inventory.find((entry) => entry.index === index) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The name an `#EXT-X-MEDIA` line carries for one soundtrack.
|
|
152
|
+
*
|
|
153
|
+
* Deliberately plain, and deliberately NOT localised: this is the name inside a
|
|
154
|
+
* playlist, and what the viewer reads in the menu is composed in the browser
|
|
155
|
+
* from the same facts, where the language table and the viewer's own locale are.
|
|
156
|
+
* The only requirements here are that it says something and that no two
|
|
157
|
+
* renditions of one file share it — hls.js groups renditions by name.
|
|
158
|
+
*
|
|
159
|
+
* @param {AudioInventoryEntry} entry
|
|
160
|
+
* @param {AudioInventoryEntry[]} inventory
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
export function audioRenditionName(entry, inventory) {
|
|
164
|
+
const parts = [];
|
|
165
|
+
if (entry.title) {
|
|
166
|
+
parts.push(entry.title);
|
|
167
|
+
} else if (entry.languageBcp47 || entry.language) {
|
|
168
|
+
parts.push(entry.languageBcp47 || entry.language);
|
|
169
|
+
} else if (entry.folders.length > 0) {
|
|
170
|
+
// The folder a dub sits in is usually the only thing naming it, and a
|
|
171
|
+
// release names it for a reason: "Rus Sound", "Ukr Dub".
|
|
172
|
+
parts.push(entry.folders[entry.folders.length - 1]);
|
|
173
|
+
} else if (entry.fileName) {
|
|
174
|
+
parts.push(entry.fileName);
|
|
175
|
+
} else {
|
|
176
|
+
parts.push(`Track ${entry.index + 1}`);
|
|
177
|
+
}
|
|
178
|
+
if (entry.isCommentary) {
|
|
179
|
+
parts.push("commentary");
|
|
180
|
+
} else if (entry.isVisualImpaired) {
|
|
181
|
+
parts.push("described");
|
|
182
|
+
}
|
|
183
|
+
const name = parts.join(" · ");
|
|
184
|
+
const clash = (Array.isArray(inventory) ? inventory : []).some(
|
|
185
|
+
(other) => other.index !== entry.index && audioRenditionNameCore(other) === audioRenditionNameCore(entry)
|
|
186
|
+
);
|
|
187
|
+
return clash ? `${name} (${entry.index + 1})` : name;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The part of a rendition name that a clash is judged on — the name without the
|
|
192
|
+
* disambiguating number, so that adding the number cannot itself cause a clash.
|
|
193
|
+
*
|
|
194
|
+
* @param {AudioInventoryEntry} entry
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function audioRenditionNameCore(entry) {
|
|
198
|
+
return (
|
|
199
|
+
entry.title ||
|
|
200
|
+
entry.languageBcp47 ||
|
|
201
|
+
entry.language ||
|
|
202
|
+
(entry.folders.length > 0 ? entry.folders[entry.folders.length - 1] : "") ||
|
|
203
|
+
entry.fileName ||
|
|
204
|
+
""
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -85,7 +85,7 @@ export class AviContainer extends Container {
|
|
|
85
85
|
return info;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
async
|
|
88
|
+
async parseKeyframeIndex() {
|
|
89
89
|
const r = await readAviKeyframeTimes(this.readRange, this.fileSize);
|
|
90
90
|
if (!r) return null;
|
|
91
91
|
if (Array.isArray(r)) return { times: r, tolerance: 0 };
|
|
@@ -408,10 +408,42 @@ export class Container {
|
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
/**
|
|
411
|
-
* Keyframe times for the video track, ascending seconds. Null when index
|
|
411
|
+
* Keyframe times for the video track, ascending seconds. Null when index
|
|
412
|
+
* absent (MPEG-TS, fragmented MP4, truncated).
|
|
413
|
+
*
|
|
414
|
+
* Read ONCE per file, like the track table and the media info beside it: this
|
|
415
|
+
* is a property of immutable bytes, so a second reading could only agree —
|
|
416
|
+
* and the reading is not cheap, since the table lives at the end of the file
|
|
417
|
+
* and comes off a torrent. The wait belongs here too: two sessions created in
|
|
418
|
+
* the same moment join one read instead of making two, which is exactly what
|
|
419
|
+
* two viewers opening one film do.
|
|
420
|
+
*
|
|
421
|
+
* The subclass says how its format states it; this says how often it is
|
|
422
|
+
* asked.
|
|
423
|
+
*
|
|
412
424
|
* @returns {Promise<{times:number[],tolerance:number}|null>}
|
|
413
425
|
*/
|
|
414
426
|
async readKeyframeIndex() {
|
|
427
|
+
if (this.keyframeIndexRead) {
|
|
428
|
+
return this.keyframeIndexRead;
|
|
429
|
+
}
|
|
430
|
+
this.keyframeIndexRead = Promise.resolve(this.parseKeyframeIndex()).catch((error) => {
|
|
431
|
+
// A failed read is not remembered as an answer: the bytes it needed may
|
|
432
|
+
// simply not have arrived yet.
|
|
433
|
+
this.keyframeIndexRead = null;
|
|
434
|
+
throw error;
|
|
435
|
+
});
|
|
436
|
+
return this.keyframeIndexRead;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* How THIS format states where its keyframes are. Overridden by every
|
|
441
|
+
* container that has such a table; the default is the honest answer for one
|
|
442
|
+
* that does not.
|
|
443
|
+
*
|
|
444
|
+
* @returns {Promise<{times:number[],tolerance:number}|null>}
|
|
445
|
+
*/
|
|
446
|
+
async parseKeyframeIndex() {
|
|
415
447
|
return null;
|
|
416
448
|
}
|
|
417
449
|
|
|
@@ -681,7 +681,7 @@ export class MatroskaContainer extends Container {
|
|
|
681
681
|
return fields.length > ASS_FIELDS_BEFORE_TEXT ? fields.slice(ASS_FIELDS_BEFORE_TEXT).join(",") : "";
|
|
682
682
|
}
|
|
683
683
|
|
|
684
|
-
async
|
|
684
|
+
async parseKeyframeIndex() {
|
|
685
685
|
const times = await readMatroskaKeyframeTimes(this.readRange, this.fileSize);
|
|
686
686
|
if (!times) return null;
|
|
687
687
|
if (Array.isArray(times)) return { times, tolerance: 0 };
|
|
@@ -519,7 +519,7 @@ export class Mp4Container extends Container {
|
|
|
519
519
|
return decodeSubtitleSample(payload, codecId);
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
-
async
|
|
522
|
+
async parseKeyframeIndex() {
|
|
523
523
|
const r = await readMp4KeyframeTimes(this.readRange, this.fileSize);
|
|
524
524
|
if (!r) return null;
|
|
525
525
|
if (Array.isArray(r)) return { times: r, tolerance: 0 };
|