☰
M3U8批量视频流下载实战:从ffmpeg命令到自动化并发脚本
2026/10/16 4:12:32 网站建设 项目流程

简介:M3U8批量视频流下载工具是一款绿色小巧的流媒体采集软件,主要面向需要下载HLS协议视频的用户,解决网页m3u8资源无法直接保存、分段ts文件难以合并的问题。资源包共13个文件,包含三个exe可执行程序,分别承担主程序、转码和下载加速功能;三个json文件用于缓存与配置管理;四个log文件记录请求、错误及常规运行日志,便于排查问题;另有aria2.conf、session及dat文件,用于下载参数配置和任务状态保存。压缩包约23.6MB,绿色免安装,下载解压即可使用。功能上支持快速解析m3u8播放地址并获取真实下载链接,可批量添加任务,下载完成后自动调用ffmpeg进行转码,输出为电脑可直接播放的格式。目前已有3478人学习下载,适合经常处理流媒体视频、需要批量抓取m3u8资源及格式转换的普通用户或技术人员。

1. M3U8批量视频流下载:一次跑完两百条 HLS 地址

早前接到一个内部回放归档需求,清单里躺着两百多条 m3u8 地址。如果按常规操作,打开页面、等播放器缓冲、再想办法另存,一条几十分钟,一整天就搭进去了。后来我把 M3U8批量视频流下载拆成“一条命令 + 一个清单文件”,半天跑完全部任务,中间还顺手处理了十几个失败项。这套流程不只能处理回放,凡是拿到一批 HLS 流地址、想落地成 mp4 文件的场景都能照搬。这篇记录会把整理过程彻底拆开:先讲 m3u8 索引里到底写了什么、工具怎么选,再给可直接复制的下载脚本和并发参数,然后把我踩过的坑一条条列出来,最后补一套带自动体检的完整流水线。适合正在做内容归档、教研备份或流媒体调试的人,新手照着步骤也能把脚本跑起来。

2. 拆开 M3U8 索引文件:分片、码率与工具选型

2.1 索引文件里到底写了什么

m3u8 本质是一个 UTF-8 编码的文本索引文件,也就是 HLS 协议里的播放列表。播放器拿到这个文件后,按里面记录的分片地址逐个去请求真正的视频切片。不少人把它当黑匣子,其实打开看一眼就明白了。

#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:10.0, https://example.com/stream/seg-001.ts #EXTINF:10.0, https://example.com/stream/seg-002.ts #EXT-X-ENDLIST

上面这段是标准的分片列表,#EXTINF后面跟的是分片时长和分片 URL。#EXT-X-TARGETDURATION表示分片最大时长,#EXT-X-MEDIA-SEQUENCE是起始序号。还有一种主索引,里面不直接写分片,而是嵌套了多个子索引,用#EXT-X-STREAM-INF标识,常见于多码率自适应流。下载前先用文本编辑器看一眼,知道是单层还是嵌套,后面处理起来才不迷糊。

对批量下载来说,能读懂这份索引就有两个直接好处:一是通过分片数量乘平均时长能估算总时长,方便和下载结果做比对;二是当服务端返回的是#EXT-X-STREAM-INF嵌套列表时,得先解析出真正要下的是哪个码率的子索引,直接拿主索引问 ffmpeg,它往往会默认选最高的那个,流量和时间成本可能翻倍。

2.2 三种下载方案的选型理由

接触过 m3u8 下载的人基本都在下面三个方案之间选过:通用工具、专业下载器、自研脚本。三者不是替代关系,而是适用场景不同。

方案学习成本批量效率自定义能力断点续传
ffmpeg 命令行低单任务可靠,批量需自己写循环参数较丰富但有限无,断了重跑
专业流媒体下载器低高,自带并发和重试较弱,受 UI 限制支持
自研 Python 脚本中高取决于并发写法完全可控可自己实现分片级续传

你可能会问:既然 ffmpeg 一条命令就能下载,为什么还要考虑另外两个?因为生产环境里批量下载的失败率远比想象中高,几十条地址里总有几条因为网络波动或服务端限制断掉。ffmpeg 挂了不会自动重试,而专业下载器和自研脚本可以把“拉分片、校验、重试”这些动作做成自动的。

我一般这样选:临时下载几个文件用 ffmpeg;上百条批量且来源单一,用专业下载器的命令行模式;需要对接业务命名规则、定时触发或深度定制重试策略,就写 Python 脚本。值得注意的是“另存为”这个思路在 m3u8 上走不通——你保存下来的 .m3u8 只是播放列表,脱离网络后根本播不了,必须把分片拉齐并合并成单一视频文件。

3. 批量下载实战:从单条命令到并发任务池

3.1 先用一条命令跑通单集

在写批量脚本之前,一定要先把单条命令跑通。很多批量任务翻车,都是因为清单里有几条本身参数就不对,放大到几百条以后错误也跟着放大。

ffmpeg -y \ -headers "Referer: https://example.com/" \ -user_agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" \ -i "https://example.com/path/playlist.m3u8" \ -c copy \ -bsf:a aac_adtstoasc \ output.mp4

-y表示覆盖同名输出文件;-headers用来携带 Referer 等防盗链信息;-user_agent伪装成浏览器请求,避免被服务端按 UA 过滤;-i指定输入的 m3u8 地址;-c copy是关键,意思是直接复制音视频流不做重编码,速度最快;-bsf:a aac_adtstoasc把 ADTS 裸 AAC 音频流转换成 MP4 容器需要的格式,不然后续某些播放器会认不出声音。

ffmpeg 拿到 m3u8 后内部会自动解析分片列表、按顺序发起 HTTP 请求、拉完写完。它的优势是省心,缺点也很明显:只要一个分片请求异常,整个任务就可能以非零退出码结束。所以在批量场景里,单条命令本身并不难,难的是怎么处理那些偶尔失败的地址。

3.2 批量脚本:清单驱动、失败重试、进度可查

单条命令验证无误后,就可以做成清单驱动。我先准备一个urls.txt,每行格式是“URL 空格 输出文件名”,脚本读取后逐个执行 ffmpeg,失败就重试。

import subprocess import time from pathlib import Path def download_one(url: str, name: str, retries: int = 3) -> bool: for attempt in range(1, retries + 1): cmd = [ "ffmpeg", "-y", "-headers", "Referer: https://example.com/", "-user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "-i", url, "-c", "copy", "-bsf:a", "aac_adtstoasc", f"{name}.mp4" ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: return True time.sleep(5 * attempt) # 失败后递增等待时间 return False if __name__ == "__main__": tasks = [] for line in Path("urls.txt").read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue url, name = line.split(maxsplit=1) tasks.append((url, name)) ok, fail = 0, 0 for index, (url, name) in enumerate(tasks, start=1): print(f"[{index}/{len(tasks)}] 开始下载: {name}") if download_one(url, name): ok += 1 print(f" -> {name} 完成") else: fail += 1 print(f" -> {name} 失败,已跳过") print(f"全部结束:成功 {ok} 条,失败 {fail} 条")

这段脚本里有两个值得注意的设计。第一是重试等待时间用5 * attempt递增,第一次失败等 5 秒,第二次等 10 秒,给服务端一个恢复窗口,避免盯着一个失败地址连续猛打。第二是capture_output=True会把 ffmpeg 的日志吞掉,只在最终结果时打印摘要,这样跑大批量时终端不会被刷屏,失败原因可以事后单独查。

如果你想把失败原因留档,可以给子进程调用加上日志重定向,把每次的result.stderr尾部写入error.log,方便第二天排查是哪条地址的问题。

3.3 并发策略:开多少个任务才不会翻车

批量下载最大的矛盾在于“想快”和“服务端不愿意给”。同一域名下如果并发开得太大,很容易触发限流,轻则部分分片返回超时,重则 IP 被临时封禁。我的经验是:普通 CDN 场景同一域名开 2 到 4 个并发比较安全;如果拿不准,先开 1 个跑十分钟,观察错误率再逐步往上加。

from concurrent.futures import ThreadPoolExecutor, as_completed def worker(task): url, name = task return name, download_one(url, name) if __name__ == "__main__": tasks = [...] # 读取同一个 urls.txt fail = [] with ThreadPoolExecutor(max_workers=4) as pool: futures = {pool.submit(worker, t): t for t in tasks} for future in as_completed(futures): name, success = future.result() if not success: fail.append(name) print(f"并发任务结束,失败 {len(fail)} 条: {fail}")

这里用ThreadPoolExecutor而不是进程池,因为下载瓶颈在网络 I/O,线程切换开销远小于进程,且 Python 的 GIL 不会成为瓶颈。max_workers=4就是上面的经验值,你可以根据实际服务端表现调整。

并发最怕的不是慢,而是“半死不活”:任务不报错但也不出数据。所以生产环境里我建议加一个超时控制,比如给subprocess.run传入timeout=600,让超过十分钟还没下完的任务直接按失败处理,然后走重试逻辑。比无限等下去要靠谱得多。

4. 避坑排查:批量下载 M3U8 最常见的五个坑

4.1 下载到 99% 卡住不动

现象:进度条停在最后一个分片,终端反复出现Connection reset或Operation timed out,任务迟迟不结束。

原因:ffmpeg 在拉取末尾分片时网络抖动,连接断开后默认不自动重连。有些服务端会在会话接近完成时主动断开连接,进一步加剧这个问题。

解决:给 ffmpeg 增加断线重连参数,命令变成:

ffmpeg -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 \ -i "https://example.com/playlist.m3u8" \ -c copy output.mp4

-reconnect 1开启重连,-reconnect_streamed 1表示对流式输入也生效,-reconnect_delay_max 5限制重连最大延迟为 5 秒。注意这个参数只对 HTTP 协议有效,遇到 HTTPS 地址时部分旧版本 ffmpeg 不生效,建议直接升级到较新版本。如果加了参数还是卡,就把它当作失败交给重试逻辑处理,通常第二次跑就能过。

4.2 音画不同步或根本没有声音

现象:下载完成后播放,发现只有画面没有声音,或者声音和嘴型对不上。

原因:m3u8 里可能包含多条音轨,比如一条 AAC 原声、一条 AC3 环绕声,ffmpeg 默认选择第一条音轨。如果第一条是空白音轨或采样信息不完整,就会出现无声音。可变帧率内容在-c copy直拷时也容易音画不同步。

解决:下载前先用 ffprobe 看流布局:

ffprobe -v error -show_entries stream=index,codec_name,codec_type \ -of csv=p=0 "https://example.com/playlist.m3u8"

输出里如果有两个audio类型的流,就用-map手工指定。比如选择第二条音轨:

ffmpeg -i "https://example.com/playlist.m3u8" \ -map 0:v:0 -map 0:a:1 \ -c copy -bsf:a aac_adtstoasc output.mp4

帧率问题更麻烦,-c copy保留原始时间戳,如果源本身时间戳抖得厉害,只能改成重编码视频轨,比如-vf fps=25。这会明显变慢,但至少同步不会错。

4.3 下载返回 403

现象:ffmpeg 输出HTTP error 403 Forbidden,一种是任务一开始就报,另一种是下载到一半开始报错。

原因:服务端做了防盗链校验,检查 Referer、User-Agent、Cookie,常见的是三者缺一,或者只校验其中某一项。部分 CDN 会在 m3u8 地址里带签名参数,签名过期后回源时直接拒绝。

解决:用浏览器开发者工具找到真实播放请求,复制完整请求头再塞给 ffmpeg:

ffmpeg -headers "Referer: https://example.com/\r\nUser-Agent: Mozilla/5.0\r\nCookie: sessionid=xxx" \ -i "https://example.com/playlist.m3u8" \ -c copy output.mp4

批量场景下要特别留意 token 时效。如果页面打开后地址几分钟内就过期,那脚本应该“拿一条地址下一条”,而不是一次性抓取一堆地址存下来慢慢跑。过期地址重试一百次也是 403,浪费时间。

4.4 拼接出来的视频时长比实际播放时长长

现象:ffprobe 显示的 duration 是 10 分 05 秒,但实际播放到 10 分钟就结束了,进度条迟迟不走。

原因:HLS 分片之间存在 GOP 重叠帧,这种问题常见于直播转点播的列表。复制流合并时不会自动去重,重叠部分被原样保留,时长就被放大了。另一个原因是 m3u8 列表里同一个分片被重复引用。

解决:先用文本方式检查列表,把重复引用的分片去掉。如果是 GOP 重叠,在自研脚本里按分片 URI 去重后,再用 ffmpeg 合并时间戳:

ffmpeg -f hls -i "https://example.com/playlist.m3u8" \ -c copy -use_wallclock_as_timestamps 1 output.mp4

-use_wallclock_as_timestamps 1会以系统时钟为准重新对齐时间戳,能缓解一部分重叠问题。如果对时长要求很严,最稳妥的办法是识别重复帧后做一次重编码,但速度上要接受代价。

4.5 自研脚本解析相对路径失败

现象:自己写脚本解析 m3u8,分片明明写着seg-001.ts,拼出的完整 URL 却请求 404。

原因:HLS 协议里的分片地址支持相对路径,解析基准是 m3u8 文件自己的 URL 目录。很多人用字符串拼接,把 query 参数也拼错,或者忽略了路径层级。

解决:用标准库处理:

from urllib.parse import urljoin base_url = "https://example.com/a/b/playlist.m3u8" ts_url = urljoin(base_url, "seg-001.ts") print(ts_url) # https://example.com/a/b/seg-001.ts

urljoin会自动处理相对路径的目录跳转。注意:如果分片路径以/开头,它会直接跳到域名根路径,比如https://example.com/seg-001.ts,这是很多脚本翻车的隐藏点。正确解析分片地址是自研下载器的地基,这一行没写对,后面全白搭。

5. 进阶技巧:抓取真实地址、分片级断点与命名规范

5.1 从页面里把真实流地址捞出来

批量下载的第一步往往不是下载,而是收集地址。在你有合法播放权限的页面上,最直接的办法是打开开发者工具切换到 Network 面板,筛选m3u8关键字,就能看到播放器发起的真实请求。拿到地址后复制出来存进清单即可。

如果地址嵌在 HTML 或脚本里,用 requests 加上正则也能快速提取:

import re import requests from urllib.parse import urljoin html = requests.get("https://example.com/video/10086", timeout=10).text matches = re.findall(r'https?://[^"\']+?\.m3u8', html) if matches: playlist_url = matches[0] print("找到播放列表:", playlist_url)

提前说明:这只适用于你有权访问的内容。真实场景里很多地址并不是直接暴露在 HTML 里的,而是通过 JavaScript 动态拼接,甚至经过加密混淆。遇到这种情况就别硬刚了,直接在 Network 面板里翻请求更高效。

5.2 分片级断点续传:中断不用从头再跑

ffmpeg 做断点续传几乎不可能,它对单个任务是“一次性拉流”,中断就得整条重来。专业下载器虽然支持续传,但可控性不够。如果走自研路线,可以自己实现一个分片级续传方案。

思路很简单:先把 m3u8 里的分片 URL 全部解析出来,逐个下载到本地临时目录;某个分片已经存在且文件大小大于 0 就跳过,只拉缺失的;全部齐了以后再按顺序合并成 mp4。

import os import subprocess from urllib.parse import urljoin def download_ts_files(playlist_url, ts_dir="ts_cache"): os.makedirs(ts_dir, exist_ok=True) # 省略解析分片列表的代码,得到 [(index, ts_url), ...] for index, ts_url in ts_urls: local_path = os.path.join(ts_dir, f"{index:05d}.ts") if os.path.exists(local_path) and os.path.getsize(local_path) > 0: continue # 已存在,跳过 subprocess.run(["curl", "-s", "-o", local_path, ts_url], check=True)

合并阶段用 ffmpeg 的 concat 方式,注意顺序要用数值排序而不是字典序,否则第 10 集可能排到第 2 集前面:

ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4

这个方案的好处是:哪怕中断十次,每次重启只补缺失的分片,不会把已经下好的几百 MB 重新拉一遍。代价是要自己处理分片完整性和顺序,但代码量不大,属于一次投入长期受益的做法。

5.3 统一命名与目录规划

批量下载最容易低估的是命名。来自 URL 的序号往往缺少上下文,比如10086.mp4和10087.mp4只是两个数字,隔两周再回来根本分不清哪个是哪个。我习惯用“日期_第几集_标题.mp4”这样的格式,解析规则看来源页面的 URL 结构。

import re title = "第03讲 流媒体协议基础" clean_title = re.sub(r'[\\/:*?"<>|]', "_", title) filename = f"2025-06-01_{clean_title}.mp4"

加上按“年-月”建目录,归档时一眼能定位。这一步看着琐碎,但批量任务一旦超过五十个文件,命名规范直接决定你后续找人时是花一分钟还是花一小时。

6. 收尾验证与自动化:每个任务都要过一遍“体检”

6.1 用 ffprobe 做下载质量体检

下载完成不等于任务结束,我会对每个文件跑一次检查,避免出现无声文件或零时长文件混进归档目录。下面这个函数用 ffprobe 取出时长和流类型,快速判断是否有问题。

check_video() { local file="$1" ffprobe -v error \ -show_entries format=duration:stream=codec_type \ -of csv=p=0 "$file" }

输出第一行是文件时长,后面的每一行是流类型。如果时长是N/A或0.000000,基本可以断定文件损坏;如果只有video没有audio,那就得看业务上是否接受无声版本。

6.2 把流水线串成一条命令

完整的自动化流程应该是:读取清单 → 批量下载 → 统一体检 → 输出报告。可以写成一个总控脚本,命令跑完直接去看 report.txt:

#!/bin/bash > report.txt while read -r url name; do echo "处理 $name" | tee -a report.txt ffmpeg -y -i "$url" -c copy "${name}.mp4" 2>/dev/null if check_video "${name}.mp4" | grep -q "audio"; then echo "$name OK" >> report.txt else echo "$name FAIL" >> report.txt fi done < urls.txt

把这份脚本放进计划任务里,就能做到每晚定时批量归档,第二天早上只看 report.txt 里的 FAIL 行,其余都是可归档的。

我记得第一次跑两百条批量任务时没做体检,结果第二天发现五十多个文件只有画面没声音,只能重新下一遍,白白烧掉一个晚上的时间。从那以后,每次批量任务我都先拿三条地址做小批量验证,确认参数和校验逻辑都对,再放全量跑。希望这套流程能帮你少走一次同样的弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询