@torrent-tv/proxy 2.62.0 → 2.64.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 (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CLAUDE.md +17 -0
  3. package/bin/cli.js +520 -512
  4. package/docs/container-architecture.md +86 -0
  5. package/package.json +1 -1
  6. package/routes/api/playback-plan/post.js +5 -6
  7. package/routes/api/subtitles/get.js +39 -208
  8. package/services/container/AviContainer.js +45 -0
  9. package/services/container/Container.js +59 -0
  10. package/services/container/ContainerFactory.js +31 -0
  11. package/services/container/MatroskaContainer.js +289 -0
  12. package/services/container/Mp4Container.js +242 -0
  13. package/services/container/index.js +5 -0
  14. package/services/controllers/PlaybackController.js +33 -0
  15. package/services/controllers/SubtitleController.js +126 -0
  16. package/services/controllers/index.js +2 -0
  17. package/services/delivery-probe.js +532 -480
  18. package/services/memory-report.js +120 -15
  19. package/services/orchestrators/ContainerOrchestrator.js +89 -0
  20. package/services/orchestrators/SubtitleOrchestrator.js +101 -0
  21. package/services/orchestrators/index.js +2 -0
  22. package/services/piece-store/shared-piece-store.js +870 -791
  23. package/services/torrent-worker/worker.js +738 -706
  24. package/services/tracks/AudioTrack.js +40 -0
  25. package/services/tracks/ContainerTrack.js +72 -0
  26. package/services/tracks/ExternalSubtitleFile.js +27 -0
  27. package/services/tracks/ImageSubtitleTrack.js +19 -0
  28. package/services/tracks/SubtitleTrack.js +38 -0
  29. package/services/tracks/TextSubtitleTrack.js +29 -0
  30. package/services/tracks/VideoTrack.js +33 -0
  31. package/services/tracks/index.js +7 -0
  32. package/test/delivery-probe.test.js +213 -158
  33. package/test/memory-budget.test.js +89 -2
  34. package/test/worker-source-race.test.js +0 -76
@@ -19,7 +19,7 @@
19
19
  * runtime already maintains.
20
20
  */
21
21
 
22
- import { readFile } from "node:fs/promises";
22
+ import { readFile, statfs } from "node:fs/promises";
23
23
  import os from "node:os";
24
24
 
25
25
  /** How often the reading is taken and written. */
@@ -86,6 +86,55 @@ export async function availableMemory() {
86
86
  return { bytes: os.freemem(), measured: false };
87
87
  }
88
88
 
89
+ /**
90
+ * Anonymous memory this process holds, from the kernel's own rollup.
91
+ *
92
+ * `process.memoryUsage()` sees what V8 knows about. It cannot see memory the
93
+ * allocator has taken and not returned, and on musl — which is what the addon
94
+ * runs on — a heavy churn of 8 MiB pieces leaves exactly that. The rollup's
95
+ * `Anonymous` counts every mapping backed by nothing but memory, which is
96
+ * where both a live `SharedArrayBuffer` and a freed-but-retained span sit, so
97
+ * the difference between it and what the isolates admit to is the size of what
98
+ * neither can explain (roadmap item 2, step 4).
99
+ *
100
+ * @returns {Promise<number | null>} Bytes, or null off Linux.
101
+ */
102
+ export async function readAnonymousMemory() {
103
+ try {
104
+ const text = await readFile("/proc/self/smaps_rollup", "utf8");
105
+ const match = /^Anonymous:\s+(\d+)\s+kB$/m.exec(text);
106
+ if (match) {
107
+ return Number(match[1]) * 1024;
108
+ }
109
+ } catch {
110
+ // silent-ok: not Linux, or the kernel is too old for the rollup. The line
111
+ // simply leaves the term out rather than printing a worse one.
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * How much room is left where this proxy spills pieces and writes segments.
118
+ *
119
+ * A budget for memory alone is half a budget: pieces evicted from memory go to
120
+ * disk, so a store that is well behaved about RAM can still fill the card an
121
+ * addon host boots from. Both limits are the machine's, and neither was
122
+ * measured before (roadmap item 2).
123
+ *
124
+ * @param {string} directory
125
+ * @returns {Promise<number | null>} Bytes free, or null where it cannot be read.
126
+ */
127
+ export async function readDiskFree(directory) {
128
+ try {
129
+ const stats = await statfs(directory);
130
+ return Number(stats.bavail) * Number(stats.bsize);
131
+ } catch {
132
+ // silent-ok: `statfs` is not everywhere, and a missing disk figure must not
133
+ // cost the memory reading beside it.
134
+ }
135
+ return null;
136
+ }
137
+
89
138
  /**
90
139
  * Render a size the way a person reads one.
91
140
  *
@@ -104,26 +153,61 @@ function megabytes(bytes) {
104
153
  * count is meaningless without the piece size, and the piece size differs per
105
154
  * torrent — on the film this proxy died under, 63 pieces meant 504 MB.
106
155
  *
156
+ * Two scopes, because two things are being asked and only one of them is
157
+ * per-thread. `rss` and the kernel's rollup belong to the PROCESS, so they are
158
+ * read once, on the main thread. The heap, external and arrayBuffer figures
159
+ * belong to an ISOLATE, and the torrent worker's are the ones that matter —
160
+ * the piece pool is a `SharedArrayBuffer` allocated there, which the main
161
+ * isolate cannot see at all. Reporting only the main thread's was half the
162
+ * reason 650 MB of a 893 MB process had no explanation on 2026-08-28.
163
+ *
107
164
  * @param {Object} reading
165
+ * @param {"process" | "thread"} [reading.scope]
166
+ * @param {string} [reading.label] - Which thread the isolate figures are of.
108
167
  * @param {{ rss: number, heapUsed: number, heapTotal: number, external: number, arrayBuffers: number }} reading.process
109
- * @param {number} reading.availableBytes
110
- * @param {boolean} reading.availableMeasured
111
- * @param {{ name: string, residentBytes: number, budgetBytes: number }[]} [reading.stores]
168
+ * @param {number} [reading.availableBytes]
169
+ * @param {boolean} [reading.availableMeasured]
170
+ * @param {number | null} [reading.anonymousBytes]
171
+ * @param {number | null} [reading.diskFreeBytes]
172
+ * @param {{ name: string, residentBytes: number, committedBytes: number, spilledBytes: number, budgetBytes: number }[]} [reading.stores]
112
173
  * @returns {string}
113
174
  */
114
- export function describeMemory({ process: usage, availableBytes, availableMeasured, stores = [] }) {
115
- const storeResident = stores.reduce((total, store) => total + (store.residentBytes || 0), 0);
116
- const storeBudget = stores.reduce((total, store) => total + (store.budgetBytes || 0), 0);
175
+ export function describeMemory({
176
+ scope = "process",
177
+ label = "",
178
+ process: usage,
179
+ availableBytes,
180
+ availableMeasured,
181
+ anonymousBytes = null,
182
+ diskFreeBytes = null,
183
+ stores = []
184
+ }) {
185
+ const total = (field) => stores.reduce((sum, store) => sum + (store[field] || 0), 0);
186
+ const storeResident = total("residentBytes");
187
+ const storeCommitted = total("committedBytes");
188
+ const storeSpilled = total("spilledBytes");
189
+ const storeBudget = total("budgetBytes");
190
+ // Holding and having taken are different quantities, and the gap between
191
+ // them is the whole of the growth this series exists to find: the pool only
192
+ // grows, so a spilled piece frees a slot and no memory.
117
193
  const storesPart = stores.length === 0
118
194
  ? "no torrent stores"
119
- : `${stores.length} torrent store(s) holding ${megabytes(storeResident)} ` +
120
- `of ${megabytes(storeBudget)} allowed`;
195
+ : `${stores.length} torrent store(s) holding ${megabytes(storeResident)}, ` +
196
+ `committed ${megabytes(storeCommitted)} of ${megabytes(storeBudget)} allowed, ` +
197
+ `${megabytes(storeSpilled)} spilled to disk`;
198
+ const isolate =
199
+ `heap=${megabytes(usage.heapUsed)}/${megabytes(usage.heapTotal)} ` +
200
+ `external=${megabytes(usage.external)} arrayBuffers=${megabytes(usage.arrayBuffers)}`;
201
+ if (scope === "thread") {
202
+ return `memory (${label || "thread"}): ${isolate}; ${storesPart}`;
203
+ }
121
204
  return (
122
- `memory: rss=${megabytes(usage.rss)} heap=${megabytes(usage.heapUsed)}/${megabytes(usage.heapTotal)} ` +
123
- `external=${megabytes(usage.external)} arrayBuffers=${megabytes(usage.arrayBuffers)}; ` +
205
+ `memory: rss=${megabytes(usage.rss)} ${isolate}` +
206
+ `${anonymousBytes === null ? "" : ` anon=${megabytes(anonymousBytes)}`}; ` +
124
207
  `${storesPart}; ` +
125
- `machine has ${megabytes(availableBytes)} available` +
126
- `${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}`
208
+ `machine has ${megabytes(availableBytes ?? 0)} available` +
209
+ `${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}` +
210
+ `${diskFreeBytes === null ? "" : `, ${megabytes(diskFreeBytes)} free on disk`}`
127
211
  );
128
212
  }
129
213
 
@@ -133,13 +217,25 @@ export function describeMemory({ process: usage, availableBytes, availableMeasur
133
217
  * @param {Object} options
134
218
  * @param {(message: string) => void} options.log
135
219
  * @param {() => { name: string, residentBytes: number, budgetBytes: number }[]} [options.readStores]
220
+ * @param {"process" | "thread"} [options.scope] - `thread` leaves out the
221
+ * process-wide figures, so the torrent worker can report its own isolate
222
+ * without reading /proc twice.
223
+ * @param {string} [options.label] - Which thread the isolate figures are of.
224
+ * @param {string} [options.diskPath] - Where pieces spill, for the free-space
225
+ * reading. Omitted, the disk term is left out rather than guessed.
136
226
  * @param {number} [options.intervalMs]
137
227
  * @returns {{ stop: () => void }}
138
228
  */
139
- export function startMemoryReport({ log, readStores, intervalMs = MEMORY_REPORT_INTERVAL_MS }) {
229
+ export function startMemoryReport({
230
+ log,
231
+ readStores,
232
+ scope = "process",
233
+ label = "",
234
+ diskPath = "",
235
+ intervalMs = MEMORY_REPORT_INTERVAL_MS
236
+ }) {
140
237
  const tick = async () => {
141
238
  try {
142
- const { bytes, measured } = await availableMemory();
143
239
  let stores = [];
144
240
  try {
145
241
  stores = typeof readStores === "function" ? readStores() ?? [] : [];
@@ -147,10 +243,19 @@ export function startMemoryReport({ log, readStores, intervalMs = MEMORY_REPORT_
147
243
  // silent-ok: a store list that cannot be read must not stop the reading
148
244
  // that matters, which is the process's own.
149
245
  }
246
+ if (scope === "thread") {
247
+ log(describeMemory({ scope, label, process: readProcessMemory(), stores }));
248
+ return;
249
+ }
250
+ const { bytes, measured } = await availableMemory();
150
251
  log(describeMemory({
252
+ scope,
253
+ label,
151
254
  process: readProcessMemory(),
152
255
  availableBytes: bytes,
153
256
  availableMeasured: measured,
257
+ anonymousBytes: await readAnonymousMemory(),
258
+ diskFreeBytes: diskPath ? await readDiskFree(diskPath) : null,
154
259
  stores
155
260
  }));
156
261
  } catch {
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file Container orchestrator — application layer over Container domain.
3
+ *
4
+ * Holds a per-file cache of Container instances (key sourceKey:fileIndex) so
5
+ * Tracks and keyframe index are read once per file, not per request.
6
+ * Delegates format detection to ContainerFactory. Transport-agnostic — takes
7
+ * readRange, knows nothing about torrents or HTTP.
8
+ */
9
+
10
+ import { ContainerFactory } from "../container/ContainerFactory.js";
11
+ import { logger } from "../../utils/logger.js";
12
+
13
+ export class ContainerOrchestrator {
14
+ constructor() {
15
+ /** @type {Map<string, import("../container/Container.js").Container|null>} */
16
+ this.cache = new Map();
17
+ /** @type {Map<string, Promise<import("../container/Container.js").Container|null>>} */
18
+ this.pending = new Map();
19
+ }
20
+
21
+ /**
22
+ * @param {object} params
23
+ * @param {string} params.sourceKey
24
+ * @param {number} params.fileIndex
25
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
26
+ * @param {number} params.fileSize
27
+ * @param {string} [params.label]
28
+ * @returns {Promise<import("../container/Container.js").Container|null>}
29
+ */
30
+ async getContainer({ sourceKey, fileIndex, readRange, fileSize, label = "" }) {
31
+ const key = `${sourceKey}:${fileIndex}`;
32
+ if (this.cache.has(key)) return this.cache.get(key);
33
+ if (this.pending.has(key)) return this.pending.get(key);
34
+ const p = ContainerFactory.create({ readRange, fileSize, label }).then((c) => {
35
+ this.cache.set(key, c);
36
+ this.pending.delete(key);
37
+ if (c) logger.info(`container: ${c.formatName} for "${label}"`);
38
+ else logger.info(`container: unknown for "${label}"`);
39
+ return c;
40
+ }).catch((e) => {
41
+ this.pending.delete(key);
42
+ logger.warn(`container: failed for "${label}": ${e?.message ?? e}`);
43
+ return null;
44
+ });
45
+ this.pending.set(key, p);
46
+ return p;
47
+ }
48
+
49
+ /**
50
+ * @param {object} params - same as getContainer
51
+ * @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
52
+ */
53
+ async getTracks(params) {
54
+ const container = await this.getContainer(params);
55
+ if (!container) return [];
56
+ try {
57
+ return await container.readTracks();
58
+ } catch (e) {
59
+ logger.warn(`container: readTracks failed for "${params.label}": ${e?.message ?? e}`);
60
+ return [];
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {object} params - same as getContainer
66
+ * @returns {Promise<{times:number[],tolerance:number}|null>}
67
+ */
68
+ async getKeyframeIndex(params) {
69
+ const container = await this.getContainer(params);
70
+ if (!container) return null;
71
+ try {
72
+ return await container.readKeyframeIndex();
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ forget(sourceKey, fileIndex) {
79
+ if (fileIndex === undefined) {
80
+ for (const k of [...this.cache.keys()]) if (k.startsWith(`${sourceKey}:`)) this.cache.delete(k);
81
+ for (const k of [...this.pending.keys()]) if (k.startsWith(`${sourceKey}:`)) this.pending.delete(k);
82
+ return;
83
+ }
84
+ this.cache.delete(`${sourceKey}:${fileIndex}`);
85
+ this.pending.delete(`${sourceKey}:${fileIndex}`);
86
+ }
87
+ }
88
+
89
+ export const containerOrchestrator = new ContainerOrchestrator();
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @file Subtitle orchestrator — application layer over subtitle domain.
3
+ *
4
+ * Wraps torrent-worker/subtitle-cues.js domain (planFor, cuesHeldFor,
5
+ * warmSubtitleCues, forgetSubtitles) behind Container/Track abstraction.
6
+ * Provides per-file track list and cue streaming, with the same "only already
7
+ * downloaded clusters" rule as before. Controllers (HTTP or data-channel)
8
+ * depend on this, not on the worker module directly.
9
+ *
10
+ * Delegates to ContainerOrchestrator for track enumeration so subtitle tracks
11
+ * and their flags come from the unified ContainerTrack hierarchy.
12
+ */
13
+
14
+ import { containerOrchestrator } from "./ContainerOrchestrator.js";
15
+ import {
16
+ cuesHeldFor as domainCuesHeldFor,
17
+ warmSubtitleCues as domainWarm,
18
+ subtitleTracksOf,
19
+ declaredSubtitleTracksOf,
20
+ forgetSubtitles as domainForget
21
+ } from "../torrent-worker/subtitle-cues.js";
22
+ import { logger } from "../../utils/logger.js";
23
+
24
+ export class SubtitleOrchestrator {
25
+ /**
26
+ * @param {import("./ContainerOrchestrator.js").ContainerOrchestrator} containerOrchestrator
27
+ */
28
+ constructor(containerOrchestrator) {
29
+ this.containers = containerOrchestrator;
30
+ }
31
+
32
+ /**
33
+ * Tracks for menu — text tracks via domain, enriched with ContainerTrack flags.
34
+ * Falls back to container tracks when domain has no plan yet.
35
+ * @param {object} torrent
36
+ * @param {number} fileIndex
37
+ * @param {string} sourceKey
38
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} [readRange]
39
+ * @param {number} [fileSize]
40
+ * @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
41
+ */
42
+ async getTracks(torrent, fileIndex, sourceKey, readRange, fileSize) {
43
+ try {
44
+ const domain = await subtitleTracksOf(torrent, fileIndex, sourceKey);
45
+ if (Array.isArray(domain) && domain.length > 0) return domain;
46
+ } catch {}
47
+ if (readRange && Number.isFinite(fileSize)) {
48
+ try {
49
+ const tracks = await this.containers.getTracks({ sourceKey, fileIndex, readRange, fileSize, label: torrent?.files?.[fileIndex]?.name ?? "" });
50
+ return tracks.filter((t) => t.type === "subtitle");
51
+ } catch {}
52
+ }
53
+ return [];
54
+ }
55
+
56
+ /**
57
+ * Declared subtitle tracks in container order (including image tracks) — for declaredIndex alignment.
58
+ */
59
+ async getDeclaredTracks(torrent, fileIndex, sourceKey) {
60
+ try {
61
+ return await declaredSubtitleTracksOf(torrent, fileIndex, sourceKey);
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Cues already downloaded for one track.
69
+ * @param {object} torrent
70
+ * @param {number} fileIndex
71
+ * @param {string} sourceKey
72
+ * @param {number} trackNumber - Container trackNumber
73
+ */
74
+ async getCues(torrent, fileIndex, sourceKey, trackNumber) {
75
+ try {
76
+ return await domainCuesHeldFor(torrent, fileIndex, sourceKey, trackNumber);
77
+ } catch (e) {
78
+ logger.warn(`subtitle-orchestrator: getCues failed: ${e?.message ?? e}`);
79
+ return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Warm all subtitle tracks of a file — called periodically and on verified pieces.
85
+ * Returns per-track fresh cues for push.
86
+ */
87
+ async warm(torrent, fileIndex, sourceKey) {
88
+ try {
89
+ return await domainWarm(torrent, fileIndex, sourceKey);
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ forget(sourceKey, fileIndex) {
96
+ domainForget(sourceKey, fileIndex);
97
+ this.containers.forget(sourceKey, fileIndex);
98
+ }
99
+ }
100
+
101
+ export const subtitleOrchestrator = new SubtitleOrchestrator(containerOrchestrator);
@@ -0,0 +1,2 @@
1
+ export { ContainerOrchestrator, containerOrchestrator } from "./ContainerOrchestrator.js";
2
+ export { SubtitleOrchestrator, subtitleOrchestrator } from "./SubtitleOrchestrator.js";