提示

返回博客列表

一个 m3u8 文件里藏了多少坑:HLS 解析、AES 解密与无损合并实战

给下载器加 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

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"来选会翻车。

稳妥的选流策略:

  1. 有明确需求(如只要最高清)就直接挑 RESOLUTION 最大的。
  2. 否则按用户可用带宽,选小于带宽 70% 的最高档,给峰值留余量。
  3. 记住:同一档可能对应多条 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 面前见招拆招。


本文由在线视频下载工具 VidDown 提供,支持 B 站、抖音、YouTube 等平台无水印视频下载。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部