200 lines
7.8 KiB
JavaScript
200 lines
7.8 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;
|
|
}
|
|
}
|