@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
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @file Subtitle controller — interface layer over SubtitleOrchestrator.
3
+ *
4
+ * Routes (HTTP or data-channel) call this, not the domain module directly.
5
+ * Handles external files vs embedded tracks branching, header setting, and
6
+ * cursor/covered-cluster bookkeeping. Domain work (cluster walk, conversion,
7
+ * language detection) stays in orchestrator/domain.
8
+ */
9
+
10
+ import { subtitleOrchestrator } from "../orchestrators/SubtitleOrchestrator.js";
11
+ import { convertSubtitleToVtt, decodeSubtitleBytes } from "../subtitle-convert.js";
12
+ import { detectLanguage } from "../language-detect.js";
13
+ import { finalizeCues } from "../torrent-worker/subtitle-cues.js";
14
+
15
+ const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
16
+
17
+ function vttTime(s) {
18
+ const safe = Math.max(0, s);
19
+ const h = Math.floor(safe / 3600);
20
+ const m = Math.floor((safe % 3600) / 60);
21
+ const r = safe % 60;
22
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${r.toFixed(3).padStart(6, "0")}`;
23
+ }
24
+
25
+ function cuesToVtt(cues, codecId) {
26
+ const lines = ["WEBVTT", ""];
27
+ for (const c of finalizeCues(cues, codecId)) {
28
+ lines.push(`${vttTime(c.startSeconds)} --> ${vttTime(c.endSeconds)}`);
29
+ lines.push(c.text);
30
+ lines.push("");
31
+ }
32
+ return lines.join("\n");
33
+ }
34
+
35
+ function readFileFully(file, maxBytes) {
36
+ return new Promise((resolve, reject) => {
37
+ const stream = file.createReadStream();
38
+ const chunks = [];
39
+ let total = 0;
40
+ stream.on("data", (chunk) => {
41
+ total += chunk.length;
42
+ if (total > maxBytes) { stream.destroy(); reject(new Error("subtitle file exceeds size cap")); return; }
43
+ chunks.push(chunk);
44
+ });
45
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
46
+ stream.on("error", reject);
47
+ });
48
+ }
49
+
50
+ export class SubtitleController {
51
+ constructor({ sourceRegistry, torrentPool }) {
52
+ this.sourceRegistry = sourceRegistry;
53
+ this.torrentPool = torrentPool;
54
+ this.orchestrator = subtitleOrchestrator;
55
+ }
56
+
57
+ /**
58
+ * Serve external subtitle file or embedded track.
59
+ * Returns { vtt, language, headers } or { error, status }.
60
+ */
61
+ async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after }) {
62
+ const rec = this.sourceRegistry.get(sourceKey);
63
+ if (!rec) return { error: "Source key was not found.", status: 404 };
64
+ const torrent = await this.torrentPool.getTorrent(rec.sourceType, rec.source);
65
+ const file = torrent.files[fileIndex];
66
+ if (!file) return { error: "File index was not found in torrent.", status: 404 };
67
+
68
+ const hasTrack = trackIndex !== undefined && trackIndex !== "" && Number.isFinite(Number(trackIndex));
69
+ if (!hasTrack) {
70
+ const name = file.name ?? "";
71
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
72
+ const release = this.torrentPool.acquireFile(torrent, fileIndex);
73
+ try {
74
+ const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
75
+ const text = decodeSubtitleBytes(bytes);
76
+ const vtt = convertSubtitleToVtt(text, ext);
77
+ if (!vtt) return { error: `Unsupported subtitle format: ${ext}`, status: 422 };
78
+ return { vtt, language: detectLanguage(text), headers: {} };
79
+ } catch (e) {
80
+ return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
81
+ } finally {
82
+ release();
83
+ }
84
+ }
85
+
86
+ const idx = Number(trackIndex);
87
+ if (!Number.isInteger(idx) || idx < 0) return { error: "trackIndex must be a non-negative integer.", status: 400 };
88
+
89
+ // Resolve via orchestrator (domain: cluster walk or MP4 sample ranges)
90
+ const tracks = await this.orchestrator.getTracks(torrent, fileIndex, sourceKey);
91
+ const track = Array.isArray(tracks) ? tracks.find((c) => c.declaredIndex === idx) ?? null : null;
92
+ // Also try domain's declaredIndex-agnostic lookup via getCues path — keep compat with existing subtitle-cues declaredIndex
93
+ let held = null;
94
+ try {
95
+ // Need trackNumber for domain call — find via declared workspace
96
+ const domainTracks = await this.orchestrator.getDeclaredTracks(torrent, fileIndex, sourceKey);
97
+ // If not found, fall back to direct cuesHeldFor via trackNumber from tracks list
98
+ const target = track ?? domainTracks.find((t) => t.declaredIndex === idx) ?? null;
99
+ const trackNumber = target?.trackNumber ?? track?.trackNumber;
100
+ if (trackNumber != null) {
101
+ held = await this.orchestrator.getCues(torrent, fileIndex, sourceKey, trackNumber);
102
+ }
103
+ } catch {}
104
+ if (held && Array.isArray(held.cues)) {
105
+ const cursor = held.cues.reduce((h, c) => Math.max(h, Number(c.seq) || 0), 0);
106
+ const fresh = Number.isInteger(since) ? held.cues.filter((c) => (Number(c.seq) || 0) > since)
107
+ : Number.isFinite(after) ? held.cues.filter((c) => c.startSeconds > after) : held.cues;
108
+ const vtt = cuesToVtt(fresh, held.track?.codecId ?? track?.codecId ?? "");
109
+ const language = held.cues.length > 0 ? detectLanguage(held.cues.map((c) => c.text).join("\n")) : null;
110
+ return {
111
+ vtt,
112
+ language,
113
+ headers: {
114
+ "X-Subtitle-Covered-Clusters": String(held.coveredClusters ?? 0),
115
+ "X-Subtitle-Indexed-Clusters": String(held.indexedClusters ?? 0),
116
+ "X-Subtitle-Cursor": String(cursor)
117
+ }
118
+ };
119
+ }
120
+ return { pending: true, status: 202 };
121
+ }
122
+
123
+ async warm(torrent, fileIndex, sourceKey) {
124
+ return this.orchestrator.warm(torrent, fileIndex, sourceKey);
125
+ }
126
+ }
@@ -0,0 +1,2 @@
1
+ export { PlaybackController } from "./PlaybackController.js";
2
+ export { SubtitleController } from "./SubtitleController.js";