做过几年播放器调试和媒资归档的人,大概率都在浏览器开发者工具的 Network 面板里见过那一串.ts请求。第一眼看上去挺唬人:几百上千个碎片,名字还带随机串,复制链接到新标签页直接 403。但只要把 HLS 这套东西拆开看,它本质上就是"一份文本清单 + 一堆切片 + 一把钥匙"的组合。这篇就围绕 m3u8 解析、session 鉴权和 TS 分片合并这三块,把我自己趟过的流程、踩过的坑、调过的参数完整写一遍。
需要先把边界说清楚:下面所有内容都建立在你对自己有权访问的内容做本地缓存、离线观看或技术研究这个前提上。平台的服务条款、内容的版权归属、会员权益的边界,这些是硬约束,技术手段解决不了也不该去解决。我写的是协议本身怎么运作,不是教你怎么突破什么。
1. 先把协议吃透:HLS 与 m3u8 的运行机制
很多人上手就想写脚本,结果卡在第一步——不知道该请求哪个地址。原因是对 HLS 的分层结构没有概念。HLS(HTTP Live Streaming)把一整段视频切成若干秒级的小文件,用一份文本清单描述播放顺序,播放器按清单逐个拉取、边下边播。这份清单就是 m3u8。
1.1 视频站为什么偏爱 HLS 而不是整段 MP4
整段 MP4 的问题在于它是"全有或全无"的:文件必须带 moov box 才能起播,要么放在文件头(需要提前做 faststart 处理),要么放在文件尾(必须先下完整个文件)。一个两小时、两个 G 的视频,用户点了播放要等多久?CDN 要回源多少次?并发一上来,源站带宽直接打满。
HLS 把这个问题拆解掉了。切片之后,每个.ts通常只有 2 到 10 秒,几 MB 大小,落在 CDN 边缘节点上,命中率高、回源压力小。播放器只需要先拿到清单,立刻就能请求第一个切片开始播。清晰度切换也变得极其自然——主清单里列着 360P、720P、1080P 几条子清单,播放器根据当前带宽实时切换,切换点就落在切片边界上,用户几乎无感。
还有一个工程上的好处:切片是独立可缓存的 HTTP 对象。任何标准 CDN 不需要特殊配置就能加速,这对视频站来说是巨大的成本优势。理解了这一点,你也就理解了为什么下载这件事天然是"多文件并发"而不是"单流传输"。
1.2 m3u8 文本清单的骨架拆解
m3u8 就是纯文本,用 UTF-8 编码,第一行必须是#EXTM3U。以#开头的行是标签(指令),不以#开头的行是 URI(切片地址或子清单地址)。这个规则记住,解析逻辑就清晰了。
我把高频标签整理成一张表,实际处理时按这张表对照基本不会漏:
| 标签 | 作用 | 处理要点 |
|---|---|---|
#EXTM3U | 文件标识 | 必须首行,否则不是合法清单 |
#EXT-X-VERSION | 协议版本 | 影响可用标签范围,低于 3 不支持浮点时长 |
#EXT-X-TARGETDURATION | 切片最大时长(整数秒) | 用于播放器缓冲策略,向上取整 |
#EXT-X-MEDIA-SEQUENCE | 首个切片的媒体序列号 | 直播场景下用于定位,点播通常为 0 |
#EXTINF | 下一段切片的时长 | 格式#EXTINF:9.009,,逗号后是标题可空 |
#EXT-X-KEY | 加密方式与密钥地址 | METHOD、URI、IV三个属性最关键 |
#EXT-X-MAP | 初始化段(fMP4 专用) | 合并前必须先拼接这个 init 文件 |
#EXT-X-BYTERANGE | 字节范围 | 表示切片是某个大文件的一段,需带 Range 请求头 |
#EXT-X-DISCONTINUITY | 不连续标记 | 广告插入、编码参数变化处,合并时要特别处理 |
#EXT-X-ENDLIST | 清单结束标记 | 有它说明是点播,没有则是直播 |
实际解析时不要用正则去硬啃整份文本,正确做法是逐行状态机:遇到#EXTINF就把时长暂存,遇到#EXT-X-KEY就更新当前密钥上下文,遇到非#开头的行就把它和当前状态配对成一条记录。这样处理#EXT-X-KEY在中途变化(比如前 100 片不加密、后面加密)的情况才不会出错。
1.3 主清单与子清单:清晰度是怎么被选择的
一个典型的播放流程是两级跳转。第一次请求拿到的是主清单(Master Playlist),里面只有#EXT-X-STREAM-INF和#EXT-X-MEDIA两类标签,前者描述视频流(含BANDWIDTH、RESOLUTION、CODECS),后者描述独立的音轨或字幕轨。
#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=1280x720,CODECS="avc1.64001F,mp4a.40.2" /stream/720p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2" /stream/1080p/index.m3u8#EXT-X-STREAM-INF的下一行就是对应子清单的地址。这里有个容易翻车的点:子清单地址可能是相对路径,必须基于主清单的 URL 做urljoin解析,不能直接当绝对路径用。我见过太多脚本在这一步拼出http://host/stream/720p/index.m3u8之外还多带了父目录,结果 404。
进入子清单后,才是真正的切片列表。如果你要的是最高画质,就在主清单里挑BANDWIDTH最大或者RESOLUTION最高的那条;如果要音画分离的版本(#EXT-X-MEDIA里TYPE=AUDIO且URI不为空),那音轨是单独一份清单,需要分别下载再合并——这块后面 4.4 会展开讲。
2. session 鉴权:链接能播但复制出来就 403 的原因
这是劝退率最高的一环。你在 Network 里右键复制某个.ts的地址,粘贴到新标签页,回车,403 Forbidden。地址明明一模一样,差别在哪?差别在请求上下文。
2.1 一次播放请求里到底带了多少"身份证明"
浏览器的请求从来不是一个裸 URL。它至少携带:
- Cookie:包含登录态、设备标识、会话 ID。很多站点的鉴权就挂在这个 session cookie 上。
- Referer:告诉 CDN "我是从哪个页面跳过来的"。防盗链的第一道闸门。
- User-Agent:部分站点会校验 UA 白名单,非浏览器 UA 直接拒。
- Origin:跨域场景下用于 CORS 校验。
- 自定义请求头:某些播放器会在拉流时附加
X-Play-Token、Authorization: Bearer xxx之类的字段。
所以正确的心智模型是:鉴权信息不只在 URL 里,它分散在整个请求头集合中。你只复制 URL,等于把身份证扔了只带了个名字去办事。
这里要区分两种鉴权形态。第一种是会话级鉴权:服务端在登录或打开播放页时,往你的 cookie 里种一个 session,之后所有切片请求都带着它去校验,服务端查这个 session 是否有权看这个视频。第二种是签名级鉴权:切片 URL 自带?sign=xxx&t=1700000000&us=yyy这类参数,CDN 边缘节点本地计算校验,不查中心服务。两者常常同时存在,形成双重保险。
2.2 Cookie、Token 与签名参数的三层校验
把这三层摊开看会更清楚:
| 层级 | 载体 | 校验位置 | 失效特征 |
|---|---|---|---|
| 会话层 | Cookie(session id) | 业务服务端 | 换浏览器或清 cookie 后 403 |
| 令牌层 | 播放接口返回的 playUrl 中的 token | 业务服务端 / 网关 | token 过期,通常几十分钟 |
| 签名层 | URL 查询参数(sign、t、key) | CDN 边缘节点 | 篡改任意参数即 403,且有时效 |
实操中我建议的定位顺序是:先看切片请求的完整 Headers,把 Cookie、Referer、UA 三样原样复刻到你自己的请求里,如果这样能通,说明是会话层的问题;如果复刻了还不行,再去对比播放接口返回的 JSON 里有没有playUrl、token、sign之类的字段,检查 URL 参数是不是被你截断了(尤其是带&的查询串在 shell 里没加引号会被吃掉)。
签名参数的校验逻辑通常是sign = md5(path + timestamp + secret)这种形式,secret 在服务端和 CDN 节点共享。你没法伪造,也没必要伪造——你要做的是原样保留,避免任何改写。我踩过的一个坑是用 URL 编码工具"顺手规范化"了一遍地址,把%2F变成了/,签名瞬间失效。结论很简单:URL 拿过来,一个字符都别动。
2.3 时效性与续期:长视频下载到一半失效的处理
签名和 token 都是有时效的。一个两小时的视频、1200 个切片,按每秒 3 个的速度下载大概 400 秒,如果签名只有 300 秒有效期,必然在中途开始大面积 403。
处理思路有三条,按推荐度排序:
第一条,缩短链路。在签名有效期内把所有切片的下载任务全部发起(并发池控制并发数,但任务队列已经建立),让每个请求在排队时就已确定,通常不影响,因为签名是在请求发出的那一刻被校验的——所以这条不完全靠得住。更稳的做法是缩短整体耗时:并发数从 5 提到 12 到 16,实测能把 1200 片的耗时压到 150 秒以内,安全区间内完成。
第二条,定期刷新。把"获取播放地址"这一步封装成函数,下载过程中如果连续 N 个切片返回 403,就暂停队列、重新走一次播放接口拿新地址、更新签名基准、继续下载。这里要注意子清单地址本身可能也带签名,刷新后要同步换掉。
第三条,降低切片粒度依赖。部分站点的签名是按切片独立生成的,URL 里的t参数间隔很小。这种情况下并发再高也没用,只能老老实实按顺序拉,把并发压到 2 到 3,避免触发风控。
注意:并发数不是越高越好。我实测过某站点并发 32 时,前 200 片顺利,之后就进入持续 403 状态,换 IP 都没用——这是典型的频次风控。稳妥区间是 8 到 12,配合 0.1 到 0.3 秒的随机间隔。
还有一点常被忽略:Cookie 的 domain 和 path。有些站点的播放鉴权 cookie 是种在api.xxx.com上的,而切片区在cdn.xxx.com,两者不同域。你用 requests 直接请求 CDN 时,如果 cookie jar 没有正确匹配 domain,就等于裸奔。解决办法是显式把 cookie 字典塞进 headers,不依赖 cookie jar 的自动匹配。
3. TS 分片:188 字节的搬运工与它的坑
切片下下来了,一堆.ts文件躺在文件夹里,双击打不开或者只能播第一段。这是很多人卡住的第二个点。要理解为什么,得先看 TS 这个容器的结构。
3.1 TS 包结构、PID 与 PES 载荷
TS(MPEG-2 Transport Stream)是定长包格式,每个包固定 188 字节,第一个字节是同步字节0x47。这四字节的包头里包含:1 位传输错误标志、1 位 payload 起始标志、13 位 PID、2 位加扰控制、4 位自适应字段控制、4 位连续计数器。
PID 是核心。一个 TS 流里混着多路数据,靠 PID 区分:
PID 0x0000:PAT(Program Association Table),告诉解复用器这个节目对应哪个 PMT。- PAT 指定的 PID:PMT(Program Map Table),列出该节目的视频流 PID、音频流 PID、PCR PID。
- 其余 PID:承载实际的 PES(Packetized Elementary Stream)数据,视频通常是
0x100、0x101这类,音频是0x102左右。
PES 层再往上打包成 ES(Elementary Stream),也就是 H.264/NAL 单元或 AAC 帧。时间信息在 PES 头里:PTS(显示时间戳)和 DTS(解码时间戳),都是 33 位、90kHz 时钟。这个时钟就是为什么合并时"时间戳不连续"会导致花屏、音画不同步。
为什么要了解这么深?因为合并的本质不是文件拼接,而是流拼接。两个独立编码、各自从 PTS=0 开始计时的 TS 文件,二进制首尾相接之后,播放器读到第二段时发现时间戳突然跳回 0,就会判定为异常,表现为卡顿、花屏甚至直接停止。理解了这一点,你才知道为什么有时候"直接 cat 拼接"能用,有时候不能用。
3.2 二进制拼接与 ffmpeg 合并的适用边界
直接二进制拼接(cat part1.ts part2.ts > out.ts)能用的前提很苛刻:
- 所有切片来自同一次编码,SPS/PPS 参数集一致;
- 时间戳天然连续(同一源的切片通常满足,因为是编码器切出来的);
- 没有经过任何加密,或者已经全部解密;
- 每个切片都是完整的 TS 包边界(188 的整数倍)。
实际站点里,前三条在不加密的点播流上基本成立,所以你会发现很多场景cat一把梭就够了,而且速度快得惊人——纯 IO 操作,几个 G 的文件几秒钟搞定,没有任何 CPU 消耗。
但只要出现下面任一情况,就必须上 ffmpeg:
- 切片是 fMP4(
.m4s后缀),带#EXT-X-MAP初始化段; - 清单里有
#EXT-X-DISCONTINUITY,说明中途编码参数变了; - 音视频是分离的两条流,需要重新封装;
- 拼接后出现花屏,说明时间戳需要重排。
ffmpeg 的 concat demuxer 是首选方案,它按文件列表逐个读取,会重新计算时间戳基准:
# list.txt 内容格式: # file '/path/to/seg_0001.ts' # file '/path/to/seg_0002.ts' ffmpeg -f concat -safe 0 -i list.txt -c copy -bsf:a aac_adtstoasc output.mp4-c copy是关键,表示不重新编码,只做封装转换,速度快且画质无损。-bsf:a aac_adtstoasc这个 bitstream filter 处理的是 AAC 从 ADTS 头转换到 MP4 需要的 ASC 格式,不加的话合并出来的 MP4 经常没声音,这是最高频的翻车点之一。
如果 concat demuxer 也救不回来(比如切片时间戳错乱严重),那就退到 concat protocol:
ffmpeg -i "concat:seg_0001.ts|seg_0002.ts|seg_0003.ts" -c copy output.mp4它对时间戳的容忍度更高,但缺点是命令行长度受限,几百个切片得靠脚本拼字符串,容易超系统限制。所以我的习惯是:先用 concat demuxer,失败再退 protocol,最后才考虑重新编码。
3.3 AES-128 加密分片的处理逻辑与 IV 推导
加密是另一个高频卡点。清单里出现#EXT-X-KEY:METHOD=AES-128,URI="key.bin",IV=0x...就说明切片被加密了。
处理逻辑是:先请求URI指向的地址拿到 16 字节密钥,然后对每个切片做 AES-128-CBC 解密。IV 的推导规则有个坑:
- 如果
#EXT-X-KEY显式写了IV=0x...,直接用,16 字节十六进制转二进制; - 如果没写 IV,则使用该切片的媒体序列号(媒体序列号 =
#EXT-X-MEDIA-SEQUENCE+ 该切片在清单中的索引),编码成 16 字节大端整数作为 IV。
第二条规则是 RFC 8216 明确规定的,但很多实现会漏掉,导致解密出来是乱码。我最初就栽在这——同样的密钥,前几片正常后面全乱,最后发现是 IV 用了固定全零。
解密时的填充方式是PKCS7,解密后尾部的填充字节要剥掉。还有一点:METHOD除了AES-128,还可能是SAMPLE-AES(样本级加密,通常对应 DRM 体系)。后者不是简单解密能处理的,涉及密钥交换和授权,遇到就说明这条路径属于受保护内容,应当止步于此。
注意:密钥的 URI 同样受鉴权保护。请求 key 的时候,Cookie 和 Referer 要和请求切片时保持一致,否则会拿到 403 或者一段错误内容。我遇到过一次 key 请求返回的是 HTML 错误页(长度 500 多字节),解密出来当然是垃圾数据,脚本还不报错——所以拿到 key 后务必校验长度必须等于 16。
4. 完整实操:从清单到成品的落地流程
理论讲完,走一遍完整链路。我把整个流程拆成五步:准备环境 → 解析清单 → 并发下载 → 解密 → 合并校验。下面的代码都是示意性的通用写法,重点看结构和参数,不要照抄去跑任何站点。
4.1 环境与工具准备
依赖不多,但版本要对:
pip install requests m3u8 pycryptodome tqdmrequests:HTTP 客户端,注意Session对象能保持 cookie。m3u8:第三方解析库,能处理大部分标准标签,但遇到非标扩展标签会抛异常,所以我习惯自己写一个轻量解析器兜底。pycryptodome:AES 解密。用cryptography也行,但前者 API 更直观。tqdm:进度条,几百个切片看不到进度会让人焦虑。
ffmpeg 单独装,建议用最近的稳定版(5.x 以上),老版本对 fMP4 的 concat 支持不完整。装完确认一下ffmpeg -version里有没有--enable-libx264,虽然我们只做 copy 封装,但部分场景需要重新封装音频。
磁盘空间要提前算:所有切片的体积之和 ≈ 最终文件大小,加上合并时的临时副本,至少准备 2.5 倍空间。一个 1080P 的两小时视频大概 2 到 3 个 G,那就留出 7 到 8 个 G。
4.2 解析清单:抽出子清单、密钥与切片列表
先写解析器。核心是逐行状态机,把密钥上下文和切片记录关联起来:
from urllib.parse import urljoin def parse_media_playlist(text, base_url): lines = [l.strip() for l in text.splitlines() if l.strip()] segs, cur_key, cur_dur = [], None, 0.0 media_seq = 0 for line in lines: if line.startswith('#EXT-X-MEDIA-SEQUENCE:'): media_seq = int(line.split(':')[1]) elif line.startswith('#EXT-X-KEY:'): attrs = dict( kv.split('=', 1) for kv in line[len('#EXT-X-KEY:'):].split(',') if '=' in kv ) cur_key = { 'method': attrs.get('METHOD', 'NONE').strip('"'), 'uri': urljoin(base_url, attrs['URI'].strip('"')) if 'URI' in attrs else None, 'iv': attrs.get('IV', '').strip('"'), } elif line.startswith('#EXTINF:'): cur_dur = float(line[len('#EXTINF:'):].split(',')[0]) elif not line.startswith('#'): segs.append({ 'index': len(segs), 'seq': media_seq + len(segs), 'url': urljoin(base_url, line), 'dur': cur_dur, 'key': dict(cur_key) if cur_key else None, }) return segs几个细节值得强调。urljoin处理相对路径是必须的,因为主清单里的子清单地址、子清单里的切片地址,大概率都是相对路径。seq字段是为了 IV 推导准备的,必须按媒体序列号 + 索引算。key做浅拷贝是防止后续#EXT-X-KEY变化时污染前面已完成解析的记录。
拿到子清单地址后,还要做一个筛选:如果有多条子清单(多清晰度),用BANDWIDTH排序取最大;如果只有一条,直接用。筛选完再去请求,少一次无用请求。
4.3 并发下载与断点续传的实现要点
并发用ThreadPoolExecutor就够了,IO 密集型任务没必要上 asyncio(虽然协程更省资源,但调试成本高)。关键在三个地方:连接复用、重试策略、落盘方式。
import os, requests from concurrent.futures import ThreadPoolExecutor, as_completed def build_session(cookie_str, referer, ua): s = requests.Session() s.headers.update({ 'Cookie': cookie_str, 'Referer': referer, 'User-Agent': ua, 'Accept': '*/*', 'Accept-Language': 'zh-CN,zh;q=0.9', }) adapter = requests.adapters.HTTPAdapter( pool_connections=20, pool_maxsize=20, max_retries=0 ) s.mount('https://', adapter) s.mount('http://', adapter) return spool_maxsize要和并发数匹配,设小了会出现连接排队。max_retries=0是因为我要自己控制重试逻辑——urllib3 的自动重试对 403 也会重试,白白浪费时间,需要自己区分状态码。
下载函数里做三件事:跳过已存在且大小非零的文件(断点续传)、失败重试 3 次并指数退避、把响应状态码分类处理:
def fetch_seg(sess, seg, outdir, retries=3): path = os.path.join(outdir, f"seg_{seg['index']:05d}.ts") if os.path.exists(path) and os.path.getsize(path) > 0: return path, True for attempt in range(retries): try: r = sess.get(seg['url'], timeout=(10, 60)) if r.status_code == 200 and len(r.content) > 0: with open(path, 'wb') as f: f.write(r.content) return path, True if r.status_code in (403, 401): return path, False # 鉴权问题,立即失败让上层处理 if r.status_code == 404: return path, False # 切片不存在,重试无意义 except requests.RequestException: pass time.sleep(1.5 ** attempt) # 1s, 1.5s, 2.25s return path, False这个结构里最重要的是对 403 立即失败。如果是签名过期,你重试一百次也没用,只会把风控触发得更彻底。把失败信号快速抛给上层,由上层决定是否刷新 token 后重来,这才是正确的分层。
落盘建议按seg_00000.ts这样的零填充命名,因为合并时要按文件名排序,seg_1、seg_10、seg_2这种命名会排错顺序,导致合并出来的视频情节乱跳——这个坑我踩过,排查了半小时才反应过来是排序问题。
4.4 分片合并的两条路线:二进制拼接 vs ffmpeg
前面 3.2 讲了原理,这里给具体做法。
路线 A:纯二进制拼接。适用于不加密、非 fMP4、无明显不连续的场景。
with open('output.ts', 'wb') as out: for path in sorted(glob.glob(os.path.join(outdir, 'seg_*.ts'))): with open(path, 'rb') as f: shutil.copyfileobj(f, out, length=1024 * 1024)copyfileobj带 1MB 缓冲,比f.read()全量读进内存省得多。合并完再跑一次 ffmpeg 转封装成 mp4,顺手让 ffmpeg 修一下时间戳:
ffmpeg -i output.ts -c copy -bsf:a aac_adtstoasc -movflags +faststart final.mp4+faststart把 moov 挪到文件头,方便后续拖动播放和网页预览。
路线 B:直接 concat demuxer。更通用,从下载目录直接生成列表文件:
ls -1 seg_*.ts | sort | sed "s|^|file '|; s|$|'|" > list.txt ffmpeg -f concat -safe 0 -i list.txt -c copy -bsf:a aac_adtstoasc final.mp4safe 0是必须的,否则 ffmpeg 会因为路径包含特殊字符拒绝读取。sort也是必须的,ls的默认排序在不同 locale 下可能不是字典序。
路线 C:音视频分离的情况。如果主清单里#EXT-X-MEDIA:TYPE=AUDIO有个独立 URI,那你要分别下载两条流,最后用 ffmpeg 合并:
ffmpeg -i video.mp4 -i audio.m4a -c copy -map 0:v:0 -map 1:a:0 final.mp4注意-map的顺序,0:v:0表示第一个输入的第一路视频,1:a:0表示第二个输入的第一路音频。如果漏了-map,ffmpeg 会按默认规则挑,有时候会挑到错的那路,尤其两个输入都有音视频时。
4.5 成品校验:时长、花屏、音画不同步的排查
合并完不能说"没报错就成了",至少做三项校验:
第一项,总时长对比。把所有#EXTINF的时长加起来,得到理论时长 T1;用ffprobe -v error -show_entries format=duration -of csv=p=0 final.mp4拿到实际时长 T2。两者差值在 1 到 2 秒内属正常(切片边界取整导致),如果差值超过 5 秒,说明有切片缺失或者时间戳有跳变。
第二项,抽样看画面。用 ffmpeg 每隔 10% 的位置截一帧:
ffmpeg -i final.mp4 -vf "select='eq(n\,0)+eq(n\,300)+eq(n\,600)'" -vsync 0 check_%d.png眼过一遍这几个关键点有没有绿屏、马赛克、上下半屏错位。绿屏通常意味着 SPS/PPS 丢失或者解码器参数不匹配,多半是切片下载不完整。
第三项,音画同步检查。挑一段有明显口型的画面,看声音和嘴型差多少。小幅度偏移(几十毫秒)人耳察觉不到,超过 300 毫秒就很明显了。偏移的根源一般是音频和视频的起始 PTS 不一致,可以在合并时加-avoid_negative_ts make_zero让 ffmpeg 统一归零处理。
还有一个隐蔽的问题:采样率和声道数不一致。如果中途遇到#EXT-X-DISCONTINUITY,前后音频参数可能不同(比如前面 44.1kHz 后面 48kHz),-c copy合并出来的文件在某些播放器上会只有前半段有声音。这种情况只能对音频重新编码:-c:v copy -c:a aac -ar 48000。
5. 踩坑实录与排查速查表
这一节是我这些年攒下来的问题清单,按出现频率排序。绝大多数 403 和花屏问题都能在表里找到对应项。
5.1 典型报错与定位思路
| 现象 | 最可能原因 | 定位方法 | 处理方式 |
|---|---|---|---|
| 切片请求全部 403 | 缺 Cookie 或 Referer | 对比浏览器请求头,逐项补齐 | 复刻 Cookie、Referer、UA |
| 部分切片 403,其余正常 | 签名过期或频次风控 | 看 403 是否从某个 index 开始连续出现 | 刷新播放地址 / 降并发 |
| key 请求返回非 16 字节 | key 地址被鉴权拦截 | 打印响应长度和 Content-Type | 补齐请求头,校验长度 |
| 解密后是乱码 | IV 推导错误或 padding 未剥离 | 用第一片单独测试解密 | 按序列号大端推导 IV,剥 PKCS7 |
| 合并后只有前几秒有画面 | 时间戳跳变 | ffprobe 看各段起始 PTS | 用 concat demuxer 或重编码 |
| 合并后没声音 | AAC ADTS 头未转换 | ffprobe 看音频流编码 | 加-bsf:a aac_adtstoasc |
| 画面花屏、绿块 | 切片不完整或 SPS/PPS 缺失 | 检查各切片大小是否接近 | 重新下载异常切片 |
| 音频只有一半 | 采样率/声道不一致 | ffprobe 对比前后段 | 对音频重新编码统一参数 |
| 文件时长对不上 | 有切片 404 被跳过 | 统计成功失败数量 | 补齐缺失切片再合并 |
| 播放器打不开 | 容器封装错误 | 换播放器或 ffprobe 检查 | 用 ffmpeg 重新封装 |
关于"部分切片 403"再多说两句。我遇到过一次很诡异的情况:前 800 片正常,801 片开始全部 403,但用浏览器打开同样的 URL 又是 200。排查下来是 CDN 对同一个 User-Agent 在短时间内的请求量做了统计,触发了限速。解决方式是把并发从 16 降到 6,并在每个请求之间加 0.2 秒随机延时,问题消失。所以并发数和请求间隔要一起调,别只盯着并发。
另一个高频问题是清单本身获取失败。有时候主清单能拿到,子清单 404,原因是子清单地址里带了时间敏感的参数,而你拿主清单的时间和拿子清单的时间差了太久。解决方式是串行快速完成两级解析,中间不要插任何耗时操作(比如写日志、做磁盘检查)。
5.2 参数与并发调优的经验值
这些东西没有标准答案,取决于目标站点的风控强度和你的网络条件。下面是我在不同场景下试出来的经验区间,供参考起点:
| 参数 | 保守值 | 激进值 | 说明 |
|---|---|---|---|
| 并发线程数 | 4 | 16 | 超过 16 收益递减,风控风险陡增 |
| 连接超时 | 10s | 10s | 太短会误杀慢节点 |
| 读超时 | 60s | 120s | 切片几 MB,60s 通常够 |
| 请求间隔 | 0.3s | 0.05s | 保守值用于冷启动探测 |
| 重试次数 | 3 | 5 | 403 不重试,5xx 和超时才重试 |
| 连接池大小 | 20 | 40 | 要大于并发数,避免排队 |
调优的正确姿势是渐进式:先用保守值跑 100 片,观察失败率和平均耗时;稳定的话把并发提到 8,再跑 100 片;继续稳定提到 12。一旦失败率超过 5%,立刻回退到上一档。不要一上来就 32 并发,那是自找麻烦。
还有一个容易被忽略的细节:连接池的 keep-alive。默认 HTTPAdapter 会复用连接,这在并发高的时候能显著降低握手开销。但如果中途遇到签名刷新,旧的连接可能还带着旧 cookie,导致新请求用错上下文。稳妥做法是刷新 token 时重建 Session 对象,而不是修改 headers 复用。
5.3 合规边界与实操中形成的判断
技术到这里基本讲完了,但有个东西比技术更重要,值得单独说。
HLS 这套设计的初衷是让播放更流畅,它本身没有任何"防护"的意图。加密、签名、会话校验,是为了确保内容只被授权的人看到。这些机制在协议层面是公开的,任何人都能读到规范,但读得懂规范和有权使用内容是两件完全不同的事。
我在实际操作中的判断标准很简单,三条:
第一,内容的所有权。是不是你自己上传的?是不是平台明确提供了下载入口的?是不是采用开放许可发布的?三者有一个成立,做本地缓存就没什么问题。
第二,服务条款的明确约定。很多平台的用户协议里写得很清楚,禁止使用自动化手段批量获取内容。这条是硬边界,绕不过去,也不该绕。
第三,用途。做技术研究、做播放器兼容性测试、做自己账号的内容备份,这些用途是正当的。把内容二次分发、用于商业用途、规避付费机制,性质就完全变了。
还有一类情况需要特别提醒:清单里出现METHOD=SAMPLE-AES或者带#EXT-X-SESSION-KEY且指向授权服务的,说明这条流是受 DRM 保护的。这类内容的设计意图就是"只能在授权环境内播放",遇到就该停手。技术上有能力不代表使用上有权利,这条线我自己划得很清楚。
真正值得投入精力的方向,其实是把 HLS 的知识用在正向场景:给自己的项目搭一套点播服务,用 ffmpeg 切片生成 m3u8,配置 CDN 缓存策略,做多码率自适应;或者调试播放器时快速定位是清单问题还是切片问题。这些才是这套协议最有价值的应用面。我后来给内部培训做播放器排障,用的就是本文这套解析思路——先看清单结构对不对,再看鉴权头齐不齐,最后查切片完整性,三步定位,比盲猜快得多。