有时,我们可能希望直接将 M3U8(HLS)视频流下载并合并为 MP4 文件 用于本地保存,而无需依赖复杂的服务器环境或第三方软件。

本文中,我将介绍两种常见且实用的方法:

  1. 使用 Python 的 downloadm3u8
  2. 使用静态 HTML + 浏览器端 FFmpeg(FFmpeg.wasm)

方法一:使用 Python downloadm3u8 包

该方法适合在服务器或本地环境中使用,需要安装对应软件,稳定、速度快,适合下载 大体积视频文件

1. 安装依赖

首先安装 ffmpeg(用于视频合并处理)以及 downloadm3u8

apt install ffmpeg -y
pip install downloadm3u8

2. 安装后查看帮助

downloadm3u8 -h
usage: m3u8downloader [-h] [--user-agent USER_AGENT] [--origin ORIGIN] [--version] [--debug] --output OUTPUT [--tempdir TEMPDIR]
                      [--keep] [--concurrency N]
                      URL

download video at m3u8 url

positional arguments:
  URL                   the m3u8 url

options:
  -h, --help            show this help message and exit
  --user-agent USER_AGENT
                        specify User-Agent header for HTTP requests
  --origin ORIGIN       specify Origin header for HTTP requests
  --version             show program's version number and exit
  --debug               enable debug log
  --output OUTPUT, -o OUTPUT
                        output video filename, e.g. ~/Downloads/foo.mp4
  --tempdir TEMPDIR     temp dir, used to store .ts files before combing them into mp4
  --keep                keep files in tempdir after converting to mp4
  --concurrency N, -c N
                        number of fragments to download at a time

假设m3u8地址为bobobk test m3u8

那么下载的时候直接使用 m3u8downloader https://www.bobobk.com//wp-content/uploads/2026/01/test.m3u8 -o test.mp4

方法二:

如果你不想安装 Python、FFmpeg,或者只是临时下载一个视频,那么 浏览器端方案 更适合你。

核心原理

  1. 解析 M3U8 文件:首先获取视频的 .m3u8 播放列表,如果是多码率的 master.m3u8,需要选择一个具体的 media playlist。
  2. 下载 TS 分片:将 M3U8 中列出的 .ts 视频片段并行下载到浏览器 IndexedDB 中缓存。
  3. 合并并转换为 MP4:使用 FFmpeg.wasm 在浏览器中将 TS 分片合并为 MP4,并提供下载。

我们讲m3u8进行base64编码

HTML 示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>M3U8 在线下载器</title>
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.11.6/dist/ffmpeg.min.js"></script>
<style>
body { font-family: Arial, sans-serif; background:#fafafa; }
#container { width:90%; max-width:600px; margin:auto; padding-top:20px; }
progress { width:100%; height:20px; margin-top:10px; }
#progress-text { text-align:center; margin-top:5px; }
#info-box { padding:10px; background:#eef; margin-bottom:10px; border-radius:5px; text-align:center; font-weight:bold; }
</style>
</head>
<body>
<div id="container">
  <h1 style="text-align:center;">M3U8 视频下载合并工具</h1>
  <div id="info-box">准备中…</div>
  <progress id="progress" value="0" max="100"></progress>
  <div id="progress-text">0%</div>
</div>
<script>
// IndexedDB 缓存
const DB_NAME = 'hls-cache';
const STORE = 'segments';
let outputName = 'video';
let downloadedCount = 0;

async function openDB() {
  return new Promise((res, rej) => {
    const r = indexedDB.open(DB_NAME,1);
    r.onupgradeneeded = () => r.result.createObjectStore(STORE,{keyPath:'sn'});
    r.onsuccess = () => res(r.result);
    r.onerror = () => rej(r.error);
  });
}

async function saveSegment(sn,data){
  const db = await openDB();
  db.transaction(STORE,'readwrite').objectStore(STORE).put({sn,data});
}

async function getSegments(){
  const db = await openDB();
  return new Promise((res,rej)=>{
    const req = db.transaction(STORE,'readonly').objectStore(STORE).getAll();
    req.onsuccess = ()=>res(req.result||[]);
    req.onerror = ()=>rej(req.error);
  });
}

async function clearSegments(){
  const db = await openDB();
  db.transaction(STORE,'readwrite').objectStore(STORE).clear();
}

// 工具函数
function qs(n){return new URLSearchParams(location.search).get(n);}
function base64Decode(str){try{return decodeURIComponent(atob(str).split('').map(c=>'%'+('00'+c.charCodeAt(0).toString(16)).slice(-2)).join(''));}catch(e){return str;}}

const infoBox = document.getElementById('info-box');
const progress = document.getElementById('progress');
const progressText = document.getElementById('progress-text');

function updateProgress(total){
  const percent = Math.min(100, downloadedCount/total*100);
  progress.value = percent;
  progressText.innerText = `已下载 ${percent.toFixed(1)}%`;
}

async function fetchWithRetry(url,retries=3,delay=1000){
  for(let i=0;i<retries;i++){
    try{
      const res = await fetch(url);
      if(!res.ok) throw new Error(`HTTP ${res.status}`);
      return new Uint8Array(await res.arrayBuffer());
    }catch(e){
      if(i<retries-1) await new Promise(r=>setTimeout(r,delay));
      else throw e;
    }
  }
}

async function getMediaPlaylist(masterUrl){
  const text = await (await fetch(masterUrl)).text();
  const lines = text.split('\n');
  for(let i=0;i<lines.length;i++){
    if(lines[i].startsWith('#EXT-X-STREAM-INF')){
      let nextLine = lines[i+1].trim();
      nextLine = new URL(nextLine,masterUrl).href;
      return nextLine;
    }
  }
  return masterUrl;
}

async function downloadSegmentsParallel(m3u8Url,concurrency=5){
  await clearSegments();
  const baseUrl = m3u8Url.split('/').slice(0,-1).join('/') + '/';
  const lines = (await (await fetch(m3u8Url)).text()).split('\n').filter(l=>l&&!l.startsWith('#'));
  const total = lines.length;
  downloadedCount = 0;
  updateProgress(total);

  let index=0;
  const workers=Array(concurrency).fill(null).map(async()=>{
    while(index<total){
      const i=index++;
      const url = lines[i].startsWith('http')?lines[i]:baseUrl+lines[i];
      try{
        const data = await fetchWithRetry(url);
        await saveSegment(i,data);
      }catch(e){console.warn(`分片 ${i} 下载失败`,e);}
      downloadedCount++;
      updateProgress(total);
    }
  });
  await Promise.all(workers);
}

async function finalize(){
  infoBox.innerText=`正在合并为 ${outputName}.mp4 …`;
  const segments = (await getSegments()).sort((a,b)=>a.sn-b.sn);
  const ffmpeg = FFmpeg.createFFmpeg({log:true});
  await ffmpeg.load();
  for(const s of segments) ffmpeg.FS('writeFile',`${s.sn}.ts`,new Uint8Array(s.data));
  const list = segments.map(s=>`file '${s.sn}.ts'`).join('\n');
  ffmpeg.FS('writeFile','list.txt',new TextEncoder().encode(list));
  await ffmpeg.run('-fflags','+genpts','-f','concat','-safe','0','-i','list.txt','-movflags','+faststart','-c','copy','out.mp4');
  const mp4 = ffmpeg.FS('readFile','out.mp4');
  const blob = new Blob([mp4.buffer],{type:'video/mp4'});
  const a=document.createElement('a');
  a.href=URL.createObjectURL(blob);
  a.download=outputName+'.mp4';
  a.click();
  await clearSegments();
  infoBox.innerText='下载完成 ✅';
  progress.value=100;
  progressText.innerText='100%';
}

(async function(){
  const encoded = qs('url');
  const nameParam = qs('name');
  if(!encoded){infoBox.innerText='未提供视频地址';return;}
  outputName=nameParam||'video';
  infoBox.innerText=`正在下载 ${outputName}.mp4 …`;
  try{
    const m3u8Url = base64Decode(encoded);
    const media = await getMediaPlaylist(m3u8Url);
    await downloadSegmentsParallel(media,5);
    await finalize();
  }catch(e){infoBox.innerText=`发生错误:${e.message}`;console.error(e);}
})();
</script>
</body>
</html>

使用方法 只需要将 HTML 保存为 index.html 在浏览器打开,例如: https://www.bobobk.com//wp-content/uploads/2026/01/test.m3u8 base64编码后

from base64 import b64decode,b64encode

url = 'https://www.bobobk.com/wp-content/uploads/2026/01/test.m3u8'
encoded = b64encode(url.encode())
print(encoded)

输出结果

aHR0cHM6Ly93d3cuYm9ib2JrLmNvbS93cC1jb250ZW50L3VwbG9hZHMvMjAyNi8wMS90ZXN0Lm0zdTg=

最终下载url就是 bobobk m3u8下载

页面会自动下载 TS 分片并生成 MP4 文件。

注意事项

  1. 浏览器端方法:依赖 FFmpeg.wasm,适合小到中等文件,内存消耗较高。
  2. Python 方法:适合大文件,支持多线程下载和自动合并。
  3. 跨域问题:HTML 方法需要 M3U8 服务器支持 CORS。
  4. 安全性:确保 M3U8 文件来源可信,避免下载受保护内容或病毒文件。