很多人第一次接触 M3U8 时,会把它理解成一种视频文件。真正动手实现下载器后才会发现:M3U8 更像一份播放清单,它描述分片地址、播放顺序、时长、加密方式,有时还会再指向一组不同码率的子播放列表。
因此,一个能稳定工作的 M3U8 下载器并不是简单的“请求 URL 后保存文件”,而是一条完整的数据流水线:
输入 M3U8
→ 解析主/媒体播放列表
→ 还原分片和密钥的绝对地址
→ 并发下载 TS 分片
→ 按需执行 AES-128 解密
→ 按原始顺序整理文件
→ 使用 FFmpeg 无损封装
→ 清理临时目录并通知界面
这篇文章复盘我在 m3u8-downloader 项目中的实现,重点不是罗列功能,而是解释几个真正影响可用性的工程决策。
一、先把职责拆开
项目采用 Python 3.8+ 和 PySide6,核心代码按职责拆成四个区域:
main.py GUI、任务卡片、队列和线程信号
m3u8_downloader.py 播放列表解析、分片下载、解密和合并
search.py 搜索渠道工厂与统一调用入口
search_*.py 各渠道的独立适配器
config.py / utils.py 主题、默认值、路径与文件名工具
这里最重要的边界是:界面不直接实现下载协议,下载核心也不依赖具体窗口控件。M3U8Downloader.download() 只接收 URL、输出路径和进度回调;界面通过回调数据更新任务状态。这样一来,下载核心以后可以继续被命令行、批处理脚本或其他 GUI 复用。
二、播放列表解析:URL 处理比文本切割更重要
解析器首先请求播放列表,然后识别两类内容:
- 主播放列表:包含
#EXT-X-STREAM-INF,下一行通常是子播放列表地址; - 媒体播放列表:包含
#EXTINF、分片 URI,以及可选的#EXT-X-KEY。
HLS 文件中经常出现相对路径,所以不能直接把每一行当作下载地址。项目使用 urljoin,以当前播放列表目录为基准恢复子列表、分片和密钥的绝对 URL。这个细节会直接决定 CDN 多级路径和带目录回退的链接能否正常下载。
解析后的结构大致如下:
playlist_info = {
"segments": [
{"index": 0, "duration": 6.0, "url": "https://cdn.example/000.ts"},
{"index": 1, "duration": 6.0, "url": "https://cdn.example/001.ts"},
],
"encryption": None,
"total_duration": 12.0,
"base_url": "https://cdn.example/",
}
遇到主播放列表时,当前实现会递归进入第一条子播放列表。这个策略足够简单,也容易预测,但它并不等于“选择最佳清晰度”。更完整的实现应该解析 BANDWIDTH、RESOLUTION 和音轨信息,再让用户选择码率。
三、双层并发:任务并发与分片并发不是一回事
下载器同时存在两层并发:
- 主窗口控制“同时运行多少个视频任务”;
- 每个视频任务内部使用
ThreadPoolExecutor并发下载多个 TS 分片。
这两个参数必须分开。假设同时运行 3 个视频任务,每个任务使用 16 个分片线程,理论上可能出现 48 个并行请求。如果只暴露一个“线程数”,用户很难判断实际压力,目标服务器也更容易触发限流。
界面侧使用 QThread 承载单个视频任务。工作线程通过信号发送进度字典,主线程只负责更新控件:
progress_data = {
"completed": completed,
"failed": failed,
"total": total,
"progress": finished / total * 100,
"speed": completed / elapsed,
"eta": remaining / current_speed,
}
进度计数由锁保护,避免多个分片线程同时完成时发生数据竞争。下载结果则预先创建与分片数量相同的列表,并按 index 写回对应位置。网络请求可以乱序完成,最终文件顺序仍然由播放列表决定。
重试采用指数退避:第一次失败后等待 1 秒,之后依次增加,单次等待上限为 10 秒。相比立即连续重试,这能减少临时网络抖动或服务端限流造成的放大效应。
四、AES-128:密钥、IV 和缓存
对于带有 #EXT-X-KEY 的播放列表,解析器会提取 METHOD、URI 和可选的 IV。当前实现处理最常见的 AES-128 + CBC 模式:
- 密钥 URI 同样通过
urljoin转为绝对地址; - 相同密钥地址只请求一次,结果缓存在内存中;
- 播放列表显式提供 IV 时直接使用;
- 没有 IV 时,将分片序号编码为 16 字节 IV;
- 解密完成后移除可能存在的 PKCS#7 填充。
密钥请求必须继承视频请求使用的请求头。部分站点会同时校验 Referer、User-Agent 或 Cookie,如果密钥请求和分片请求不处于同一访问上下文,即使 M3U8 本身可以读取,解密仍会失败。
这里还有一个需要继续完善的边界:HLS 允许在播放过程中切换密钥。当前下载链路更适合“整份列表使用同一把密钥”的常见场景;如果要完整支持密钥轮换,应让每个分片携带并使用自己的加密上下文,而不是只依赖播放列表级的最后一份加密信息。
五、为什么优先交给 FFmpeg 合并
TS 分片按二进制顺序拼接,某些情况下确实可以播放,但“可以播放”不代表封装正确。每个 TS 片段可能带有自己的 PAT/PMT、时间戳和连续性信息,简单拼接容易造成跳转卡顿或音画不同步。
项目启动时按以下顺序寻找 FFmpeg:
- 项目内置的可执行文件;
- 系统
PATH; - Windows、macOS 和 Linux 的常见安装路径。
找到 FFmpeg 后,程序生成 concat 文件列表并执行:
ffmpeg -f concat -safe 0 -i concat.txt -c copy -y output.mp4
-c copy 不重新编码音视频,只重新整理容器,速度通常远快于转码,也避免额外画质损失。
如果 FFmpeg 不可用,项目会启用兜底合并:保留第一个 TS 的完整数据,后续片段跳过前 10 个 188 字节 TS 包后再写入。这是针对重复 PAT/PMT 的启发式处理,只适合作为降级路径,不能替代真正的容器解析。
六、把“搜源”做成可替换的适配层
项目包含可选的片源搜索,但它没有被写死在下载核心里。search.py 负责三件事:
- 保存渠道元数据;
- 根据渠道创建对应搜索引擎;
- 屏蔽不同引擎在参数和返回格式上的差异。
界面只面对统一流程:选择渠道、搜索、展示结果、提取剧集链接,再把 M3U8 交给下载队列。每个渠道自行处理 HTML 解析、JSON 接口、签名刷新、Cookie 会话或浏览器辅助验证。
这种结构很适合外部站点适配,因为站点接口本来就是高频变化区。某个适配器失效时,下载核心仍然可以正常处理用户直接提供的合法 M3U8 地址。测试也可以围绕工厂分发、参数归一化、域名约束、签名构造和响应解析分别编写。
需要强调的是,搜索与浏览器辅助能力只应在获得授权、遵守目标站点服务条款的前提下使用。项目本身不保存用户输入的 Cookie,技术实现也不应成为绕过版权或访问控制的理由。
七、桌面端还需要处理哪些细节
协议之外,桌面工具的可用性往往来自一组不显眼的处理:
- 单链接和批量链接使用不同输入流程;
- 输出前检查目录是否存在、是否可写;
- 自动清理文件名中的跨平台非法字符;
- 文件重名时添加递增后缀,避免静默覆盖;
- 自定义请求头随任务复制,避免后续修改影响已创建任务;
- 用户设置写入本地
settings.json,开发环境与打包环境使用不同基准目录; - 使用系统托盘、实时日志和完成状态汇总降低长任务的操作成本。
主题系统也被收敛为配置数据,而不是在每个控件中散落颜色值。当前仓库为主题注册表、搜索适配层和较复杂的签名/限流分支编写了 24 个单元测试;本次整理文章时,这些测试全部通过。
八、当前实现的限制与下一步
一个项目值得分享的地方不只有“做成了什么”,也包括它暂时没有解决什么。当前版本还有几个明确的改进方向:
- 码率选择:解析主播放列表的全部变体,让用户按分辨率或带宽选择;
- 密钥轮换:让每个分片使用自己的
EXT-X-KEY上下文; - TLS 安全:当前部分请求为了兼容来源站关闭了证书验证,后续应改为默认校验、按站点显式降级,并给出清晰告警;
- 停止语义:停止标志可以阻止新分片继续提交,但正在阻塞的网络请求仍需等待返回或超时;
- 断点续传:临时目录已天然保存分片,下一步可以增加清单指纹和已完成分片校验;
- 合并能力:对 fMP4、独立音轨、字幕和不连续序列,需要更完整的 HLS 数据模型;
- 适配器维护:外部搜索渠道容易变化,应该把它们视为可独立替换的插件,而不是核心功能的稳定承诺。
九、这次实现带来的三个经验
第一,下载器的核心不是“多线程”,而是把解析、下载、解密、排序和封装之间的边界设计清楚。并发只是其中一个加速手段。
第二,GUI 程序必须把耗时工作放到后台,但后台线程不应直接操作控件。稳定的信号协议比零散的界面调用更容易测试和扩展。
第三,外部站点适配天然不稳定。让它停留在适配层,并始终保留“直接输入标准 M3U8”的主路径,项目才能在部分渠道失效时继续有价值。
如果你也准备实现类似工具,我建议先做一个只支持未加密媒体播放列表的最小版本,再依次加入主播放列表、失败重试、AES-128、FFmpeg 和 GUI 队列。每增加一层能力,都先定义它与上一层交换的数据结构。这样写出来的不只是一个能跑的脚本,而是一套可以继续演进的下载流水线。
请仅下载你有权获取的内容,并遵守当地法律、版权规则和目标站点服务条款。