1. 从播放列表到本地文件:m3u8 下载这件事到底难在哪
很多人第一次接触 m3u8 这个概念,是在浏览器开发者工具的 Network 面板里。打开一个在线视频页面,F12 一按,满屏的.ts请求像瀑布一样刷下来,中间还夹着一个几百字节的.m3u8文件。直觉告诉你,把这些.ts全下载下来拼在一起,视频就到手了。真动手之后才发现,事情没那么简单:有的.ts直接下载就能播,有的下载下来是一堆乱码,还有的下载到一半就 403 了。
我自己在这个方向上折腾了不少时间,从最早用浏览器插件傻瓜式抓取,到后来自己写脚本解析索引、处理加密、调用 ffmpeg 合并,中间踩过的坑足够写一篇长文。这篇内容就是把这套全链路拆开讲清楚:m3u8 索引怎么定位、AES-128 加密怎么解、ts 分片怎么合并,以及每一步背后的原理和实际会遇到的问题。适合有一定动手能力、想搞清楚流媒体下载底层逻辑的开发者,也适合只是想把自己买的课程视频存到本地、不想被平台限制播放期限的普通用户。
先说清楚一个前提:这套技术本身是中性的,它解决的是"如何把基于 HTTP 的分片流媒体内容保存为本地可播放文件"这个工程问题。实际使用时请务必遵守内容平台的用户协议和相关法律法规,仅对自己有权保存的内容进行操作。技术归技术,边界归边界,这一点先摆在前面。
整个链路可以概括成四个阶段:定位索引文件、解析分片列表、处理加密分片、合并输出成片。听起来线性,但每个阶段都有分支情况。比如索引文件可能是 master playlist(主播放列表)也可能是 media playlist(媒体播放列表),前者里面嵌套着不同码率的子列表,后者才是真正的分片清单。再比如加密,可能是 AES-128 的 CBC 模式,也可能是 SAMPLE-AES,甚至有的平台用自定义的密钥获取逻辑。下面我按实际操作的顺序,一层一层往下拆。
2. m3u8 索引定位:找到那个"目录文件"
2.1 m3u8 到底是什么,为什么视频要切成这样
m3u8 本质上是一个 UTF-8 编码的文本文件,内容是一系列 URI 和标签。它是 HLS(HTTP Live Streaming)协议的核心载体。HLS 的思路很朴素:把一整段视频切成无数个几秒钟的小片段(通常是.ts格式),然后写一个文本清单告诉播放器"先播这个,再播那个"。这样做的好处是自适应码率——网络好的时候给你高清分片,网络差的时候自动切到低清分片,播放不卡顿。
一个典型的 media playlist 长这样:
#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:9.009, segment0.ts #EXTINF:9.009, segment1.ts #EXTINF:9.009, segment2.ts #EXT-X-ENDLIST#EXTINF后面跟的是这个分片的时长(秒),下一行是分片的相对路径或绝对路径。#EXT-X-ENDLIST表示列表结束,说明这是一个点播(VOD)内容,不是直播。直播的列表里没有这个标签,而且会不断更新。
而 master playlist 则是另一副样子:
#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH=1280000,RESOLUTION=720x480 low/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=2560000,RESOLUTION=1280x720 mid/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=7680000,RESOLUTION=1920x1080 high/index.m3u8它不包含分片,只包含不同码率的子播放列表地址。你要下载高清版本,就得先解析 master,找到BANDWIDTH最大的那条,再进去拿真正的分片清单。很多人第一次抓取失败,就是因为把 master playlist 当成了分片清单,结果下载下来一堆.m3u8文件而不是.ts。
2.2 在浏览器里精准定位索引地址
定位 m3u8 最直接的办法是浏览器开发者工具。打开视频页面,按 F12,切到 Network 面板,在过滤框里输入m3u8。然后刷新页面、点击播放,通常就能看到请求列表里出现.m3u8文件。
这里有几个实操细节值得注意。第一,有些平台把 m3u8 的请求藏在 XHR/Fetch 里,而不是 Media 类型,所以过滤时不要只勾 Media,最好选 All 再手动搜。第二,如果页面用了 Service Worker 拦截请求,Network 面板可能看不到真实的 m3u8 地址,这时候需要在 Application 面板里检查 Service Worker 的注册情况,或者临时禁用。第三,部分平台会对 m3u8 地址做一次性的 token 校验,地址里带?auth=之类的参数,这种地址有时效性,复制出来要尽快用。
我常用的一个技巧是:在 Network 面板里找到 m3u8 请求后,右键选择 "Copy link address",然后直接在浏览器新标签页打开。如果能看到文本内容,说明这个地址是公开可访问的;如果返回 403 或一堆乱码,说明有防盗链或加密,需要进一步处理请求头。
2.3 请求头里藏着的关键信息
很多平台的 m3u8 和 ts 请求都带防盗链校验,最常见的是检查Referer和User-Agent。你直接用 curl 或下载工具去拉,服务器一看请求头不对,直接返回 403。解决办法是把浏览器请求里的完整头信息复制出来。
在开发者工具里找到 m3u8 请求,右键 "Copy as cURL",粘贴到文本编辑器里,你能看到类似这样的内容:
curl 'https://example.com/video/index.m3u8' \ -H 'Referer: https://example.com/play/12345' \ -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...' \ -H 'Origin: https://example.com'把Referer、User-Agent、Origin这几个头记下来,后续无论是用 ffmpeg 还是自己写脚本,都要带上。ffmpeg 支持通过-headers参数传入自定义头:
ffmpeg -headers "Referer: https://example.com/play/12345\r\nUser-Agent: Mozilla/5.0 ...\r\n" \ -i "https://example.com/video/index.m3u8" \ -c copy output.mp4注意\r\n是必须的,多个头之间用它分隔。这个细节我第一次用的时候没注意,头信息死活不生效,排查了半天才发现是分隔符的问题。
提示:如果 m3u8 地址里带有
expires或token参数,说明地址有时效。下载大文件时如果中途失效,需要重新抓取新地址,或者用脚本自动刷新。
3. AES-128 解密:把乱码变回正常分片
3.1 加密是怎么标记在 m3u8 里的
不是所有 m3u8 都加密,但主流的付费内容平台基本都会加密。加密信息写在 media playlist 里,通过#EXT-X-KEY标签声明:
#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key.bin",IV=0x00000000000000000000000000000000 #EXTINF:9.009, segment0.ts #EXTINF:9.009, segment1.tsMETHOD=AES-128表示用 AES-128 加密,URI是密钥文件的地址,IV是初始化向量。如果IV没写,默认用分片的序号(#EXT-X-MEDIA-SEQUENCE的值)作为 IV。这一点非常关键,很多人解密失败就是因为 IV 用错了。
密钥文件通常是一个 16 字节的二进制文件。你可以用 curl 下载下来,用xxd看一下:
curl -H "Referer: https://example.com/play/12345" \ -o key.bin "https://example.com/key.bin" xxd key.bin输出应该是 16 个字节的十六进制,比如1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d。如果下载下来是 403 或者内容不对,说明密钥地址也有防盗链,同样需要带上正确的请求头。
3.2 AES-128-CBC 解密的原理和参数
AES-128 指的是密钥长度 128 位(16 字节),CBC 是分组密码的工作模式。每个 ts 分片被切成 16 字节的块,用密钥和 IV 做异或和加密运算。解密时反过来操作即可。
用 Python 的cryptography库解密一个分片的代码大概是这样:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend def decrypt_ts(encrypted_data, key, iv): cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) decryptor = cipher.decryptor() return decryptor.update(encrypted_data) + decryptor.finalize()这里有几个坑。第一,ts 分片的大小必须是 16 的整数倍,如果不是,说明分片本身有问题或者下载不完整。第二,IV 的格式要对,m3u8 里写的是十六进制字符串(0x开头),要转成 bytes。第三,如果 m3u8 里没写 IV,要用分片序号补齐 16 字节,比如第 0 个分片 IV 是\x00* 16,第 1 个是\x00* 15 +\x01,以此类推。
3.3 用 ffmpeg 一步到位处理加密
如果你不想自己写解密代码,ffmpeg 其实可以直接处理加密的 m3u8。只要密钥地址可访问,ffmpeg 会自动下载密钥并解密:
ffmpeg -headers "Referer: https://example.com/play/12345\r\n" \ -i "https://example.com/video/index.m3u8" \ -c copy -bsf:a aac_adtstoasc output.mp4-c copy表示不重新编码,直接复制流,速度快、无损。-bsf:a aac_adtstoasc是处理 AAC 音频的比特流过滤器,有些 ts 里的 AAC 是 ADTS 格式,封装进 MP4 需要转成 ASC 格式,不加这个参数可能导致音频无法播放。
实测下来,ffmpeg 处理标准 AES-128 加密的 m3u8 成功率很高。但如果平台用了非标准的密钥获取方式(比如密钥地址需要 POST 请求、或者密钥本身又经过了一层加密),ffmpeg 就无能为力了,这时候只能自己写脚本处理。
注意:有些平台的密钥 URI 是相对路径,需要相对于 m3u8 的地址拼接出完整 URL。ffmpeg 一般能自动处理,但自己写脚本时别忘了这一步。
4. ts 分片合并:从碎片到完整视频
4.1 为什么不能简单地把 ts 拼起来
理论上,把解密后的 ts 分片按顺序cat到一起,就能得到一个完整的 ts 文件。这个文件用 VLC 或 PotPlayer 能直接播放。但实际用的时候会发现两个问题:一是播放器兼容性差,很多播放器对拼接的 ts 支持不好,拖动进度条会卡;二是 ts 容器本身效率低,不适合长期存储。
所以更常见的做法是把 ts 合并后转封装成 MP4。转封装(remux)不重新编码,只是换个容器,速度快、画质无损。ffmpeg 做这件事只需要一条命令:
ffmpeg -i concat.txt -c copy -bsf:a aac_adtstoasc output.mp4其中concat.txt是分片列表文件,格式是:
file 'segment0.ts' file 'segment1.ts' file 'segment2.ts'注意路径要用单引号包起来,如果路径里有特殊字符还要转义。这个 concat 方式比直接cat更可靠,因为 ffmpeg 会正确处理时间戳和流信息。
4.2 分片顺序和命名陷阱
大部分平台的 ts 分片是按序号命名的,比如segment0.ts、segment1.ts,但也有平台用时间戳命名,比如1699999999.ts。更麻烦的是,有些平台的分片序号不是从 0 开始,而是从#EXT-X-MEDIA-SEQUENCE指定的值开始。如果你手动下载分片,一定要按 m3u8 里的顺序来,不能想当然地按文件名排序。
我遇到过一个案例:分片文件名是out000.ts、out001.ts……看起来是按顺序的,但 m3u8 里的顺序其实是打乱的,平台故意这么做来防止简单的顺序下载。解决办法就是严格按 m3u8 里出现的顺序来拼接,不要依赖文件名。
还有一个常见问题是分片缺失。下载过程中如果某个分片失败,合并出来的视频会在对应位置卡顿或花屏。所以下载脚本一定要做校验:检查每个分片的大小是否合理(通常几十 KB 到几 MB),检查解密后的数据是否能被 ffmpeg 识别。
4.3 完整的手动下载合并流程
把前面几步串起来,一个完整的手动流程是这样的:
- 用浏览器开发者工具定位 m3u8 地址,复制请求头。
- 下载 m3u8 文件,解析出分片列表和加密信息。
- 如果有加密,下载密钥文件。
- 逐个下载 ts 分片,带上正确的请求头。
- 对每个分片解密(如果有加密)。
- 生成 concat 列表,用 ffmpeg 合并转封装。
用 Python 写的话,核心逻辑大概是这样:
import requests import m3u8 from urllib.parse import urljoin headers = { "Referer": "https://example.com/play/12345", "User-Agent": "Mozilla/5.0 ..." } m3u8_url = "https://example.com/video/index.m3u8" playlist = m3u8.load(m3u8_url, headers=headers) key = None if playlist.keys and playlist.keys[0]: key_uri = urljoin(m3u8_url, playlist.keys[0].uri) key = requests.get(key_uri, headers=headers).content for i, segment in enumerate(playlist.segments): seg_url = urljoin(m3u8_url, segment.uri) data = requests.get(seg_url, headers=headers).content if key: iv = segment.key.iv if segment.key.iv else i.to_bytes(16, 'big') data = decrypt_ts(data, key, bytes.fromhex(iv.replace('0x', ''))) with open(f"segment{i}.ts", "wb") as f: f.write(data)这个脚本用了m3u8这个库来解析播放列表,省去了手写解析器的麻烦。实际使用时还要加上重试机制、并发下载、进度显示等,但核心逻辑就是这些。
5. 常见问题与排查技巧实录
5.1 下载下来是乱码或无法播放
这是最常见的问题,原因通常有三个。第一,分片是加密的但你没解密,或者解密时密钥/IV 用错了。排查方法是拿一个分片用xxd看开头,正常的 ts 分片开头应该是0x47(同步字节),如果开头是随机数据,说明没解密或解密错误。第二,请求头不对,服务器返回的是错误页面而不是真实分片。排查方法是看下载文件的大小和内容,如果所有分片大小都一样且很小,多半是错误页。第三,分片顺序错了,合并出来的视频能播但内容混乱。
5.2 ffmpeg 报 "Invalid data found when processing input"
这个错误通常出现在合并阶段,说明某个 ts 分片损坏或格式不对。排查步骤是:先用 ffmpeg 单独测试每个分片,找出有问题的那个;如果是下载不完整,重新下载;如果是解密问题,检查密钥和 IV。还有一个可能是分片本身是 fMP4 格式而不是 ts,m3u8 里会有#EXT-X-MAP标签指定初始化段,这种情况处理方式不同,需要先下载 init 段再合并。
5.3 密钥地址返回 403
密钥地址的防盗链通常比 ts 更严格,除了 Referer 和 User-Agent,有些平台还会校验 Cookie 或签名参数。解决办法是把浏览器里密钥请求的完整头信息复制出来,包括 Cookie。如果密钥地址带签名且有时效,需要在有效期内完成下载,或者写脚本自动刷新。
5.4 合并后的视频音画不同步
这通常是时间戳问题。ts 分片的时间戳是连续的,但如果下载或解密过程中某个分片的时间戳被破坏,合并后就会音画不同步。解决办法是用 ffmpeg 重新生成时间戳:
ffmpeg -fflags +genpts -i input.ts -c copy output.mp4-fflags +genpts会让 ffmpeg 重新生成 presentation timestamp,通常能解决大部分同步问题。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 分片下载后是乱码 | 未解密或密钥错误 | xxd 查看开头是否为 0x47 | 检查密钥和 IV,重新解密 |
| 所有分片大小相同且很小 | 请求头不对,返回错误页 | 查看文件内容 | 补全 Referer/UA/Cookie |
| ffmpeg 合并报错 | 分片损坏或格式不符 | 单独测试每个分片 | 重新下载或转换格式 |
| 密钥地址 403 | 防盗链或签名失效 | 对比浏览器请求头 | 复制完整头信息,及时下载 |
| 音画不同步 | 时间戳损坏 | 检查分片时间戳 | 用 -fflags +genpts 重新生成 |
| 视频能播但拖动卡顿 | ts 容器效率低 | 检查输出格式 | 转封装成 MP4 |
提示:批量下载时建议加延时,不要并发太高,否则容易触发服务器的限流机制,导致后续请求全部失败。
6. 工具选型与自动化思路
6.1 现成工具 vs 自己写脚本
如果只是偶尔下载一两个视频,用现成工具最省事。N_m3u8DL-RE 是一个用 C# 写的开源工具,支持多线程下载、AES 解密、自动合并,命令行操作,跨平台。它的优势是处理各种非标准 m3u8 的能力强,很多自己写脚本搞不定的情况它都能处理。
但如果你需要批量处理、或者平台有特殊的加密逻辑,自己写脚本更灵活。Python 生态里有m3u8、requests、cryptography这些库,组合起来能覆盖大部分场景。我自己的做法是:先用现成工具试,能搞定就用;搞不定再分析具体卡在哪一步,针对性写脚本。
6.2 自动化下载脚本的设计要点
一个健壮的下载脚本要考虑这几件事:断点续传(记录已下载的分片,中断后不用重来)、失败重试(网络抖动时自动重试,带退避策略)、并发控制(多线程加速但别把服务器打挂)、完整性校验(检查分片大小和解密结果)、日志记录(出问题时能定位到具体哪一步)。
并发数建议控制在 4 到 8 之间。太高容易触发限流,太低下载慢。重试策略用指数退避,第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 到 5 次。
6.3 关于合法使用的边界
最后还是要强调一下使用边界。这套技术可以用来备份自己购买的内容、下载公开授权的素材、做技术研究,但不能用来传播盗版或绕过付费墙。很多平台的用户协议里明确禁止下载,实际操作前先看清楚规则。技术能力是一回事,怎么用是另一回事,这个分寸自己把握。
我在实际使用中的体会是,这套链路里最耗时间的往往不是技术难点,而是各种非标准的边缘情况:这个平台的密钥要 POST 获取,那个平台的 m3u8 地址十分钟就过期,另一个平台的分片命名毫无规律。所以真正实用的做法是准备一套模块化的脚本,把"获取索引""下载分片""解密""合并"拆成独立函数,遇到新平台时只需要改获取索引和请求头这两块,后面的流程可以复用。这样折腾几次之后,大部分平台都能在十几分钟内搞定。