@torrent-tv/proxy 2.73.1 → 2.74.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 (33) hide show
  1. package/CHANGELOG.md +1447 -1437
  2. package/CLAUDE.md +165 -160
  3. package/docs/container-architecture.md +192 -184
  4. package/package.json +1 -1
  5. package/routes/api/subtitles/get.js +205 -205
  6. package/services/container/Container.js +354 -135
  7. package/services/container/MatroskaContainer.js +1155 -516
  8. package/services/container/Mp4Container.js +858 -392
  9. package/services/container/SubtitleFileContainer.js +323 -261
  10. package/services/controllers/SubtitleController.js +128 -127
  11. package/services/delivery-probe.js +64 -6
  12. package/services/hls-session-manager.js +32 -35
  13. package/services/language-detect.js +174 -228
  14. package/services/playback-planner.js +747 -747
  15. package/services/produced-index.js +300 -0
  16. package/services/torrent-worker/subtitle-cues.js +582 -633
  17. package/services/tracks/TextSubtitleTrack.js +287 -47
  18. package/services/tracks/index.js +14 -14
  19. package/test/delivery-probe.test.js +67 -0
  20. package/test/matroska-blocks.test.js +0 -0
  21. package/test/mp4-subtitles.test.js +173 -127
  22. package/test/produced-index.test.js +188 -0
  23. package/test/subtitle-cue-framing.test.js +200 -202
  24. package/test/subtitle-cue-walk.test.js +369 -0
  25. package/test/subtitle-defaults.test.js +97 -97
  26. package/test/subtitle-language.test.js +252 -252
  27. package/test/subtitle-track-numbering.test.js +370 -370
  28. package/services/container-index/matroska-blocks.js +0 -202
  29. package/services/container-index/matroska-subtitles.js +0 -372
  30. package/services/container-index/mp4-subtitles.js +0 -404
  31. package/services/subtitle-convert.js +0 -144
  32. package/services/subtitle-defaults.js +0 -157
  33. package/services/tracks/subtitle-markup.js +0 -104
@@ -0,0 +1,300 @@
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
+ }