@torrent-tv/proxy 2.80.18 → 2.81.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 (67) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/docs/encode-architecture.md +51 -2
  3. package/package.json +1 -1
  4. package/research/double-spawn-2026-09-10.md +171 -0
  5. package/services/disk/DiskSpace.js +146 -0
  6. package/services/disk/wire.js +60 -0
  7. package/services/encode/EncodeRun.js +37 -9
  8. package/services/encode/SegmentStore.js +284 -232
  9. package/services/encode/run-command.js +16 -1
  10. package/services/hls-session-manager.js +31 -128
  11. package/services/orchestrators/EncodeOrchestrator.js +52 -38
  12. package/services/piece-store/allowance.js +107 -0
  13. package/services/piece-store/piece-disk-store.js +365 -0
  14. package/services/piece-store/shared-piece-store.js +1549 -1535
  15. package/services/segment-formats/fmp4.js +54 -0
  16. package/services/segment-formats/mpegts.js +54 -0
  17. package/services/torrent-worker/client.js +32 -0
  18. package/services/torrent-worker/pool-adapter.js +15 -0
  19. package/services/torrent-worker/protocol.js +9 -0
  20. package/services/torrent-worker/worker.js +8 -1
  21. package/services/viewer/positions.js +48 -0
  22. package/test/audio-inventory.test.js +176 -176
  23. package/test/auto-quality-step.test.js +514 -514
  24. package/test/concurrent-cost.test.js +138 -138
  25. package/test/coverage-follows-the-disk.test.js +191 -187
  26. package/test/coverage-map.test.js +195 -195
  27. package/test/declared-tracks.test.js +35 -35
  28. package/test/disk-space.test.js +138 -0
  29. package/test/encode-orchestrator.test.js +0 -3
  30. package/test/encode-run.test.js +5 -12
  31. package/test/held-request-width.test.js +155 -155
  32. package/test/helpers/encode-run.js +2 -2
  33. package/test/matroska-blocks.test.js +0 -0
  34. package/test/matroska-cues-track.test.js +192 -192
  35. package/test/mp4-composition-times.test.js +0 -0
  36. package/test/mp4-subtitles.test.js +173 -173
  37. package/test/one-authority.test.js +281 -220
  38. package/test/orchestrator-wired.test.js +199 -195
  39. package/test/packet-witness-ring.test.js +236 -236
  40. package/test/packet-witness.test.js +148 -148
  41. package/test/piece-disk-store.test.js +267 -0
  42. package/test/piece-reader.test.js +4 -4
  43. package/test/piece-store-eviction.test.js +17 -17
  44. package/test/piece-store-reservations.test.js +20 -1
  45. package/test/piece-store-slow-disk.test.js +16 -1
  46. package/test/produced-copy-choice.test.js +258 -358
  47. package/test/read-window.test.js +6 -6
  48. package/test/run-intervals.test.js +100 -100
  49. package/test/seek-landing.test.js +109 -109
  50. package/test/segment-serve-wiring.test.js +8 -9
  51. package/test/segment-store-eviction.test.js +232 -0
  52. package/test/segment-store.test.js +238 -216
  53. package/test/segments-are-shared.test.js +1 -1
  54. package/test/shared-piece-store.test.js +12 -12
  55. package/test/sidecar-naming.test.js +142 -142
  56. package/test/subtitle-cue-framing.test.js +200 -200
  57. package/test/subtitle-cue-walk.test.js +369 -369
  58. package/test/subtitle-defaults.test.js +97 -97
  59. package/test/subtitle-track-numbering.test.js +370 -370
  60. package/test/tail-duplication.test.js +167 -167
  61. package/test/tracks-begin-together.test.js +195 -195
  62. package/test/two-viewers-one-picture.test.js +374 -374
  63. package/test/video-facts.test.js +102 -102
  64. package/test/wedge-certainty.test.js +131 -131
  65. package/services/encode/open-piece.js +0 -135
  66. package/services/piece-store/disk-tier.js +0 -151
  67. package/test/open-piece.test.js +0 -152
@@ -76,6 +76,9 @@ function findFragmentBounds(bytes) {
76
76
  }
77
77
 
78
78
  const INIT_FILE_NAME = "init.mp4";
79
+ // What a piece is called while it is still being written. See
80
+ // `makingFileNameTemplate`.
81
+ const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.mp4$/;
79
82
  const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
80
83
 
81
84
  /**
@@ -140,6 +143,57 @@ export const fmp4Format = {
140
143
  },
141
144
 
142
145
  /** The output path template for the `segment` muxer. */
146
+ /**
147
+ * The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
148
+ *
149
+ * Not the name it is served under. A piece being written is not a piece, and
150
+ * under its final name it is indistinguishable from one — which is how half a
151
+ * segment came to be served: the proof of completeness was "the next file
152
+ * exists", true of one writer walking forward and false the moment two runs
153
+ * share an output, because the next file is then written by another process.
154
+ * Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
155
+ * 4 510 940, and the browser refused the whole one for the rest of the session.
156
+ *
157
+ * With a working name, the served name appears only when the encoder has said
158
+ * the piece is closed, and existence under it IS the proof — one rule, the
159
+ * same for every branch, and true whether or not our own process is alive.
160
+ *
161
+ * @returns {string}
162
+ */
163
+ makingFileNameTemplate(tag) {
164
+ return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.mp4`;
165
+ },
166
+
167
+ /**
168
+ * Which run is writing this working name, or null when the name is not one.
169
+ *
170
+ * The tag is what makes clearing up after a dead run possible without
171
+ * guessing: several runs write into one directory, so "the unfinished pieces"
172
+ * is only a well-formed question per run. It used to be answered by looking
173
+ * for the highest SERVED name inside the stretch the ended run was given and
174
+ * judging its bytes — a guess, and under the naming rule above it would remove
175
+ * a complete piece somebody else had closed.
176
+ *
177
+ * @param {string} name
178
+ * @returns {string | null}
179
+ */
180
+ makingTagOf(name) {
181
+ const match = MAKING_PATTERN.exec(String(name ?? "").trim());
182
+ return match ? match[1] : null;
183
+ },
184
+
185
+ /**
186
+ * The name a piece just closed under a working name is served as, or null when
187
+ * the name is not one of ours.
188
+ *
189
+ * @param {string} makingName
190
+ * @returns {string | null}
191
+ */
192
+ servedNameOf(makingName) {
193
+ const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
194
+ return match ? this.segmentFileName(Number(match[2])) : null;
195
+ },
196
+
143
197
  segmentFileNameTemplate() {
144
198
  return "segment-%05d.mp4";
145
199
  },
@@ -14,6 +14,9 @@
14
14
  * See {@link SegmentFormat} in `./index.js` for the interface contract.
15
15
  */
16
16
 
17
+ // What a piece is called while it is still being written. See
18
+ // `makingFileNameTemplate`.
19
+ const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.ts$/;
17
20
  const SEGMENT_PATTERN = /^segment-(\d{5})\.ts$/;
18
21
 
19
22
  /**
@@ -47,6 +50,57 @@ export const mpegtsFormat = {
47
50
  },
48
51
 
49
52
  /** The output path template for the `segment` muxer. */
53
+ /**
54
+ * The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
55
+ *
56
+ * Not the name it is served under. A piece being written is not a piece, and
57
+ * under its final name it is indistinguishable from one — which is how half a
58
+ * segment came to be served: the proof of completeness was "the next file
59
+ * exists", true of one writer walking forward and false the moment two runs
60
+ * share an output, because the next file is then written by another process.
61
+ * Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
62
+ * 4 510 940, and the browser refused the whole one for the rest of the session.
63
+ *
64
+ * With a working name, the served name appears only when the encoder has said
65
+ * the piece is closed, and existence under it IS the proof — one rule, the
66
+ * same for every branch, and true whether or not our own process is alive.
67
+ *
68
+ * @returns {string}
69
+ */
70
+ makingFileNameTemplate(tag) {
71
+ return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.ts`;
72
+ },
73
+
74
+ /**
75
+ * Which run is writing this working name, or null when the name is not one.
76
+ *
77
+ * The tag is what makes clearing up after a dead run possible without
78
+ * guessing: several runs write into one directory, so "the unfinished pieces"
79
+ * is only a well-formed question per run. It used to be answered by looking
80
+ * for the highest SERVED name inside the stretch the ended run was given and
81
+ * judging its bytes — a guess, and under the naming rule above it would remove
82
+ * a complete piece somebody else had closed.
83
+ *
84
+ * @param {string} name
85
+ * @returns {string | null}
86
+ */
87
+ makingTagOf(name) {
88
+ const match = MAKING_PATTERN.exec(String(name ?? "").trim());
89
+ return match ? match[1] : null;
90
+ },
91
+
92
+ /**
93
+ * The name a piece just closed under a working name is served as, or null when
94
+ * the name is not one of ours.
95
+ *
96
+ * @param {string} makingName
97
+ * @returns {string | null}
98
+ */
99
+ servedNameOf(makingName) {
100
+ const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
101
+ return match ? this.segmentFileName(Number(match[2])) : null;
102
+ },
103
+
50
104
  segmentFileNameTemplate() {
51
105
  return "segment-%05d.ts";
52
106
  },
@@ -676,6 +676,38 @@ export class TorrentWorkerClient {
676
676
  *
677
677
  * @returns {Promise<void>}
678
678
  */
679
+ /**
680
+ * What the spilled pieces weigh, as of the last revision. Zero until one has
681
+ * happened, which is what "we have not asked yet" means.
682
+ *
683
+ * @type {number}
684
+ */
685
+ spilledBytes = 0;
686
+
687
+ /**
688
+ * Say how much disk the spilled pieces on this thread may take between them.
689
+ *
690
+ * Best effort and never awaited by anything that matters: a share that does
691
+ * not arrive leaves the ceiling where it was, and the next revision is a
692
+ * minute away.
693
+ *
694
+ * @param {number} bytes
695
+ * @returns {Promise<void>}
696
+ */
697
+ async allowSpillBytes(bytes) {
698
+ try {
699
+ const revised = await this.#caller.call(Command.SPILL_ALLOWANCE, { bytes });
700
+ // The reply says what those stores actually hold, which is what the owner
701
+ // of the disk needs for the next division. One exchange, both directions.
702
+ this.spilledBytes = Array.isArray(revised)
703
+ ? revised.reduce((sum, store) => sum + (Number(store?.bytes) || 0), 0)
704
+ : this.spilledBytes;
705
+ } catch {
706
+ // The thread is gone or busy; the next revision says it again.
707
+ }
708
+ return this.spilledBytes;
709
+ }
710
+
679
711
  async destroyAll() {
680
712
  this.#stopping = true;
681
713
  try {
@@ -70,6 +70,21 @@ export class WorkerTorrentPool {
70
70
  * @param {number} fileIndex
71
71
  * @returns {() => void}
72
72
  */
73
+ /** What the spilled pieces weigh on the torrent thread, as last revised. */
74
+ get spilledBytes() {
75
+ return this.#client.spilledBytes;
76
+ }
77
+
78
+ /**
79
+ * Say how much disk those spilled pieces may take between them.
80
+ *
81
+ * @param {number} bytes
82
+ * @returns {Promise<number>} What they hold now.
83
+ */
84
+ allowSpillBytes(bytes) {
85
+ return this.#client.allowSpillBytes(bytes);
86
+ }
87
+
73
88
  acquireFile(torrent, fileIndex) {
74
89
  const sourceKey = torrent?.sourceKey;
75
90
  if (!sourceKey) {
@@ -115,6 +115,15 @@ export const Command = {
115
115
  /** Cues of one subtitle track, from the clusters already downloaded. */
116
116
  SUBTITLE_CUES: "subtitle-cues",
117
117
  /** Shut the client down, optionally deleting downloaded data. */
118
+ /**
119
+ * How much disk the spilled pieces may take between them.
120
+ *
121
+ * Sent from the main thread, because the disk has one owner and this store is
122
+ * not its only user: the segments an encoder produces are on the same disk,
123
+ * and a ceiling one of two users sets for itself is not a ceiling. The worker
124
+ * divides its share between the stores it holds.
125
+ */
126
+ SPILL_ALLOWANCE: "spill-allowance",
118
127
  DESTROY_ALL: "destroy-all"
119
128
  };
120
129
 
@@ -65,7 +65,7 @@ forwardLogsTo((_level, message) => {
65
65
  // the hook above had a chance to register. Verified the hard way: with a static
66
66
  // import the process still aborted, and the stack named the genuine polyfill.
67
67
  const { TorrentPool, resolveDhtBootstrap } = await import("../torrent-pool.js");
68
- const { collectStoreStats, machineReserveBytes, pieceBufferCollection, reviseStoreBudgets } =
68
+ const { collectStoreStats, machineReserveBytes, pieceBufferCollection, reviseSpillBudgets, reviseStoreBudgets } =
69
69
  await import("../piece-store/shared-piece-store.js");
70
70
 
71
71
  // Resolved before the client exists, because the client builds its DHT in its
@@ -580,6 +580,13 @@ async function runCommand(command, params, id) {
580
580
  return true;
581
581
  }
582
582
 
583
+ case Command.SPILL_ALLOWANCE: {
584
+ // The disk has one owner and it is on the main thread, where the segments
585
+ // are. What arrives is this thread’s whole share; the stores divide it
586
+ // between themselves.
587
+ return reviseSpillBudgets(Number(params.bytes));
588
+ }
589
+
583
590
  case Command.DESTROY_ALL: {
584
591
  fileClaims.closeAll();
585
592
  torrentsByKey.clear();
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @file Where the viewers of one output stand.
3
+ *
4
+ * Asked by whatever has to give disk back: what lies behind every viewer has
5
+ * been played and will not be wanted again unless somebody seeks back, and what
6
+ * lies ahead of the furthest will be wanted eventually. So an answer of "nobody"
7
+ * is the strongest statement there is — everything that output holds is worth
8
+ * less than anything anybody is on their way to.
9
+ *
10
+ * Pure: it is handed the sessions, the clock and a way to turn seconds into a
11
+ * segment number, and it holds none of them.
12
+ */
13
+
14
+ import { viewersOf } from "./Viewer.js";
15
+
16
+ /**
17
+ * @param {object} params
18
+ * @param {Iterable<{ outputKey?: string }>} params.sessions - Every live session.
19
+ * @param {string} params.outputKey - The output being asked about.
20
+ * @param {(session: object, seconds: number) => number} params.segmentAt - Which
21
+ * segment of THAT session's timeline a moment of film falls in.
22
+ * @param {number} params.now
23
+ * @param {number} params.staleAfterMs - Silence longer than any a watching
24
+ * viewer can produce.
25
+ * @returns {number[]} A segment number per present viewer, unsorted.
26
+ */
27
+ export function viewerSegmentsOn({ sessions, outputKey, segmentAt, now, staleAfterMs }) {
28
+ const at = [];
29
+ for (const session of sessions) {
30
+ if (session.outputKey !== outputKey) {
31
+ continue;
32
+ }
33
+ for (const viewer of viewersOf(session).values()) {
34
+ if (!viewer.isPresent(now, staleAfterMs)) {
35
+ continue;
36
+ }
37
+ const seconds = viewer.positionSeconds();
38
+ if (!Number.isFinite(seconds)) {
39
+ continue;
40
+ }
41
+ const index = segmentAt(session, /** @type {number} */ (seconds));
42
+ if (Number.isInteger(index) && index >= 0) {
43
+ at.push(index);
44
+ }
45
+ }
46
+ }
47
+ return at;
48
+ }
@@ -1,176 +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 { 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
- });
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
+ });