@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.
Files changed (124) hide show
  1. package/CHANGELOG.md +1504 -1453
  2. package/CLAUDE.md +19 -6
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +27 -6
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +9 -214
  18. package/services/container/AviContainer.js +266 -81
  19. package/services/container/Container.js +281 -80
  20. package/services/container/ContainerFactory.js +67 -0
  21. package/services/container/MatroskaContainer.js +327 -8
  22. package/services/container/Mp4Container.js +373 -27
  23. package/services/container/SubtitleFileContainer.js +0 -1
  24. package/services/controllers/SubtitleController.js +128 -128
  25. package/services/demand/index.js +7 -10
  26. package/services/download/registry.js +0 -14
  27. package/services/encode/CoverageMap.js +281 -0
  28. package/services/encode/EncodePlan.js +255 -0
  29. package/services/encode/EncodeRun.js +587 -0
  30. package/services/encode/Encoder.js +84 -0
  31. package/services/encode/NvencEncoder.js +45 -0
  32. package/services/encode/QsvEncoder.js +47 -0
  33. package/services/encode/SegmentDemand.js +0 -0
  34. package/services/encode/SegmentStore.js +529 -0
  35. package/services/encode/SoftwareEncoder.js +111 -0
  36. package/services/encode/V4l2m2mEncoder.js +53 -0
  37. package/services/encode/VaapiEncoder.js +53 -0
  38. package/services/encode/args.js +200 -0
  39. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  40. package/services/encode/index.js +9 -0
  41. package/services/encode/run-command.js +647 -0
  42. package/services/hls-session-manager.js +11073 -10711
  43. package/services/hwaccel.js +1688 -1992
  44. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  45. package/services/output/LiveOutputs.js +213 -0
  46. package/services/output/Output.js +94 -0
  47. package/services/output/OutputSpec.js +195 -0
  48. package/services/output/Timeline.js +220 -0
  49. package/services/output/index.js +1 -0
  50. package/services/output/ladder.js +26 -0
  51. package/services/playback-planner.js +806 -747
  52. package/services/produced-index.js +222 -300
  53. package/services/source/SourceFile.js +346 -0
  54. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  55. package/services/torrent/naming.js +619 -0
  56. package/services/torrent-worker/client.js +10 -0
  57. package/services/torrent-worker/container-tracks.js +71 -43
  58. package/services/torrent-worker/pool-adapter.js +370 -333
  59. package/services/torrent-worker/protocol.js +7 -0
  60. package/services/torrent-worker/subtitle-cues.js +549 -549
  61. package/services/torrent-worker/worker.js +18 -0
  62. package/services/tracks/AudioTrack.js +131 -40
  63. package/services/tracks/TextSubtitleTrack.js +287 -287
  64. package/services/tracks/index.js +15 -14
  65. package/services/viewer/Viewer.js +145 -0
  66. package/services/viewer/Viewers.js +124 -0
  67. package/test/audio-inventory.test.js +176 -177
  68. package/test/auto-quality-step.test.js +508 -506
  69. package/test/behind-head-repair.test.js +17 -7
  70. package/test/coverage-map.test.js +153 -0
  71. package/test/cut-times-timeline.test.js +6 -5
  72. package/test/cuts-follow-published-grid.test.js +4 -4
  73. package/test/decode-cost.test.js +31 -12
  74. package/test/encode-exit.test.js +1 -1
  75. package/test/encode-orchestrator.test.js +196 -0
  76. package/test/encode-plan.test.js +245 -0
  77. package/test/encode-run-state.test.js +2 -2
  78. package/test/encode-run.test.js +168 -0
  79. package/test/encoder-kinds.test.js +122 -0
  80. package/test/held-request-width.test.js +9 -3
  81. package/test/helpers/encode-run.js +128 -0
  82. package/test/keyframe-index-accuracy.test.js +19 -12
  83. package/test/keyframes-belong-to-the-file.test.js +132 -0
  84. package/test/matroska-cues-track.test.js +192 -192
  85. package/test/mp4-composition-times.test.js +0 -0
  86. package/test/orchestrator-wired.test.js +164 -0
  87. package/test/output-shape.test.js +68 -0
  88. package/test/output-spec.test.js +157 -0
  89. package/test/produced-copy-choice.test.js +58 -92
  90. package/test/produced-index.test.js +142 -188
  91. package/test/quality-variants.test.js +1079 -1075
  92. package/test/run-graph-drift.test.js +1 -1
  93. package/test/run-intervals.test.js +329 -0
  94. package/test/run-position-follows-published-grid.test.js +4 -4
  95. package/test/seek-landing.test.js +8 -8
  96. package/test/seek-target-not-superseded.test.js +21 -9
  97. package/test/segment-demand.test.js +82 -0
  98. package/test/segment-serve-wiring.test.js +47 -52
  99. package/test/segment-store.test.js +187 -0
  100. package/test/segments-are-shared.test.js +175 -0
  101. package/test/sidecar-naming.test.js +142 -0
  102. package/test/source-file.test.js +133 -0
  103. package/test/stale-request-after-seek.test.js +18 -12
  104. package/test/subtitle-language.test.js +252 -252
  105. package/test/timeline.test.js +95 -0
  106. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  107. package/test/torrent-naming.test.js +255 -0
  108. package/test/tracks-begin-together.test.js +44 -32
  109. package/test/two-viewers-one-picture.test.js +347 -0
  110. package/test/video-facts.test.js +102 -0
  111. package/test/viewer-outputs.test.js +273 -0
  112. package/test/viewer.test.js +91 -0
  113. package/utils/perf.js +1 -63
  114. package/services/container/index.js +0 -6
  115. package/services/container-index/avi.js +0 -167
  116. package/services/container-index/index.js +0 -118
  117. package/services/container-index/matroska.js +0 -336
  118. package/services/container-index/mp4.js +0 -358
  119. package/services/controllers/index.js +0 -2
  120. package/services/download/index.js +0 -8
  121. package/services/orchestrators/index.js +0 -2
  122. /package/services/{container-index → container}/ebml-reader.js +0 -0
  123. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  124. /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 assert from "node:assert/strict";
19
- import {
20
- audioPairingHolds,
21
- audioRenditionName,
22
- buildAudioInventory,
23
- codecNameOf,
24
- mergeContainerAudioFlags,
25
- resolveAudioIndex
26
- } from "../services/audio-inventory.js";
27
-
28
- test("a pair agreeing on language is accepted", () => {
29
- assert.equal(audioPairingHolds({ language: "jpn" }, { language: "jpn" }), true);
30
- });
31
-
32
- test("a pair disagreeing on language is refused", () => {
33
- assert.equal(audioPairingHolds({ language: "jpn" }, { language: "rus" }), false);
34
- });
35
-
36
- test("two tracks that say nothing about themselves do not break the alignment", () => {
37
- assert.equal(audioPairingHolds({ language: "und", title: "" }, { language: "", name: "" }), true);
38
- });
39
-
40
- test("the container's own flags reach the merged track", () => {
41
- const merged = mergeContainerAudioFlags(
42
- [
43
- { index: 0, language: "eng", title: "", isDefault: true, codec: "aac" },
44
- { index: 1, language: "eng", title: "Director", isDefault: true, codec: "ac3" }
45
- ],
46
- [
47
- { language: "eng", name: "", isOriginal: true, isDefault: true, declaresDefault: true, channels: 6 },
48
- { language: "eng", name: "Director", isCommentary: true, isDefault: false, declaresDefault: true, channels: 2 }
49
- ]
50
- );
51
- assert.equal(merged.aligned, true);
52
- assert.equal(merged.tracks[0].isOriginal, true);
53
- assert.equal(merged.tracks[0].channels, 6);
54
- assert.equal(merged.tracks[1].isCommentary, true);
55
- // Matroska defaults FlagDefault to 1 and ffmpeg prints the applied default, so
56
- // the banner said both tracks were default. The container says otherwise.
57
- assert.equal(merged.tracks[1].isDefault, false);
58
- });
59
-
60
- test("a count that differs drops the container reading whole", () => {
61
- const merged = mergeContainerAudioFlags(
62
- [{ index: 0, language: "eng", isDefault: true }],
63
- [{ language: "eng" }, { language: "rus" }]
64
- );
65
- assert.equal(merged.aligned, false);
66
- assert.match(merged.reason, /declares 2 audio tracks and the probe found 1/);
67
- // Nothing of it is used — not even the flags that happened to line up.
68
- assert.equal(merged.tracks[0].isCommentary, false);
69
- assert.equal(merged.tracks[0].declaresDefault, false);
70
- });
71
-
72
- test("one pair that agrees on neither language nor title drops it too", () => {
73
- const merged = mergeContainerAudioFlags(
74
- [{ index: 0, language: "jpn", title: "" }, { index: 1, language: "rus", title: "" }],
75
- [{ language: "jpn", name: "" }, { language: "eng", name: "" }]
76
- );
77
- assert.equal(merged.aligned, false);
78
- assert.match(merged.reason, /audio 1 is/);
79
- });
80
-
81
- test("embedded tracks keep the numbers they have always had, sidecars follow", () => {
82
- const inventory = buildAudioInventory({
83
- embedded: [
84
- { language: "jpn", codec: "aac", isDefault: true },
85
- { language: "eng", codec: "ac3", title: "Commentary", isCommentary: true }
86
- ],
87
- videoFileIndex: 24,
88
- sidecars: [
89
- {
90
- file: { fileIndex: 12, name: "ep.mka", folders: ["Rus Sound"], extension: ".mka" },
91
- tracks: [{ language: "rus", codecId: "A_AC3", channels: 6 }]
92
- }
93
- ]
94
- });
95
-
96
- assert.deepEqual(inventory.map((entry) => entry.index), [0, 1, 2]);
97
- assert.deepEqual(inventory.map((entry) => entry.fileIndex), [24, 24, 12]);
98
- assert.deepEqual(inventory.map((entry) => entry.sourceTrackIndex), [0, 1, 0]);
99
- assert.deepEqual(inventory.map((entry) => entry.kind), ["embedded", "embedded", "sidecar"]);
100
- assert.equal(inventory[2].codec, "ac3");
101
- assert.deepEqual(inventory[2].folders, ["Rus Sound"]);
102
- assert.equal(inventory[2].fileName, "ep.mka");
103
- });
104
-
105
- test("a sidecar whose table could not be read is still offered, as one track", () => {
106
- const inventory = buildAudioInventory({
107
- embedded: [{ language: "eng", codec: "aac" }],
108
- videoFileIndex: 0,
109
- sidecars: [{ file: { fileIndex: 1, name: "dub.ac3", folders: [], extension: ".ac3" }, tracks: [] }]
110
- });
111
- assert.equal(inventory.length, 2);
112
- assert.equal(inventory[1].sourceTrackIndex, 0);
113
- // The extension of a bare elementary stream IS its codec.
114
- assert.equal(inventory[1].codec, "ac3");
115
- });
116
-
117
- test("a sidecar carrying two tracks contributes both", () => {
118
- const inventory = buildAudioInventory({
119
- embedded: [],
120
- videoFileIndex: 0,
121
- sidecars: [
122
- {
123
- file: { fileIndex: 3, name: "dubs.mka", folders: [], extension: ".mka" },
124
- tracks: [{ language: "rus" }, { language: "ukr" }]
125
- }
126
- ]
127
- });
128
- assert.deepEqual(inventory.map((entry) => [entry.index, entry.sourceTrackIndex]), [[0, 0], [1, 1]]);
129
- });
130
-
131
- test("the flat number resolves back to a file and a track inside it", () => {
132
- const inventory = buildAudioInventory({
133
- embedded: [{ language: "jpn" }],
134
- videoFileIndex: 24,
135
- sidecars: [{ file: { fileIndex: 12, name: "ep.mka", folders: [], extension: ".mka" }, tracks: [{}] }]
136
- });
137
- assert.equal(resolveAudioIndex(inventory, 1).fileIndex, 12);
138
- assert.equal(resolveAudioIndex(inventory, 1).sourceTrackIndex, 0);
139
- assert.equal(resolveAudioIndex(inventory, 9), null);
140
- });
141
-
142
- test("codec identifiers are translated to the names the browser judges by", () => {
143
- assert.equal(codecNameOf({ codec: "AAC" }), "aac");
144
- assert.equal(codecNameOf({ codecId: "A_AC3" }), "ac3");
145
- assert.equal(codecNameOf({ codecId: "A_AAC/MPEG4/LC" }), "aac");
146
- assert.equal(codecNameOf({ codecId: "A_PCM/INT/LIT" }), "pcm");
147
- assert.equal(codecNameOf({ codecId: "ec-3" }), "eac3");
148
- assert.equal(codecNameOf({}, ".dts"), "dts");
149
- assert.equal(codecNameOf({}, ".unknown"), "");
150
- });
151
-
152
- test("a rendition is named by what the file says, and two never share a name", () => {
153
- const inventory = buildAudioInventory({
154
- embedded: [{ language: "jpn" }],
155
- videoFileIndex: 0,
156
- sidecars: [
157
- { file: { fileIndex: 1, name: "a.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] },
158
- { file: { fileIndex: 2, name: "b.mka", folders: ["Rus Sound"], extension: ".mka" }, tracks: [{}] }
159
- ]
160
- });
161
- assert.equal(audioRenditionName(inventory[0], inventory), "jpn");
162
- // Both sidecars sit in the same folder and neither names itself, so the names
163
- // would collide — and hls.js groups renditions by name.
164
- assert.notEqual(
165
- audioRenditionName(inventory[1], inventory),
166
- audioRenditionName(inventory[2], inventory)
167
- );
168
- });
169
-
170
- test("a commentary says so in its rendition name", () => {
171
- const inventory = buildAudioInventory({
172
- embedded: [{ language: "eng" }, { language: "eng", title: "Director", isCommentary: true }],
173
- videoFileIndex: 0,
174
- sidecars: []
175
- });
176
- assert.match(audioRenditionName(inventory[1], inventory), /commentary/);
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
+ });