How to Download M3U8 Videos Using Pure JavaScript

From Principles to Practical Implementation for Streaming Media Download

M3U8 is one of the most common formats in streaming media playback, essentially a text file containing URLs of TS segment files. If you want to download and merge M3U8 videos using pure JavaScript, this article will help you master the entire process from principles and implementation steps to complete code.

I. Core Principles

The implementation of M3U8 video download with pure JavaScript relies on four core steps, each addressing a key part of the download process:

  1. Parse the M3U8 file: Read the contents of the M3U8 file and extract all TS segment URL addresses
  2. Download TS segments: Batch download all TS segment files through the Fetch API
  3. Merge TS files: Combine all downloaded TS segments into a complete MP4 file in order
  4. File export: Implement local download using Blob and URL.createObjectURL

Note: This solution only works for unencrypted M3U8 files and is subject to browser cross-origin restrictions. It is recommended to run it in a same-origin environment or on a server with CORS configured.

II. Complete Implementation Code

The following is a pure JavaScript code that can be run directly, including complete M3U8 parsing, segment downloading, merging and exporting functions:

/**
 * M3U8 Video Downloader
 */
class M3U8Downloader {
  constructor() {
    this.tsUrls = [];     // Stores all TS segment URLs
    this.tsBlobs = [];    // Stores downloaded TS Blobs
    this.downloadProgress = 0; // Download progress percentage
    this.lastResult = null;   // Last operation result
  }

  /**
   * Parse M3U8 content and extract TS URLs
   * @param {string} m3u8Content - Content of the M3U8 file
   * @param {string} baseUrl - Base URL of the M3U8 file
   * @returns {Array} List of TS URLs
   */
  parseM3U8(m3u8Content, baseUrl) {
    const lines = m3u8Content.split('\n');
    const tsUrls = [];

    lines.forEach(line => {
      const trimmedLine = line.trim();
      // Extract lines that are TS segment URLs
      if (trimmedLine && trimmedLine.endsWith('.ts')) {
        let tsUrl = trimmedLine;
        // Handle relative URLs
        if (!tsUrl.startsWith('http')) {
          tsUrl = new URL(tsUrl, baseUrl).href;
        }
        tsUrls.push(tsUrl);
      }
    });

    this.tsUrls = tsUrls;
    return tsUrls;
  }

  /**
   * Download a single TS segment
   * @param {string} url - TS segment URL
   * @param {number} index - Segment index to maintain order
   * @returns {Promise<{index: number, blob: Blob}>}
   */
  async downloadTS(url, index) {
    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`Failed to download: ${url} (Status: ${response.status})`);
      }
      const blob = await response.blob();
      return { index, blob };
    } catch (error) {
      console.error(`Failed to download segment ${index + 1}:`, error);
      throw error;
    }
  }

  /**
   * Download all TS segments with concurrency control
   * @param {Function} onProgress - Progress callback
   * @param {number} concurrency - Number of concurrent downloads (default: 5)
   * @returns {Promise>} Blobs in order
   */
  async downloadAllTS(onProgress, concurrency = 5) {
    const total = this.tsUrls.length;
    const results = new Array(total);
    let completed = 0;

    // Process batches to control concurrency
    async function processBatch(batchStart) {
      const batchEnd = Math.min(batchStart + concurrency, total);
      const batchPromises = [];

      for (let i = batchStart; i < batchEnd; i++) {
        batchPromises.push(
          this.downloadTS(this.tsUrls[i], i).then(result => {
            results[result.index] = result.blob;
            completed++;
            this.downloadProgress = (completed / total) * 100;
            onProgress && onProgress(this.downloadProgress, completed, total);
          })
        );
      }

      await Promise.all(batchPromises);

      // Process next batch
      if (batchEnd < total) {
        await processBatch.call(this, batchEnd);
      }
    }

    await processBatch.call(this, 0);
    this.tsBlobs = results;
    return results;
  }

  /**
   * Merge TS Blobs and trigger download
   * @param {string} filename - Output filename
   */
  async mergeAndDownload(filename = 'video.mp4') {
    if (this.tsBlobs.length === 0) {
      throw new Error('No TS segments to merge');
    }

    // Merge all TS Blobs into one (video/mp2t format)
    const combinedBlob = new Blob(this.tsBlobs, { type: 'video/mp2t' });

    // Create download link
    const downloadLink = document.createElement('a');
    downloadLink.href = URL.createObjectURL(combinedBlob);
    downloadLink.download = filename;

    // Trigger download
    document.body.appendChild(downloadLink);
    downloadLink.click();

    // Cleanup
    document.body.removeChild(downloadLink);
    URL.revokeObjectURL(downloadLink.href);

    console.log('Video download completed!');
  }

  /**
   * Full M3U8 download process
   * @param {string} m3u8Url - URL of the M3U8 file
   * @param {string} filename - Output filename
   * @param {Function} onProgress - Progress callback
   */
  async download(m3u8Url, filename = 'video.mp4', onProgress) {
    try {
      // Step 1: Fetch and parse M3U8
      onProgress && onProgress(0, 0, 100, 'Parsing M3U8 file...');
      const m3u8Response = await fetch(m3u8Url);
      if (!m3u8Response.ok) {
        throw new Error(`Failed to fetch M3U8: ${m3u8Response.status}`);
      }
      const m3u8Content = await m3u8Response.text();
      this.parseM3U8(m3u8Content, m3u8Url);

      if (this.tsUrls.length === 0) {
        throw new Error('No TS segments found in M3U8');
      }

      // Step 2: Download all TS segments
      await this.downloadAllTS((progress, completed, total) => {
        onProgress && onProgress(progress, completed, total, `Downloading segments: ${completed}/${total}`);
      });

      // Step 3: Merge and download
      onProgress && onProgress(100, this.tsUrls.length, this.tsUrls.length, 'Merging and downloading...');
      await this.mergeAndDownload(filename);

      this.lastResult = { success: true, message: 'Download completed successfully' };
      return this.lastResult;
    } catch (error) {
      console.error('Download error:', error);
      this.lastResult = { success: false, message: error.message };
      return this.lastResult;
    }
  }
}

// ====================== Usage Example ======================
// 1. Create an instance
const downloader = new M3U8Downloader();

// 2. Bind to HTML elements (assuming the following HTML exists)
/*
*/ document.addEventListener('DOMContentLoaded', () => { const m3u8UrlInput = document.getElementById('m3u8Url'); const fileNameInput = document.getElementById('fileName'); const downloadBtn = document.getElementById('downloadBtn'); const progressDiv = document.getElementById('progress'); downloadBtn.addEventListener('click', async () => { const m3u8Url = m3u8UrlInput.value.trim(); const fileName = fileNameInput.value.trim() || 'video.mp4'; if (!m3u8Url) { alert('Please enter an M3U8 URL'); return; } // Disable button to prevent duplicate clicks downloadBtn.disabled = true; downloadBtn.textContent = 'Downloading...'; // Start download await downloader.download( m3u8Url, fileName, (progress, completed, total, message) => { // Update progress UI progressDiv.innerHTML = `

${message}

Progress: ${progress.toFixed(2)}% (${completed}/${total})

`; } ); // Restore button state downloadBtn.disabled = false; downloadBtn.textContent = 'Start Download'; alert(downloader.lastResult?.message || 'Operation finished'); }); });

III. Code Explanation

1. Core Class: M3U8Downloader

- parseM3U8: Parses the M3U8 text, filters out lines ending with .ts, and resolves relative URLs using the base URL of the M3U8 file.

- downloadTS: Downloads a single TS segment and returns an object containing the segment index and Blob to ensure correct ordering.

- downloadAllTS: Downloads all segments with concurrency control (default: 5) to avoid overwhelming the network or server.

- mergeAndDownload: Merges all TS Blobs into a single Blob and creates a download link for the user to save the file locally.

- download: Orchestrates the entire process: fetching the M3U8, parsing, downloading segments, merging, and exporting.

2. Usage Notes

1. CORS Issues: Browsers restrict cross-origin requests by default. To resolve this:

  • Ensure the target server has CORS enabled for your domain.
  • Use a proxy server to forward requests.
  • For local testing, you can start Chrome with disabled web security (not recommended for general use):
    chrome --disable-web-security --user-data-dir=/tmp/chrome-test

2. Encrypted M3U8: If the M3U8 contains an #EXT-X-KEY tag (indicating AES-128 encryption), additional decryption logic is required.

3. Large File Handling: For very large videos, browser memory may be insufficient. Consider downloading and merging in chunks.

4. Compatibility: This solution relies on the Fetch API and Blob, which are supported in modern browsers (Chrome, Firefox, Edge, etc.). Internet Explorer is not supported.

IV. Possible Enhancements

You can further optimize the downloader according to actual needs:

  1. Resume Interrupted Downloads: Record downloaded segments and resume from where it left off.
  2. Encryption Support: Parse the #EXT-X-KEY tag and implement AES-128 decryption for encrypted TS segments.
  3. Progress Persistence: Save download progress to localStorage so it can be restored after a page refresh.
  4. Format Conversion: Use FFmpeg.wasm to convert the merged TS file into a standard MP4 (with proper metadata and better compatibility).
  5. Retry Failed Downloads: Implement automatic retries for failed segment downloads with exponential backoff.

Conclusion

The core steps to download M3U8 videos with pure JavaScript are: parse M3U8 → download TS segments → merge Blobs → export the file. Key considerations are maintaining segment order and controlling concurrency to prevent out-of-order downloads or request limits.

Due to browser CORS restrictions, you may need a proxy or proper CORS configuration in production. Encrypted M3U8 streams require additional decryption handling. This lightweight, backend-free solution is suitable for building frontend tools, local testing, or learning how M3U8 streaming works.