春江暮客

春江暮客的个人学习分享网站

Electron 实战:开发一个 M3U8 视频下载器

2026-06-18 技术
Electron 实战:开发一个 M3U8 视频下载器

很多网页视频背后并不是一个直接可下载的 MP4 文件,而是一组 HLS 分片和一个 .m3u8 播放列表。浏览器负责边下边播,用户真正需要保存视频时,就要先找到播放列表,再把分片合并成一个本地文件。

这个项目的目标很直接:做一个桌面版 M3U8 Downloader。用户输入网页地址或 .m3u8 地址,应用自动解析候选播放列表,选择清晰度,最后调用 FFmpeg 下载并合并成 MP4。

如果你只想先完成 M3U8 转 MP4,可以先看上一篇基础教程:M3U8转MP4教程:使用Python或静态HTML在线下载M3U8视频。本文继续往前走,把同一条下载链路封装成一个可打包的 Electron 桌面应用。

方法 1:先确定应用边界

这个工具没有做成浏览器插件,也没有做成命令行脚本,而是选择 Electron,原因有三个:

  1. 需要一个普通用户能直接操作的界面。
  2. 需要访问系统文件选择器,保存 MP4 到本地目录。
  3. 需要稳定调用本机或打包内置的 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

用户可能输入两类地址:

  1. 直接的 .m3u8 播放列表地址。
  2. 普通网页地址,页面 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(/&amp;/g, '&')
  .replace(/&#x2F;/gi, '/')
  .replace(/&#47;/g, '/');

这个处理不复杂,但很实用。很多“页面里明明有 m3u8 但是正则找不到”的问题,都是 URL 被转义造成的。

方法 5:解析 Master Playlist 的清晰度

HLS 有两种常见播放列表:

  1. media playlist:直接列出视频分片。
  2. 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-AgentReferer 或 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。启动前先寻找可执行文件:

  1. 优先读取 FFMPEG_PATH 环境变量。
  2. 再尝试 ffmpeg-static 可选依赖。
  3. 再查常见系统路径。
  4. 最后用 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 一次总时长,再从下载过程的日志行里提取 timespeedsize

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 里维护三套文案:

  1. English
  2. 简体中文
  3. 繁体中文

主进程通过 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 站点发布后,可以直接通过下面的链接下载:

  1. Windows:下载 m3u8.exe
  2. macOS:下载 m3u8.dmg

Windows 安装包约 109 MB,macOS DMG 约 131 MB。如果浏览器提示“此文件不常下载”,请确认文件来自本站,并在运行前用你常用的安全工具检查一次。

方法 12:几个开发中值得保留的取舍

这个应用并不复杂,但有几个取舍值得记录:

  1. 下载合并交给 FFmpeg,而不是自己下载 ts 分片再拼接。
  2. 渲染进程只处理 UI,所有系统能力都走 IPC。
  3. 手动请求头比自动模拟浏览器更可控,也更容易排查。
  4. Master playlist 只做必要解析,不试图完整实现 HLS 规范。
  5. 进度条来自 FFmpeg 输出,能满足多数视频下载场景。

如果继续迭代,我会优先补三件事:

  1. 增加任务队列,支持多个视频顺序下载。
  2. 保存最近使用的请求头和输出目录。
  3. 给解析器增加单元测试,覆盖转义 URL、base64 片段和 master playlist。

验证:本地运行和打包检查

开发时先确认 Electron 能打开主窗口:

npm install
npm start

输入一条可访问的 .m3u8 地址后,至少检查三件事:

  1. 能否识别 media playlist 或 master playlist 里的清晰度。
  2. 选择输出路径后,FFmpeg 是否开始写入 MP4。
  3. 取消按钮是否能停止当前 FFmpeg 子进程。

打包前再跑一次构建命令:

npm run build:all

如果只需要测试当前系统,可以先跑单个平台的构建脚本,避免每次都生成全部安装包。

常见问题与修复

1. 提示找不到 FFmpeg

原因通常是系统没有安装 ffmpeg,或者应用没有找到可执行文件。

修复:

  1. 先在终端执行 ffmpeg -version
  2. 如果命令不存在,安装系统版 FFmpeg。
  3. 如果已经安装但应用找不到,设置 FFMPEG_PATH 指向完整路径。

2. 分析 URL 时返回 403

原因通常是源站校验 RefererUser-Agent 或 Cookie。

修复:

  1. 在 Request headers 输入框补充真实页面的 Referer
  2. 补充浏览器里复制出来的 Cookie
  3. 确认 .m3u8 地址没有过期。

3. 下载完成但 MP4 无法播放

原因通常是分片没有完整下载,或者源站使用了加密 HLS。

修复:

  1. 打开日志,检查是否有 403404Invalid data
  2. 换一个较短的测试流确认程序流程。
  3. 如果播放列表里包含 #EXT-X-KEY,先确认 FFmpeg 能否访问密钥地址。

相关阅读

总结

M3U8 Downloader 的核心不是“写一个复杂播放器”,而是把 HLS 下载链路拆清楚:发现 .m3u8,识别清晰度,把请求头交给 FFmpeg,监听进度,允许取消,最后生成可播放的 MP4。

Electron 适合这类工具型应用:界面开发成本低,又能稳定调用本机能力。只要把主进程和渲染进程的边界设计好,整个项目可以保持很小,但功能已经足够覆盖日常下载需求。

友情链接

其它