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