kv-music/js/dash-downloader.js
2026-01-16 18:35:32 +01:00

200 lines
7.7 KiB
JavaScript

export class DashDownloader {
constructor() {}
async downloadDashStream(manifestBlobUrl, options = {}) {
const { onProgress, signal } = 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);
// 3. Download Segments
const chunks = [];
let downloadedBytes = 0;
// Estimate total size? Hard to know exactly without Content-Length of each.
// We can just track progress by segment count.
const totalSegments = urls.length;
for (let i = 0; i < urls.length; i++) {
if (signal?.aborted) throw new Error('AbortError');
const url = urls[i];
const segmentResponse = await fetch(url, { signal });
if (!segmentResponse.ok) {
// Retry once?
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;
}
if (onProgress) {
onProgress({
stage: 'downloading',
receivedBytes: downloadedBytes, // accurate byte count
totalBytes: undefined, // Unknown total
currentSegment: i + 1,
totalSegments: totalSegments,
});
}
}
// 4. Concatenate
return new Blob(chunks, { type: 'audio/mp4' });
}
parseManifest(manifestText) {
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');
// Prefer highest bandwidth audio adaptation set
const adaptationSets = Array.from(period.querySelectorAll('AdaptationSet'));
let audioSet = adaptationSets.find((as) => as.getAttribute('mimeType')?.startsWith('audio'));
// Fallback: look for any adaptation set if mimeType is missing (rare)
if (!audioSet && adaptationSets.length > 0) audioSet = adaptationSets[0];
if (!audioSet) throw new Error('No AdaptationSet found');
// Find Representation
// Get all representations and sort by bandwidth descending
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');
// Find SegmentTemplate
// Can be in Representation or AdaptationSet
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);
// BaseURL
// Can be at MPD, Period, AdaptationSet, or Representation level.
// We strictly need to find the "deepest" one or combine them?
// Usually simpler manifests have it at one level.
// Let's resolve closest BaseURL.
const baseUrlTag =
rep.querySelector('BaseURL') ||
audioSet.querySelector('BaseURL') ||
period.querySelector('BaseURL') ||
mpd.querySelector('BaseURL');
const baseUrl = baseUrlTag ? baseUrlTag.textContent.trim() : '';
// SegmentTimeline
const segmentTimeline = segmentTemplate.querySelector('SegmentTimeline');
const segments = [];
if (segmentTimeline) {
const sElements = segmentTimeline.querySelectorAll('S');
let currentTime = 0;
let currentNumber = startNumber;
sElements.forEach((s) => {
// t is optional, defaults to previous end
const tAttr = s.getAttribute('t');
if (tAttr) currentTime = parseInt(tAttr, 10);
const d = parseInt(s.getAttribute('d'), 10);
const r = parseInt(s.getAttribute('r') || '0', 10);
// Initial segment
segments.push({ number: currentNumber, time: currentTime });
currentTime += d;
currentNumber++;
// Repeats
// r is the number of REPEATS (so total occurrences = 1 + r)
// If r is negative, it refers to open-ended? (Usually not in static manifests)
for (let i = 0; i < r; i++) {
segments.push({ number: currentNumber, time: currentTime });
currentTime += d;
currentNumber++;
}
});
}
return {
baseUrl,
initialization,
media,
segments,
repId,
};
}
generateSegmentUrls(manifest) {
const { baseUrl, initialization, media, segments, repId } = manifest;
const urls = [];
// Helper to resolve template strings
const resolveTemplate = (template, number, time) => {
return template
.replace(/\$RepresentationID\$/g, repId)
.replace(/\$Number(?:%0([0-9]+)d)?\$/g, (match, width) => {
if (width) {
return number.toString().padStart(parseInt(width), '0');
}
return number;
})
.replace(/\$Time(?:%0([0-9]+)d)?\$/g, (match, width) => {
if (width) {
return time.toString().padStart(parseInt(width), '0');
}
return time;
});
};
// Helper to join paths handling slashes
const joinPath = (base, part) => {
if (!base) return part;
if (part.startsWith('http')) return part; // Absolute path
return base.endsWith('/') ? base + part : base + '/' + part;
};
// 1. Initialization Segment
if (initialization) {
const initPath = resolveTemplate(initialization, 0, 0); // Init often doesn't use Number/Time but just in case
urls.push(joinPath(baseUrl, initPath));
}
// 2. Media Segments
if (segments && segments.length > 0) {
segments.forEach((seg) => {
const path = resolveTemplate(media, seg.number, seg.time);
urls.push(joinPath(baseUrl, path));
});
}
return urls;
}
}