@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,289 @@
1
+ /**
2
+ * @file Matroska/WebM container — RFC 9559.
3
+ *
4
+ * Reads Tracks in one pass for all media types (video, audio, subtitle).
5
+ * Implements spec-accurate flag handling:
6
+ * - FlagEnabled 0xB9 default 1, zero-length element = default (not disabled)
7
+ * - FlagDefault 0x88 default 1, declaresDefault tracks whether element was written
8
+ * - FlagForced 0x55AA only for subtitles, FlagHearingImpaired 0x55AB, FlagVisualImpaired 0x55AC,
9
+ * FlagTextDescriptions 0x55AD, FlagOriginal 0x55AE, FlagCommentary 0x55AF
10
+ * - Language 0x22B59C default "eng", LanguageBCP47 0x22B59D MUST — when present, Language ignored
11
+ * - CodecID 0x86, CodecPrivate 0x63A2, Name 0x536E, TrackType 0x83 (1 video, 2 audio, 17 subtitle)
12
+ *
13
+ * Delegates low-level Cues/cluster and keyframe work to existing readers
14
+ * (ebml-reader, matroska.js, matroska-subtitles.js) but centralizes the single Tracks walk.
15
+ */
16
+
17
+ import { Container } from "./Container.js";
18
+ import { isMatroska, readMatroskaKeyframeTimes } from "../container-index/matroska.js";
19
+ import { readSubtitlePlan } from "../container-index/matroska-subtitles.js";
20
+ import { VideoTrack } from "../tracks/VideoTrack.js";
21
+ import { AudioTrack } from "../tracks/AudioTrack.js";
22
+ import { TextSubtitleTrack, TEXT_CODECS_MATROSKA } from "../tracks/TextSubtitleTrack.js";
23
+ import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
24
+ import { ContainerTrack } from "../tracks/ContainerTrack.js";
25
+ import { findElement, iterateElements, readUint } from "../container-index/ebml-reader.js";
26
+
27
+ const HEAD_BYTES = 64 * 1024;
28
+ const ID_TRACKS = 0x1654ae6b;
29
+ const ID_TRACK_ENTRY = 0xae;
30
+ const ID_TRACK_NUMBER = 0xd7;
31
+ const ID_TRACK_TYPE = 0x83;
32
+ const ID_FLAG_ENABLED = 0xb9;
33
+ const ID_FLAG_DEFAULT = 0x88;
34
+ const ID_FLAG_FORCED = 0x55aa;
35
+ const ID_FLAG_HEARING = 0x55ab;
36
+ const ID_FLAG_VISUAL = 0x55ac;
37
+ const ID_FLAG_TEXT_DESCR = 0x55ad;
38
+ const ID_FLAG_ORIGINAL = 0x55ae;
39
+ const ID_FLAG_COMMENTARY = 0x55af;
40
+ const ID_CODEC_ID = 0x86;
41
+ const ID_CODEC_PRIVATE = 0x63a2;
42
+ const ID_LANGUAGE = 0x22b59c;
43
+ const ID_LANGUAGE_BCP47 = 0x22b59d;
44
+ const ID_NAME = 0x536e;
45
+ const ID_VIDEO = 0xe0;
46
+ const ID_AUDIO = 0xe1;
47
+ const ID_PIXEL_WIDTH = 0xb0;
48
+ const ID_PIXEL_HEIGHT = 0xba;
49
+ const ID_DISPLAY_WIDTH = 0x54b0;
50
+ const ID_DISPLAY_HEIGHT = 0x54ba;
51
+ const ID_SAMPLING_FREQUENCY = 0xb5;
52
+ const ID_CHANNELS = 0x9f;
53
+
54
+ function readString(buf, el) {
55
+ return buf.toString("utf8", el.dataOffset, el.dataOffset + el.size).replace(/\0+$/, "");
56
+ }
57
+
58
+ export class MatroskaContainer extends Container {
59
+ get formatName() {
60
+ return "matroska";
61
+ }
62
+
63
+ static detect(head) {
64
+ return isMatroska(head);
65
+ }
66
+
67
+ async readTracks() {
68
+ const head = await this.readRange(0, Math.min(HEAD_BYTES - 1, this.fileSize - 1));
69
+ if (!head || !isMatroska(head)) return [];
70
+
71
+ // Delegate to subtitle plan reader for subtitle tracks (already handles Flags spec-correct),
72
+ // but we need video/audio tracks too. Do a dedicated Tracks walk here for all types,
73
+ // then merge subtitle detail (clusterPositions, declaresDefault etc.) from the plan.
74
+ const seg = findElement(head, 0x18538067, []);
75
+ if (!seg) return [];
76
+ const tracksEl = findElement(head, ID_TRACKS, [], seg.dataOffset);
77
+ if (!tracksEl) return [];
78
+
79
+ const tracksEnd = Math.min(head.length, tracksEl.dataOffset + tracksEl.size);
80
+ /** @type {import("../tracks/index.js").ContainerTrack[]} */
81
+ const result = [];
82
+ // Subtitle declaredIndex is position among subtitle tracks, not global — track per-type counters.
83
+ let subtitleDeclaredIndex = -1;
84
+ let audioDeclaredIndex = -1;
85
+ let videoDeclaredIndex = -1;
86
+
87
+ // For subtitle flag enrichment, read the existing plan (it already does Cues walk)
88
+ let subtitlePlan = null;
89
+ try {
90
+ const shim = async (s, e) => this.readRange(s, Math.min(e, this.fileSize - 1));
91
+ subtitlePlan = await readSubtitlePlan(shim, this.fileSize);
92
+ } catch {
93
+ subtitlePlan = null;
94
+ }
95
+ const declaredByNumber = new Map();
96
+ const planTracksByNumber = new Map();
97
+ if (subtitlePlan?.declared) {
98
+ for (const d of subtitlePlan.declared) declaredByNumber.set(d.trackNumber, d);
99
+ }
100
+ if (subtitlePlan?.tracks) {
101
+ for (const t of subtitlePlan.tracks) planTracksByNumber.set(t.trackNumber, t);
102
+ }
103
+
104
+ for (const entry of iterateElements(head, tracksEl.dataOffset, tracksEnd)) {
105
+ if (entry.id !== ID_TRACK_ENTRY) continue;
106
+ const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
107
+ let trackNumber = null;
108
+ let typeNum = null;
109
+ let codecId = "";
110
+ let language = "";
111
+ let languageBcp47 = "";
112
+ let name = "";
113
+ let codecPrivateB64 = "";
114
+ let isEnabled = true;
115
+ let isDefault = true;
116
+ let declaresDefault = false;
117
+ let isForced = false;
118
+ let isHearing = false;
119
+ let isVisual = false;
120
+ let isOriginal = false;
121
+ let isCommentary = false;
122
+ let pixelWidth = null;
123
+ let pixelHeight = null;
124
+ let displayWidth = null;
125
+ let displayHeight = null;
126
+ let samplingFreq = null;
127
+ let channels = null;
128
+
129
+ for (const f of iterateElements(head, entry.dataOffset, entryEnd)) {
130
+ switch (f.id) {
131
+ case ID_TRACK_NUMBER: trackNumber = readUint(head, f.dataOffset, f.size); break;
132
+ case ID_TRACK_TYPE: typeNum = readUint(head, f.dataOffset, f.size); break;
133
+ case ID_CODEC_ID: codecId = readString(head, f); break;
134
+ case ID_CODEC_PRIVATE: codecPrivateB64 = head.toString("base64", f.dataOffset, f.dataOffset + f.size); break;
135
+ case ID_LANGUAGE: language = readString(head, f); break;
136
+ case ID_LANGUAGE_BCP47: languageBcp47 = readString(head, f); break;
137
+ case ID_NAME: name = readString(head, f); break;
138
+ case ID_FLAG_ENABLED: isEnabled = f.size === 0 || readUint(head, f.dataOffset, f.size) !== 0; break;
139
+ case ID_FLAG_DEFAULT: isDefault = readUint(head, f.dataOffset, f.size) === 1; declaresDefault = true; break;
140
+ case ID_FLAG_FORCED: isForced = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
141
+ case ID_FLAG_HEARING: isHearing = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
142
+ case ID_FLAG_VISUAL: isVisual = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
143
+ case ID_FLAG_TEXT_DESCR: break;
144
+ case ID_FLAG_ORIGINAL: isOriginal = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
145
+ case ID_FLAG_COMMENTARY: isCommentary = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
146
+ default: break;
147
+ }
148
+ // Video/Audio sub-elements are nested, not at entry level — read separately below.
149
+ }
150
+ if (trackNumber === null) continue;
151
+
152
+ // Parse Video/Audio sub-elements if present
153
+ const videoEl = findElement(head, ID_VIDEO, [], entry.dataOffset, entryEnd);
154
+ if (videoEl) {
155
+ for (const vf of iterateElements(head, videoEl.dataOffset, Math.min(entryEnd, videoEl.dataOffset + videoEl.size))) {
156
+ if (vf.id === ID_PIXEL_WIDTH) pixelWidth = readUint(head, vf.dataOffset, vf.size);
157
+ else if (vf.id === ID_PIXEL_HEIGHT) pixelHeight = readUint(head, vf.dataOffset, vf.size);
158
+ else if (vf.id === ID_DISPLAY_WIDTH) displayWidth = readUint(head, vf.dataOffset, vf.size);
159
+ else if (vf.id === ID_DISPLAY_HEIGHT) displayHeight = readUint(head, vf.dataOffset, vf.size);
160
+ }
161
+ }
162
+ const audioEl = findElement(head, ID_AUDIO, [], entry.dataOffset, entryEnd);
163
+ if (audioEl) {
164
+ for (const af of iterateElements(head, audioEl.dataOffset, Math.min(entryEnd, audioEl.dataOffset + audioEl.size))) {
165
+ if (af.id === ID_SAMPLING_FREQUENCY) {
166
+ // SamplingFrequency is float64
167
+ if (af.size === 8) samplingFreq = head.readDoubleBE(af.dataOffset);
168
+ else samplingFreq = readUint(head, af.dataOffset, af.size);
169
+ } else if (af.id === ID_CHANNELS) channels = readUint(head, af.dataOffset, af.size);
170
+ }
171
+ }
172
+
173
+ // RFC 9559 LanguageBCP47 MUST — when present, Language ignored
174
+ const resolvedLang = languageBcp47 || language;
175
+ const bcpTag = languageBcp47;
176
+
177
+ if (typeNum === 1) {
178
+ videoDeclaredIndex += 1;
179
+ result.push(new VideoTrack({
180
+ trackNumber,
181
+ declaredIndex: videoDeclaredIndex,
182
+ codecId,
183
+ language: resolvedLang,
184
+ languageBcp47: bcpTag,
185
+ name,
186
+ isEnabled,
187
+ isDefault,
188
+ declaresDefault,
189
+ codecPrivateB64,
190
+ width: pixelWidth,
191
+ height: pixelHeight,
192
+ displayWidth,
193
+ displayHeight
194
+ }));
195
+ } else if (typeNum === 2) {
196
+ audioDeclaredIndex += 1;
197
+ result.push(new AudioTrack({
198
+ trackNumber,
199
+ declaredIndex: audioDeclaredIndex,
200
+ codecId,
201
+ language: resolvedLang,
202
+ languageBcp47: bcpTag,
203
+ name,
204
+ isEnabled,
205
+ isDefault,
206
+ declaresDefault,
207
+ codecPrivateB64,
208
+ isOriginal,
209
+ isCommentary,
210
+ isVisualImpaired: isVisual,
211
+ channels,
212
+ samplingFrequency: samplingFreq
213
+ }));
214
+ } else if (typeNum === 17) {
215
+ subtitleDeclaredIndex += 1;
216
+ // Only Forced/Hearing belong to subtitles; Original/Commentary must not leak.
217
+ const declared = declaredByNumber.get(trackNumber);
218
+ const planTrack = planTracksByNumber.get(trackNumber);
219
+ // Prefer plan's flags when available (already spec-correct), else use parsed.
220
+ const finalForced = planTrack ? !!planTrack.isForced : isForced;
221
+ const finalHearing = planTrack ? !!planTrack.isHearingImpaired : isHearing;
222
+ const finalEnabled = declared ? declared.isEnabled !== false : isEnabled;
223
+ const finalDefault = declared ? !!declared.isDefault : isDefault;
224
+ const finalDeclares = declared ? !!declared.declaresDefault : declaresDefault;
225
+ const clusterPositions = planTrack ? planTrack.clusterPositions ?? [] : [];
226
+ const isText = TEXT_CODECS_MATROSKA.has(codecId);
227
+ // disabled image/text tracks still counted (declaredIndex above) — offerable flag controls visibility
228
+ if (isText && finalEnabled) {
229
+ result.push(new TextSubtitleTrack({
230
+ trackNumber,
231
+ declaredIndex: subtitleDeclaredIndex,
232
+ codecId,
233
+ language: resolvedLang,
234
+ languageBcp47: bcpTag,
235
+ name,
236
+ isEnabled: finalEnabled,
237
+ isDefault: finalDefault,
238
+ declaresDefault: finalDeclares,
239
+ codecPrivateB64,
240
+ isForced: finalForced,
241
+ isHearingImpaired: finalHearing,
242
+ clusterPositions
243
+ }));
244
+ } else {
245
+ // Image or disabled — keep declaredIndex, not offerable if disabled
246
+ const Target = isText ? TextSubtitleTrack : ImageSubtitleTrack;
247
+ result.push(new Target({
248
+ trackNumber,
249
+ declaredIndex: subtitleDeclaredIndex,
250
+ codecId,
251
+ language: resolvedLang,
252
+ languageBcp47: bcpTag,
253
+ name,
254
+ isEnabled: finalEnabled,
255
+ isDefault: finalDefault,
256
+ declaresDefault: finalDeclares,
257
+ codecPrivateB64,
258
+ isForced: finalForced,
259
+ isHearingImpaired: finalHearing,
260
+ clusterPositions: isText ? clusterPositions : []
261
+ }));
262
+ }
263
+ } else {
264
+ // Other TrackType (complex, logo, buttons, control) — keep as generic, not video
265
+ result.push(new ContainerTrack({
266
+ trackNumber,
267
+ declaredIndex: -1,
268
+ type: "other",
269
+ codecId,
270
+ language: resolvedLang,
271
+ languageBcp47: bcpTag,
272
+ name,
273
+ isEnabled,
274
+ isDefault,
275
+ declaresDefault,
276
+ codecPrivateB64
277
+ }));
278
+ }
279
+ }
280
+ return result;
281
+ }
282
+
283
+ async readKeyframeIndex() {
284
+ const times = await readMatroskaKeyframeTimes(this.readRange, this.fileSize);
285
+ if (!times) return null;
286
+ if (Array.isArray(times)) return { times, tolerance: 0 };
287
+ return times;
288
+ }
289
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * @file MP4/MOV container — ISO/IEC 14496-12.
3
+ *
4
+ * Parses moov for all track types in one walk:
5
+ * - tkhd: track_ID, flags track_enabled (0x000001), alternate_group, width/height
6
+ * - mdhd: timescale, language (packed 5-bit), version handling
7
+ * - hdlr: handler_type (vide/soun/text/sbtl/subt/subp/clcp)
8
+ * - elng: extendedLanguage BCP47 (when present, replaces mdhd language per spec)
9
+ * - stsd: sample entry format (avc1/hev1/mp4a/tx3g/wvtt/stpp)
10
+ * - stbl tables for subtitle cue ranges (stts/stsz/stsc/stco/co64) and video keyframes (stss/stts/ctts/elst)
11
+ *
12
+ * Delegates keyframe and subtitle sample reading to existing mp4.js / mp4-subtitles.js
13
+ * but centralizes track creation so FlagEnabled / LanguageBCP47 / alternate_group
14
+ * are handled once for every media type.
15
+ */
16
+
17
+ import { Container } from "./Container.js";
18
+ import { isMp4, readMp4KeyframeTimes } from "../container-index/mp4.js";
19
+ import { readMp4SubtitlePlan } from "../container-index/mp4-subtitles.js";
20
+ import { VideoTrack } from "../tracks/VideoTrack.js";
21
+ import { AudioTrack } from "../tracks/AudioTrack.js";
22
+ import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
23
+ import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
24
+
25
+ export class Mp4Container extends Container {
26
+ get formatName() {
27
+ return "mp4";
28
+ }
29
+
30
+ static detect(head) {
31
+ return isMp4(head);
32
+ }
33
+
34
+ async readTracks() {
35
+ const head = await this.readRange(0, Math.min(64 - 1, this.fileSize - 1));
36
+ if (!head || !isMp4(head)) return [];
37
+
38
+ // Use the subtitle plan reader's moov parsing as source for subtitle tracks,
39
+ // and a direct moov walk for video/audio to collect tkhd/mdhd/hdlr/elng uniformly.
40
+ // For simplicity, delegate entirely to readMp4SubtitlePlan for subtitles and
41
+ // do a lightweight moov walk for video/audio here — then merge.
42
+ const subtitlePlan = await readMp4SubtitlePlan(this.readRange, this.fileSize).catch(() => null);
43
+ const subtitleByDecl = new Map();
44
+ if (subtitlePlan?.tracks) {
45
+ for (const t of subtitlePlan.tracks) subtitleByDecl.set(t.declaredIndex, t);
46
+ }
47
+
48
+ // Minimal moov walk for video/audio: reuse isMp4 + findMoov logic by reading via existing helper
49
+ // Instead of duplicating, parse tracks via a second full moov read that collects vide/soun.
50
+ // We read moov box directly to extract video/audio tracks.
51
+ const tracks = await this.#readVideoAudioTracks();
52
+ // Append subtitle tracks from plan, converting to domain objects
53
+ if (subtitlePlan?.tracks) {
54
+ for (const s of subtitlePlan.tracks) {
55
+ const isText = TEXT_FORMATS_MP4.has(s.format);
56
+ const Cls = isText ? TextSubtitleTrack : ImageSubtitleTrack;
57
+ tracks.push(new Cls({
58
+ trackNumber: s.trackId,
59
+ declaredIndex: s.declaredIndex,
60
+ codecId: s.format,
61
+ language: s.language,
62
+ languageBcp47: "",
63
+ name: "",
64
+ isEnabled: true,
65
+ isDefault: s.declaredIndex === 0,
66
+ declaresDefault: false,
67
+ codecPrivateB64: "",
68
+ isForced: false,
69
+ isHearingImpaired: false,
70
+ clusterPositions: [],
71
+ samples: s.samples
72
+ }));
73
+ }
74
+ // Count non-text subtitle handlers (subp/clcp/stpp) for declaredIndex correctness — they are already
75
+ // accounted for in subtitlePlan's declaredIndex via SUBTITLE_HANDLERS, but we didn't create objects for
76
+ // them above when they were stpp (non-text not in plan's tracks). The plan already excludes stpp from tracks
77
+ // but increments declaredIndex, so alignment holds: we don't need extra placeholders.
78
+ }
79
+ return tracks;
80
+ }
81
+
82
+ async #readVideoAudioTracks() {
83
+ // Lightweight: scan moov for trak with hdlr vide/soun. We reuse readMp4SubtitlePlan's findMoov
84
+ // by reimplementing a minimal header walk here.
85
+ const PROBE = 64;
86
+ const MAX_MOOV = 32 * 1024 * 1024;
87
+ // find moov offset
88
+ let at = 0;
89
+ let moovBox = null;
90
+ while (at < this.fileSize) {
91
+ const probe = await this.readRange(at, Math.min(this.fileSize - 1, at + PROBE - 1));
92
+ if (!probe || probe.length < 8) break;
93
+ let size = probe.readUInt32BE(0);
94
+ const type = probe.toString("latin1", 4, 8);
95
+ let header = 8;
96
+ if (size === 1) {
97
+ if (probe.length < 16) break;
98
+ size = Number(probe.readBigUInt64BE(8));
99
+ header = 16;
100
+ }
101
+ if (size <= 0) break;
102
+ if (type === "moov") { moovBox = { offset: at, size, header }; break; }
103
+ at += size;
104
+ }
105
+ if (!moovBox || moovBox.size > MAX_MOOV) return [];
106
+ const moov = await this.readRange(moovBox.offset, Math.min(this.fileSize - 1, moovBox.offset + moovBox.size - 1));
107
+ if (!moov) return [];
108
+
109
+ const result = [];
110
+ let videoIdx = -1;
111
+ let audioIdx = -1;
112
+ // iterate trak boxes inside moov
113
+ const readBox = (buf, off) => {
114
+ if (off + 8 > buf.length) return null;
115
+ let sz = buf.readUInt32BE(off);
116
+ const tp = buf.toString("latin1", off + 4, off + 8);
117
+ let hb = 8;
118
+ if (sz === 1) { if (off + 16 > buf.length) return null; sz = Number(buf.readBigUInt64BE(off + 8)); hb = 16; }
119
+ if (sz < hb) return null;
120
+ return { type: tp, size: sz, dataOffset: off + hb, end: off + sz };
121
+ };
122
+ const childrenOf = (buf, start, end, type) => {
123
+ const out = [];
124
+ let p = start;
125
+ while (p + 8 <= end) {
126
+ const b = readBox(buf, p);
127
+ if (!b) break;
128
+ if (b.type === type) out.push(b);
129
+ p = b.end;
130
+ }
131
+ return out;
132
+ };
133
+ const childOf = (buf, s, e, t) => childrenOf(buf, s, e, t)[0] ?? null;
134
+
135
+ const moovContentStart = moovBox.header;
136
+ const moovEnd = moov.length;
137
+ for (const trak of childrenOf(moov, moovContentStart, moovEnd, "trak")) {
138
+ const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
139
+ if (!mdia) continue;
140
+ const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
141
+ const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
142
+ const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
143
+ const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
144
+ let language = "";
145
+ if (mdhd) {
146
+ const ver = moov[mdhd.dataOffset];
147
+ const langAt = ver === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
148
+ if (langAt + 2 <= mdhd.end) {
149
+ const packed = moov.readUInt16BE(langAt);
150
+ language = [10, 5, 0].map((s) => String.fromCharCode(((packed >> s) & 0x1f) + 0x60)).join("").replace(/[^a-z]/g, "");
151
+ }
152
+ }
153
+ // elng overrides mdhd language per spec §8.4.6
154
+ let languageBcp47 = "";
155
+ const elng = childOf(moov, mdia.dataOffset, mdia.end, "elng");
156
+ if (elng && elng.end - elng.dataOffset >= 4) {
157
+ languageBcp47 = moov.toString("utf8", elng.dataOffset + 4, elng.end).replace(/\0+$/, "");
158
+ }
159
+ let trackId = 0;
160
+ let isEnabled = true;
161
+ let alternateGroup = 0;
162
+ let width = null;
163
+ let height = null;
164
+ if (tkhd) {
165
+ const ver = moov[tkhd.dataOffset];
166
+ const flags = moov.readUInt32BE(tkhd.dataOffset + 1) & 0xffffff; // 3 bytes after version
167
+ isEnabled = (flags & 0x000001) !== 0;
168
+ trackId = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12);
169
+ alternateGroup = moov.readUInt16BE(ver === 1 ? tkhd.dataOffset + 26 : tkhd.dataOffset + 18);
170
+ // width/height are 16.16 fixed point at end of tkhd
171
+ if (tkhd.end - tkhd.dataOffset >= 84) {
172
+ const w = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 76 : tkhd.dataOffset + 68);
173
+ const h = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 80 : tkhd.dataOffset + 72);
174
+ width = w / 65536;
175
+ height = h / 65536;
176
+ }
177
+ }
178
+ const resolvedLang = languageBcp47 || language;
179
+ if (handler === "vide") {
180
+ videoIdx += 1;
181
+ // stsd format for codecId
182
+ let codecId = "";
183
+ const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
184
+ const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
185
+ const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
186
+ if (stsd) {
187
+ const first = readBox(moov, stsd.dataOffset + 8);
188
+ if (first) codecId = first.type;
189
+ }
190
+ result.push(new VideoTrack({
191
+ trackNumber: trackId,
192
+ declaredIndex: videoIdx,
193
+ codecId,
194
+ language: resolvedLang,
195
+ languageBcp47,
196
+ name: "",
197
+ isEnabled,
198
+ isDefault: true,
199
+ declaresDefault: false,
200
+ codecPrivateB64: "",
201
+ alternateGroup,
202
+ width,
203
+ height
204
+ }));
205
+ } else if (handler === "soun") {
206
+ audioIdx += 1;
207
+ let codecId = "";
208
+ const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
209
+ const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
210
+ const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
211
+ if (stsd) {
212
+ const first = readBox(moov, stsd.dataOffset + 8);
213
+ if (first) codecId = first.type;
214
+ }
215
+ result.push(new AudioTrack({
216
+ trackNumber: trackId,
217
+ declaredIndex: audioIdx,
218
+ codecId,
219
+ language: resolvedLang,
220
+ languageBcp47,
221
+ name: "",
222
+ isEnabled,
223
+ isDefault: true,
224
+ declaresDefault: false,
225
+ codecPrivateB64: "",
226
+ alternateGroup,
227
+ isOriginal: false,
228
+ isCommentary: false,
229
+ isVisualImpaired: false
230
+ }));
231
+ }
232
+ }
233
+ return result;
234
+ }
235
+
236
+ async readKeyframeIndex() {
237
+ const r = await readMp4KeyframeTimes(this.readRange, this.fileSize);
238
+ if (!r) return null;
239
+ if (Array.isArray(r)) return { times: r, tolerance: 0 };
240
+ return r;
241
+ }
242
+ }
@@ -0,0 +1,5 @@
1
+ export { Container } from "./Container.js";
2
+ export { MatroskaContainer } from "./MatroskaContainer.js";
3
+ export { Mp4Container } from "./Mp4Container.js";
4
+ export { AviContainer } from "./AviContainer.js";
5
+ export { ContainerFactory } from "./ContainerFactory.js";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @file Playback controller — interface layer over playback planning.
3
+ *
4
+ * Thin adapter between HTTP/routes and the application orchestrators.
5
+ * Does not parse containers itself — delegates to containerOrchestrator and
6
+ * the existing playback-planner service. Exists so routes depend on a
7
+ * controller contract, not on service internals.
8
+ */
9
+
10
+ import { containerOrchestrator } from "../orchestrators/ContainerOrchestrator.js";
11
+
12
+ export class PlaybackController {
13
+ /**
14
+ * @param {object} deps
15
+ * @param {import("../torrent-pool.js").TorrentPool} deps.torrentPool
16
+ * @param {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} deps.sourceRegistry
17
+ * @param {string} deps.ffmpegBin
18
+ * @param {string} deps.localBaseUrl
19
+ * @param {ReturnType<import("../playback-planner.js").createPlaybackPlanner>} deps.playbackPlanner
20
+ */
21
+ constructor({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner }) {
22
+ this.torrentPool = torrentPool;
23
+ this.sourceRegistry = sourceRegistry;
24
+ this.ffmpegBin = ffmpegBin;
25
+ this.localBaseUrl = localBaseUrl;
26
+ this.playbackPlanner = playbackPlanner;
27
+ this.containers = containerOrchestrator;
28
+ }
29
+
30
+ async getPlan(params) {
31
+ return this.playbackPlanner.getPlan(params);
32
+ }
33
+ }