@torrent-tv/proxy 2.63.0 → 2.64.1
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 +8 -0
- package/CLAUDE.md +17 -0
- package/docs/container-architecture.md +86 -0
- package/package.json +1 -1
- package/routes/api/playback-plan/post.js +5 -6
- package/routes/api/subtitles/get.js +39 -208
- package/services/container/AviContainer.js +45 -0
- package/services/container/Container.js +59 -0
- package/services/container/ContainerFactory.js +31 -0
- package/services/container/MatroskaContainer.js +289 -0
- package/services/container/Mp4Container.js +242 -0
- package/services/container/index.js +5 -0
- package/services/controllers/PlaybackController.js +33 -0
- package/services/controllers/SubtitleController.js +126 -0
- package/services/controllers/index.js +2 -0
- package/services/orchestrators/ContainerOrchestrator.js +89 -0
- package/services/orchestrators/SubtitleOrchestrator.js +101 -0
- package/services/orchestrators/index.js +2 -0
- package/services/piece-store/shared-piece-store.js +5 -1
- package/services/tracks/AudioTrack.js +40 -0
- package/services/tracks/ContainerTrack.js +72 -0
- package/services/tracks/ExternalSubtitleFile.js +27 -0
- package/services/tracks/ImageSubtitleTrack.js +19 -0
- package/services/tracks/SubtitleTrack.js +38 -0
- package/services/tracks/TextSubtitleTrack.js +29 -0
- package/services/tracks/VideoTrack.js +33 -0
- package/services/tracks/index.js +7 -0
- 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 = f.size === 0 || 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,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
|
+
}
|