Back to Blog
Feb 17, 20264 min readOnuzulike Anthony

Building an HLS Download Pipeline for Offline Video

M3U8 parsing, segment URL rewriting, concurrent downloads, reassembly, OPFS storage, and offline playback.

Full-StackHLSPWAVideo

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:

ts
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.

ts
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.

ts
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:

ts
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:

ts
// 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:

tsx
// 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.