@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.
- package/CHANGELOG.md +1447 -1437
- package/CLAUDE.md +165 -160
- package/docs/container-architecture.md +192 -184
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +205 -205
- package/services/container/Container.js +354 -135
- package/services/container/MatroskaContainer.js +1155 -516
- package/services/container/Mp4Container.js +858 -392
- package/services/container/SubtitleFileContainer.js +323 -261
- package/services/controllers/SubtitleController.js +128 -127
- package/services/delivery-probe.js +64 -6
- package/services/hls-session-manager.js +32 -35
- package/services/language-detect.js +174 -228
- package/services/playback-planner.js +747 -747
- package/services/produced-index.js +300 -0
- package/services/torrent-worker/subtitle-cues.js +582 -633
- package/services/tracks/TextSubtitleTrack.js +287 -47
- package/services/tracks/index.js +14 -14
- package/test/delivery-probe.test.js +67 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -127
- package/test/produced-index.test.js +188 -0
- package/test/subtitle-cue-framing.test.js +200 -202
- package/test/subtitle-cue-walk.test.js +369 -0
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +370 -370
- package/services/container-index/matroska-blocks.js +0 -202
- package/services/container-index/matroska-subtitles.js +0 -372
- package/services/container-index/mp4-subtitles.js +0 -404
- package/services/subtitle-convert.js +0 -144
- package/services/subtitle-defaults.js +0 -157
- 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
|
+
}
|