import { SegmentedDownloadProgress } from './progressEvents'; import { getProxyUrl } from './proxy-utils'; export class HlsDownloader { constructor() {} async downloadHlsStream(masterUrl, options = {}) { const { onProgress, signal } = options; const response = await fetch(getProxyUrl(masterUrl), { signal }); const masterText = await response.text(); const variantUrl = this.getBestVariantUrl(masterUrl, masterText); const mediaResponse = await fetch(getProxyUrl(variantUrl), { signal }); const mediaText = await mediaResponse.text(); const segments = this.parseMediaPlaylist(variantUrl, mediaText); if (segments.length === 0) { throw new Error('No segments found in HLS playlist'); } const chunks = []; let downloadedBytes = 0; const totalSegments = segments.length; for (let i = 0; i < totalSegments; i++) { if (signal?.aborted) throw new Error('AbortError'); onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i, totalSegments)); const segmentUrl = segments[i]; const segmentResponse = await fetch(getProxyUrl(segmentUrl), { signal }); if (!segmentResponse.ok) { throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`); } const chunk = await segmentResponse.arrayBuffer(); chunks.push(chunk); downloadedBytes += chunk.byteLength; onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i + 1, totalSegments)); } const mimeType = segments[0].endsWith('.m4s') || segments[0].includes('mp4') ? 'video/mp4' : 'video/mp2t'; return new Blob(chunks, { type: mimeType }); } getBestVariantUrl(masterUrl, masterText) { if (!masterText.includes('#EXT-X-STREAM-INF')) { return masterUrl; } const lines = masterText.split('\n'); const variants = []; let currentVariant = null; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('#EXT-X-STREAM-INF:')) { const bandwidthMatch = trimmed.match(/BANDWIDTH=(\d+)/); const resolutionMatch = trimmed.match(/RESOLUTION=(\d+x\d+)/); currentVariant = { bandwidth: bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0, resolution: resolutionMatch ? resolutionMatch[1] : 'unknown', }; } else if (trimmed && !trimmed.startsWith('#')) { if (currentVariant) { currentVariant.url = this.resolveUrl(masterUrl, trimmed); variants.push(currentVariant); currentVariant = null; } } } if (variants.length === 0) return masterUrl; variants.sort((a, b) => b.bandwidth - a.bandwidth); return variants[0].url; } parseMediaPlaylist(mediaUrl, mediaText) { const lines = mediaText.split('\n'); const segments = []; for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { segments.push(this.resolveUrl(mediaUrl, trimmed)); } } return segments; } resolveUrl(baseUrl, relativeUrl) { try { return new URL(relativeUrl, baseUrl).href; } catch { return relativeUrl; } } }