@torrent-tv/proxy 2.87.11 → 2.87.13
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 +9 -0
- package/package.json +1 -1
- package/routes/api/playback-plan/audio-tracks/post.js +29 -29
- package/routes/api/sources/warm/post.js +217 -217
- package/routes/api/transcode-sessions/post.js +13 -0
- package/server.js +725 -725
- package/services/encode/EncodeAdmission.js +45 -1
- package/services/encode/OutputOpening.js +4 -1
- package/services/encode/quality/EncodeCost.js +31 -0
- package/services/encode/quality/output-format.js +58 -9
- package/services/media/audio-inventory.js +245 -245
- package/services/server/ViewerRequests.js +25 -1
- package/services/server/controllers/PlaybackController.js +40 -40
- package/services/server/wire-outputs.js +3 -0
- package/services/storage/piece-store/shared-piece-store.js +50 -30
- package/services/torrent/torrent-pool.js +2675 -2675
- package/services/torrent/worker/client.js +765 -765
- package/services/torrent/worker/pool-adapter.js +365 -365
- package/services/torrent/worker/protocol.js +184 -184
- package/services/torrent/worker/worker.js +1129 -1129
- package/services/transport/data-channel-handler.js +1369 -1369
- package/services/viewer/Viewer.js +8 -1
- package/test/audio-inventory.test.js +212 -212
- package/test/data-channel-viewer-lifecycle.test.js +92 -92
- package/test/piece-store-eviction.test.js +37 -2
|
@@ -1,245 +1,245 @@
|
|
|
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 {number | null} bitrateKbps - The rate the file states for this track; an average, not a bound.
|
|
60
|
-
* @property {string} fileName - For a sidecar: its own file name. "" otherwise.
|
|
61
|
-
* @property {string[]} folders - For a sidecar: the folders above it, relative
|
|
62
|
-
* to the torrent root. What the browser reads a language and a releaser from.
|
|
63
|
-
*/
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* One numbered list from the picture's own tracks and its sidecar files.
|
|
67
|
-
*
|
|
68
|
-
* Order is load-bearing: embedded tracks keep the numbers they have always had,
|
|
69
|
-
* so a session created before this existed and one created after agree about
|
|
70
|
-
* what `audioTrackIndex: 1` means, and sidecars are appended after them in
|
|
71
|
-
* torrent-file order. Sidecar files are stable in that order for a given
|
|
72
|
-
* torrent, so the numbering is stable for a given file.
|
|
73
|
-
*
|
|
74
|
-
* @param {object} params
|
|
75
|
-
* @param {object[]} params.embedded - Merged banner+container tracks of the video.
|
|
76
|
-
* @param {number} params.videoFileIndex
|
|
77
|
-
* @param {Array<{ file: import("../torrent/files.js").SidecarFile, tracks: object[] }>} params.sidecars
|
|
78
|
-
* Each sidecar file with the audio tracks IT holds. A file whose container
|
|
79
|
-
* could not be read contributes one track, which is what a bare elementary
|
|
80
|
-
* stream is.
|
|
81
|
-
* @returns {AudioInventoryEntry[]}
|
|
82
|
-
*/
|
|
83
|
-
export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
|
|
84
|
-
/** @type {AudioInventoryEntry[]} */
|
|
85
|
-
const inventory = [];
|
|
86
|
-
const add = (track, fileIndex, sourceTrackIndex, kind, file) => {
|
|
87
|
-
inventory.push({
|
|
88
|
-
index: inventory.length,
|
|
89
|
-
fileIndex,
|
|
90
|
-
sourceTrackIndex,
|
|
91
|
-
kind,
|
|
92
|
-
// The name the browser judges "can I play this?" by. For an embedded
|
|
93
|
-
// track it is ffmpeg's own; for a sidecar it is translated from what the
|
|
94
|
-
// container wrote, or from the extension when there was no table to read.
|
|
95
|
-
codec: AudioTrack.codecNameOf(track, file?.extension ?? ""),
|
|
96
|
-
language: typeof track?.language === "string" ? track.language : "",
|
|
97
|
-
languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
|
|
98
|
-
title:
|
|
99
|
-
typeof track?.title === "string" && track.title.length > 0
|
|
100
|
-
? track.title
|
|
101
|
-
: (typeof track?.name === "string" ? track.name : ""),
|
|
102
|
-
isDefault: track?.isDefault === true,
|
|
103
|
-
declaresDefault: track?.declaresDefault === true,
|
|
104
|
-
isOriginal: track?.isOriginal === true,
|
|
105
|
-
isCommentary: track?.isCommentary === true,
|
|
106
|
-
isVisualImpaired: track?.isVisualImpaired === true,
|
|
107
|
-
isEnabled: track?.isEnabled !== false,
|
|
108
|
-
channels: Number.isFinite(track?.channels) ? track.channels : null,
|
|
109
|
-
// The rate the file STATES for this track, in kbit/s — an average, not a
|
|
110
|
-
// bound — or null where nothing states one (a sidecar read from its
|
|
111
|
-
// container table, a Matroska stream without statistics tags). What a
|
|
112
|
-
// viewer's link is asked to carry for a copied soundtrack is this.
|
|
113
|
-
bitrateKbps: Number.isFinite(track?.bitrateKbps) && track.bitrateKbps > 0 ? track.bitrateKbps : null,
|
|
114
|
-
fileName: kind === "sidecar" ? (file?.name ?? "") : "",
|
|
115
|
-
folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : [],
|
|
116
|
-
// What the file's own path says, for a track that ships as its own file:
|
|
117
|
-
// read here, by the grammar that also decided this file belongs to this
|
|
118
|
-
// picture. The browser used to read the same path again to build the
|
|
119
|
-
// label, which is one name read twice by two rules.
|
|
120
|
-
naming: kind === "sidecar" ? (file?.naming ?? null) : null
|
|
121
|
-
});
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
for (const [order, track] of (Array.isArray(embedded) ? embedded : []).entries()) {
|
|
125
|
-
add(track, videoFileIndex, order, "embedded", null);
|
|
126
|
-
}
|
|
127
|
-
for (const sidecar of Array.isArray(sidecars) ? sidecars : []) {
|
|
128
|
-
const tracks = Array.isArray(sidecar?.tracks) && sidecar.tracks.length > 0
|
|
129
|
-
? sidecar.tracks
|
|
130
|
-
// A file whose track table could not be read is one track: `.ac3`, `.dts`
|
|
131
|
-
// and `.mp3` have no table to read, and a `.mka` whose head has not
|
|
132
|
-
// arrived yet is better offered than hidden — ffmpeg will find its first
|
|
133
|
-
// audio stream either way.
|
|
134
|
-
: [{}];
|
|
135
|
-
for (const [order, track] of tracks.entries()) {
|
|
136
|
-
add(track, sidecar.file.fileIndex, order, "sidecar", sidecar.file);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return inventory;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Resolve the flat number back to the file and the track inside it.
|
|
144
|
-
*
|
|
145
|
-
* @param {AudioInventoryEntry[]} inventory
|
|
146
|
-
* @param {number} index
|
|
147
|
-
* @returns {AudioInventoryEntry | null}
|
|
148
|
-
*/
|
|
149
|
-
export function resolveAudioIndex(inventory, index) {
|
|
150
|
-
if (!Array.isArray(inventory) || !Number.isInteger(index) || index < 0) {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
return inventory.find((entry) => entry.index === index) ?? null;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Enrich the already-published first entry for a sidecar without changing the
|
|
158
|
-
* inventory's length or any address used by a player.
|
|
159
|
-
*
|
|
160
|
-
* A late container read can reveal language, codec and flags after the HLS
|
|
161
|
-
* master playlist has been loaded. Adding entries here would make the browser's
|
|
162
|
-
* rendition order disagree with the playlist it already holds.
|
|
163
|
-
*
|
|
164
|
-
* @param {AudioInventoryEntry[]} inventory
|
|
165
|
-
* @param {import("../torrent/files.js").SidecarFile} file
|
|
166
|
-
* @param {object} declaredTrack
|
|
167
|
-
* @returns {boolean} Whether an existing entry was updated.
|
|
168
|
-
*/
|
|
169
|
-
export function enrichAudioInventoryFromSidecar(inventory, file, declaredTrack) {
|
|
170
|
-
if (!Array.isArray(inventory) || !file || !declaredTrack) return false;
|
|
171
|
-
const current = inventory.find(
|
|
172
|
-
(entry) => entry.kind === "sidecar" && entry.fileIndex === file.fileIndex && entry.sourceTrackIndex === 0
|
|
173
|
-
);
|
|
174
|
-
if (!current) return false;
|
|
175
|
-
const enriched = buildAudioInventory({
|
|
176
|
-
embedded: [],
|
|
177
|
-
videoFileIndex: -1,
|
|
178
|
-
sidecars: [{ file, tracks: [declaredTrack] }]
|
|
179
|
-
})[0];
|
|
180
|
-
Object.assign(current, enriched, {
|
|
181
|
-
index: current.index,
|
|
182
|
-
fileIndex: current.fileIndex,
|
|
183
|
-
sourceTrackIndex: current.sourceTrackIndex,
|
|
184
|
-
kind: current.kind
|
|
185
|
-
});
|
|
186
|
-
return true;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* The name an `#EXT-X-MEDIA` line carries for one soundtrack.
|
|
191
|
-
*
|
|
192
|
-
* Deliberately plain, and deliberately NOT localised: this is the name inside a
|
|
193
|
-
* playlist, and what the viewer reads in the menu is composed in the browser
|
|
194
|
-
* from the same facts, where the language table and the viewer's own locale are.
|
|
195
|
-
* The only requirements here are that it says something and that no two
|
|
196
|
-
* renditions of one file share it — hls.js groups renditions by name.
|
|
197
|
-
*
|
|
198
|
-
* @param {AudioInventoryEntry} entry
|
|
199
|
-
* @param {AudioInventoryEntry[]} inventory
|
|
200
|
-
* @returns {string}
|
|
201
|
-
*/
|
|
202
|
-
export function audioRenditionName(entry, inventory) {
|
|
203
|
-
const parts = [];
|
|
204
|
-
if (entry.title) {
|
|
205
|
-
parts.push(entry.title);
|
|
206
|
-
} else if (entry.languageBcp47 || entry.language) {
|
|
207
|
-
parts.push(entry.languageBcp47 || entry.language);
|
|
208
|
-
} else if (entry.folders.length > 0) {
|
|
209
|
-
// The folder a dub sits in is usually the only thing naming it, and a
|
|
210
|
-
// release names it for a reason: "Rus Sound", "Ukr Dub".
|
|
211
|
-
parts.push(entry.folders[entry.folders.length - 1]);
|
|
212
|
-
} else if (entry.fileName) {
|
|
213
|
-
parts.push(entry.fileName);
|
|
214
|
-
} else {
|
|
215
|
-
parts.push(`Track ${entry.index + 1}`);
|
|
216
|
-
}
|
|
217
|
-
if (entry.isCommentary) {
|
|
218
|
-
parts.push("commentary");
|
|
219
|
-
} else if (entry.isVisualImpaired) {
|
|
220
|
-
parts.push("described");
|
|
221
|
-
}
|
|
222
|
-
const name = parts.join(" · ");
|
|
223
|
-
const clash = (Array.isArray(inventory) ? inventory : []).some(
|
|
224
|
-
(other) => other.index !== entry.index && audioRenditionNameCore(other) === audioRenditionNameCore(entry)
|
|
225
|
-
);
|
|
226
|
-
return clash ? `${name} (${entry.index + 1})` : name;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* The part of a rendition name that a clash is judged on — the name without the
|
|
231
|
-
* disambiguating number, so that adding the number cannot itself cause a clash.
|
|
232
|
-
*
|
|
233
|
-
* @param {AudioInventoryEntry} entry
|
|
234
|
-
* @returns {string}
|
|
235
|
-
*/
|
|
236
|
-
function audioRenditionNameCore(entry) {
|
|
237
|
-
return (
|
|
238
|
-
entry.title ||
|
|
239
|
-
entry.languageBcp47 ||
|
|
240
|
-
entry.language ||
|
|
241
|
-
(entry.folders.length > 0 ? entry.folders[entry.folders.length - 1] : "") ||
|
|
242
|
-
entry.fileName ||
|
|
243
|
-
""
|
|
244
|
-
);
|
|
245
|
-
}
|
|
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 {number | null} bitrateKbps - The rate the file states for this track; an average, not a bound.
|
|
60
|
+
* @property {string} fileName - For a sidecar: its own file name. "" otherwise.
|
|
61
|
+
* @property {string[]} folders - For a sidecar: the folders above it, relative
|
|
62
|
+
* to the torrent root. What the browser reads a language and a releaser from.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One numbered list from the picture's own tracks and its sidecar files.
|
|
67
|
+
*
|
|
68
|
+
* Order is load-bearing: embedded tracks keep the numbers they have always had,
|
|
69
|
+
* so a session created before this existed and one created after agree about
|
|
70
|
+
* what `audioTrackIndex: 1` means, and sidecars are appended after them in
|
|
71
|
+
* torrent-file order. Sidecar files are stable in that order for a given
|
|
72
|
+
* torrent, so the numbering is stable for a given file.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} params
|
|
75
|
+
* @param {object[]} params.embedded - Merged banner+container tracks of the video.
|
|
76
|
+
* @param {number} params.videoFileIndex
|
|
77
|
+
* @param {Array<{ file: import("../torrent/files.js").SidecarFile, tracks: object[] }>} params.sidecars
|
|
78
|
+
* Each sidecar file with the audio tracks IT holds. A file whose container
|
|
79
|
+
* could not be read contributes one track, which is what a bare elementary
|
|
80
|
+
* stream is.
|
|
81
|
+
* @returns {AudioInventoryEntry[]}
|
|
82
|
+
*/
|
|
83
|
+
export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
|
|
84
|
+
/** @type {AudioInventoryEntry[]} */
|
|
85
|
+
const inventory = [];
|
|
86
|
+
const add = (track, fileIndex, sourceTrackIndex, kind, file) => {
|
|
87
|
+
inventory.push({
|
|
88
|
+
index: inventory.length,
|
|
89
|
+
fileIndex,
|
|
90
|
+
sourceTrackIndex,
|
|
91
|
+
kind,
|
|
92
|
+
// The name the browser judges "can I play this?" by. For an embedded
|
|
93
|
+
// track it is ffmpeg's own; for a sidecar it is translated from what the
|
|
94
|
+
// container wrote, or from the extension when there was no table to read.
|
|
95
|
+
codec: AudioTrack.codecNameOf(track, file?.extension ?? ""),
|
|
96
|
+
language: typeof track?.language === "string" ? track.language : "",
|
|
97
|
+
languageBcp47: typeof track?.languageBcp47 === "string" ? track.languageBcp47 : "",
|
|
98
|
+
title:
|
|
99
|
+
typeof track?.title === "string" && track.title.length > 0
|
|
100
|
+
? track.title
|
|
101
|
+
: (typeof track?.name === "string" ? track.name : ""),
|
|
102
|
+
isDefault: track?.isDefault === true,
|
|
103
|
+
declaresDefault: track?.declaresDefault === true,
|
|
104
|
+
isOriginal: track?.isOriginal === true,
|
|
105
|
+
isCommentary: track?.isCommentary === true,
|
|
106
|
+
isVisualImpaired: track?.isVisualImpaired === true,
|
|
107
|
+
isEnabled: track?.isEnabled !== false,
|
|
108
|
+
channels: Number.isFinite(track?.channels) ? track.channels : null,
|
|
109
|
+
// The rate the file STATES for this track, in kbit/s — an average, not a
|
|
110
|
+
// bound — or null where nothing states one (a sidecar read from its
|
|
111
|
+
// container table, a Matroska stream without statistics tags). What a
|
|
112
|
+
// viewer's link is asked to carry for a copied soundtrack is this.
|
|
113
|
+
bitrateKbps: Number.isFinite(track?.bitrateKbps) && track.bitrateKbps > 0 ? track.bitrateKbps : null,
|
|
114
|
+
fileName: kind === "sidecar" ? (file?.name ?? "") : "",
|
|
115
|
+
folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : [],
|
|
116
|
+
// What the file's own path says, for a track that ships as its own file:
|
|
117
|
+
// read here, by the grammar that also decided this file belongs to this
|
|
118
|
+
// picture. The browser used to read the same path again to build the
|
|
119
|
+
// label, which is one name read twice by two rules.
|
|
120
|
+
naming: kind === "sidecar" ? (file?.naming ?? null) : null
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
for (const [order, track] of (Array.isArray(embedded) ? embedded : []).entries()) {
|
|
125
|
+
add(track, videoFileIndex, order, "embedded", null);
|
|
126
|
+
}
|
|
127
|
+
for (const sidecar of Array.isArray(sidecars) ? sidecars : []) {
|
|
128
|
+
const tracks = Array.isArray(sidecar?.tracks) && sidecar.tracks.length > 0
|
|
129
|
+
? sidecar.tracks
|
|
130
|
+
// A file whose track table could not be read is one track: `.ac3`, `.dts`
|
|
131
|
+
// and `.mp3` have no table to read, and a `.mka` whose head has not
|
|
132
|
+
// arrived yet is better offered than hidden — ffmpeg will find its first
|
|
133
|
+
// audio stream either way.
|
|
134
|
+
: [{}];
|
|
135
|
+
for (const [order, track] of tracks.entries()) {
|
|
136
|
+
add(track, sidecar.file.fileIndex, order, "sidecar", sidecar.file);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return inventory;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve the flat number back to the file and the track inside it.
|
|
144
|
+
*
|
|
145
|
+
* @param {AudioInventoryEntry[]} inventory
|
|
146
|
+
* @param {number} index
|
|
147
|
+
* @returns {AudioInventoryEntry | null}
|
|
148
|
+
*/
|
|
149
|
+
export function resolveAudioIndex(inventory, index) {
|
|
150
|
+
if (!Array.isArray(inventory) || !Number.isInteger(index) || index < 0) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return inventory.find((entry) => entry.index === index) ?? null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Enrich the already-published first entry for a sidecar without changing the
|
|
158
|
+
* inventory's length or any address used by a player.
|
|
159
|
+
*
|
|
160
|
+
* A late container read can reveal language, codec and flags after the HLS
|
|
161
|
+
* master playlist has been loaded. Adding entries here would make the browser's
|
|
162
|
+
* rendition order disagree with the playlist it already holds.
|
|
163
|
+
*
|
|
164
|
+
* @param {AudioInventoryEntry[]} inventory
|
|
165
|
+
* @param {import("../torrent/files.js").SidecarFile} file
|
|
166
|
+
* @param {object} declaredTrack
|
|
167
|
+
* @returns {boolean} Whether an existing entry was updated.
|
|
168
|
+
*/
|
|
169
|
+
export function enrichAudioInventoryFromSidecar(inventory, file, declaredTrack) {
|
|
170
|
+
if (!Array.isArray(inventory) || !file || !declaredTrack) return false;
|
|
171
|
+
const current = inventory.find(
|
|
172
|
+
(entry) => entry.kind === "sidecar" && entry.fileIndex === file.fileIndex && entry.sourceTrackIndex === 0
|
|
173
|
+
);
|
|
174
|
+
if (!current) return false;
|
|
175
|
+
const enriched = buildAudioInventory({
|
|
176
|
+
embedded: [],
|
|
177
|
+
videoFileIndex: -1,
|
|
178
|
+
sidecars: [{ file, tracks: [declaredTrack] }]
|
|
179
|
+
})[0];
|
|
180
|
+
Object.assign(current, enriched, {
|
|
181
|
+
index: current.index,
|
|
182
|
+
fileIndex: current.fileIndex,
|
|
183
|
+
sourceTrackIndex: current.sourceTrackIndex,
|
|
184
|
+
kind: current.kind
|
|
185
|
+
});
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The name an `#EXT-X-MEDIA` line carries for one soundtrack.
|
|
191
|
+
*
|
|
192
|
+
* Deliberately plain, and deliberately NOT localised: this is the name inside a
|
|
193
|
+
* playlist, and what the viewer reads in the menu is composed in the browser
|
|
194
|
+
* from the same facts, where the language table and the viewer's own locale are.
|
|
195
|
+
* The only requirements here are that it says something and that no two
|
|
196
|
+
* renditions of one file share it — hls.js groups renditions by name.
|
|
197
|
+
*
|
|
198
|
+
* @param {AudioInventoryEntry} entry
|
|
199
|
+
* @param {AudioInventoryEntry[]} inventory
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
export function audioRenditionName(entry, inventory) {
|
|
203
|
+
const parts = [];
|
|
204
|
+
if (entry.title) {
|
|
205
|
+
parts.push(entry.title);
|
|
206
|
+
} else if (entry.languageBcp47 || entry.language) {
|
|
207
|
+
parts.push(entry.languageBcp47 || entry.language);
|
|
208
|
+
} else if (entry.folders.length > 0) {
|
|
209
|
+
// The folder a dub sits in is usually the only thing naming it, and a
|
|
210
|
+
// release names it for a reason: "Rus Sound", "Ukr Dub".
|
|
211
|
+
parts.push(entry.folders[entry.folders.length - 1]);
|
|
212
|
+
} else if (entry.fileName) {
|
|
213
|
+
parts.push(entry.fileName);
|
|
214
|
+
} else {
|
|
215
|
+
parts.push(`Track ${entry.index + 1}`);
|
|
216
|
+
}
|
|
217
|
+
if (entry.isCommentary) {
|
|
218
|
+
parts.push("commentary");
|
|
219
|
+
} else if (entry.isVisualImpaired) {
|
|
220
|
+
parts.push("described");
|
|
221
|
+
}
|
|
222
|
+
const name = parts.join(" · ");
|
|
223
|
+
const clash = (Array.isArray(inventory) ? inventory : []).some(
|
|
224
|
+
(other) => other.index !== entry.index && audioRenditionNameCore(other) === audioRenditionNameCore(entry)
|
|
225
|
+
);
|
|
226
|
+
return clash ? `${name} (${entry.index + 1})` : name;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The part of a rendition name that a clash is judged on — the name without the
|
|
231
|
+
* disambiguating number, so that adding the number cannot itself cause a clash.
|
|
232
|
+
*
|
|
233
|
+
* @param {AudioInventoryEntry} entry
|
|
234
|
+
* @returns {string}
|
|
235
|
+
*/
|
|
236
|
+
function audioRenditionNameCore(entry) {
|
|
237
|
+
return (
|
|
238
|
+
entry.title ||
|
|
239
|
+
entry.languageBcp47 ||
|
|
240
|
+
entry.language ||
|
|
241
|
+
(entry.folders.length > 0 ? entry.folders[entry.folders.length - 1] : "") ||
|
|
242
|
+
entry.fileName ||
|
|
243
|
+
""
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -72,10 +72,12 @@ export class ViewerRequests {
|
|
|
72
72
|
* @returns {Promise<import("../encode/output/EncodedOutput.js").EncodedOutput>}
|
|
73
73
|
*/
|
|
74
74
|
async createOrGetSession({ consumerId = "", ...request }) {
|
|
75
|
+
const requestStartedAt = Date.now();
|
|
75
76
|
// What this viewer's link last measured, where the request does not say:
|
|
76
77
|
// a viewer who comes back to open another film has a link this proxy has
|
|
77
78
|
// already measured, and the output they are given is judged by it.
|
|
78
79
|
const known = consumerId ? this.#host.viewers.get(consumerId) : null;
|
|
80
|
+
const viewerLinkMbps = request.viewerLinkMbps ?? known?.linkReading()?.linkMbps ?? null;
|
|
79
81
|
const startPositionSeconds = request.startPositionSeconds ?? 0;
|
|
80
82
|
// Whether they were already on the output this request lands on, read at
|
|
81
83
|
// the moment they are put on it.
|
|
@@ -101,7 +103,7 @@ export class ViewerRequests {
|
|
|
101
103
|
: null;
|
|
102
104
|
const { output, existed, audio, verdict } = await this.#host.opening.open({
|
|
103
105
|
...request,
|
|
104
|
-
viewerLinkMbps
|
|
106
|
+
viewerLinkMbps,
|
|
105
107
|
claim
|
|
106
108
|
});
|
|
107
109
|
if (consumerId) {
|
|
@@ -115,6 +117,23 @@ export class ViewerRequests {
|
|
|
115
117
|
// The picture as their page sees it, which bounds the height made for
|
|
116
118
|
// them from here on (roadmap item 98).
|
|
117
119
|
viewer.noteVisiblePicture(request.visiblePicture);
|
|
120
|
+
if (!alreadyOn) {
|
|
121
|
+
const measuredAge = Number(request.linkSampleAgeMs);
|
|
122
|
+
viewer.report({
|
|
123
|
+
linkMbps: viewerLinkMbps,
|
|
124
|
+
linkSampleMbps: request.linkSampleMbps,
|
|
125
|
+
linkSampleAt: request.linkSampleAt,
|
|
126
|
+
linkSampleAgeMs: Number.isFinite(measuredAge) && measuredAge >= 0
|
|
127
|
+
? measuredAge + Date.now() - requestStartedAt
|
|
128
|
+
: null,
|
|
129
|
+
bufferedAheadSec: request.bufferedAheadSec,
|
|
130
|
+
bufferLimitSeconds: request.bufferLimitSeconds,
|
|
131
|
+
positionSeconds: startPositionSeconds,
|
|
132
|
+
waiting: true,
|
|
133
|
+
playing: false,
|
|
134
|
+
visiblePicture: request.visiblePicture
|
|
135
|
+
});
|
|
136
|
+
}
|
|
118
137
|
// On what their output was judged. Recorded where the output is opened
|
|
119
138
|
// for them; a step opened on their behalf is recorded by `Renditions`.
|
|
120
139
|
this.#host.noteServingVerdict(consumerId, verdict ? { ...verdict, outputKey: output.outputKey } : null);
|
|
@@ -697,6 +716,11 @@ export class ViewerRequests {
|
|
|
697
716
|
const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
|
|
698
717
|
if (Number.isFinite(reading?.speed) && reading.speed > 0 && Number.isFinite(reading.at)) {
|
|
699
718
|
trend.add(reading.at, reading.speed);
|
|
719
|
+
} else if (!trend.snapshot()) {
|
|
720
|
+
const projected = Number(this.#host.projectedEncodeSpeedOf?.(output));
|
|
721
|
+
if (Number.isFinite(projected) && projected > 0) {
|
|
722
|
+
trend.add(now, projected);
|
|
723
|
+
}
|
|
700
724
|
}
|
|
701
725
|
return trend.snapshot();
|
|
702
726
|
}
|
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Playback controller — interface layer over playback planning.
|
|
3
|
-
*
|
|
4
|
-
* Thin adapter between HTTP/routes and the playback plan. Parses nothing
|
|
5
|
-
* itself. Exists so routes depend on a controller contract, not on service
|
|
6
|
-
* internals.
|
|
7
|
-
*
|
|
8
|
-
* It used to hold a `ContainerOrchestrator` as well, and that was the main
|
|
9
|
-
* thread's own instance of a module singleton the torrent worker also loads —
|
|
10
|
-
* so it held no container, was asked nothing, and made it look as though a
|
|
11
|
-
* container could be consulted here. It cannot: the file's bytes are in the
|
|
12
|
-
* worker, and an object with methods does not cross a thread. What crosses is a
|
|
13
|
-
* message.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
export class PlaybackController {
|
|
17
|
-
/**
|
|
18
|
-
* @param {object} deps
|
|
19
|
-
* @param {import("../../torrent/torrent-pool.js").TorrentPool} deps.torrentPool
|
|
20
|
-
* @param {ReturnType<import("../../../store/source-registry.js").createSourceRegistry>} deps.sourceRegistry
|
|
21
|
-
* @param {string} deps.ffmpegBin
|
|
22
|
-
* @param {string} deps.localBaseUrl
|
|
23
|
-
* @param {ReturnType<import("../../media/playback-planner.js").createPlaybackPlanner>} deps.playbackPlanner
|
|
24
|
-
*/
|
|
25
|
-
constructor({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner }) {
|
|
26
|
-
this.torrentPool = torrentPool;
|
|
27
|
-
this.sourceRegistry = sourceRegistry;
|
|
28
|
-
this.ffmpegBin = ffmpegBin;
|
|
29
|
-
this.localBaseUrl = localBaseUrl;
|
|
30
|
-
this.playbackPlanner = playbackPlanner;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async getPlan(params) {
|
|
34
|
-
return this.playbackPlanner.getPlan(params);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async refreshAudioTracks(params) {
|
|
38
|
-
return this.playbackPlanner.refreshAudioTracks(params);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Playback controller — interface layer over playback planning.
|
|
3
|
+
*
|
|
4
|
+
* Thin adapter between HTTP/routes and the playback plan. Parses nothing
|
|
5
|
+
* itself. Exists so routes depend on a controller contract, not on service
|
|
6
|
+
* internals.
|
|
7
|
+
*
|
|
8
|
+
* It used to hold a `ContainerOrchestrator` as well, and that was the main
|
|
9
|
+
* thread's own instance of a module singleton the torrent worker also loads —
|
|
10
|
+
* so it held no container, was asked nothing, and made it look as though a
|
|
11
|
+
* container could be consulted here. It cannot: the file's bytes are in the
|
|
12
|
+
* worker, and an object with methods does not cross a thread. What crosses is a
|
|
13
|
+
* message.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export class PlaybackController {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} deps
|
|
19
|
+
* @param {import("../../torrent/torrent-pool.js").TorrentPool} deps.torrentPool
|
|
20
|
+
* @param {ReturnType<import("../../../store/source-registry.js").createSourceRegistry>} deps.sourceRegistry
|
|
21
|
+
* @param {string} deps.ffmpegBin
|
|
22
|
+
* @param {string} deps.localBaseUrl
|
|
23
|
+
* @param {ReturnType<import("../../media/playback-planner.js").createPlaybackPlanner>} deps.playbackPlanner
|
|
24
|
+
*/
|
|
25
|
+
constructor({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner }) {
|
|
26
|
+
this.torrentPool = torrentPool;
|
|
27
|
+
this.sourceRegistry = sourceRegistry;
|
|
28
|
+
this.ffmpegBin = ffmpegBin;
|
|
29
|
+
this.localBaseUrl = localBaseUrl;
|
|
30
|
+
this.playbackPlanner = playbackPlanner;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getPlan(params) {
|
|
34
|
+
return this.playbackPlanner.getPlan(params);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async refreshAudioTracks(params) {
|
|
38
|
+
return this.playbackPlanner.refreshAudioTracks(params);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -511,6 +511,7 @@ export function wireOutputs({
|
|
|
511
511
|
get enabled() { return parts.enabled; },
|
|
512
512
|
get encodeCost() { return parts.encodeCost; },
|
|
513
513
|
get encodeRuns() { return parts.encodeRuns; },
|
|
514
|
+
get admission() { return parts.admission; },
|
|
514
515
|
get getCachedMediaInfo() { return parts.getCachedMediaInfo; },
|
|
515
516
|
get hostLoad() { return parts.hostLoad; },
|
|
516
517
|
get hostTimings() { return parts.hostTimings; },
|
|
@@ -544,6 +545,7 @@ export function wireOutputs({
|
|
|
544
545
|
waitUntilReady: (...args) => parts.serving.waitUntilReady(...args),
|
|
545
546
|
get encodeRuns() { return parts.encodeRuns; },
|
|
546
547
|
encodeSpeedReadingOf: (output) => parts.encodeCost.latestSpeedReadingOf(output),
|
|
548
|
+
projectedEncodeSpeedOf: () => parts.admission.projectedSpeedX(),
|
|
547
549
|
get opening() { return parts.opening; },
|
|
548
550
|
get outputTimes() { return parts.outputTimes; },
|
|
549
551
|
get outputs() { return parts.outputs; },
|
|
@@ -867,6 +869,7 @@ export function wireOutputs({
|
|
|
867
869
|
},
|
|
868
870
|
finished: (address) => parts.outputs.outputsOn(address).some((session) => finishedOutput(session)),
|
|
869
871
|
loadOf: (address) => parts.encodeCost.loadOfOutput(address),
|
|
872
|
+
loadForCandidate: (spec, file) => parts.encodeCost.loadForCandidate(spec, file),
|
|
870
873
|
availability: () => parts.hostLoad.hostAvailability ?? null
|
|
871
874
|
});
|
|
872
875
|
parts.encodeOrchestrator = new EncodeOrchestrator({
|