kv-music/js/dash-downloader.ts
2026-04-04 01:37:47 +03:00

241 lines
8.4 KiB
TypeScript

import { AbortError } from './errorTypes';
import { SegmentedDownloadProgress } from './progressEvents';
export interface DashDownloadOptions {
onProgress?: MonochromeProgressListener<SegmentedDownloadProgress>;
signal?: AbortSignal;
calculateDashBytes?: boolean;
}
interface DashSegment {
number: number;
time: number;
}
interface DashManifest {
baseUrl: string;
initialization: string | null;
media: string | null;
segments: DashSegment[];
repId: string | null;
mimeType: string | null;
}
export class DashDownloader {
constructor() {}
async getTotalSize(urls: string[], signal?: AbortSignal): Promise<number | undefined> {
try {
let totalSize = 0;
await Promise.all(
urls.map(async (url) => {
const result = await fetch(url, { method: 'HEAD', signal });
if (result.ok) {
const contentLength = result.headers.get('Content-Length');
if (contentLength) totalSize += parseInt(contentLength, 10);
} else {
throw new Error(`Failed to fetch segment HEAD: ${result.status}`);
}
})
);
return totalSize;
} catch {
return undefined;
}
}
async downloadDashStream(manifestBlobUrl: string, options: DashDownloadOptions = {}): Promise<Blob> {
const { onProgress, signal, calculateDashBytes = true } = options;
// 1. Fetch and Parse Manifest
const response = await fetch(manifestBlobUrl);
const manifestText = await response.text();
const manifest = this.parseManifest(manifestText);
if (!manifest) {
throw new Error('Failed to parse DASH manifest');
}
// 2. Generate URLs
const urls = this.generateSegmentUrls(manifest);
const mimeType = manifest.mimeType || 'audio/mp4';
// 3. Download Segments
const chunks: ArrayBuffer[] = [];
let downloadedBytes = 0;
const totalSegments = urls.length;
const totalSize = calculateDashBytes ? await this.getTotalSize(urls, signal) : undefined;
for (let i = 0; i < urls.length; i++) {
if (signal?.aborted) throw new AbortError();
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i, totalSegments));
const url = urls[i];
const segmentResponse = await fetch(url, { signal });
if (!segmentResponse.ok) {
console.warn(`Failed to fetch segment ${i}, retrying...`);
await new Promise((r) => setTimeout(r, 1000));
const retryResponse = await fetch(url, { signal });
if (!retryResponse.ok) {
throw new Error(`Failed to fetch segment ${i}: ${retryResponse.status}`);
}
const chunk = await retryResponse.arrayBuffer();
chunks.push(chunk);
downloadedBytes += chunk.byteLength;
} else {
const chunk = await segmentResponse.arrayBuffer();
chunks.push(chunk);
downloadedBytes += chunk.byteLength;
}
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i + 1, totalSegments));
}
// 4. Concatenate
return new Blob(chunks, { type: mimeType });
}
parseManifest(manifestText: string): DashManifest {
const parser = new DOMParser();
const xml = parser.parseFromString(manifestText, 'text/xml');
const mpd = xml.querySelector('MPD');
if (!mpd) throw new Error('Invalid DASH manifest: No MPD tag');
const period = mpd.querySelector('Period');
if (!period) throw new Error('Invalid DASH manifest: No Period tag');
const adaptationSets = Array.from(period.querySelectorAll('AdaptationSet'));
adaptationSets.sort((a, b) => {
const getMaxBandwidth = (set: Element) => {
const reps = Array.from(set.querySelectorAll('Representation'));
return reps.length ? Math.max(...reps.map((r) => parseInt(r.getAttribute('bandwidth') || '0', 10))) : 0;
};
return getMaxBandwidth(b) - getMaxBandwidth(a);
});
let audioSet = adaptationSets.find((as) => as.getAttribute('mimeType')?.startsWith('audio')) ?? null;
if (!audioSet && adaptationSets.length > 0) audioSet = adaptationSets[0];
if (!audioSet) throw new Error('No AdaptationSet found');
const representations = Array.from(audioSet.querySelectorAll('Representation')).sort((a, b) => {
const bwA = parseInt(a.getAttribute('bandwidth') || '0');
const bwB = parseInt(b.getAttribute('bandwidth') || '0');
return bwB - bwA;
});
if (representations.length === 0) throw new Error('No Representation found');
const rep = representations[0];
const repId = rep.getAttribute('id');
const segmentTemplate = rep.querySelector('SegmentTemplate') || audioSet.querySelector('SegmentTemplate');
if (!segmentTemplate) throw new Error('No SegmentTemplate found');
const initialization = segmentTemplate.getAttribute('initialization');
const media = segmentTemplate.getAttribute('media');
const startNumber = parseInt(segmentTemplate.getAttribute('startNumber') || '1', 10);
const baseUrlTag =
rep.querySelector('BaseURL') ||
audioSet.querySelector('BaseURL') ||
period.querySelector('BaseURL') ||
mpd.querySelector('BaseURL');
const baseUrl = baseUrlTag?.textContent?.trim() || '';
const segmentTimeline = segmentTemplate.querySelector('SegmentTimeline');
const segments: DashSegment[] = [];
if (segmentTimeline) {
const sElements = segmentTimeline.querySelectorAll('S');
let currentTime = 0;
let currentNumber = startNumber;
sElements.forEach((s) => {
const tAttr = s.getAttribute('t');
if (tAttr) currentTime = parseInt(tAttr, 10);
const d = parseInt(s.getAttribute('d') || '0', 10);
const r = parseInt(s.getAttribute('r') || '0', 10);
segments.push({ number: currentNumber, time: currentTime });
currentTime += d;
currentNumber++;
for (let i = 0; i < r; i++) {
segments.push({ number: currentNumber, time: currentTime });
currentTime += d;
currentNumber++;
}
});
}
return {
baseUrl,
initialization,
media,
segments,
repId,
mimeType: audioSet.getAttribute('mimeType'),
};
}
generateSegmentUrls(manifest: DashManifest): string[] {
const { baseUrl, initialization, media, segments, repId } = manifest;
const urls: string[] = [];
const resolveTemplate = (template: string, number: number, time: number): string => {
return template
.replace(/\$RepresentationID\$/g, repId ?? '')
.replace(/\$Number(?:%0([0-9]+)d)?\$/g, (_, width: string) => {
if (width) {
return number.toString().padStart(parseInt(width), '0');
}
return number.toString();
})
.replace(/\$Time(?:%0([0-9]+)d)?\$/g, (_, width: string) => {
if (width) {
return time.toString().padStart(parseInt(width), '0');
}
return time.toString();
});
};
const joinPath = (base: string, part: string): string => {
if (!base) return part;
if (part.startsWith('http')) return part;
return base.endsWith('/') ? base + part : base + '/' + part;
};
if (initialization) {
const initPath = resolveTemplate(initialization, 0, 0);
urls.push(joinPath(baseUrl, initPath));
}
if (media && segments.length > 0) {
segments.forEach((seg) => {
const path = resolveTemplate(media, seg.number, seg.time);
urls.push(joinPath(baseUrl, path));
});
}
return urls;
}
}