M3U8转MP4教程:使用Python或静态HTML在线下载M3U8视频
有时,我们可能希望直接将 M3U8(HLS)视频流下载并合并为 MP4 文件 用于本地保存,而无需依赖复杂的服务器环境或第三方软件。
本文中,我将介绍两种常见且实用的方法:
- 使用 Python 的
downloadm3u8包 - 使用静态 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,或者只是临时下载一个视频,那么 浏览器端方案 更适合你。
核心原理
- 解析 M3U8 文件:首先获取视频的
.m3u8播放列表,如果是多码率的 master.m3u8,需要选择一个具体的 media playlist。 - 下载 TS 分片:将 M3U8 中列出的
.ts视频片段并行下载到浏览器 IndexedDB 中缓存。 - 合并并转换为 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 文件。
注意事项
- 浏览器端方法:依赖 FFmpeg.wasm,适合小到中等文件,内存消耗较高。
- Python 方法:适合大文件,支持多线程下载和自动合并。
- 跨域问题:HTML 方法需要 M3U8 服务器支持 CORS。
- 安全性:确保 M3U8 文件来源可信,避免下载受保护内容或病毒文件。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/how_to_generate_mp4_from_m3u8.html
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。