Building an HLS Download Pipeline for Offline Video
M3U8 parsing, segment URL rewriting, concurrent downloads, reassembly, OPFS storage, and offline playback.
HLS (HTTP Live Streaming) delivers video as a sequence of small .ts segments described by an M3U8 playlist. Downloading for offline use means fetching every segment, storing them locally, and reconstructing a valid playlist that points to your local storage. Here's the full pipeline.
Step 1: Fetch and Parse the M3U8
The M3U8 is a plain-text file. Parse it to extract segment URLs and metadata:
interface HlsManifest {
version: number;
targetDuration: number;
segments: { uri: string; duration: number; sequence: number }[];
isVod: boolean;
}
async function parseM3U8(url: string): Promise<HlsManifest> {
const res = await fetch(url);
const text = await res.text();
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
const manifest: HlsManifest = {
version: 3,
targetDuration: 0,
segments: [],
isVod: text.includes("#EXT-X-ENDLIST"),
};
let sequence = 0;
let pendingDuration = 0;
for (const line of lines) {
if (line.startsWith("#EXT-X-VERSION:")) {
manifest.version = parseInt(line.split(":")[1]);
} else if (line.startsWith("#EXT-X-TARGETDURATION:")) {
manifest.targetDuration = parseInt(line.split(":")[1]);
} else if (line.startsWith("#EXTINF:")) {
pendingDuration = parseFloat(line.split(":")[1].split(",")[0]);
} else if (!line.startsWith("#")) {
// Resolve relative URLs
const segmentUrl = new URL(line, url).href;
manifest.segments.push({ uri: segmentUrl, duration: pendingDuration, sequence });
sequence++;
pendingDuration = 0;
}
}
return manifest;
}Step 2: Rewrite Segment URLs
Before storing the manifest, rewrite segment URIs to point to your local OPFS storage. After download, the manifest becomes a map of sequence → local file.
function buildLocalManifest(jobId: string, manifest: HlsManifest): string {
const lines = [
"#EXTM3U",
`#EXT-X-VERSION:${manifest.version}`,
`#EXT-X-TARGETDURATION:${manifest.targetDuration}`,
"#EXT-X-MEDIA-SEQUENCE:0",
];
for (const seg of manifest.segments) {
lines.push(`#EXTINF:${seg.duration},`);
// Point to our service worker intercept route
lines.push(`/offline-segment/${jobId}/${seg.sequence}`);
}
lines.push("#EXT-X-ENDLIST");
return lines.join("\n");
}Step 3: Concurrent Segment Downloads
Download segments in parallel batches. Too many concurrent requests will get rate-limited; too few is slow.
async function downloadSegments(
jobId: string,
segments: HlsManifest["segments"],
onProgress: (downloaded: number, total: number) => void,
signal: AbortSignal,
concurrency = 4
) {
let downloaded = 0;
for (let i = 0; i < segments.length; i += concurrency) {
if (signal.aborted) throw new Error("Download aborted");
const batch = segments.slice(i, i + concurrency);
await Promise.all(
batch.map(async (seg) => {
const res = await fetch(seg.uri, { signal });
if (!res.ok) throw new Error(`Segment ${seg.sequence} failed: ${res.status}`);
const data = await res.arrayBuffer();
await writeSegmentToOPFS(jobId, seg.sequence, data);
downloaded++;
onProgress(downloaded, segments.length);
})
);
}
}Step 4: Resume Downloads
On resume, check which segments are already in OPFS and skip them:
async function getDownloadedSegments(jobId: string): Promise<Set<number>> {
const dir = await getJobDir(jobId);
const downloaded = new Set<number>();
for await (const [name] of dir.entries()) {
const match = name.match(/^seg-(\d+)\.ts$/);
if (match) downloaded.add(parseInt(match[1]));
}
return downloaded;
}
async function resumeDownload(jobId: string, manifest: HlsManifest, ...) {
const downloaded = await getDownloadedSegments(jobId);
const remaining = manifest.segments.filter((s) => !downloaded.has(s.sequence));
await downloadSegments(jobId, remaining, ...);
}Step 5: Service Worker Intercept for Offline Playback
Register a fetch handler that intercepts segment requests and serves from OPFS:
// sw.ts
self.addEventListener("fetch", (event: FetchEvent) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith("/offline-segment/")) {
const [, , jobId, sequence] = url.pathname.split("/");
event.respondWith(serveFromOPFS(jobId, parseInt(sequence)));
}
});
async function serveFromOPFS(jobId: string, sequence: number): Promise<Response> {
try {
const data = await readSegmentFromOPFS(jobId, sequence);
return new Response(data, {
headers: { "Content-Type": "video/mp2t" },
});
} catch {
return new Response("Segment not found", { status: 404 });
}
}Step 6: Offline Playback
Point the video element at the local manifest, served through the service worker:
// app/offline/[id]/page.tsx
export default function OfflinePage({ params }) {
const manifestUrl = `/offline-manifest/${params.id}`;
return (
<video
src={manifestUrl}
controls
className="w-full"
onError={(e) => console.error("Playback error", e)}
/>
);
}The service worker also intercepts /offline-manifest/:jobId and returns the locally-stored M3U8 text with the rewritten segment URLs — completing the loop.
The whole pipeline: parse → rewrite → download → store → intercept → play. Each step is independent, which makes pause/resume and error recovery straightforward to implement.