Electron 实战:开发一个 M3U8 视频下载器
很多网页视频背后并不是一个直接可下载的 MP4 文件,而是一组 HLS 分片和一个 .m3u8 播放列表。浏览器负责边下边播,用户真正需要保存视频时,就要先找到播放列表,再把分片合并成一个本地文件。
这个项目的目标很直接:做一个桌面版 M3U8 Downloader。用户输入网页地址或 .m3u8 地址,应用自动解析候选播放列表,选择清晰度,最后调用 FFmpeg 下载并合并成 MP4。
如果你只想先完成 M3U8 转 MP4,可以先看上一篇基础教程:M3U8转MP4教程:使用Python或静态HTML在线下载M3U8视频。本文继续往前走,把同一条下载链路封装成一个可打包的 Electron 桌面应用。
方法 1:先确定应用边界
这个工具没有做成浏览器插件,也没有做成命令行脚本,而是选择 Electron,原因有三个:
- 需要一个普通用户能直接操作的界面。
- 需要访问系统文件选择器,保存 MP4 到本地目录。
- 需要稳定调用本机或打包内置的 FFmpeg。
项目的核心文件比较少:
m3u8-downloader/
├── package.json
├── scripts/
│ └── build-all.js
└── src/
├── main.js
├── preload.js
├── renderer.js
├── i18n.js
├── index.html
└── styles.css
其中 main.js 负责窗口、IPC、网络解析和 FFmpeg 子进程;preload.js 暴露安全的渲染进程 API;renderer.js 负责界面状态、表单、进度条和日志。
方法 2:用 Electron 搭出最小桌面壳
package.json 里把入口指向 src/main.js:
{
"name": "m3u8-downloader",
"version": "1.0.0",
"description": "Electron app for detecting, downloading, and merging HLS/m3u8 streams.",
"main": "src/main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder"
}
}
主进程创建窗口时开启 contextIsolation,关闭 nodeIntegration:
const win = new BrowserWindow({
width: 980,
height: 720,
minWidth: 760,
minHeight: 560,
title: 'M3U8 Downloader',
backgroundColor: '#f6f7f9',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
这样做的好处是渲染进程不能直接访问 Node.js 能力。所有下载、保存文件、启动 FFmpeg 的动作,都必须通过 preload 暴露的白名单 API。
方法 3:设计安全的 IPC 接口
preload.js 只暴露应用真正需要的能力:
contextBridge.exposeInMainWorld('m3u8App', {
getLocale: () => ipcRenderer.invoke('get-locale'),
getFfmpegStatus: () => ipcRenderer.invoke('get-ffmpeg-status'),
analyzeUrl: (payload) => ipcRenderer.invoke('analyze-url', payload),
chooseOutput: (payload) => ipcRenderer.invoke('choose-output', payload),
startDownload: (payload) => ipcRenderer.invoke('start-download', payload),
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
onProgress: (callback) => {
const listener = (_event, data) => callback(data);
ipcRenderer.on('download-progress', listener);
return () => ipcRenderer.removeListener('download-progress', listener);
},
});
界面层不需要知道 FFmpeg 在哪里,也不需要直接操作 child_process。它只负责把用户输入传给主进程,再根据返回结果更新界面。
方法 4:同时支持网页 URL 和 M3U8 URL
用户可能输入两类地址:
- 直接的
.m3u8播放列表地址。 - 普通网页地址,页面 HTML 或脚本里藏着
.m3u8。
所以解析流程分成两步。第一步先请求输入 URL,判断返回内容是不是播放列表:
function isM3u8Text(text) {
const sample = String(text || '').slice(0, 2048);
return sample.includes('#EXTM3U') && /#EXT-X-|#EXTINF/i.test(sample);
}
如果输入本身就是播放列表,就直接分析它。如果不是,就从页面内容里提取候选地址:
const patterns = [
/https?:\/\/[^\s"'<>\\]+?\.m3u8(?:\?[^\s"'<>\\]*)?/gi,
/(?:src|href|url)\s*[:=]\s*["']([^"']+?\.m3u8(?:\?[^"']*)?)["']/gi,
/["']([^"']+?\.m3u8(?:\?[^"']*)?)["']/gi,
];
实际网页里经常会把 URL 做转义,所以解析前还要处理常见格式:
const normalized = String(sourceText || '')
.replace(/\\u002f/gi, '/')
.replace(/\\\//g, '/')
.replace(/&/g, '&')
.replace(///gi, '/')
.replace(///g, '/');
这个处理不复杂,但很实用。很多“页面里明明有 m3u8 但是正则找不到”的问题,都是 URL 被转义造成的。
方法 5:解析 Master Playlist 的清晰度
HLS 有两种常见播放列表:
- media playlist:直接列出视频分片。
- master playlist:列出不同清晰度、码率、编码的子播放列表。
Master playlist 里常见这一类内容:
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/index.m3u8
应用会解析 #EXT-X-STREAM-INF 后面的属性,再把下一行 URI 转成绝对地址:
variants.push({
url: new URL(uri, playlistUrl).href,
label: labelParts.join(' | ') || `Variant ${variants.length + 1}`,
bandwidth,
resolution,
codecs: attrs.CODECS || '',
});
最后按带宽从高到低排序。这样用户打开下拉框时,通常会先看到最高码率版本。
方法 6:给请求头留一个手动入口
不少视频网站会校验 User-Agent、Referer 或 Cookie。如果下载器只裸请求 .m3u8,很容易遇到 403。
项目里先提供一组默认请求头:
const DEFAULT_HEADERS = {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36',
Accept: '*/*',
};
同时界面提供“Request headers”输入框,用户可以按行补充:
Cookie: session=...
Referer: https://example.com/video
主进程会把这些文本解析成对象,并传给页面请求和 FFmpeg。这样调试受保护的视频源时,不需要改代码。
方法 7:用 FFmpeg 下载并合并
解析只是第一步,真正保存 MP4 还是交给 FFmpeg。启动前先寻找可执行文件:
- 优先读取
FFMPEG_PATH环境变量。 - 再尝试
ffmpeg-static可选依赖。 - 再查常见系统路径。
- 最后用
command -v ffmpeg或 Windows 的where ffmpeg。
下载命令的关键参数如下:
const args = [
'-hide_banner',
'-y',
'-headers',
ffmpegHeaders(headers),
'-user_agent',
headers['User-Agent'] || DEFAULT_HEADERS['User-Agent'],
'-allowed_extensions',
'ALL',
'-protocol_whitelist',
'file,http,https,tcp,tls,crypto',
'-i',
inputUrl,
'-map',
'0:v?',
'-map',
'0:a?',
'-c',
'copy',
'-movflags',
'+faststart',
outputPath,
];
这里没有重新编码,而是使用 -c copy 直接封装成 MP4。速度更快,也能避免二次压缩损失画质。+faststart 会把 MP4 元数据前置,方便文件下载完成后更快开始播放。
方法 8:从 FFmpeg stderr 里解析进度
FFmpeg 的进度默认输出在 stderr。应用先 probe 一次总时长,再从下载过程的日志行里提取 time、speed 和 size:
function parseFfmpegProgress(line, duration) {
const timeMatch = line.match(/time=(\d+:\d+:\d+(?:\.\d+)?)/);
const speedMatch = line.match(/speed=\s*([0-9.]+x)/);
const sizeMatch = line.match(/size=\s*([0-9A-Za-z.]+)\s*/);
const time = timeMatch ? parseTimeToSeconds(timeMatch[1]) : null;
const percent = time && duration ? Math.min(99.5, (time / duration) * 100) : null;
return {
percent,
time: timeMatch ? timeMatch[1] : null,
speed: speedMatch ? speedMatch[1] : null,
size: sizeMatch ? sizeMatch[1] : null,
};
}
主进程通过 download-progress 事件把进度推给渲染进程,界面再更新进度条和状态文字。
方法 9:取消下载要管理子进程
下载任务可能正在 probe,也可能正在正式写文件。为了让取消按钮有效,项目用 jobs 保存每个窗口对应的任务:
const jobs = new Map();
每个 job 里记录所有子进程:
const job = {
cancelled: false,
children: new Set(),
reject: null,
};
取消时先发 SIGTERM,如果进程没有及时退出,再延迟发 SIGKILL:
for (const child of job.children) {
killProcess(child, 'SIGTERM');
setTimeout(() => killProcess(child, 'SIGKILL'), 1500).unref?.();
}
桌面工具里这一步很重要。否则用户点了取消,界面看起来停了,但后台 FFmpeg 还在继续下载。
方法 10:做一个轻量多语言方案
这个项目没有引入复杂 i18n 框架,而是在 src/i18n.js 里维护三套文案:
- English
- 简体中文
- 繁体中文
主进程通过 app.getLocale() 获取系统语言,再把结果归一化:
function supportedLocale(locale) {
const normalized = String(locale || '').toLowerCase();
if (normalized.startsWith('zh-hant') || normalized.includes('tw') || normalized.includes('hk')) {
return 'zh-TW';
}
if (normalized.startsWith('zh')) return 'zh-CN';
return 'en';
}
渲染进程保存用户选择,支持“跟随系统”和手动切换。对这种小工具来说,这种方案足够直接,也方便后续补充文案。
方法 11:用 electron-builder 打包三端
electron-builder 负责生成 macOS、Windows 和 Debian 包:
{
"build": {
"appId": "com.m3u8.downloader",
"productName": "M3U8 Downloader",
"directories": {
"output": "dist"
},
"mac": {
"target": ["dmg", "zip"],
"category": "public.app-category.video"
},
"win": {
"target": ["nsis", "portable"]
},
"linux": {
"target": ["deb"],
"category": "AudioVideo"
},
"deb": {
"depends": ["ffmpeg"]
}
}
}
为了少敲命令,项目里还加了一个 scripts/build-all.js:
const targets = [
['macOS', ['run', 'build:mac']],
['Windows', ['run', 'build:win', '--', '--x64']],
['Debian', ['run', 'build:debian']],
];
日常开发用:
npm start
需要打包时用:
npm run build:all
下载已打包的应用
构建程序已经生成可直接使用的桌面安装包,并放到了站点的 static 目录。Hugo 站点发布后,可以直接通过下面的链接下载:
- Windows:下载 m3u8.exe
- macOS:下载 m3u8.dmg
Windows 安装包约 109 MB,macOS DMG 约 131 MB。如果浏览器提示“此文件不常下载”,请确认文件来自本站,并在运行前用你常用的安全工具检查一次。
方法 12:几个开发中值得保留的取舍
这个应用并不复杂,但有几个取舍值得记录:
- 下载合并交给 FFmpeg,而不是自己下载 ts 分片再拼接。
- 渲染进程只处理 UI,所有系统能力都走 IPC。
- 手动请求头比自动模拟浏览器更可控,也更容易排查。
- Master playlist 只做必要解析,不试图完整实现 HLS 规范。
- 进度条来自 FFmpeg 输出,能满足多数视频下载场景。
如果继续迭代,我会优先补三件事:
- 增加任务队列,支持多个视频顺序下载。
- 保存最近使用的请求头和输出目录。
- 给解析器增加单元测试,覆盖转义 URL、base64 片段和 master playlist。
验证:本地运行和打包检查
开发时先确认 Electron 能打开主窗口:
npm install
npm start
输入一条可访问的 .m3u8 地址后,至少检查三件事:
- 能否识别 media playlist 或 master playlist 里的清晰度。
- 选择输出路径后,FFmpeg 是否开始写入 MP4。
- 取消按钮是否能停止当前 FFmpeg 子进程。
打包前再跑一次构建命令:
npm run build:all
如果只需要测试当前系统,可以先跑单个平台的构建脚本,避免每次都生成全部安装包。
常见问题与修复
1. 提示找不到 FFmpeg
原因通常是系统没有安装 ffmpeg,或者应用没有找到可执行文件。
修复:
- 先在终端执行
ffmpeg -version。 - 如果命令不存在,安装系统版 FFmpeg。
- 如果已经安装但应用找不到,设置
FFMPEG_PATH指向完整路径。
2. 分析 URL 时返回 403
原因通常是源站校验 Referer、User-Agent 或 Cookie。
修复:
- 在 Request headers 输入框补充真实页面的
Referer。 - 补充浏览器里复制出来的
Cookie。 - 确认
.m3u8地址没有过期。
3. 下载完成但 MP4 无法播放
原因通常是分片没有完整下载,或者源站使用了加密 HLS。
修复:
- 打开日志,检查是否有
403、404或Invalid data。 - 换一个较短的测试流确认程序流程。
- 如果播放列表里包含
#EXT-X-KEY,先确认 FFmpeg 能否访问密钥地址。
相关阅读
总结
M3U8 Downloader 的核心不是“写一个复杂播放器”,而是把 HLS 下载链路拆清楚:发现 .m3u8,识别清晰度,把请求头交给 FFmpeg,监听进度,允许取消,最后生成可播放的 MP4。
Electron 适合这类工具型应用:界面开发成本低,又能稳定调用本机能力。只要把主进程和渲染进程的边界设计好,整个项目可以保持很小,但功能已经足够覆盖日常下载需求。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/electron-m3u8-downloader-development.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。