@torrent-tv/proxy 2.75.0 → 2.76.1

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 (113) hide show
  1. package/CHANGELOG.md +1509 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  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 +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11100 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -1,300 +1,222 @@
1
- /**
2
- * @file One statement of what a session has produced.
3
- *
4
- * A session's encoder writes segments into a directory of its own per run, and
5
- * a run is started afresh at every backward seek, so a session accumulates
6
- * `run-1`, `run-2`, and keeps them: a segment any run ever finished is still
7
- * the right answer for that number, which is what lets a seek back into an
8
- * earlier stretch be served from disk with no restart.
9
- *
10
- * Three places used to ask what the session holds, and each answered for
11
- * itself: the look-ahead counted numbers, the serving path looked for a file,
12
- * the header derivation listed names. Two of them held opposite beliefs about
13
- * one file for ten minutes on 2026-09-03 — a segment a killed run had opened
14
- * and never written was a NAME to one and an empty file to the other, so the
15
- * encoder was stopped for having produced it while the request for it was
16
- * refused for its being unwritten. That defect is fixed; what allowed it was
17
- * three rules with no shared definition, and this is the shared definition.
18
- *
19
- * It is also what those three cost. Every one of them walked every run
20
- * directory on the thread that carries the data channel — 1350 files for a
21
- * 90-minute film, on every segment request. A directory's modification time
22
- * changes when an entry is added or removed, so this asks THAT of each run and
23
- * re-reads only the ones that moved. A quiet request costs one `stat` per run
24
- * instead of a listing plus a `stat` per file.
25
- *
26
- * Newest run wins, and that rule is applied when the question is asked rather
27
- * than when a directory is read: each run's contents are held separately, so
28
- * re-reading one run cannot overwrite what a newer one answers.
29
- */
30
-
31
- import path from "node:path";
32
- import { readdirSync, statSync } from "node:fs";
33
-
34
- /**
35
- * What one run's directory holds.
36
- *
37
- * @typedef {object} RunContents
38
- * @property {number} readAt - The directory's modification time when it was read.
39
- * @property {Map<string, string>} byName - File name to full path, every file.
40
- * @property {Map<number, string>} byNumber - Segment number to full path, only
41
- * for segments carrying bytes.
42
- */
43
-
44
- /**
45
- * What a session has produced, and where.
46
- *
47
- * Not a cache of a truth kept elsewhere: this IS where the answer lives, and
48
- * the disk is read only to build it and to notice that a run has moved on.
49
- */
50
- export class ProducedIndex {
51
- /** @type {string} */
52
- #dirPath;
53
-
54
- /** @type {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} */
55
- #segmentFormat;
56
-
57
- /** Run directory to what it holds. @type {Map<string, RunContents>} */
58
- #runs = new Map();
59
-
60
- /**
61
- * Paths already seen carrying bytes. A piece that has bytes never loses them
62
- * and a run rewriting a number writes into a directory of its own, so this
63
- * answer never has to be taken back — which is what makes it worth keeping.
64
- *
65
- * @type {Set<string>}
66
- */
67
- #nonEmpty = new Set();
68
-
69
- /** The run directories, newest first, as of the last listing. @type {string[]} */
70
- #runDirs = [];
71
-
72
- /** The session directory's modification time when the runs were listed. */
73
- #runsListedAt = -1;
74
-
75
- /**
76
- * How many times a run directory has been listed.
77
- *
78
- * The whole point of this class is that the answer is (nearly) one per change
79
- * rather than one per request, and a claim like that is worth being able to
80
- * check rather than believe.
81
- */
82
- #directoryReads = 0;
83
-
84
- /**
85
- * @param {object} options
86
- * @param {string} options.dirPath - The session's own directory; runs live under it.
87
- * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} options.segmentFormat
88
- */
89
- constructor({ dirPath, segmentFormat }) {
90
- this.#dirPath = dirPath;
91
- this.#segmentFormat = segmentFormat;
92
- }
93
-
94
- /** How many times a run directory has been listed. @returns {number} */
95
- get directoryReads() {
96
- return this.#directoryReads;
97
- }
98
-
99
- /**
100
- * The run directories, newest first.
101
- *
102
- * Re-listed only when the session directory itself has changed, which happens
103
- * when a run is created or removed and at no other time.
104
- *
105
- * @returns {string[]}
106
- */
107
- runDirs() {
108
- let changedAt;
109
- try {
110
- changedAt = statSync(this.#dirPath).mtimeMs;
111
- } catch {
112
- this.#runDirs = [];
113
- this.#runsListedAt = -1;
114
- return this.#runDirs;
115
- }
116
- if (changedAt === this.#runsListedAt) {
117
- return this.#runDirs;
118
- }
119
- try {
120
- this.#runDirs = readdirSync(this.#dirPath, { withFileTypes: true })
121
- .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
122
- .map((entry) => entry.name)
123
- .sort((a, b) => Number(b.slice(4)) - Number(a.slice(4)))
124
- .map((name) => path.join(this.#dirPath, name));
125
- this.#runsListedAt = changedAt;
126
- } catch {
127
- this.#runDirs = [];
128
- this.#runsListedAt = -1;
129
- }
130
- return this.#runDirs;
131
- }
132
-
133
- /**
134
- * Bring the index up to date with the disk, reading only what has moved.
135
- *
136
- * @returns {void}
137
- */
138
- refresh() {
139
- const dirs = this.runDirs();
140
- const live = new Set(dirs);
141
- for (const dir of [...this.#runs.keys()]) {
142
- if (!live.has(dir)) {
143
- this.#forgetDir(dir);
144
- }
145
- }
146
- for (const dir of dirs) {
147
- let changedAt;
148
- try {
149
- changedAt = statSync(dir).mtimeMs;
150
- } catch {
151
- this.#forgetDir(dir);
152
- continue;
153
- }
154
- if (this.#runs.get(dir)?.readAt === changedAt) {
155
- continue;
156
- }
157
- this.#readDir(dir, changedAt);
158
- }
159
- }
160
-
161
- /**
162
- * Where a produced file is, or null when no run has written it.
163
- *
164
- * @param {string} fileName
165
- * @returns {string | null}
166
- */
167
- pathOf(fileName) {
168
- this.refresh();
169
- for (const dir of this.#runDirs) {
170
- const held = this.#runs.get(dir)?.byName.get(fileName);
171
- if (held !== undefined) {
172
- return held;
173
- }
174
- }
175
- return null;
176
- }
177
-
178
- /**
179
- * Every segment number some run holds with bytes in it.
180
- *
181
- * @returns {Set<number>}
182
- */
183
- segmentNumbers() {
184
- this.refresh();
185
- const numbers = new Set();
186
- for (const dir of this.#runDirs) {
187
- for (const index of this.#runs.get(dir)?.byNumber.keys() ?? []) {
188
- numbers.add(index);
189
- }
190
- }
191
- return numbers;
192
- }
193
-
194
- /**
195
- * Every produced file name, whatever run holds it.
196
- *
197
- * @returns {string[]}
198
- */
199
- fileNames() {
200
- this.refresh();
201
- const names = new Set();
202
- for (const dir of this.#runDirs) {
203
- for (const name of this.#runs.get(dir)?.byName.keys() ?? []) {
204
- names.add(name);
205
- }
206
- }
207
- return [...names];
208
- }
209
-
210
- /**
211
- * Forget what is held about a directory, so the next question re-reads it.
212
- *
213
- * Used where a file has just been removed on purpose: the directory's own
214
- * time has moved, so a refresh would find it anyway, but a caller that
215
- * deletes a file and asks in the same tick should not be told it is there.
216
- *
217
- * @param {string} [dir] - One run, or every run when not given.
218
- * @returns {void}
219
- */
220
- invalidate(dir) {
221
- if (dir === undefined) {
222
- for (const held of [...this.#runs.keys()]) {
223
- this.#forgetDir(held);
224
- }
225
- this.#runsListedAt = -1;
226
- return;
227
- }
228
- this.#forgetDir(dir);
229
- }
230
-
231
- /**
232
- * Read one run's directory into the index.
233
- *
234
- * @param {string} dir
235
- * @param {number} changedAt
236
- * @returns {void}
237
- */
238
- #readDir(dir, changedAt) {
239
- let names;
240
- try {
241
- names = readdirSync(dir, { withFileTypes: false });
242
- this.#directoryReads += 1;
243
- } catch {
244
- this.#forgetDir(dir);
245
- return;
246
- }
247
- /** @type {RunContents} */
248
- const contents = { readAt: changedAt, byName: new Map(), byNumber: new Map() };
249
- for (const name of names) {
250
- const full = path.join(dir, name);
251
- if (!this.#segmentFormat.isSegmentFileName(name)) {
252
- contents.byName.set(name, full);
253
- continue;
254
- }
255
- const index = this.#segmentFormat.segmentIndexFromName(name);
256
- if (index < 0) {
257
- continue;
258
- }
259
- if (!this.#nonEmpty.has(full)) {
260
- let size = 0;
261
- try {
262
- size = statSync(full).size;
263
- } catch {
264
- continue; // Vanished between the listing and the question.
265
- }
266
- if (size === 0) {
267
- continue; // Opened, nothing written into it yet — or ever.
268
- }
269
- this.#nonEmpty.add(full);
270
- }
271
- contents.byName.set(name, full);
272
- contents.byNumber.set(index, full);
273
- }
274
- const previous = this.#runs.get(dir);
275
- if (previous) {
276
- for (const held of previous.byName.values()) {
277
- if (!contents.byName.has(path.basename(held))) {
278
- this.#nonEmpty.delete(held);
279
- }
280
- }
281
- }
282
- this.#runs.set(dir, contents);
283
- }
284
-
285
- /**
286
- * Drop everything a directory answered for.
287
- *
288
- * @param {string} dir
289
- * @returns {void}
290
- */
291
- #forgetDir(dir) {
292
- const contents = this.#runs.get(dir);
293
- if (contents) {
294
- for (const held of contents.byName.values()) {
295
- this.#nonEmpty.delete(held);
296
- }
297
- }
298
- this.#runs.delete(dir);
299
- }
300
- }
1
+ /**
2
+ * @file One statement of what an output has produced.
3
+ *
4
+ * Every run of an output writes into that output's own directory, and there is
5
+ * exactly one. Two runs cannot want the same name because they are given
6
+ * stretches that do not overlap which is what replaced the directory-per-run
7
+ * scheme that once kept them apart, and with it the whole notion of a run
8
+ * having a place of its own.
9
+ *
10
+ * Three places used to ask what has been produced, and each answered for
11
+ * itself: the look-ahead counted numbers, the serving path looked for a file,
12
+ * the header derivation listed names. Two of them held opposite beliefs about
13
+ * one file for ten minutes on 2026-09-03 — a segment a killed run had opened
14
+ * and never written was a NAME to one and an empty file to the other, so the
15
+ * encoder was stopped for having produced it while the request for it was
16
+ * refused for its being unwritten. That defect is fixed; what allowed it was
17
+ * three rules with no shared definition, and this is the shared definition.
18
+ *
19
+ * It is also what those three cost. Every one of them listed the directory on
20
+ * the thread that carries the data channel — 1350 files for a 90-minute film,
21
+ * on every segment request. A directory's modification time changes when an
22
+ * entry is added or removed, so this asks THAT and re-reads only when it has
23
+ * moved. A quiet request costs one `stat` instead of a listing plus a `stat`
24
+ * per file.
25
+ */
26
+
27
+ import path from "node:path";
28
+ import { readdirSync, statSync } from "node:fs";
29
+
30
+ /**
31
+ * What an output's directory holds.
32
+ *
33
+ * @typedef {object} DirectoryContents
34
+ * @property {number} readAt - The directory's modification time when it was read.
35
+ * @property {Map<string, string>} byName - File name to full path, every file.
36
+ * @property {Map<number, string>} byNumber - Segment number to full path, only
37
+ * for segments carrying bytes.
38
+ */
39
+
40
+ /**
41
+ * What an output has produced, and where.
42
+ *
43
+ * Not a cache of a truth kept elsewhere: this IS where the answer lives, and
44
+ * the disk is read only to build it and to notice that it has moved on.
45
+ */
46
+ export class ProducedIndex {
47
+ /** @type {string} */
48
+ #dirPath;
49
+
50
+ /** @type {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} */
51
+ #segmentFormat;
52
+
53
+ /** @type {DirectoryContents | null} */
54
+ #contents = null;
55
+
56
+ /**
57
+ * Paths already seen carrying bytes. A piece that has bytes never loses them,
58
+ * so this answer never has to be taken back — which is what makes it worth
59
+ * keeping.
60
+ *
61
+ * @type {Set<string>}
62
+ */
63
+ #nonEmpty = new Set();
64
+
65
+ /**
66
+ * How many times the directory has been listed.
67
+ *
68
+ * The whole point of this class is that the answer is (nearly) one per change
69
+ * rather than one per request, and a claim like that is worth being able to
70
+ * check rather than believe.
71
+ */
72
+ #directoryReads = 0;
73
+
74
+ /**
75
+ * @param {object} options
76
+ * @param {string} options.dirPath - The output's directory. Every run writes
77
+ * straight into it.
78
+ * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} options.segmentFormat
79
+ */
80
+ constructor({ dirPath, segmentFormat }) {
81
+ this.#dirPath = dirPath;
82
+ this.#segmentFormat = segmentFormat;
83
+ }
84
+
85
+ /** How many times the directory has been listed. @returns {number} */
86
+ get directoryReads() {
87
+ return this.#directoryReads;
88
+ }
89
+
90
+ /** Where the output writes. @returns {string} */
91
+ get dirPath() {
92
+ return this.#dirPath;
93
+ }
94
+
95
+ /**
96
+ * Bring the index up to date with the disk, reading only if it has moved.
97
+ *
98
+ * @returns {void}
99
+ */
100
+ refresh() {
101
+ let changedAt;
102
+ try {
103
+ changedAt = statSync(this.#dirPath).mtimeMs;
104
+ } catch {
105
+ this.#forget();
106
+ return;
107
+ }
108
+ if (this.#contents?.readAt === changedAt) {
109
+ return;
110
+ }
111
+ this.#read(changedAt);
112
+ }
113
+
114
+ /**
115
+ * Where a produced file is, or null when nothing has written it.
116
+ *
117
+ * @param {string} fileName
118
+ * @returns {string | null}
119
+ */
120
+ pathOf(fileName) {
121
+ this.refresh();
122
+ return this.#contents?.byName.get(fileName) ?? null;
123
+ }
124
+
125
+ /**
126
+ * Every segment number the output holds with bytes in it.
127
+ *
128
+ * @returns {Set<number>}
129
+ */
130
+ segmentNumbers() {
131
+ this.refresh();
132
+ return new Set(this.#contents?.byNumber.keys() ?? []);
133
+ }
134
+
135
+ /**
136
+ * Every produced file name.
137
+ *
138
+ * @returns {string[]}
139
+ */
140
+ fileNames() {
141
+ this.refresh();
142
+ return [...(this.#contents?.byName.keys() ?? [])];
143
+ }
144
+
145
+ /**
146
+ * Forget what is held, so the next question re-reads the disk.
147
+ *
148
+ * Used where a file has just been removed on purpose: the directory's own
149
+ * time has moved, so a refresh would find it anyway, but a caller that
150
+ * deletes a file and asks in the same tick should not be told it is there.
151
+ *
152
+ * @returns {void}
153
+ */
154
+ invalidate() {
155
+ this.#forget();
156
+ }
157
+
158
+ /**
159
+ * Read the directory into the index.
160
+ *
161
+ * @param {number} changedAt
162
+ * @returns {void}
163
+ */
164
+ #read(changedAt) {
165
+ let names;
166
+ try {
167
+ names = readdirSync(this.#dirPath, { withFileTypes: false });
168
+ this.#directoryReads += 1;
169
+ } catch {
170
+ this.#forget();
171
+ return;
172
+ }
173
+ /** @type {DirectoryContents} */
174
+ const contents = { readAt: changedAt, byName: new Map(), byNumber: new Map() };
175
+ for (const name of names) {
176
+ const full = path.join(this.#dirPath, name);
177
+ if (!this.#segmentFormat.isSegmentFileName(name)) {
178
+ contents.byName.set(name, full);
179
+ continue;
180
+ }
181
+ const index = this.#segmentFormat.segmentIndexFromName(name);
182
+ if (index < 0) {
183
+ continue;
184
+ }
185
+ if (!this.#nonEmpty.has(full)) {
186
+ let size = 0;
187
+ try {
188
+ size = statSync(full).size;
189
+ } catch {
190
+ continue; // Vanished between the listing and the question.
191
+ }
192
+ if (size === 0) {
193
+ continue; // Opened, nothing written into it yet — or ever.
194
+ }
195
+ this.#nonEmpty.add(full);
196
+ }
197
+ contents.byName.set(name, full);
198
+ contents.byNumber.set(index, full);
199
+ }
200
+ const previous = this.#contents;
201
+ if (previous) {
202
+ for (const held of previous.byName.values()) {
203
+ if (!contents.byName.has(path.basename(held))) {
204
+ this.#nonEmpty.delete(held);
205
+ }
206
+ }
207
+ }
208
+ this.#contents = contents;
209
+ }
210
+
211
+ /**
212
+ * Drop everything the index answered for.
213
+ *
214
+ * @returns {void}
215
+ */
216
+ #forget() {
217
+ for (const held of this.#contents?.byName.values() ?? []) {
218
+ this.#nonEmpty.delete(held);
219
+ }
220
+ this.#contents = null;
221
+ }
222
+ }