给下载器加 m3u8 支持的时候,我心里想的是件特别简单的事:读文件 → 拿到一串
.ts地址 → 挨个下载 → 拼起来。半小时的活。结果第一个视频就让我改了四个小时:先是段地址 404,一查是相对路径没拼 base;拼上之后下载下来了,合并完却发现是花屏,原来是
AES-128加密的段没解密就拼;等解密逻辑写完,又遇到一个 fMP4 的流,EXT-X-MAP的初始化段没下,整条流直接废掉。最后我把 m3u8 翻了个底朝天,才发现它的复杂度全藏在细节里。这篇把我理解的 m3u8 结构、以及所有踩过的坑一次写清楚。
TL;DR:m3u8 分 master playlist(列多个码率档)和 media playlist(列具体分片)。解析时三样东西必须统一处理相对路径:分片 URI、
EXT-X-KEY的 key、EXT-X-MAP的初始化段。加密流要读EXT-X-KEY,IV 缺省时用MEDIA-SEQUENCE序号当 IV。fMP4 流先下初始化段再下分片。合并用ffmpeg -f concat -c copy而不是cat,尤其是 fMP4,直接 cat 必坏。别把 m3u8 当成"一堆文件地址",它是一个有状态、可加密、可分段 range 的流协议。
目录
- 一、m3u8 到底长什么样:master 与 media playlist
- 二、选流:从多码率里挑最合适的一条
- 三、分段下载:并发、重试与断点
- 四、AES-128 加密流:EXT-X-KEY 与 IV
- 五、fMP4 与 EXT-X-MAP:初始化段别丢
- 六、无损合并:ffmpeg concat 而不是 cat 拼接
- 七、坑清单
- 八、完整下载脚本骨架
一、m3u8 到底长什么样:master 与 media playlist
m3u8 是 HLS 协议的播放列表文件,本质是个纯文本,一行一个条目。它分两种,理解这个区别是第一步。
master playlist(主列表)——描述"有哪些码率可以选",不含具体分片:
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1920x1080,CODECS="avc1.640028"
1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,CODECS="avc1.64001f"
720p/index.m3u8
#EXT-X-STREAM-INF 这一行描述它下面那一条流的属性:BANDWIDTH 是平均码率(bps),RESOLUTION 是分辨率,CODECS 是编码。下面紧跟的 1080p/index.m3u8 就是这一档的 media playlist 地址。
media playlist(媒体列表)——描述"具体分片在哪",也就是我们真正要下载的目标:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="key.bin",IV=0x00000000000000000000000000000001
#EXTINF:10.0,
seg0.ts
#EXTINF:10.0,
seg1.ts
#EXT-X-ENDLIST
逐行拆一下:
#EXT-X-TARGETDURATION:单个分片的最大时长(秒),下载器可用来做超时预估。#EXT-X-MEDIA-SEQUENCE:第一个分片的序号,对后面算 IV 很重要。#EXT-X-KEY:分片加密方式,URI指向密钥文件,IV可选。#EXTINF:后面那个分片的时长,逗号后面是注释(可有可无)。#EXT-X-ENDLIST:点播的标志。没有它的就是直播,需要轮询追新段。
这里埋着第一个大坑:seg0.ts、key.bin、以及后面会看到的初始化段,都是相对地址,必须基于当前 m3u8 的 URL 去解析,不能直接拿来当完整 URL 用。
二、选流:从多码率里挑最合适的一条
拿到 master playlist 之后,不能随手抓第一档。BANDWIDTH 只代表平均码率,实际峰值往往高出 20%–50%,所以按"带宽恰好等于 BANDWIDTH"来选会翻车。
稳妥的选流策略:
- 有明确需求(如只要最高清)就直接挑
RESOLUTION最大的。 - 否则按用户可用带宽,选小于带宽 70% 的最高档,给峰值留余量。
- 记住:同一档可能对应多条 media playlist(不同的音视频编码组合),下载场景优先选带
avc1(H.264)的那条,兼容性最好。
选完之后把那条 media playlist 的地址 urljoin(master_url, stream_uri) 拼出来,再进入解析。
三、分段下载:并发、重试与断点
分片之间没有依赖,天然适合并发。串行一个接一个下,几十上百个分片能把时间拖到离谱;并发度开到 8–16 就够,再高容易被限流。
两个必须有的兜底:重试(网络抖动、偶发 5xx)和断点续传(一个大分片下到一半断了,从头下太亏)。核心逻辑:
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
def download_segment(url, path, retries=3):
for attempt in range(retries):
try:
# Range 请求实现断点续传:已有多少字节就从哪继续
downloaded = 0
if os.path.exists(path):
downloaded = os.path.getsize(path)
headers = {"Range": f"bytes={downloaded}-"} if downloaded else {}
with requests.get(url, headers=headers, stream=True, timeout=30) as r:
if r.status_code in (200, 206):
mode = "ab" if downloaded else "wb"
with open(path, mode) as f:
for chunk in r.iter_content(65536):
f.write(chunk)
return path
r.raise_for_status()
except Exception:
if attempt == retries - 1:
raise
time.sleep(1.5 ** attempt) # 指数退避
def download_all(segments, out_dir, workers=8):
os.makedirs(out_dir, exist_ok=True)
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = {
ex.submit(download_segment, seg["url"], os.path.join(out_dir, seg["name"])): seg
for seg in segments
}
for fut in as_completed(futures):
fut.result() # 有异常会在这里抛出来
注意别用一个文件反复 open("wb") 覆盖,断点续传要配合 Range 和 ab 追加模式。
四、AES-128 加密流:EXT-X-KEY 与 IV
这是最容易翻车的一环。#EXT-X-KEY:METHOD=AES-128,URI="key.bin",IV=... 表示后续分片都被 AES-128-CBC 加密,解密需要两样东西:密钥和IV。
- 密钥:从
URI(同样要拼 base)下载,一般 16 字节。 - IV:分两种情况。
IV=显式给出,直接按十六进制解析成 16 字节。- 没有
IV=:用分片的MEDIA-SEQUENCE序号,转成 16 字节大端整数当 IV。也就是第 N 个分片的 IV =N.to_bytes(16, 'big')。
解密用 PyCryptodome:
from Crypto.Cipher import AES
def decrypt_segment(data, key, iv):
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(data)
# 去掉 PKCS#7 填充
pad_len = decrypted[-1]
return decrypted[:-pad_len]
两个细节:METHOD 也可能是 NONE(不加密)或 SAMPLE-AES(少见,按厂商处理);密钥文件下载一次缓存住,别每个分片都重新下载一遍 key。
五、fMP4 与 EXT-X-MAP:初始化段别丢
传统 HLS 用 .ts 分片,TS 天生是"可独立解码的连续流";但现代流越来越多用 fMP4(fragmented MP4),它的分片是 .m4s,不能独立解码——因为 MP4 的元数据(moov box)被单独抽出来放进了"初始化段"。
fMP4 的 media playlist 里会多一行:
#EXT-X-MAP:URI="init.mp4"
这个 init.mp4 就是初始化段,包含解码器配置和采样表。没有它,后面的 .m4s 分片全是不可解的黑盒。下载顺序必须是:先下 init.mp4,再按顺序下所有 .m4s,合并时初始化段放最前面。
这也是为什么判断流类型很重要:看到 #EXT-X-MAP 或分片后缀是 .m4s/.mp4,就要走 fMP4 的分支;全是 .ts 才是传统 TS 流。
六、无损合并:ffmpeg concat 而不是 cat 拼接
分片下载完,最后一步是拼成一个完整视频。不要用 cat,尤其 fMP4。
对 TS 流,cat seg*.ts > out.ts 在"碰运气"的情况下能播,但分片之间时间戳(PTS/DTS)不连续,播放器可能卡顿、花屏,音画错位。对 fMP4 流,cat 拼出来的文件结构是坏的,基本必废。
正确姿势是用 ffmpeg 的 concat demuxer,-c copy 做无损拼接(不重编码):
# 生成列表文件 list.txt,内容形如:
# file 'seg0.ts'
# file 'seg1.ts'
# ...
ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4
fMP4 的列表把初始化段放第一行即可:
file 'init.mp4'
file 'seg0.m4s'
file 'seg1.m4s'
关键点是 -c copy:分片本来就是编码好的,拼接只是"搬运",不重新编码。一旦不加 -c copy 让 ffmpeg 重编码,耗时翻十几倍不说,画质还会损失一档。
七、坑清单
- 相对路径没拼 base:分片、key、init 段三处都要
urljoin(m3u8_url, uri),漏一个就 404 或解密失败。 - 文件带 BOM:m3u8 首行可能是
\ufeff#EXTM3U,解析前先lstrip('\ufeff'),否则第一行匹配不上。 - IV 缺省用序号:没有
IV=时,IV = 分片序号的大端 16 字节,不是 0,也不是固定值。 - key 重复下载:key 文件很小,但反复下载会拖慢整个流程,记得缓存。
- 直播流没有
ENDLIST:没有这行的要轮询 playlist 追新分片,不能下完一轮就收工。 EXT-X-BYTERANGE子区间:有些流一个分片只占文件的一段字节,要发Range请求且不能整文件下载。EXT-X-DISCONTINUITY断点:出现这行说明后面的分片时间戳重新开始了,拼接时可能需要分段处理,-c copy有时会在这里报时间戳问题。- 忘记
-c copy:合并时重编码 = 慢 + 糊,纯属白干。 - 把 master 当 media 解析:master 里没有分片,直接拿去"下载"会得到一堆空文件,先判断有没有
#EXT-X-STREAM-INF。
八、完整下载脚本骨架
把上面的逻辑串起来,一个能跑的最小实现长这样:
import os, re, time, subprocess
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urljoin
from Crypto.Cipher import AES
def fetch(url):
r = requests.get(url, timeout=30)
r.raise_for_status()
return r.text.lstrip('\ufeff')
def get_base(url):
return url.rsplit('/', 1)[0] + '/'
def parse_media(text, base):
"""解析 media playlist,返回 segments 和加密信息"""
segments, key_info, seq = [], None, 0
seq_match = re.search(r'#EXT-X-MEDIA-SEQUENCE:(\d+)', text)
if seq_match:
seq = int(seq_match.group(1))
key_match = re.search(r'#EXT-X-KEY:METHOD=([^,]+)(?:,URI="([^"]+)")?(?:,IV=0x([0-9A-Fa-f]+))?', text)
if key_match and key_match.group(1) != 'NONE':
key_info = {
'uri': urljoin(base, key_match.group(2)),
'iv': bytes.fromhex(key_match.group(3)) if key_match.group(3) else None,
}
lines = [l for l in text.splitlines() if l.strip() and not l.strip().startswith('#')]
for i, uri in enumerate(lines):
segments.append({'url': urljoin(base, uri), 'name': uri.split('/')[-1], 'index': seq + i})
return segments, key_info
def decrypt_file(path, key, iv):
with open(path, 'rb') as f:
data = f.read()
dec = AES.new(key, AES.MODE_CBC, iv).decrypt(data)
with open(path, 'wb') as f:
f.write(dec[:-dec[-1]]) # 去 PKCS#7 填充
def main(m3u8_url, out='output.mp4', workers=8):
text = fetch(m3u8_url)
base = get_base(m3u8_url)
# 是 master 就取第一档(这里简化,实际按第二节的选流策略挑)
if '#EXT-X-STREAM-INF' in text:
uri = [l for l in text.splitlines() if l.strip() and not l.startswith('#')][0]
m3u8_url = urljoin(base, uri)
base = get_base(m3u8_url)
text = fetch(m3u8_url)
segments, key_info = parse_media(text, base)
tmp = 'segments'
os.makedirs(tmp, exist_ok=True)
with ThreadPoolExecutor(max_workers=workers) as ex:
list(ex.map(lambda s: download(s['url'], os.path.join(tmp, s['name'])), segments))
if key_info: # 解密
key = requests.get(key_info['uri'], timeout=30).content
for s in segments:
iv = key_info['iv'] or s['index'].to_bytes(16, 'big')
decrypt_file(os.path.join(tmp, s['name']), key, iv)
# 合并
with open('list.txt', 'w') as f:
for s in segments:
f.write(f"file '{os.path.join(tmp, s['name'])}'\n")
subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', 'list.txt', '-c', 'copy', out], check=True)
这个骨架是"能用"级别,生产里还要补上:直播轮询、EXT-X-BYTERANGE、EXT-X-DISCONTINUITY、fMP4 的 EXT-X-MAP 初始化段下载、以及分片的并发重试(见第三节)。但核心链路——解析、选流、下载、解密、合并——都已经覆盖,剩下的是在真实 m3u8 面前见招拆招。