☰
m3u8解析、session鉴权与TS分片合并实战指南
2026/11/3 11:24:14 网站建设 项目流程

1. 先把技术地图和边界画清楚

很多人第一次碰见m3u8解析、session鉴权、TS分片合并这三个词,是在一个很具体的场景里:想把自己发布过的视频、买过的课程、或者公开的讲座资料存一份到本地,方便离线看、方便做字幕、方便做二次剪辑。结果打开开发者工具一看,视频不是一整个 mp4 文件,而是一串.ts结尾的小碎片,外加一个几百行的.m3u8文本清单,直接右键另存为根本拿不到完整文件。这三个词就是解决这条链路的三个关卡:m3u8 决定“要下哪些文件”,session 鉴权决定“服务端认不认你”,TS 合并决定“下完之后怎么拼成一个能正常拖动的视频”。

需要先说清楚合规边界:这套技术适合用在你自己拥有权限的内容上,比如你自己上传的投稿、你已购的课程资料做本地备份、明确开放下载的公开课、公司内部培训视频的归档、以及做播放器兼容性测试时抓自己的测试流。它不适合用来绕过付费墙、搬运他人作品、做二次分发。技术本身是中性的,用在哪儿决定了它是不是正当。我下面讲的所有内容,默认前提是“你已经用正常账号、正常权限访问到了这个播放页”,讨论的是如何把一个已经在浏览器里能正常播放的流,稳定地落到本地磁盘上,而不是如何获得你本来就没有的访问权。

1.1 为什么现在的视频站普遍用 HLS 而不是一整个 mp4

早期视频站喜欢给一个完整的 mp4 地址,简单粗暴,但问题很明显:一个两小时的视频就是 1~2 GB,用户想跳到第 90 分钟,浏览器必须先把前面的数据缓冲下来,或者服务端要做复杂的字节范围请求支持;用户网络差的时候,一个连接断了就得从头再来;更麻烦的是清晰度切换,一个 mp4 只能对应一个码率,想要 1080P 和 480P 自动切换,就得准备两个文件、两个地址,播放器自己判断,体验很割裂。

HLS(HTTP Live Streaming)把这些问题一次性解决了。它把一个长视频切成一个个 2~10 秒的小分片,每片单独编码成 TS(MPEG-TS)容器,然后用一个纯文本的 m3u8 清单把这些分片按顺序列出来。播放器的工作变成了:读清单 → 按当前网速决定先下哪几片 → 播完一片再取下几片 → 卡了就把码率降下来换一个清单。这个模型对服务端极其友好,因为所有请求都是普通的 HTTP GET,可以直接扔给 CDN 缓存,一片被多少人看过就缓存多少份,成本摊得极薄。这也是为什么你今天打开任何一个稍微正规一点的视频站,抓到的几乎都是 m3u8 而不是 mp4。

理解了这层动机,你就能理解后面所有的技术细节为什么长这样:分片小是为了快速起播和码率自适应,清单是文本是为了方便动态生成,地址带签名和时效是为了防盗链,而这些设计反过来给“下载到本地”制造了三个必须逐个攻破的麻烦。

1.2 一条播放链路上究竟发生了几次交互

很多人抓包抓懵,是因为没意识到“点一下播放”背后至少有三到四次独立的 HTTP 交互,每一次都可能是你脚本失败的原因。我用一张表把这条链路拆开:

阶段请求对象返回内容失败时的典型表现
1. 播放页视频详情页 HTML页面骨架、初始配置页面能开但播放器转圈
2. 播放凭证播放地址接口带签名的 m3u8 地址、有效期提示“播放失败,请刷新”
3. 主清单master.m3u8各清晰度的子清单地址拿到清单但里面没有分片
4. 媒体清单某个码率的 index.m3u8TS 分片列表、密钥地址有列表但下载全 403
5. 分片xxx.ts二进制视频数据下到一半断流、文件损坏

第 2 步是最容易被忽略的:你从浏览器地址栏复制出来的那个 m3u8 链接,往往带着一串?sign=xxx&t=xxx&us=xxx之类的参数,这些参数由服务端在你登录状态下临时签发,有效期可能只有几分钟到几小时。你复制下来放着,过了半小时再跑脚本,签名过期,全线 403。所以正确的顺序永远是:先拿到新鲜的播放地址,再立刻解析和下载,中间不要有太多手工停顿。

1.3 三类鉴权方式,决定了你要抄哪些请求头

不同的站点在这件事上的严格程度差别很大,粗分三类:

  • 纯 URL 签名:地址里带时间戳和哈希,服务端校验哈希对不对、时间有没有过期,不看你 cookie。这种最省事,把地址原样拿去请求就行。
  • URL 签名 + Referer/UA 校验:除了签名,还要求请求头里的 Referer 必须是本站播放页,UA 不能是明显的脚本特征。这种情况你需要在脚本里补上这两个头。
  • Cookie/Session + 签名:最严格的一种,服务端会校验你请求里的 cookie 是否对应一个有效会话,会话里是否包含“这个用户有权看这个视频”的记录。这就是标题里session鉴权的含义,也是脚本最容易翻车的地方。

判断自己遇到的是哪一类,有个很实用的笨办法:把浏览器里能正常播放的请求,用 curl 原样复制出来(浏览器开发者工具里右键请求 → Copy as cURL),在终端里跑一遍。如果能下,说明只是 URL 层面的事;如果报 403,就逐个删头去试,删到哪个头开始失败,那个头就是关键。这个“减法测试”比读文档快得多,后面第 3 章我会展开讲。

2. m3u8 解析:从一坨文本到一份可执行的下载清单

m3u8 本质上是 UTF-8 编码的纯文本,语法源自早期的 M3U 播放列表格式,用#EXT开头的行表示指令,非#开头的行表示资源地址。它简单到用记事本就能改,但也正因为简单,不同站点生成的清单风格差异很大,有的加了大量自定义标签,有的把主清单和媒体清单合并成一个,有的还会在清单里塞广告分片。写解析器之前,先把结构看明白。

2.1 主清单和媒体清单是两级结构,别搞混

一个典型的带多清晰度的站点,你第一次请求拿到的往往是这样一份主清单(master playlist):

#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2" /stream/1080p/index.m3u8?sign=abc #EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2" /stream/720p/index.m3u8?sign=abc #EXT-X-STREAM-INF:BANDWIDTH=600000,RESOLUTION=854x480,CODECS="avc1.4d401e,mp4a.40.2" /stream/480p/index.m3u8?sign=abc

这里#EXT-X-STREAM-INF描述的是“有这么一路流,带宽多少、分辨率多少、编码是什么”,紧跟着的下一行才是这路流的清单地址。注意这是相对路径,需要和主清单的 URL 做一次 urljoin 拼成绝对地址。很多人第一次写脚本,直接把/stream/1080p/index.m3u8扔进 requests,结果报 MissingSchema,就是少了这一步。

拿到子清单之后,才是真正的媒体清单(media playlist):

#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-KEY:METHOD=AES-128,URI="/key/k1.bin",IV=0x1234... #EXTINF:9.800, seg-0.ts #EXTINF:9.600, seg-1.ts #EXTINF:10.000, seg-2.ts #EXT-X-ENDLIST

#EXTINF后面跟的是这一片的时长(秒),下一行是分片地址。#EXT-X-ENDLIST表示这是一个点播(VOD)清单,列表是完整的、不会再变;如果没有这一行,说明是直播流,清单会滚动更新,你每次请求拿到的分片列表都不一样,处理逻辑完全不同。这个区别非常关键,遇到没有 ENDLIST 的清单还按点播逻辑一把梭,你会发现自己下到的是一个永远下不完的东西。

2.2 几个必须认识的标签,以及它们对应的坑

我把实际工作中最常打交道的标签整理成下表,重点看“踩坑提示”那一列,这部分是文档里通常不写的:

标签作用踩坑提示
#EXT-X-STREAM-INF声明一路码率流紧跟的下一行才是地址,中间可能有空行,解析时别假设“下一行必定是地址”
#EXT-X-TARGETDURATION分片最大时长只用于播放器预算,不能当成每片真实时长
#EXTINF单片时长保留三位小数,合并时用来算总时长,别丢精度
#EXT-X-KEY声明加密方式和密钥地址可能中途换密钥,同一个清单里出现多个 KEY 是合法的
#EXT-X-MAP初始化分片(fMP4 用)HLS 用 fMP4 分片时必须有,缺了它合并出来的文件播不了
#EXT-X-BYTERANGE字节范围出现这个说明一个 TS 文件里切了多段,不能简单按文件下
#EXT-X-DISCONTINUITY时间轴断点常见于广告插入,合并时要考虑是否剔除

其中#EXT-X-KEY和#EXT-X-BYTERANGE是两个最容易翻车的。前者意味着你下的分片是密文,不先解密拼出来就是一坨乱码;后者的意思是“这一个文件里有好几个分片,靠字节偏移区分”,如果你还按“一行一个 URL 下一个文件”去做,会重复下载同一个大文件几十次,白白浪费带宽。

2.3 用 Python 写一个能直接跑的解析器

下面这段代码不依赖第三方 m3u8 库,纯标准库实现,方便你嵌到任何脚本里。它的输出是一份结构化的分片列表,包含地址、时长、密钥信息,后面下载和合并都直接吃这个结构。

from urllib.parse import urljoin, urlparse, parse_qs import re def parse_m3u8(text, base_url): lines = [l.strip() for l in text.splitlines() if l.strip()] result = {"is_master": False, "variants": [], "segments": [], "keys": []} pending_inf = None pending_key = None for i, line in enumerate(lines): if line.startswith("#EXT-X-STREAM-INF"): result["is_master"] = True attrs = parse_attrs(line) # 往下找第一条非注释行作为地址 for nxt in lines[i+1:]: if not nxt.startswith("#"): result["variants"].append({ "bandwidth": int(attrs.get("BANDWIDTH", 0)), "resolution": attrs.get("RESOLUTION", ""), "url": urljoin(base_url, nxt), }) break elif line.startswith("#EXT-X-KEY"): attrs = parse_attrs(line) pending_key = { "method": attrs.get("METHOD", "NONE"), "uri": urljoin(base_url, attrs["URI"]) if "URI" in attrs else None, "iv": attrs.get("IV"), } result["keys"].append(pending_key) elif line.startswith("#EXTINF"): pending_inf = float(line.split(":", 1)[1].split(",")[0]) elif not line.startswith("#"): result["segments"].append({ "url": urljoin(base_url, line), "duration": pending_inf or 0.0, "key": pending_key, }) pending_inf = None return result def parse_attrs(line): """把 KEY=VALUE,KEY="VALUE" 形式的属性串解析成 dict""" out = {} body = line.split(":", 1)[1] if ":" in line else "" for m in re.finditer(r'([A-Z0-9\-]+)=("[^"]*"|[^,]*)', body): out[m.group(1)] = m.group(2).strip('"') return out

用法上有个细节值得强调:先判断 is_master,如果是主清单,就挑一路码率再请求一次;如果不是,直接进下载。挑哪一路也有讲究,不是无脑选最高。如果只是为了存档,选中等码率(比如 720p)通常性价比最高,1080p 的文件体积可能是它的两倍多,而实际观感差异在笔记本屏幕上并不明显。如果是为了做字幕或二次剪辑,才值得上最高码率。

注意:解析时不要对清单做“行号假设”。有的站点会在#EXTINF和分片地址之间插入空行或自定义注释行,用“下一行一定是 URL”的写法迟早会炸。上面代码里“收集待定时长、遇到非注释行才消费”的模式更稳。

3. session 鉴权:为什么浏览器能放,脚本就 403

这是三个环节里最让人抓狂的一环。表现通常是:m3u8 解析得好好的,分片地址也拼对了,一请求全是 403 或者 401,但同一个链接粘到浏览器新标签页里又能下。这种“浏览器行、脚本不行”的差异,百分之九十出在请求头上,剩下百分之十出在 cookie 的有效期或者 IP 绑定上。

3.1 鉴权信息到底藏在哪几个地方

我把常见的藏身点列一下,你可以按这个顺序排查:

  • URL 查询参数:sign、token、expires、t、us、auth_key这类名字,值通常是长串十六进制或 base64。这类参数一般对分片地址也有效,但有的站点只对 m3u8 有效,分片地址需要另外带。
  • Cookie:SESSION、SESSIONID、sid、uid之类的键,是服务端会话的句柄。它通常不参与 URL 签名,但服务端在校验签名之后还会查一次会话是否有效。
  • 请求头 Referer:防盗链最常见的手段,要求 Referer 必须是本站的播放页域名。有些站还会校验 Referer 的路径部分。
  • 请求头 User-Agent:反感爬的站点会拒绝明显的脚本 UA,比如python-requests/2.x。改成和浏览器一致的 UA 通常就能过。
  • 请求头 Origin:跨域场景下会出现,某些 CDN 会校验。
  • 自定义头:少数站点会加X-Requested-With、X-Client-Id之类的业务头,这种只能靠抓包发现。

排查方法就是我前面提到的“减法测试”:先用完整请求(所有头 + cookie)跑通,确认能下;然后逐个删掉一个头重试,删到某个头开始失败,那个头就是必需品。这个方法笨,但是准,比猜快得多。我一般会写个小循环,把头名列表遍历一遍,自动输出“删掉 X 后状态码变成多少”,一分钟就能定位。

3.2 最小可复现请求集,以及怎么把它固化进脚本

定位完之后,你要做的是把“最小可复现请求集”固化下来:一组恰好够用、不多不少的头 + 一份有效的 cookie。多了没必要,少了会挂。我通常会写成一个配置字典放在脚本顶部,像这样:

HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Referer": "https://example.com/", # 换成实际播放页域名 "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9", "Range": "bytes=0-", # 部分 CDN 需要,便于断点续传 } COOKIES = { "SESSION": "从浏览器复制的值", }

有个经验:Accept-Encoding最好不要手动设成gzip。分片是二进制视频数据,本身不压缩,你设了 gzip 反而可能让某些 CDN 返回压缩后的内容,落地成文件就打不开了。requests 默认会自己处理,保持默认即可。同理,下载分片时不要开stream=False一次性读进内存,两小时的高码率视频,单片虽然只有几 MB,但并发几十路一起读内存也会顶上去。

3.3 会话的时效性,以及失效后的表现

session 是会过期的。服务端通常会在会话里带一个过期时间,短则 30 分钟,长则几天。过期的表现很有规律:

  • 401/403 突然批量出现:说明 cookie 失效或者签名过期,重新登录页面复制一次即可。
  • 前几片成功,后面全挂:可能是签名参数对分片地址单独计算,你需要从每个分片 URL 里单独取参数,而不是复用 m3u8 的。
  • 返回 200 但内容是几 KB 的 HTML:这是最隐蔽的一种,CDN 把错误页当成正常响应返回了,你的脚本乐呵呵地把 HTML 存成.ts文件,最后合并出来是个坏文件。所以必须做内容校验,看返回的Content-Type是不是video/mp2t或者application/octet-stream,看第一字节是不是0x47(TS 包的同步字节)。

提示:session 鉴权这套东西,本质是服务端在确认“请求来自一个有权限的会话”。你在脚本里复用自己的 cookie,等价于用自己的身份重新发一次浏览器发过的请求,这在技术上属于“重放自己的合法请求”。但请务必确认你操作的内容属于你有权访问的范围,不要用它去试探未授权的资源。

4. TS 分片下载与合并:从几百个小文件到一个能播的视频

走到这一步,你已经有一份分片地址列表和一套能通的请求头了。剩下的事情看起来最机械,实际上坑最多:并发怎么开、断点怎么续、校验怎么做、最后怎么合并才能保证音画同步和可拖动。

4.1 并发数的取舍,以及一个我算过的账

分片下载天然适合并发,因为它就是一堆互不依赖的小文件。但并发不是越高越好,我实测下来的经验值是这样的:

并发数表现适用场景
1稳定但慢,两小时视频约 15~25 分钟网络差、站点风控严
4~8速度提升明显,失败率低绝大多数场景的推荐区间
16速度接近带宽上限,偶发 429带宽充足、CDN 宽松
32+容易触发限流,反而变慢不推荐,除非你确认站点无风控

这里的账很好算:假设一路 720p 流码率 1.4 Mbps,两小时视频总大小约 1.4 × 3600 × 2 ÷ 8 ≈ 1260 MB,切成 6 秒一片就是 1200 片,平均每片约 1 MB。你的实际带宽如果是 100 Mbps,理论峰值 12.5 MB/s,单片下载耗时约 0.08 秒,加上 TCP 握手和 TLS 协商的固定开销(每片大约 50~150 毫秒),单线程的实际吞吐会被拉到 3~5 MB/s。开 8 路并发,握手开销被重叠掉,能逼近 8~10 MB/s。开 16 路以上,收益就不明显了,因为瓶颈从“连接建立”转移到了“带宽本身”。

所以我的默认配置是ThreadPoolExecutor(max_workers=8),同时加一个简单的令牌桶限速,避免瞬间打满把线路搞拥塞。限速不是为了“礼貌”,而是实测下来不限速反而更容易触发 CDN 的 429,一触发就要退避重试,整体耗时更长。

4.2 分片完整性校验:三道关卡

下载完必须校验,否则你会在合并阶段浪费更多时间。我一般设三道关:

  1. HTTP 层:状态码必须 200(或 206),Content-Length与你实际收到的字节数一致。不一致就重试。
  2. 格式层:TS 文件的第一字节必须是0x47,而且理论上每 188 字节应该出现一次0x47(MPEG-TS 的包长固定为 188)。抽样检查前 10 个包位置,命中率低说明文件坏了或者是错误页。
  3. 时长层:把实际下载的分片时长累加,和清单里#EXTINF的总和对比,误差超过 5% 就要查是不是漏片了。

第三关很多人不做,但它能抓到最隐蔽的问题:清单里有 1200 片,你的脚本因为某个异常静默跳过了第 587 片,合并出来的视频在中间会出现一段卡顿或者音画不同步,而文件大小看起来“差不多对”。累加时长对账是最省事的兜底手段。

4.3 合并方案对比:二进制拼接 vs ffmpeg remux

这是被问得最多的问题。两种方案我都长期用过,结论是:优先用 ffmpeg remux,二进制拼接只作为应急。

二进制拼接的原理很简单:TS 是流式容器,直接把多个 TS 文件首尾相接,得到的仍然是一个合法的 TS 流,因为每个 TS 包自带同步字节和 PID,播放器能自己重新同步。命令行一行就够:

# 先按顺序生成文件列表 for f in $(ls seg-*.ts | sort -V); do echo "file '$f'"; done > filelist.txt # 直接拼接(最快的方案,不重新编码) ffmpeg -f concat -safe 0 -i filelist.txt -c copy output.ts

-c copy是关键,它表示不重新编码,只做容器层面的封装,速度极快,两小时视频通常几十秒到两分钟,CPU 占用极低。如果你需要 mp4 容器(兼容性更好、能被更多软件直接拖进去),把输出后缀改成.mp4即可,ffmpeg 会自动做 TS→MP4 的封装转换:

ffmpeg -f concat -safe 0 -i filelist.txt -c copy -bsf:a aac_adtstoasc output.mp4

-bsf:a aac_adtstoasc这个参数是老版本 ffmpeg 转 MP4 时 AAC 音频需要加的 bitstream filter,新版多数情况能自动处理,但加上不会有坏处。

纯cat拼接(cat seg-*.ts > out.ts)也能work,但有两个隐患:一是文件名排序,seg-10.ts会排在seg-2.ts前面,必须用自然排序;二是如果清单里有#EXT-X-DISCONTINUITY,纯拼接可能在断点处出现时间戳跳变,某些播放器会卡住。ffmpeg 的 concat 会处理时间戳连续性,所以更稳。

方案速度兼容性适用场景
cat直接拼接最快一般,时间戳可能不连续应急、快速验证
ffmpeg concat +-c copy快好推荐默认方案
ffmpeg 重新编码慢(接近实时)最好需要转码、修复花屏时

4.4 加密分片的处理思路

清单里出现#EXT-X-KEY:METHOD=AES-128时,流程要多一步:先请求URI指向的密钥文件拿到 16 字节密钥,然后对每个分片做 AES-128-CBC 解密。IV 有两个来源:清单里显式写了IV=0x...就用它;没写就用分片的序号(#EXT-X-MEDIA-SEQUENCE+ 当前索引)作为 IV,这是 HLS 规范里的默认行为,很多实现会漏掉这一条,导致解密出来的数据前半段正常后半段乱码。

from Crypto.Cipher import AES def decrypt_segment(data, key, iv_int): iv = iv_int.to_bytes(16, byteorder="big") cipher = AES.new(key, AES.MODE_CBC, iv) return cipher.decrypt(data)

注意解密后的数据末尾会有 PKCS7 填充,长度是 16 的整数倍补齐。如果你发现解密出来的 TS 文件比预期长了十几字节,末尾有一串重复的填充值,那就是没去填充。严格来说 TS 解析能容忍这点冗余,但为了干净,还是用unpad处理一下。

注意:密钥地址同样受 session 鉴权保护,请求密钥时也要带同样的 cookie 和 Referer。很多人解密失败不是算法错了,而是拿到的“密钥”其实是一个 403 的 HTML 页面,长度都不是 16 字节。

5. 工程化收尾:断点续传、目录结构和日志

一次性脚本和能反复用的工具,差别就在这一章。我踩过的坑基本都集中在“跑到 80% 断了要重来”这种场景上,做好下面几件事,能省下大量重复劳动。

5.1 断点续传:把已完成的分片记下来

最简单的做法是“文件存在且大小匹配就跳过”。分片下载时先写.part临时文件,校验通过后再改名成正式文件,这样即使中途断电,也不会留下半截文件被误认为完整。合并前扫一遍目录,缺失的片号列出来单独补下,比整个重跑快得多。

import os def seg_path(i): return f"segs/seg-{i:05d}.ts" def need_download(i, expected_size=None): p = seg_path(i) if not os.path.exists(p): return True if expected_size and os.path.getsize(p) != expected_size: return True return False

文件名用五位补零(seg-00001.ts),这样字典序和自然序一致,合并时不用再操心排序问题。这是个很小的习惯,但能省掉一类很烦的 bug。

5.2 目录结构和日志,让排查成本降一个量级

我固定的目录结构是这样的:

work/ meta.json # 清单原始内容、总时长、分片数、码率 segs/ # 分片 segs_bad/ # 校验失败的,保留现场 filelist.txt # 合并用的列表 output.mp4 run.log

meta.json里存一份原始 m3u8 内容和解析结果,出问题时能立刻判断是清单变了还是下载错了。segs_bad/保留坏片,方便单独分析是网络问题还是站点返回了错误页。日志里每片记一行:片号、耗时、状态码、字节数,失败重试也记进去。这几样东西加起来不到 30 行代码,但在排查“为什么第 300 片之后全挂了”这类问题时,价值巨大。

5.3 重试策略:指数退避 + 分类处理

无脑for i in range(3)重试是不够的,因为不同类型的失败该用不同策略:

  • 连接超时、连接重置:立刻重试,通常第二次就好。
  • 429 / 503:必须退避,等 2 秒、4 秒、8 秒再试,否则越试越封。
  • 403:不要重试,重试一百次也没用,要回去检查签名和 cookie 是否过期。
  • 200 但内容不对:重试一次,还是不对就标记为坏片,记日志,继续下一片,最后统一处理。

这个分类逻辑写好了,整个脚本的健壮性会上一个台阶。我见过太多脚本是把所有异常一视同仁地except: pass,结果跑完发现少了两百片,还得从头查。

6. 常见问题与排查实录

最后这一章是我这些年攒下来的问题清单,基本覆盖了新手会遇到的八成情况。建议对照着看,遇到问题时先在这里找一遍,比重新读一遍代码快。

6.1 故障对照速查表

现象最可能的原因处理办法
m3u8 请求 403签名过期 / 缺 Referer重新从播放页拿地址,补齐 Referer
只有主清单没有分片请求的是 master playlist挑一路 variant 再请求一次
分片全 403,m3u8 正常分片需要单独的签名参数检查分片 URL 是否带参数,别只复用 m3u8 的
返回 200 但文件只有几 KBCDN 返回了错误页校验首字节是否为 0x47
合并后能播但拖不动时间戳不连续用 ffmpeg concat 而不是 cat
合并后没声音音频流单独在另一个清单里检查是否有 audio-only 的 variant,需要单独下再 mux
合并后画面花屏有分片损坏或被截断抽样校验 188 字节包长,重下坏片
下载到一半全 429并发太高触发限流降到 4~8,加退避重试
解密后数据乱码IV 用错 / 密钥拿错没写 IV 时用分片序号当 IV
总时长对不上漏片累加 EXTINF 对账,补下缺失片号

表里“没声音”这一条值得单独说一句:现在很多站点会把音视频分离,视频清单里只有视频流,音频在另一路清单里,播放器自己把两路 mux 到一起。你用脚本只下了视频那一路,合并出来自然是无声的。判断方法很简单,看主清单里有没有#EXT-X-MEDIA:TYPE=AUDIO这类标签,有的话就需要把音频清单也下下来,最后用 ffmpeg 把两路合成:

ffmpeg -i video.ts -i audio.ts -c copy -map 0:v:0 -map 1:a:0 merged.mp4

6.2 几个我踩过的坑,以及踩完之后总结的习惯

第一个坑是用错了码率。早期我图省事,总是挑清单里第一个 variant,结果有些站点的第一路是“自动”或者最低码率,下下来糊得看不清。后来改成先按 BANDWIDTH 排序,再根据用途选:存档选中位,剪辑选最高。这个习惯保持到现在。

第二个坑是文件系统层面的问题。一次性创建上千个文件,在某些文件系统上会明显变慢,尤其是网络盘或者机械盘。我的处理是把分片按 100 个一组放进子目录,合并时再递归找。另外,单个目录文件数太多时,ls都会卡,排查起来很痛苦。

第三个坑是误把清单当静态文件。有次我缓存了一份 m3u8,隔了两天再用,发现分片数量对不上,还以为是站点改了。其实是那份清单本来就是动态生成的,每次请求的分片切分点可能略有不同。所以清单要“用的时候现取”,不要存着反复用,除非你确认它是稳定的 VOD 清单。

第四个坑是忽略了磁盘空间。两小时 1080p 的中间产物(分片 + 合并后的文件)加起来可能是最终文件的两倍多。我现在的做法是合并成功后自动清理segs/,并且开始前检查剩余空间,不够就直接报错,而不是下到 90% 才发现写不进去。

第五个坑,也是最值得说的一个:不要迷信"跑通一次就万事大吉"。这类脚本的失败模式基本都跟外部状态有关——签名有效期、cookie 生命周期、CDN 策略、站点改版。我现在的习惯是把脚本写成"每次运行都重新取一遍清单和鉴权信息"的形式,宁可多花几秒,也不要复用上一次的缓存。看似麻烦,实际省下的排查时间远超那几秒。

我个人在实际操作中的体会是,这条链路的技术难度其实不高,真正的门槛在于“耐心地把每一次失败归类”。403、429、坏片、缺片、无声,每一种背后都有唯一确定的成因,找到它靠的不是更聪明的代码,而是更细的日志和更克制的重试。把日志写清楚,把校验做扎实,剩下的就只是等进度条走完。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询