kv-music/js/hls-downloader.js
2026-04-18 12:57:40 +01:00

104 lines
3.5 KiB
JavaScript

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;
}
}
}