@invintusmedia/tomp4 1.0.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/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@invintusmedia/tomp4",
3
+ "version": "1.0.0",
4
+ "description": "Convert MPEG-TS and fMP4 streams to standard MP4 - pure JavaScript, zero dependencies",
5
+ "main": "dist/tomp4.js",
6
+ "module": "src/index.js",
7
+ "type": "module",
8
+ "files": [
9
+ "src",
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "node build.js",
14
+ "dev": "npx serve . -p 3000",
15
+ "release": "npm run build && git add -A && git commit -m \"Build v$(node -p \"require('./package.json').version\")\" && git push",
16
+ "release:patch": "npm version patch --no-git-tag-version && npm run release",
17
+ "release:minor": "npm version minor --no-git-tag-version && npm run release",
18
+ "release:major": "npm version major --no-git-tag-version && npm run release",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/TVWIT/toMp4.js.git"
24
+ },
25
+ "keywords": [
26
+ "mp4",
27
+ "mpeg-ts",
28
+ "mpegts",
29
+ "fmp4",
30
+ "video",
31
+ "converter",
32
+ "transmux",
33
+ "hls",
34
+ "streaming"
35
+ ],
36
+ "author": "Invintus Media",
37
+ "license": "MIT",
38
+ "bugs": {
39
+ "url": "https://github.com/TVWIT/toMp4.js/issues"
40
+ },
41
+ "homepage": "https://tvwit.github.io/toMp4.js/"
42
+ }
43
+
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Fragmented MP4 to Standard MP4 Converter
3
+ * Pure JavaScript - no dependencies
4
+ */
5
+
6
+ // ============================================
7
+ // Box Utilities
8
+ // ============================================
9
+ function parseBoxes(data, offset = 0, end = data.byteLength) {
10
+ const boxes = [];
11
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
12
+ while (offset < end) {
13
+ if (offset + 8 > end) break;
14
+ const size = view.getUint32(offset);
15
+ const type = String.fromCharCode(data[offset+4], data[offset+5], data[offset+6], data[offset+7]);
16
+ if (size === 0 || size < 8) break;
17
+ boxes.push({ type, offset, size, data: data.subarray(offset, offset + size) });
18
+ offset += size;
19
+ }
20
+ return boxes;
21
+ }
22
+
23
+ function findBox(boxes, type) {
24
+ for (const box of boxes) if (box.type === type) return box;
25
+ return null;
26
+ }
27
+
28
+ function parseChildBoxes(box, headerSize = 8) {
29
+ return parseBoxes(box.data, headerSize, box.size);
30
+ }
31
+
32
+ function createBox(type, ...payloads) {
33
+ let size = 8;
34
+ for (const p of payloads) size += p.byteLength;
35
+ const result = new Uint8Array(size);
36
+ const view = new DataView(result.buffer);
37
+ view.setUint32(0, size);
38
+ result[4] = type.charCodeAt(0); result[5] = type.charCodeAt(1); result[6] = type.charCodeAt(2); result[7] = type.charCodeAt(3);
39
+ let offset = 8;
40
+ for (const p of payloads) { result.set(p, offset); offset += p.byteLength; }
41
+ return result;
42
+ }
43
+
44
+ // ============================================
45
+ // trun/tfhd Parsing
46
+ // ============================================
47
+ function parseTrunWithOffset(trunData) {
48
+ const view = new DataView(trunData.buffer, trunData.byteOffset, trunData.byteLength);
49
+ const version = trunData[8];
50
+ const flags = (trunData[9] << 16) | (trunData[10] << 8) | trunData[11];
51
+ const sampleCount = view.getUint32(12);
52
+ let offset = 16, dataOffset = 0;
53
+ if (flags & 0x1) { dataOffset = view.getInt32(offset); offset += 4; }
54
+ if (flags & 0x4) offset += 4;
55
+ const samples = [];
56
+ for (let i = 0; i < sampleCount; i++) {
57
+ const sample = {};
58
+ if (flags & 0x100) { sample.duration = view.getUint32(offset); offset += 4; }
59
+ if (flags & 0x200) { sample.size = view.getUint32(offset); offset += 4; }
60
+ if (flags & 0x400) { sample.flags = view.getUint32(offset); offset += 4; }
61
+ if (flags & 0x800) { sample.compositionTimeOffset = version === 0 ? view.getUint32(offset) : view.getInt32(offset); offset += 4; }
62
+ samples.push(sample);
63
+ }
64
+ return { samples, dataOffset };
65
+ }
66
+
67
+ function parseTfhd(tfhdData) {
68
+ return new DataView(tfhdData.buffer, tfhdData.byteOffset, tfhdData.byteLength).getUint32(12);
69
+ }
70
+
71
+ // ============================================
72
+ // Moov Rebuilding
73
+ // ============================================
74
+ function rebuildMvhd(mvhdBox, duration) {
75
+ const data = new Uint8Array(mvhdBox.data);
76
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
77
+ const version = data[8];
78
+ const durationOffset = version === 0 ? 24 : 32;
79
+ if (version === 0) view.setUint32(durationOffset, duration);
80
+ else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, duration); }
81
+ return data;
82
+ }
83
+
84
+ function rebuildTkhd(tkhdBox, trackInfo, maxDuration) {
85
+ const data = new Uint8Array(tkhdBox.data);
86
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
87
+ const version = data[8];
88
+ let trackDuration = maxDuration;
89
+ if (trackInfo) { trackDuration = 0; for (const s of trackInfo.samples) trackDuration += s.duration || 0; }
90
+ if (version === 0) view.setUint32(28, trackDuration);
91
+ else { view.setUint32(36, 0); view.setUint32(40, trackDuration); }
92
+ return data;
93
+ }
94
+
95
+ function rebuildMdhd(mdhdBox, trackInfo, maxDuration) {
96
+ const data = new Uint8Array(mdhdBox.data);
97
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
98
+ const version = data[8];
99
+ let trackDuration = 0;
100
+ if (trackInfo) for (const s of trackInfo.samples) trackDuration += s.duration || 0;
101
+ const durationOffset = version === 0 ? 24 : 32;
102
+ if (version === 0) view.setUint32(durationOffset, trackDuration);
103
+ else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, trackDuration); }
104
+ return data;
105
+ }
106
+
107
+ function rebuildStbl(stblBox, trackInfo) {
108
+ const stblChildren = parseChildBoxes(stblBox);
109
+ const newParts = [];
110
+ for (const child of stblChildren) if (child.type === 'stsd') { newParts.push(child.data); break; }
111
+ const samples = trackInfo?.samples || [];
112
+ const chunkOffsets = trackInfo?.chunkOffsets || [];
113
+
114
+ // stts
115
+ const sttsEntries = [];
116
+ let curDur = null, count = 0;
117
+ for (const s of samples) {
118
+ const d = s.duration || 0;
119
+ if (d === curDur) count++;
120
+ else { if (curDur !== null) sttsEntries.push({ count, duration: curDur }); curDur = d; count = 1; }
121
+ }
122
+ if (curDur !== null) sttsEntries.push({ count, duration: curDur });
123
+ const sttsData = new Uint8Array(8 + sttsEntries.length * 8);
124
+ const sttsView = new DataView(sttsData.buffer);
125
+ sttsView.setUint32(4, sttsEntries.length);
126
+ let off = 8;
127
+ for (const e of sttsEntries) { sttsView.setUint32(off, e.count); sttsView.setUint32(off + 4, e.duration); off += 8; }
128
+ newParts.push(createBox('stts', sttsData));
129
+
130
+ // stsc
131
+ const stscEntries = [];
132
+ if (chunkOffsets.length > 0) {
133
+ let currentSampleCount = chunkOffsets[0].sampleCount, firstChunk = 1;
134
+ for (let i = 1; i <= chunkOffsets.length; i++) {
135
+ const sampleCount = i < chunkOffsets.length ? chunkOffsets[i].sampleCount : -1;
136
+ if (sampleCount !== currentSampleCount) {
137
+ stscEntries.push({ firstChunk, samplesPerChunk: currentSampleCount, sampleDescriptionIndex: 1 });
138
+ firstChunk = i + 1; currentSampleCount = sampleCount;
139
+ }
140
+ }
141
+ } else stscEntries.push({ firstChunk: 1, samplesPerChunk: samples.length, sampleDescriptionIndex: 1 });
142
+ const stscData = new Uint8Array(8 + stscEntries.length * 12);
143
+ const stscView = new DataView(stscData.buffer);
144
+ stscView.setUint32(4, stscEntries.length);
145
+ off = 8;
146
+ for (const e of stscEntries) { stscView.setUint32(off, e.firstChunk); stscView.setUint32(off + 4, e.samplesPerChunk); stscView.setUint32(off + 8, e.sampleDescriptionIndex); off += 12; }
147
+ newParts.push(createBox('stsc', stscData));
148
+
149
+ // stsz
150
+ const stszData = new Uint8Array(12 + samples.length * 4);
151
+ const stszView = new DataView(stszData.buffer);
152
+ stszView.setUint32(8, samples.length);
153
+ off = 12;
154
+ for (const s of samples) { stszView.setUint32(off, s.size || 0); off += 4; }
155
+ newParts.push(createBox('stsz', stszData));
156
+
157
+ // stco
158
+ const numChunks = chunkOffsets.length || 1;
159
+ const stcoData = new Uint8Array(8 + numChunks * 4);
160
+ const stcoView = new DataView(stcoData.buffer);
161
+ stcoView.setUint32(4, numChunks);
162
+ for (let i = 0; i < numChunks; i++) stcoView.setUint32(8 + i * 4, chunkOffsets[i]?.offset || 0);
163
+ newParts.push(createBox('stco', stcoData));
164
+
165
+ // ctts
166
+ const hasCtts = samples.some(s => s.compositionTimeOffset);
167
+ if (hasCtts) {
168
+ const cttsEntries = [];
169
+ let curOff = null; count = 0;
170
+ for (const s of samples) {
171
+ const o = s.compositionTimeOffset || 0;
172
+ if (o === curOff) count++;
173
+ else { if (curOff !== null) cttsEntries.push({ count, offset: curOff }); curOff = o; count = 1; }
174
+ }
175
+ if (curOff !== null) cttsEntries.push({ count, offset: curOff });
176
+ const cttsData = new Uint8Array(8 + cttsEntries.length * 8);
177
+ const cttsView = new DataView(cttsData.buffer);
178
+ cttsView.setUint32(4, cttsEntries.length);
179
+ off = 8;
180
+ for (const e of cttsEntries) { cttsView.setUint32(off, e.count); cttsView.setInt32(off + 4, e.offset); off += 8; }
181
+ newParts.push(createBox('ctts', cttsData));
182
+ }
183
+
184
+ // stss
185
+ const syncSamples = [];
186
+ for (let i = 0; i < samples.length; i++) {
187
+ const flags = samples[i].flags;
188
+ if (flags !== undefined) { if (!((flags >> 16) & 0x1)) syncSamples.push(i + 1); }
189
+ }
190
+ if (syncSamples.length > 0 && syncSamples.length < samples.length) {
191
+ const stssData = new Uint8Array(8 + syncSamples.length * 4);
192
+ const stssView = new DataView(stssData.buffer);
193
+ stssView.setUint32(4, syncSamples.length);
194
+ off = 8;
195
+ for (const n of syncSamples) { stssView.setUint32(off, n); off += 4; }
196
+ newParts.push(createBox('stss', stssData));
197
+ }
198
+
199
+ return createBox('stbl', ...newParts);
200
+ }
201
+
202
+ function rebuildMinf(minfBox, trackInfo) {
203
+ const minfChildren = parseChildBoxes(minfBox);
204
+ const newParts = [];
205
+ for (const child of minfChildren) {
206
+ if (child.type === 'stbl') newParts.push(rebuildStbl(child, trackInfo));
207
+ else newParts.push(child.data);
208
+ }
209
+ return createBox('minf', ...newParts);
210
+ }
211
+
212
+ function rebuildMdia(mdiaBox, trackInfo, maxDuration) {
213
+ const mdiaChildren = parseChildBoxes(mdiaBox);
214
+ const newParts = [];
215
+ for (const child of mdiaChildren) {
216
+ if (child.type === 'minf') newParts.push(rebuildMinf(child, trackInfo));
217
+ else if (child.type === 'mdhd') newParts.push(rebuildMdhd(child, trackInfo, maxDuration));
218
+ else newParts.push(child.data);
219
+ }
220
+ return createBox('mdia', ...newParts);
221
+ }
222
+
223
+ function rebuildTrak(trakBox, trackIdMap, maxDuration) {
224
+ const trakChildren = parseChildBoxes(trakBox);
225
+ let trackId = 1;
226
+ for (const child of trakChildren) {
227
+ if (child.type === 'tkhd') {
228
+ const view = new DataView(child.data.buffer, child.data.byteOffset, child.data.byteLength);
229
+ trackId = child.data[8] === 0 ? view.getUint32(20) : view.getUint32(28);
230
+ }
231
+ }
232
+ const trackInfo = trackIdMap.get(trackId);
233
+ const newParts = [];
234
+ let hasEdts = false;
235
+ for (const child of trakChildren) {
236
+ if (child.type === 'edts') { hasEdts = true; newParts.push(child.data); }
237
+ else if (child.type === 'mdia') newParts.push(rebuildMdia(child, trackInfo, maxDuration));
238
+ else if (child.type === 'tkhd') newParts.push(rebuildTkhd(child, trackInfo, maxDuration));
239
+ else newParts.push(child.data);
240
+ }
241
+ if (!hasEdts && trackInfo) {
242
+ let trackDuration = 0;
243
+ for (const s of trackInfo.samples) trackDuration += s.duration || 0;
244
+ const elstData = new Uint8Array(20);
245
+ const elstView = new DataView(elstData.buffer);
246
+ elstView.setUint32(4, 1); elstView.setUint32(8, maxDuration); elstView.setInt32(12, 0); elstView.setInt16(16, 1);
247
+ const elst = createBox('elst', elstData);
248
+ const edts = createBox('edts', elst);
249
+ const tkhdIndex = newParts.findIndex(p => p.length >= 8 && String.fromCharCode(p[4], p[5], p[6], p[7]) === 'tkhd');
250
+ if (tkhdIndex >= 0) newParts.splice(tkhdIndex + 1, 0, edts);
251
+ }
252
+ return createBox('trak', ...newParts);
253
+ }
254
+
255
+ function updateStcoOffsets(output, ftypSize, moovSize) {
256
+ const mdatContentOffset = ftypSize + moovSize + 8;
257
+ const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
258
+ function scan(start, end) {
259
+ let pos = start;
260
+ while (pos + 8 <= end) {
261
+ const size = view.getUint32(pos);
262
+ if (size < 8) break;
263
+ const type = String.fromCharCode(output[pos+4], output[pos+5], output[pos+6], output[pos+7]);
264
+ if (type === 'stco') {
265
+ const entryCount = view.getUint32(pos + 12);
266
+ for (let i = 0; i < entryCount; i++) {
267
+ const entryPos = pos + 16 + i * 4;
268
+ view.setUint32(entryPos, mdatContentOffset + view.getUint32(entryPos));
269
+ }
270
+ } else if (['moov', 'trak', 'mdia', 'minf', 'stbl'].includes(type)) scan(pos + 8, pos + size);
271
+ pos += size;
272
+ }
273
+ }
274
+ scan(0, output.byteLength);
275
+ }
276
+
277
+ /**
278
+ * Convert fragmented MP4 to standard MP4
279
+ * @param {Uint8Array} fmp4Data - fMP4 data
280
+ * @returns {Uint8Array} Standard MP4 data
281
+ */
282
+ export function convertFmp4ToMp4(fmp4Data) {
283
+ const boxes = parseBoxes(fmp4Data);
284
+ const ftyp = findBox(boxes, 'ftyp');
285
+ const moov = findBox(boxes, 'moov');
286
+ if (!ftyp || !moov) throw new Error('Invalid fMP4: missing ftyp or moov');
287
+
288
+ const moovChildren = parseChildBoxes(moov);
289
+ const originalTrackIds = [];
290
+ for (const child of moovChildren) {
291
+ if (child.type === 'trak') {
292
+ const trakChildren = parseChildBoxes(child);
293
+ for (const tc of trakChildren) {
294
+ if (tc.type === 'tkhd') {
295
+ const view = new DataView(tc.data.buffer, tc.data.byteOffset, tc.data.byteLength);
296
+ originalTrackIds.push(tc.data[8] === 0 ? view.getUint32(20) : view.getUint32(28));
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ const tracks = new Map();
303
+ const mdatChunks = [];
304
+ let combinedMdatOffset = 0;
305
+
306
+ for (let i = 0; i < boxes.length; i++) {
307
+ const box = boxes[i];
308
+ if (box.type === 'moof') {
309
+ const moofChildren = parseChildBoxes(box);
310
+ const moofStart = box.offset;
311
+ let nextMdatOffset = 0;
312
+ for (let j = i + 1; j < boxes.length; j++) {
313
+ if (boxes[j].type === 'mdat') { nextMdatOffset = boxes[j].offset; break; }
314
+ if (boxes[j].type === 'moof') break;
315
+ }
316
+ for (const child of moofChildren) {
317
+ if (child.type === 'traf') {
318
+ const trafChildren = parseChildBoxes(child);
319
+ const tfhd = findBox(trafChildren, 'tfhd');
320
+ const trun = findBox(trafChildren, 'trun');
321
+ if (tfhd && trun) {
322
+ const trackId = parseTfhd(tfhd.data);
323
+ const { samples, dataOffset } = parseTrunWithOffset(trun.data);
324
+ if (!tracks.has(trackId)) tracks.set(trackId, { samples: [], chunkOffsets: [] });
325
+ const track = tracks.get(trackId);
326
+ const chunkOffset = combinedMdatOffset + (moofStart + dataOffset) - (nextMdatOffset + 8);
327
+ track.chunkOffsets.push({ offset: chunkOffset, sampleCount: samples.length });
328
+ track.samples.push(...samples);
329
+ }
330
+ }
331
+ }
332
+ } else if (box.type === 'mdat') {
333
+ mdatChunks.push({ data: box.data.subarray(8), offset: combinedMdatOffset });
334
+ combinedMdatOffset += box.data.subarray(8).byteLength;
335
+ }
336
+ }
337
+
338
+ const totalMdatSize = mdatChunks.reduce((sum, c) => sum + c.data.byteLength, 0);
339
+ const combinedMdat = new Uint8Array(totalMdatSize);
340
+ for (const chunk of mdatChunks) combinedMdat.set(chunk.data, chunk.offset);
341
+
342
+ const trackIdMap = new Map();
343
+ const fmp4TrackIds = Array.from(tracks.keys()).sort((a, b) => a - b);
344
+ for (let i = 0; i < fmp4TrackIds.length && i < originalTrackIds.length; i++) {
345
+ trackIdMap.set(originalTrackIds[i], tracks.get(fmp4TrackIds[i]));
346
+ }
347
+
348
+ let maxDuration = 0;
349
+ for (const [, track] of tracks) {
350
+ let dur = 0;
351
+ for (const s of track.samples) dur += s.duration || 0;
352
+ maxDuration = Math.max(maxDuration, dur);
353
+ }
354
+
355
+ const newMoovParts = [];
356
+ for (const child of moovChildren) {
357
+ if (child.type === 'mvex') continue;
358
+ if (child.type === 'trak') newMoovParts.push(rebuildTrak(child, trackIdMap, maxDuration));
359
+ else if (child.type === 'mvhd') newMoovParts.push(rebuildMvhd(child, maxDuration));
360
+ else newMoovParts.push(child.data);
361
+ }
362
+
363
+ const newMoov = createBox('moov', ...newMoovParts);
364
+ const newMdat = createBox('mdat', combinedMdat);
365
+ const output = new Uint8Array(ftyp.size + newMoov.byteLength + newMdat.byteLength);
366
+ output.set(ftyp.data, 0);
367
+ output.set(newMoov, ftyp.size);
368
+ output.set(newMdat, ftyp.size + newMoov.byteLength);
369
+ updateStcoOffsets(output, ftyp.size, newMoov.byteLength);
370
+
371
+ return output;
372
+ }
373
+
374
+ export default convertFmp4ToMp4;
375
+
package/src/hls.js ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * HLS Playlist Parser and Downloader
3
+ * Handles master playlists, variant selection, and segment downloading
4
+ */
5
+
6
+ /**
7
+ * Represents a quality variant in an HLS stream
8
+ */
9
+ class HlsVariant {
10
+ constructor({ bandwidth, resolution, codecs, url, name }) {
11
+ this.bandwidth = bandwidth;
12
+ this.resolution = resolution;
13
+ this.codecs = codecs;
14
+ this.url = url;
15
+ this.name = name || this._generateName();
16
+ }
17
+
18
+ _generateName() {
19
+ if (this.resolution) return this.resolution;
20
+ if (this.bandwidth) return `${Math.round(this.bandwidth / 1000)}kbps`;
21
+ return 'unknown';
22
+ }
23
+
24
+ /** Bandwidth in kbps */
25
+ get kbps() {
26
+ return Math.round(this.bandwidth / 1000);
27
+ }
28
+
29
+ /** Bandwidth in Mbps */
30
+ get mbps() {
31
+ return (this.bandwidth / 1000000).toFixed(2);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Represents a parsed HLS stream with quality variants
37
+ */
38
+ class HlsStream {
39
+ constructor(masterUrl, variants, segments = null) {
40
+ this.masterUrl = masterUrl;
41
+ this.variants = variants;
42
+ this.segments = segments;
43
+ this._selectedVariant = null;
44
+ }
45
+
46
+ /** Whether this is a master playlist with multiple qualities */
47
+ get isMaster() {
48
+ return this.variants.length > 0;
49
+ }
50
+
51
+ /** Get all available qualities sorted by bandwidth (highest first) */
52
+ get qualities() {
53
+ return [...this.variants].sort((a, b) => b.bandwidth - a.bandwidth);
54
+ }
55
+
56
+ /** Get the highest quality variant */
57
+ get highest() {
58
+ return this.qualities[0] || null;
59
+ }
60
+
61
+ /** Get the lowest quality variant */
62
+ get lowest() {
63
+ const q = this.qualities;
64
+ return q[q.length - 1] || null;
65
+ }
66
+
67
+ /** Currently selected variant */
68
+ get selected() {
69
+ return this._selectedVariant || this.highest;
70
+ }
71
+
72
+ /**
73
+ * Select a quality variant
74
+ * @param {string|number|HlsVariant} selector - 'highest', 'lowest', bandwidth number, or variant object
75
+ * @returns {HlsStream} this for chaining
76
+ */
77
+ select(selector) {
78
+ if (selector === 'highest') {
79
+ this._selectedVariant = this.highest;
80
+ } else if (selector === 'lowest') {
81
+ this._selectedVariant = this.lowest;
82
+ } else if (typeof selector === 'number') {
83
+ // Find by bandwidth (closest match)
84
+ this._selectedVariant = this.qualities.reduce((best, v) =>
85
+ Math.abs(v.bandwidth - selector) < Math.abs(best.bandwidth - selector) ? v : best
86
+ );
87
+ } else if (selector instanceof HlsVariant) {
88
+ this._selectedVariant = selector;
89
+ } else if (typeof selector === 'string' && selector.includes('x')) {
90
+ // Match by resolution string like "1920x1080"
91
+ this._selectedVariant = this.variants.find(v => v.resolution === selector) || this.highest;
92
+ }
93
+ return this;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Convert relative URL to absolute
99
+ */
100
+ function toAbsoluteUrl(relative, base) {
101
+ if (relative.startsWith('http://') || relative.startsWith('https://')) {
102
+ return relative;
103
+ }
104
+ return new URL(relative, base).href;
105
+ }
106
+
107
+ /**
108
+ * Parse an HLS playlist text
109
+ * @param {string} text - Playlist content
110
+ * @param {string} baseUrl - Base URL for resolving relative paths
111
+ * @returns {{ variants: HlsVariant[], segments: string[] }}
112
+ */
113
+ function parsePlaylistText(text, baseUrl) {
114
+ const lines = text.split('\n').map(l => l.trim());
115
+ const variants = [];
116
+ const segments = [];
117
+
118
+ for (let i = 0; i < lines.length; i++) {
119
+ const line = lines[i];
120
+
121
+ // Parse master playlist variants
122
+ if (line.startsWith('#EXT-X-STREAM-INF:')) {
123
+ const attrs = line.substring(18);
124
+ const bandwidth = parseInt(attrs.match(/BANDWIDTH=(\d+)/)?.[1] || '0');
125
+ const resolution = attrs.match(/RESOLUTION=(\d+x\d+)/)?.[1] || null;
126
+ const codecs = attrs.match(/CODECS="([^"]+)"/)?.[1] || null;
127
+
128
+ // Next non-comment line is the URL
129
+ let urlLine = lines[i + 1];
130
+ if (urlLine && !urlLine.startsWith('#')) {
131
+ variants.push(new HlsVariant({
132
+ bandwidth,
133
+ resolution,
134
+ codecs,
135
+ url: toAbsoluteUrl(urlLine, baseUrl)
136
+ }));
137
+ }
138
+ }
139
+
140
+ // Parse media playlist segments
141
+ if (line && !line.startsWith('#')) {
142
+ // It's a segment URL
143
+ if (!lines.some(l => l.startsWith('#EXT-X-STREAM-INF'))) {
144
+ segments.push(toAbsoluteUrl(line, baseUrl));
145
+ }
146
+ }
147
+ }
148
+
149
+ return { variants, segments };
150
+ }
151
+
152
+ /**
153
+ * Parse an HLS playlist from URL
154
+ * If it's a master playlist, returns variants. If media playlist, returns segments.
155
+ *
156
+ * @param {string} url - HLS playlist URL
157
+ * @param {object} [options] - Options
158
+ * @param {function} [options.onProgress] - Progress callback
159
+ * @returns {Promise<HlsStream>}
160
+ */
161
+ async function parseHls(url, options = {}) {
162
+ const log = options.onProgress || (() => {});
163
+
164
+ log('Fetching playlist...');
165
+ const response = await fetch(url);
166
+ if (!response.ok) {
167
+ throw new Error(`Failed to fetch playlist: ${response.status} ${response.statusText}`);
168
+ }
169
+
170
+ const text = await response.text();
171
+ const { variants, segments } = parsePlaylistText(text, url);
172
+
173
+ if (variants.length > 0) {
174
+ // Master playlist
175
+ log(`Found ${variants.length} quality variants`);
176
+ return new HlsStream(url, variants);
177
+ } else if (segments.length > 0) {
178
+ // Media playlist (no variants)
179
+ log(`Found ${segments.length} segments`);
180
+ return new HlsStream(url, [], segments);
181
+ } else {
182
+ throw new Error('Invalid HLS playlist: no variants or segments found');
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Download segments from an HLS stream
188
+ *
189
+ * @param {HlsStream|string} source - HlsStream object or URL
190
+ * @param {object} [options] - Options
191
+ * @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth number
192
+ * @param {number} [options.maxSegments] - Max segments to download (default: all)
193
+ * @param {function} [options.onProgress] - Progress callback
194
+ * @returns {Promise<Uint8Array>} Combined segment data
195
+ */
196
+ async function downloadHls(source, options = {}) {
197
+ const log = options.onProgress || (() => {});
198
+
199
+ // Parse if given a URL string
200
+ let stream = source;
201
+ if (typeof source === 'string') {
202
+ stream = await parseHls(source, options);
203
+ }
204
+
205
+ // Select quality if specified
206
+ if (options.quality) {
207
+ stream.select(options.quality);
208
+ }
209
+
210
+ // Get segments
211
+ let segments = stream.segments;
212
+
213
+ // If master playlist, fetch the selected variant's media playlist
214
+ if (stream.isMaster && stream.selected) {
215
+ const variant = stream.selected;
216
+ log(`Selected: ${variant.name} (${variant.kbps} kbps)`);
217
+
218
+ const mediaResponse = await fetch(variant.url);
219
+ if (!mediaResponse.ok) {
220
+ throw new Error(`Failed to fetch media playlist: ${mediaResponse.status}`);
221
+ }
222
+
223
+ const mediaText = await mediaResponse.text();
224
+ const { segments: mediaSegments } = parsePlaylistText(mediaText, variant.url);
225
+ segments = mediaSegments;
226
+ }
227
+
228
+ if (!segments || segments.length === 0) {
229
+ throw new Error('No segments found in playlist');
230
+ }
231
+
232
+ // Limit segments if specified
233
+ const toDownload = options.maxSegments ? segments.slice(0, options.maxSegments) : segments;
234
+ log(`Downloading ${toDownload.length} segment${toDownload.length > 1 ? 's' : ''}...`);
235
+
236
+ // Download all segments in parallel
237
+ const buffers = await Promise.all(
238
+ toDownload.map(async (url, i) => {
239
+ const resp = await fetch(url);
240
+ if (!resp.ok) {
241
+ throw new Error(`Segment ${i + 1} failed: ${resp.status}`);
242
+ }
243
+ return new Uint8Array(await resp.arrayBuffer());
244
+ })
245
+ );
246
+
247
+ // Combine into single buffer
248
+ const totalSize = buffers.reduce((sum, buf) => sum + buf.length, 0);
249
+ const combined = new Uint8Array(totalSize);
250
+ let offset = 0;
251
+ for (const buf of buffers) {
252
+ combined.set(buf, offset);
253
+ offset += buf.length;
254
+ }
255
+
256
+ log(`Downloaded ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
257
+ return combined;
258
+ }
259
+
260
+ /**
261
+ * Check if a URL looks like an HLS playlist
262
+ * @param {string} url - URL to check
263
+ * @returns {boolean}
264
+ */
265
+ function isHlsUrl(url) {
266
+ if (typeof url !== 'string') return false;
267
+ const lower = url.toLowerCase();
268
+ return lower.includes('.m3u8') || lower.includes('format=m3u8');
269
+ }
270
+
271
+ export {
272
+ HlsStream,
273
+ HlsVariant,
274
+ parseHls,
275
+ downloadHls,
276
+ isHlsUrl,
277
+ parsePlaylistText,
278
+ toAbsoluteUrl
279
+ };
280
+