@torrent-tv/proxy 2.74.1 → 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 -1453
- package/CLAUDE.md +19 -6
- package/biome.json +182 -1
- package/docs/container-architecture.md +27 -6
- 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 +9 -214
- package/services/container/AviContainer.js +266 -81
- package/services/container/Container.js +281 -80
- package/services/container/ContainerFactory.js +67 -0
- package/services/container/MatroskaContainer.js +327 -8
- package/services/container/Mp4Container.js +373 -27
- 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 -747
- 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 +370 -333
- 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/AudioTrack.js +131 -40
- 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/audio-inventory.test.js +176 -177
- 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/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -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/video-facts.test.js +102 -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/container-index/avi.js +0 -167
- package/services/container-index/index.js +0 -118
- package/services/container-index/matroska.js +0 -336
- package/services/container-index/mp4.js +0 -358
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{container-index → container}/ebml-reader.js +0 -0
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file One person watching, and everything that is true of them alone.
|
|
3
|
+
*
|
|
4
|
+
* A viewer is not a property of the material. What they are listening to, which
|
|
5
|
+
* quality step they have on screen, what is being prepared for them, where they
|
|
6
|
+
* are and what their link can carry — none of it changes a byte of what any
|
|
7
|
+
* encoder produces, and none of it belongs to a session, which is a description
|
|
8
|
+
* of an OUTPUT.
|
|
9
|
+
*
|
|
10
|
+
* It was six parallel maps hung on the session, each keyed by consumer id:
|
|
11
|
+
* `audioChoiceByConsumer`, `activeVariantByConsumer`,
|
|
12
|
+
* `warmingVariantByConsumer`, `warmingAudioByConsumer`, `consumerHeads`,
|
|
13
|
+
* `netReports`. Six places to remember to update and six to remember to forget,
|
|
14
|
+
* and the forgetting was already wrong — releasing a consumer emptied none of
|
|
15
|
+
* them, so a viewer who had left went on counting as wanting their soundtrack
|
|
16
|
+
* until their head expired, and their entries stayed for the life of the
|
|
17
|
+
* session.
|
|
18
|
+
*
|
|
19
|
+
* One object, one map, one thing to remove.
|
|
20
|
+
*
|
|
21
|
+
* **Nothing here knows about ffmpeg, the disk or the torrent.** A viewer states
|
|
22
|
+
* what they want and where they are; what to make of that is the orchestrator's
|
|
23
|
+
* question, and it reads a union of viewers rather than any one of them.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export class Viewer {
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} id - The consumer id the browser sends with every request
|
|
29
|
+
* that means "this viewer".
|
|
30
|
+
*/
|
|
31
|
+
constructor(id) {
|
|
32
|
+
this.id = String(id ?? "");
|
|
33
|
+
/**
|
|
34
|
+
* Which soundtrack this viewer is listening to and whether their browser
|
|
35
|
+
* needs it re-encoded. Theirs alone: two viewers of one picture may have
|
|
36
|
+
* chosen different languages, and one browser may decode a track another
|
|
37
|
+
* cannot.
|
|
38
|
+
* @type {{ trackIndex: number, transcode: boolean }}
|
|
39
|
+
*/
|
|
40
|
+
this.audio = { trackIndex: 0, transcode: false };
|
|
41
|
+
/** The quality step on their screen. Null means the base session. @type {string | null} */
|
|
42
|
+
this.activeVariantId = null;
|
|
43
|
+
/** A step being prepared for a switch they have not made yet. @type {string | null} */
|
|
44
|
+
this.warmingVariantId = null;
|
|
45
|
+
/** A soundtrack being prepared for the same reason. @type {string | null} */
|
|
46
|
+
this.warmingAudioId = null;
|
|
47
|
+
/**
|
|
48
|
+
* Where they are: the segment they last asked for, or the position they
|
|
49
|
+
* stated by seeking. `seeked` holds the stated position for as long as they
|
|
50
|
+
* stay there, which is the distinction a cold open's soundtrack placement
|
|
51
|
+
* turns on.
|
|
52
|
+
* @type {{ segment: number, seconds: number, at: number, seeked?: number } | null}
|
|
53
|
+
*/
|
|
54
|
+
this.head = null;
|
|
55
|
+
/** What their link was last measured to carry. @type {object | null} */
|
|
56
|
+
this.netReport = null;
|
|
57
|
+
/**
|
|
58
|
+
* Every output this viewer is watching, by session id: the picture, the
|
|
59
|
+
* quality step on their screen, the soundtrack they chose.
|
|
60
|
+
*
|
|
61
|
+
* WHY THERE ARE TWO SETS AND NOT ONE. There is one relation — this person
|
|
62
|
+
* watches this output — and it is asked from both ends. An output asks "has
|
|
63
|
+
* anybody left?", to decide whether to go on producing. A viewer who leaves
|
|
64
|
+
* asks "what was I watching?", so that each of those outputs can be told.
|
|
65
|
+
* Neither question can be answered from the other side without walking every
|
|
66
|
+
* session in the process, so the relation is indexed both ways. It is
|
|
67
|
+
* written in exactly one place — `Viewers.of` and `Viewers.leaves` write
|
|
68
|
+
* both directions together — which is what keeps two indexes of one relation
|
|
69
|
+
* from becoming two different answers.
|
|
70
|
+
*
|
|
71
|
+
* This is what replaced a film object. There is no "film" anywhere in this
|
|
72
|
+
* proxy — its parts are born at different times, die at different times and
|
|
73
|
+
* are addressed separately — and the three link fields that stood in for one
|
|
74
|
+
* could not say how many people were listening to a soundtrack.
|
|
75
|
+
*
|
|
76
|
+
* @type {Set<string>}
|
|
77
|
+
*/
|
|
78
|
+
this.outputs = new Set();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether this viewer has been heard from recently enough to still be
|
|
83
|
+
* watching.
|
|
84
|
+
*
|
|
85
|
+
* Nothing releases a session when a channel closes, so without this a viewer
|
|
86
|
+
* whose tab is gone would hold a soundtrack or a quality step for the whole
|
|
87
|
+
* life of the session.
|
|
88
|
+
*
|
|
89
|
+
* @param {number} now
|
|
90
|
+
* @param {number} staleAfterMs
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
isLive(now, staleAfterMs) {
|
|
94
|
+
return this.head !== null && now - this.head.at <= staleAfterMs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where this viewer is, in seconds, or null when they have never said.
|
|
99
|
+
*
|
|
100
|
+
* @returns {number | null}
|
|
101
|
+
*/
|
|
102
|
+
positionSeconds() {
|
|
103
|
+
if (this.head === null) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return Number.isFinite(this.head.seeked) ? this.head.seeked : this.head.seconds;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The viewers of one session, made on first use.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} session
|
|
114
|
+
* @returns {Map<string, Viewer>}
|
|
115
|
+
*/
|
|
116
|
+
export function viewersOf(session) {
|
|
117
|
+
if (!(session.viewers instanceof Map)) {
|
|
118
|
+
session.viewers = new Map();
|
|
119
|
+
}
|
|
120
|
+
return session.viewers;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The viewer with this id, made if this session has not met them before.
|
|
125
|
+
*
|
|
126
|
+
* Use `Viewers.of` instead wherever a registry is at hand: this makes ONE
|
|
127
|
+
* viewer per session, so the same person watching a picture, a quality step and
|
|
128
|
+
* a soundtrack is three objects, and `outputs` — a fact about the person — is
|
|
129
|
+
* then three sets that nothing keeps in step. It is kept for a session assembled
|
|
130
|
+
* by hand in a test that has no registry.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} session
|
|
133
|
+
* @param {string} consumerId
|
|
134
|
+
* @returns {Viewer}
|
|
135
|
+
*/
|
|
136
|
+
export function viewerOf(session, consumerId) {
|
|
137
|
+
const viewers = viewersOf(session);
|
|
138
|
+
let viewer = viewers.get(consumerId);
|
|
139
|
+
if (!viewer) {
|
|
140
|
+
viewer = new Viewer(consumerId);
|
|
141
|
+
viewers.set(consumerId, viewer);
|
|
142
|
+
}
|
|
143
|
+
viewer.outputs.add(session.id);
|
|
144
|
+
return viewer;
|
|
145
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Every viewer this proxy has met, one object per person.
|
|
3
|
+
*
|
|
4
|
+
* A viewer used to be made per SESSION: the same person watching a picture, a
|
|
5
|
+
* quality step and a soundtrack was three objects, each with its own copy of
|
|
6
|
+
* what that person had chosen and where they were. Two of those copies were
|
|
7
|
+
* always wrong, and the field that says which outputs a person is watching was
|
|
8
|
+
* worse than wrong — being per session, each copy could only ever hold the id
|
|
9
|
+
* of the session that owned it, so it carried no information at all and the one
|
|
10
|
+
* place that read it could learn nothing from it.
|
|
11
|
+
*
|
|
12
|
+
* One person is one object here, keyed by the consumer id the browser sends.
|
|
13
|
+
* That id is minted once per film opened in the page, so one id is one person
|
|
14
|
+
* watching one film, and sharing the object cannot conflate two films.
|
|
15
|
+
*
|
|
16
|
+
* The relation "this person watches this output" is indexed both ways — the
|
|
17
|
+
* output holds its viewers, the viewer holds its outputs — because it is asked
|
|
18
|
+
* from both ends, and both indexes are written here and nowhere else.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Viewer, viewersOf } from "./Viewer.js";
|
|
22
|
+
|
|
23
|
+
export class Viewers {
|
|
24
|
+
/**
|
|
25
|
+
* One object per named viewer. An unnamed one is not in here: a viewer that
|
|
26
|
+
* cannot say who it is is not the same viewer as another that cannot, so it
|
|
27
|
+
* belongs to the session that met it and to no one else.
|
|
28
|
+
*
|
|
29
|
+
* @type {Map<string, Viewer>}
|
|
30
|
+
*/
|
|
31
|
+
#byId = new Map();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* This viewer, watching this output.
|
|
35
|
+
*
|
|
36
|
+
* Both directions of the relation are written here. Asking for a viewer of an
|
|
37
|
+
* output IS the statement that they are watching it: every caller either
|
|
38
|
+
* records where they are, what they chose, or what is being prepared for
|
|
39
|
+
* them, and each of those is only true of somebody watching.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} output - A session.
|
|
42
|
+
* @param {string} consumerId
|
|
43
|
+
* @returns {Viewer}
|
|
44
|
+
*/
|
|
45
|
+
of(output, consumerId) {
|
|
46
|
+
const viewers = viewersOf(output);
|
|
47
|
+
const known = viewers.get(consumerId);
|
|
48
|
+
if (known) {
|
|
49
|
+
known.outputs.add(output.id);
|
|
50
|
+
return known;
|
|
51
|
+
}
|
|
52
|
+
const viewer = consumerId
|
|
53
|
+
? this.#byId.get(consumerId) ?? new Viewer(consumerId)
|
|
54
|
+
: new Viewer("");
|
|
55
|
+
if (consumerId) {
|
|
56
|
+
this.#byId.set(consumerId, viewer);
|
|
57
|
+
}
|
|
58
|
+
viewers.set(consumerId, viewer);
|
|
59
|
+
viewer.outputs.add(output.id);
|
|
60
|
+
return viewer;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The viewer with this id, or null when nobody by that name is watching
|
|
65
|
+
* anything. Never makes one.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} consumerId
|
|
68
|
+
* @returns {Viewer | null}
|
|
69
|
+
*/
|
|
70
|
+
get(consumerId) {
|
|
71
|
+
return this.#byId.get(consumerId) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The outputs this viewer is watching, as ids, copied so that leaving them
|
|
76
|
+
* can be walked without mutating what is being walked.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} anyOutput - A session they are known to, for an unnamed
|
|
79
|
+
* viewer whose record lives on that session alone.
|
|
80
|
+
* @param {string} consumerId
|
|
81
|
+
* @returns {string[]}
|
|
82
|
+
*/
|
|
83
|
+
watching(anyOutput, consumerId) {
|
|
84
|
+
const viewer = consumerId
|
|
85
|
+
? this.#byId.get(consumerId)
|
|
86
|
+
: viewersOf(anyOutput).get("");
|
|
87
|
+
return viewer ? [...viewer.outputs] : [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* This viewer is no longer watching this output.
|
|
92
|
+
*
|
|
93
|
+
* Both directions again, and the viewer itself is forgotten once it is
|
|
94
|
+
* watching nothing — otherwise the registry would be a map that only grows,
|
|
95
|
+
* which is the shape of half the memory faults recorded in this repository.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} output - A session.
|
|
98
|
+
* @param {string} consumerId
|
|
99
|
+
* @returns {boolean} Whether they were watching it.
|
|
100
|
+
*/
|
|
101
|
+
leaves(output, consumerId) {
|
|
102
|
+
const viewers = viewersOf(output);
|
|
103
|
+
const viewer = viewers.get(consumerId);
|
|
104
|
+
if (!viewer) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
viewers.delete(consumerId);
|
|
108
|
+
viewer.outputs.delete(output.id);
|
|
109
|
+
if (consumerId && viewer.outputs.size === 0) {
|
|
110
|
+
this.#byId.delete(consumerId);
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* How many named viewers are watching anything. For the log line and for a
|
|
117
|
+
* check that the registry does not grow.
|
|
118
|
+
*
|
|
119
|
+
* @returns {number}
|
|
120
|
+
*/
|
|
121
|
+
get size() {
|
|
122
|
+
return this.#byId.size;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -1,177 +1,176 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file One numbered list of soundtracks, from two readings of the same file.
|
|
3
|
-
*
|
|
4
|
-
* Two things are being pinned here, and each has cost a real failure elsewhere
|
|
5
|
-
* in this project:
|
|
6
|
-
*
|
|
7
|
-
* 1. **The alignment guard.** The container reading is used ONLY when it can be
|
|
8
|
-
* lined up with ffmpeg's own numbering, because `0:a:N` is what the encoder
|
|
9
|
-
* is handed — a flag attributed to the wrong track is worse than a missing
|
|
10
|
-
* one. Same discipline as `subtitle-defaults.js`, for the same reason.
|
|
11
|
-
* 2. **The flat numbering.** The browser's menu, the `audioTrackIndex` on a
|
|
12
|
-
* session request and the `a/<n>/` address of a rendition are one number.
|
|
13
|
-
* Embedded tracks must keep the numbers they have always had, so that a
|
|
14
|
-
* session created against an older cached plan means the same thing.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import test from "node:test";
|
|
18
|
-
import
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
{ index:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
{ language: "eng", name: "",
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
);
|
|
51
|
-
assert.equal(merged.
|
|
52
|
-
assert.equal(merged.tracks[0].
|
|
53
|
-
assert.equal(merged.tracks[
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
[{
|
|
63
|
-
|
|
64
|
-
);
|
|
65
|
-
assert.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
assert.equal(merged.tracks[0].
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
[{
|
|
75
|
-
|
|
76
|
-
);
|
|
77
|
-
assert.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{ language: "
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
assert.deepEqual(inventory.map((entry) => entry.
|
|
97
|
-
assert.deepEqual(inventory.map((entry) => entry.
|
|
98
|
-
assert.deepEqual(inventory.map((entry) => entry.
|
|
99
|
-
assert.
|
|
100
|
-
assert.
|
|
101
|
-
assert.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
assert.equal(inventory.
|
|
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
|
-
assert.equal(resolveAudioIndex(inventory, 1).
|
|
138
|
-
assert.equal(resolveAudioIndex(inventory,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
assert.equal(codecNameOf({
|
|
144
|
-
assert.equal(codecNameOf({ codecId: "
|
|
145
|
-
assert.equal(codecNameOf({ codecId: "
|
|
146
|
-
assert.equal(codecNameOf({ codecId: "
|
|
147
|
-
assert.equal(codecNameOf({
|
|
148
|
-
assert.equal(codecNameOf({}, ".
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{ file: { fileIndex:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
audioRenditionName(inventory[
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @file One numbered list of soundtracks, from two readings of the same file.
|
|
3
|
+
*
|
|
4
|
+
* Two things are being pinned here, and each has cost a real failure elsewhere
|
|
5
|
+
* in this project:
|
|
6
|
+
*
|
|
7
|
+
* 1. **The alignment guard.** The container reading is used ONLY when it can be
|
|
8
|
+
* lined up with ffmpeg's own numbering, because `0:a:N` is what the encoder
|
|
9
|
+
* is handed — a flag attributed to the wrong track is worse than a missing
|
|
10
|
+
* one. Same discipline as `subtitle-defaults.js`, for the same reason.
|
|
11
|
+
* 2. **The flat numbering.** The browser's menu, the `audioTrackIndex` on a
|
|
12
|
+
* session request and the `a/<n>/` address of a rendition are one number.
|
|
13
|
+
* Embedded tracks must keep the numbers they have always had, so that a
|
|
14
|
+
* session created against an older cached plan means the same thing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import { Container } from "../services/container/Container.js";
|
|
19
|
+
import { AudioTrack } from "../services/tracks/AudioTrack.js";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import {
|
|
22
|
+
audioRenditionName,
|
|
23
|
+
buildAudioInventory,
|
|
24
|
+
resolveAudioIndex
|
|
25
|
+
} from "../services/audio-inventory.js";
|
|
26
|
+
|
|
27
|
+
test("a pair agreeing on language is accepted", () => {
|
|
28
|
+
assert.equal(Container.pairingHolds({ language: "jpn" }, { language: "jpn" }), true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a pair disagreeing on language is refused", () => {
|
|
32
|
+
assert.equal(Container.pairingHolds({ language: "jpn" }, { language: "rus" }), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("two tracks that say nothing about themselves do not break the alignment", () => {
|
|
36
|
+
assert.equal(Container.pairingHolds({ language: "und", title: "" }, { language: "", name: "" }), true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("the container's own flags reach the merged track", () => {
|
|
40
|
+
const merged = Container.mergeAudioFlags(
|
|
41
|
+
[
|
|
42
|
+
{ index: 0, language: "eng", title: "", isDefault: true, codec: "aac" },
|
|
43
|
+
{ index: 1, language: "eng", title: "Director", isDefault: true, codec: "ac3" }
|
|
44
|
+
],
|
|
45
|
+
[
|
|
46
|
+
{ language: "eng", name: "", isOriginal: true, isDefault: true, declaresDefault: true, channels: 6 },
|
|
47
|
+
{ language: "eng", name: "Director", isCommentary: true, isDefault: false, declaresDefault: true, channels: 2 }
|
|
48
|
+
]
|
|
49
|
+
);
|
|
50
|
+
assert.equal(merged.aligned, true);
|
|
51
|
+
assert.equal(merged.tracks[0].isOriginal, true);
|
|
52
|
+
assert.equal(merged.tracks[0].channels, 6);
|
|
53
|
+
assert.equal(merged.tracks[1].isCommentary, true);
|
|
54
|
+
// Matroska defaults FlagDefault to 1 and ffmpeg prints the applied default, so
|
|
55
|
+
// the banner said both tracks were default. The container says otherwise.
|
|
56
|
+
assert.equal(merged.tracks[1].isDefault, false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a count that differs drops the container reading whole", () => {
|
|
60
|
+
const merged = Container.mergeAudioFlags(
|
|
61
|
+
[{ index: 0, language: "eng", isDefault: true }],
|
|
62
|
+
[{ language: "eng" }, { language: "rus" }]
|
|
63
|
+
);
|
|
64
|
+
assert.equal(merged.aligned, false);
|
|
65
|
+
assert.match(merged.reason, /declares 2 audio tracks and the probe found 1/);
|
|
66
|
+
// Nothing of it is used — not even the flags that happened to line up.
|
|
67
|
+
assert.equal(merged.tracks[0].isCommentary, false);
|
|
68
|
+
assert.equal(merged.tracks[0].declaresDefault, false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("one pair that agrees on neither language nor title drops it too", () => {
|
|
72
|
+
const merged = Container.mergeAudioFlags(
|
|
73
|
+
[{ index: 0, language: "jpn", title: "" }, { index: 1, language: "rus", title: "" }],
|
|
74
|
+
[{ language: "jpn", name: "" }, { language: "eng", name: "" }]
|
|
75
|
+
);
|
|
76
|
+
assert.equal(merged.aligned, false);
|
|
77
|
+
assert.match(merged.reason, /audio 1 is/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("embedded tracks keep the numbers they have always had, sidecars follow", () => {
|
|
81
|
+
const inventory = buildAudioInventory({
|
|
82
|
+
embedded: [
|
|
83
|
+
{ language: "jpn", codec: "aac", isDefault: true },
|
|
84
|
+
{ language: "eng", codec: "ac3", title: "Commentary", isCommentary: true }
|
|
85
|
+
],
|
|
86
|
+
videoFileIndex: 24,
|
|
87
|
+
sidecars: [
|
|
88
|
+
{
|
|
89
|
+
file: { fileIndex: 12, name: "ep.mka", folders: ["Rus Sound"], extension: ".mka" },
|
|
90
|
+
tracks: [{ language: "rus", codecId: "A_AC3", channels: 6 }]
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(inventory.map((entry) => entry.index), [0, 1, 2]);
|
|
96
|
+
assert.deepEqual(inventory.map((entry) => entry.fileIndex), [24, 24, 12]);
|
|
97
|
+
assert.deepEqual(inventory.map((entry) => entry.sourceTrackIndex), [0, 1, 0]);
|
|
98
|
+
assert.deepEqual(inventory.map((entry) => entry.kind), ["embedded", "embedded", "sidecar"]);
|
|
99
|
+
assert.equal(inventory[2].codec, "ac3");
|
|
100
|
+
assert.deepEqual(inventory[2].folders, ["Rus Sound"]);
|
|
101
|
+
assert.equal(inventory[2].fileName, "ep.mka");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a sidecar whose table could not be read is still offered, as one track", () => {
|
|
105
|
+
const inventory = buildAudioInventory({
|
|
106
|
+
embedded: [{ language: "eng", codec: "aac" }],
|
|
107
|
+
videoFileIndex: 0,
|
|
108
|
+
sidecars: [{ file: { fileIndex: 1, name: "dub.ac3", folders: [], extension: ".ac3" }, tracks: [] }]
|
|
109
|
+
});
|
|
110
|
+
assert.equal(inventory.length, 2);
|
|
111
|
+
assert.equal(inventory[1].sourceTrackIndex, 0);
|
|
112
|
+
// The extension of a bare elementary stream IS its codec.
|
|
113
|
+
assert.equal(inventory[1].codec, "ac3");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a sidecar carrying two tracks contributes both", () => {
|
|
117
|
+
const inventory = buildAudioInventory({
|
|
118
|
+
embedded: [],
|
|
119
|
+
videoFileIndex: 0,
|
|
120
|
+
sidecars: [
|
|
121
|
+
{
|
|
122
|
+
file: { fileIndex: 3, name: "dubs.mka", folders: [], extension: ".mka" },
|
|
123
|
+
tracks: [{ language: "rus" }, { language: "ukr" }]
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
});
|
|
127
|
+
assert.deepEqual(inventory.map((entry) => [entry.index, entry.sourceTrackIndex]), [[0, 0], [1, 1]]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the flat number resolves back to a file and a track inside it", () => {
|
|
131
|
+
const inventory = buildAudioInventory({
|
|
132
|
+
embedded: [{ language: "jpn" }],
|
|
133
|
+
videoFileIndex: 24,
|
|
134
|
+
sidecars: [{ file: { fileIndex: 12, name: "ep.mka", folders: [], extension: ".mka" }, tracks: [{}] }]
|
|
135
|
+
});
|
|
136
|
+
assert.equal(resolveAudioIndex(inventory, 1).fileIndex, 12);
|
|
137
|
+
assert.equal(resolveAudioIndex(inventory, 1).sourceTrackIndex, 0);
|
|
138
|
+
assert.equal(resolveAudioIndex(inventory, 9), null);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("codec identifiers are translated to the names the browser judges by", () => {
|
|
142
|
+
assert.equal(AudioTrack.codecNameOf({ codec: "AAC" }), "aac");
|
|
143
|
+
assert.equal(AudioTrack.codecNameOf({ codecId: "A_AC3" }), "ac3");
|
|
144
|
+
assert.equal(AudioTrack.codecNameOf({ codecId: "A_AAC/MPEG4/LC" }), "aac");
|
|
145
|
+
assert.equal(AudioTrack.codecNameOf({ codecId: "A_PCM/INT/LIT" }), "pcm");
|
|
146
|
+
assert.equal(AudioTrack.codecNameOf({ codecId: "ec-3" }), "eac3");
|
|
147
|
+
assert.equal(AudioTrack.codecNameOf({}, ".dts"), "dts");
|
|
148
|
+
assert.equal(AudioTrack.codecNameOf({}, ".unknown"), "");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a rendition is named by what the file says, and two never share a name", () => {
|
|
152
|
+
const inventory = buildAudioInventory({
|
|
153
|
+
embedded: [{ language: "jpn" }],
|
|
154
|
+
videoFileIndex: 0,
|
|
155
|
+
sidecars: [
|
|
156
|
+
{ file: { fileIndex: 1, name: "a.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] },
|
|
157
|
+
{ file: { fileIndex: 2, name: "b.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] }
|
|
158
|
+
]
|
|
159
|
+
});
|
|
160
|
+
assert.equal(audioRenditionName(inventory[0], inventory), "jpn");
|
|
161
|
+
// Both sidecars sit in the same folder and neither names itself, so the names
|
|
162
|
+
// would collide — and hls.js groups renditions by name.
|
|
163
|
+
assert.notEqual(
|
|
164
|
+
audioRenditionName(inventory[1], inventory),
|
|
165
|
+
audioRenditionName(inventory[2], inventory)
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a commentary says so in its rendition name", () => {
|
|
170
|
+
const inventory = buildAudioInventory({
|
|
171
|
+
embedded: [{ language: "eng" }, { language: "eng", title: "Director", isCommentary: true }],
|
|
172
|
+
videoFileIndex: 0,
|
|
173
|
+
sidecars: []
|
|
174
|
+
});
|
|
175
|
+
assert.match(audioRenditionName(inventory[1], inventory), /commentary/);
|
|
176
|
+
});
|