平时在网络上下载视频资源时最头疼的往往不是“找不到视频”而是“找到了却下不下来”。浏览器里能流畅播放的画面背后的地址可能是一长串.m3u8又或者视频本身被切成几十个.ts分片存放在服务器上。即使通过开发者工具抓到链接没有对应的下载工具和合并方案也很难得到一段能直接播放、编辑的完整 MP4 文件。本文就来系统解决这个问题。我会先讲清楚 M3U8、HLS、DASH、TS、MP4 这几个高频出现但容易混淆的概念再带大家从零搭建一个基于 Python ffmpeg 的视频下载与格式转换工具覆盖 M3U8 下载、TS 合并、M3U8 转 MP4、DASH 音视频流合成、以及常见视频格式互转等完整操作流程。如果你之前也遇到过m3u8 视频转换失败、下载之后只有一堆 ts 文件、ffmpeg 合并多个 ts 文件、m4s 格式转换 mp4这类的具体问题那这篇文章基本都能覆盖到。文章会提供可复制的完整脚本和命令不只是讲原理还会把执行过程和排错思路一并交代。1. 背景与核心概念1.1 为什么视频网站不直接给你一个 MP4 地址很多视频网站、在线课程平台、直播服务、监控回放系统在播放时并不会像普通文件服务器那样直接暴露一个.mp4文件链接。原因有几个视频文件太大直接下载完整文件对服务器带宽压力非常大。用户拖动进度条时如果整段视频是一个文件需要服务器快速响应 Range 请求这个能力不是所有静态服务器都默认支持得好。版权保护需要更灵活的控制方式比如鉴权失效后自动禁止播放。网络环境复杂尤其移动端经常切换网络把视频切成小分片后可以逐段缓冲播放体验会更稳定。所以就出现了流媒体传输协议。HLS 和 DASH 是当前最主流的两种方案而它们的共同特征就是“边下边播、按需拉取”。1.2 高频词到底指什么M3U8 是一个索引文件本质上是文本文件里面记录了视频分片的地址、时长、顺序等信息。HLS 是苹果推出的流媒体协议使用 M3U8 作为索引扩展名通常是.m3u8。TS 全称是 MPEG Transport Stream是 HLS 协议中实际承载音视频数据的封装格式扩展名通常是.ts。简单说术语作用类比M3U8播放列表索引一本书的目录HLS流媒体传输协议一套出版流程TS视频分片封装格式书里的一页页纸张DASH另一种流媒体协议另一套出版流程MP4常见完整视频封装装订好的成品书DASH 跟 HLS 类似也是把视频拆成多个分片但它的索引文件格式是 MPDXML 格式而且 DASH 经常把视频流和音频流分开传输播放器在本地做音视频同步。这就导致下载 DASH 视频时往往需要分别下载视频轨和音频轨再在本地合并。1.3 什么场景下需要下载器和转换器典型场景包括在线课程平台需要离线学习但官网没有提供离线缓存。监控录像回放需要把时间段内的分片拼接成完整视频存档。视频编辑人员从内部素材系统下载分片素材需要批量转成 MP4 进剪辑软件。从 DASH 流中提取某个清晰度的视频轨重新封装成 MP4。手头有大量.ts/.m4s/.flv文件需要统一转成 MP4 格式。这些操作的核心工具其实就两样能解析索引的脚本以及能处理封装和编码转换的 ffmpeg。2. 环境准备与版本说明2.1 安装 ffmpegffmpeg 是目前视频处理领域的事实标准几乎所有视频格式转换工具底层都是它在工作。它支持读取 M3U8 索引、合并 TS 分片、将 TS 流转封装为 MP4也能解析部分 MPD 文件。在 Ubuntu / Debian 系统上可以通过 apt 安装sudo apt update sudo apt install ffmpeg在 CentOS / RHEL 系统上可以使用 yum 或 dnfsudo yum install epel-release sudo yum install ffmpegmacOS 推荐使用 Homebrewbrew install ffmpegWindows 用户可以从 ffmpeg 官网下载编译好的二进制包把bin目录加入系统 PATH 后在命令行中直接使用ffmpeg命令。安装完成后在终端执行ffmpeg -version如果能看到版本信息说明安装成功。提示不同发行版打包的 ffmpeg 版本差异较大。本文示例用的是常见配置思路具体版本请根据你的实际系统调整。2.2 Python 环境与依赖本文后面的脚本主要基于 Python 3并且依赖requests库。建议在虚拟环境中操作python3 -m venv video-tool-env source video-tool-env/bin/activate # Windows 下为 video-tool-env\Scripts\activate pip install requests如果后续要处理 AES-128 加密的 HLS 分片还需要安装pycryptodomepip install pycryptodome2.3 测试资源准备建议自己准备一个视频文件用 ffmpeg 生成一套本地测试用的 HLS 分片。这样在调试脚本时不需要依赖外网资源也不会涉及版权问题。# 准备一个测试视频 input.mp4然后生成 m3u8 ts 分片 ffmpeg -i input.mp4 -c:v libx264 -c:a aac -f hls -hls_time 10 -hls_list_size 0 -hls_segment_filename seg_%03d.ts playlist.m3u8执行完成后当前目录会生成playlist.m3u8和多个seg_000.ts、seg_001.ts文件。这套文件就是后续脚本的本地测试输入。3. 核心原理拆解3.1 M3U8 索引到底长什么样M3U8 文件本质上是一个文本文件常见的结构如下#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:10.000000, seg_000.ts #EXTINF:10.000000, seg_001.ts #EXTINF:9.000000, seg_002.ts #EXT-X-ENDLIST关键标签的含义#EXTM3U声明这是一个 M3U 播放列表。#EXT-X-VERSION协议版本号。#EXT-X-TARGETDURATION单个分片的最大时长。#EXTINF后面第一个数字表示当前分片的持续时长秒第二个参数是分片标题。#EXT-X-ENDLIST表示列表结束说明这是一个点播视频而不是直播流。如果分片地址不完整比如文件中是seg_000.ts这种相对路径那么实际请求地址需要基于 M3U8 的 URL 拼接。这是很多初学者容易忽略的地方。3.2 HLS 的 AES-128 加密有些 HLS 流为了限制直接下载会在分片层面对视频数据做加密比较常见的是 AES-128。此时 M3U8 文件里会出现这样的标签#EXT-X-KEY:METHODAES-128,URIkey.key,IV0x00000000000000000000000000000000这个标签的含义是当前分片及后续分片使用 AES-128 算法解密密钥地址是相对于 M3U8 位置的key.keyIV 初始向量如果未指定默认使用分片的序列号。ffmpeg 对标准 AES-128 加密的 HLS 流是支持自动解密的。只要 key.key 地址可以被访问直接执行ffmpeg -i https://example.com/path/playlist.m3u8 -c copy output.mp4就能得到解密后的 MP4 文件。但如果 key 地址需要特殊鉴权或者服务器限制了 Referer、Cookie就会下载失败。3.3 DASH 与 MPD 的差异DASH 的索引文件是.mpd内容格式是 XML。与 HLS 最大的不同点在于DASH 通常把视频和音频分成独立的分片描述。打开一个典型 MPD 文件会看到视频 AdaptationSet 和音频 AdaptationSet。FFmpeg 对 Standard DASH 支持较好但如果流本身带了 Widevine、FairPlay 等商业 DRM 保护FFmpeg 无法解密。本文讨论的范围只针对未加密或标准加密的可合法访问内容。3.4 ffmpeg 合并与转封装的核心命令先理解 ffmpeg 的基本思路输入一个索引文件ffmpeg 自动按索引顺序拉取所有分片输出时会重新封装成目标格式。# 最简单的 M3U8 转 MP4 ffmpeg -i playlist.m3u8 -c copy output.mp4 # 指定分辨率或编码参数重新编码 ffmpeg -i playlist.m3u8 -c:v libx264 -c:a aac -preset fast output.mp4第一条命令是直接复制编码不重新编码速度很快但要求原始视频编码能被 MP4 容器兼容。H.264 AAC 编码的 TS 分片通常可以直接 copy 转成 MP4。如果原始是 H.265 / HEVC 编码放入 MP4 后某些播放器可能无法播放此时需要考虑转换成 H.264或者明确目标播放器的兼容性。多条 TS 文件合并成 MP4 的方式# 方式一concat demuxer ffmpeg -f concat -safe 0 -i filelist.txt -c copy output.mp4其中filelist.txt内容格式如下file seg_000.ts file seg_001.ts file seg_002.ts这种方式在 Windows 和 Linux 下都更稳定推荐使用。4. 完整实战构建一个视频下载与转换工具接下来我们把前面讲的原理落地成一个 Python 脚本项目。项目会包含三个核心能力解析并下载 M3U8 TS 分片、自动调用 ffmpeg 合并生成 MP4、支持 DASH 音视频分别下载后合成。4.1 项目结构在开始写代码之前先定义好项目目录video-tool/ ├── main.py # 入口脚本 ├── utils.py # 公共函数 ├── requirements.txt # 依赖 └── output/ # 输出目录4.2 编写基础下载函数在utils.py中封装通用下载逻辑和文件名处理逻辑import os import re import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } def safe_filename(name: str) - str: 将字符串转换为安全的文件名 name re.sub(r[\\/:*?\|], _, name) return name.strip( .) def download_file(url: str, save_path: str): 通用文件下载 resp requests.get(url, headersHEADERS, timeout30) resp.raise_for_status() with open(save_path, wb) as f: f.write(resp.content) def resolve_url(base_url: str, ref: str) - str: 拼接相对地址为完整地址 if ref.startswith(http): return ref base_dir base_url.rsplit(/, 1)[0] return f{base_dir}/{ref}resolve_url的作用非常关键。M3U8 文件里的分片地址可能是相对路径也可能是完整 URL必须统一处理。下载前先safe_filename做文件名清洗避免特殊字符导致写入失败。requirements.txt内容如下requests2.25.04.3 下载 M3U8 并合并为 MP4在main.py中实现下载 M3U8 的流程。核心逻辑是读取 M3U8 文本、解析出所有 TS 分片、逐个下载到本地、生成filelist.txt、调用 ffmpeg 合并。import os import subprocess import sys from utils import HEADERS, download_file, resolve_url import requests def parse_m3u8(m3u8_url: str) - list: 解析 m3u8 文件返回分片 URL 列表 resp requests.get(m3u8_url, headersHEADERS, timeout15) resp.encoding utf-8 lines resp.text.splitlines() ts_urls [] for line in lines: line line.strip() if line and not line.startswith(#): ts_urls.append(resolve_url(m3u8_url, line)) return ts_urls def download_m3u8(m3u8_url: str, output_name: str output): 下载 m3u8 所有分片并合并为 MP4 cache_dir ts_cache os.makedirs(cache_dir, exist_okTrue) ts_urls parse_m3u8(m3u8_url) print(f共发现 {len(ts_urls)} 个分片) # 1. 下载所有分片 for idx, ts_url in enumerate(ts_urls): ts_path os.path.join(cache_dir, f{idx:05d}.ts) print(f[{idx 1}/{len(ts_urls)}] 下载 {ts_url}) download_file(ts_url, ts_path) # 2. 生成 concat 文件列表 filelist_path os.path.join(cache_dir, filelist.txt) with open(filelist_path, w, encodingutf-8) as f: for idx in range(len(ts_urls)): f.write(ffile {os.path.join(cache_dir, f{idx:05d}.ts)}\n) # 3. 调用 ffmpeg 合并 output_path f{output_name}.mp4 cmd [ ffmpeg, -y, -f, concat, -safe, 0, -i, filelist_path, -c, copy, output_path ] print(开始合并...) subprocess.run(cmd, checkTrue) print(f合并完成{output_path}) if __name__ __main__: url sys.argv[1] if len(sys.argv) 1 else playlist.m3u8 name sys.argv[2] if len(sys.argv) 2 else output download_m3u8(url, name)这个脚本的执行流程很清晰先解析出分片地址再循环下载最后用 ffmpeg 的 concat demuxer 合并。使用-c copy参数可以避免重复编码所以合并速度快对 CPU 占用也很低。执行方式python main.py https://example.com/path/playlist.m3u8 my_video如果下载的是本地测试文件可以直接调用python main.py playlist.m3u8 test_output4.4 处理 AES-128 加密分片当 M3U8 文件里带#EXT-X-KEY时分片本身是加密的。ffmpeg 在合并时如果 key 能直接访问会自动处理。但如果你希望把解密过程放在自己的脚本里也可以使用pycryptodome手动解。核心思路解析#EXT-X-KEY标签拿METHOD、URI、IV下载 key 文件再对每个 TS 分片做 AES-128-CBC 解密。解密后的数据重新写入新文件。下面是一个示例思路import base64 import os from Crypto.Cipher import AES from utils import download_file, resolve_url def parse_key_info(lines, base_url): 从 m3u8 内容中解析 EXT-X-KEY 信息 method, uri, iv None, None, None for line in lines: if line.startswith(#EXT-X-KEY:): key_content line.split(:, 1)[1] parts key_content.split(,) for part in parts: if part.startswith(METHOD): method part.split(, 1)[1] elif part.startswith(URI): uri part.split(, 1)[1].strip() elif part.startswith(IV): iv part.split(, 1)[1] if method and uri: key_url resolve_url(base_url, uri) return method, key_url, iv return None def decrypt_ts(ts_data: bytes, key_data: bytes, iv_hex: str None) - bytes: 手动对 ts 分片做 AES-128 解密 if iv_hex: iv bytes.fromhex(iv_hex.replace(0x, )) else: iv b\x00 * 16 cipher AES.new(key_data, AES.MODE_CBC, iv) return cipher.decrypt(ts_data)需要特别说明的是这里只适用于标准 AES-128 HLS 场景。商业 DRM 不在讨论范围内。实际生产环境中ffmpeg 通常能自动完成解密优先推荐使用 ffmpeg 而不是自己实现解密因为 AES-CBC 的填充处理、IV 默认值、分片边界等细节容易出错。4.5 下载 DASH 音视频流并合成DASH 的 MPD 文件是 XML。解析后可以发现视频流和音频流通常是分开的。以最简单的静态 MPD 为例处理流程是用xml.etree.ElementTree解析出 BaseURL 和 AdaptationSet然后分别下载视频轨、音频轨最后用 ffmpeg 将两者合成一个 MP4。import subprocess import xml.etree.ElementTree as ET from utils import HEADERS, download_file, resolve_url import requests def download_dash(mpd_url: str, output_name: str dash_output): 下载 DASH 音视频流并合成 MP4 resp requests.get(mpd_url, headersHEADERS, timeout15) root ET.fromstring(resp.text) ns {mpd: urn:mpeg:dash:schema:mpd:2011} video_urls [] audio_urls [] # 解析 MPD 结构 for adaptation in root.findall(.//mpd:AdaptationSet, ns): mime_type adaptation.get(mimeType, ) for rep in adaptation.findall(mpd:Representation, ns): base_url rep.findtext(mpd:BaseURL, default, namespacesns) if not base_url: continue full_url resolve_url(mpd_url, base_url) if video in mime_type: video_urls.append(full_url) elif audio in mime_type: audio_urls.append(full_url) if not video_urls or not audio_urls: raise RuntimeError(未找到完整的音视频流可能该 DASH 流存在其他结构或 DRM 保护) # 下载视频和音频 download_file(video_urls[0], video_stream.mp4) download_file(audio_urls[0], audio_stream.mp4) # 合并 cmd [ ffmpeg, -y, -i, video_stream.mp4, -i, audio_stream.mp4, -c, copy, f{output_name}.mp4 ] subprocess.run(cmd, checkTrue) print(fDASH 合成完成{output_name}.mp4)DASH 的 MPD 文件命名空间在不同服务商实现中会有差异所以实际使用时需要根据真实 MPD 结构调整解析逻辑。但整体思路是一致的找到视频轨和音频轨的地址分别下载然后用 ffmpeg 合成。4.6 批量 TS 合并与格式转换有时候我们手里已经有一批 TS 文件它们不在 M3U8 索引中而是分散存放的。这种情况下只需要一个批量合并函数import os import subprocess def merge_ts_files(ts_dir: str, output_name: str merged.mp4): 合并目录下所有 ts 文件为 MP4 ts_files sorted( [f for f in os.listdir(ts_dir) if f.endswith(.ts)] ) filelist_path os.path.join(ts_dir, filelist.txt) with open(filelist_path, w, encodingutf-8) as f: for ts_file in ts_files: f.write(ffile {os.path.join(ts_dir, ts_file)}\n) cmd [ ffmpeg, -y, -f, concat, -safe, 0, -i, filelist_path, -c, copy, output_name ] subprocess.run(cmd, checkTrue) print(f合并完成{output_name})merge_ts_files适合处理监控录像、缓存目录等场景下的 TS 文件。注意sorted排序时如果文件名不是纯数字建议统一用%05d这种格式命名否则排序结果可能不符合预期。4.7 运行验证与预期输出以本地测试的playlist.m3u8为例运行合并脚本后终端输出大致如下共发现 3 个分片 [1/3] 下载 seg_000.ts [2/3] 下载 seg_001.ts [3/3] 下载 seg_002.ts 开始合并... 合并完成test_output.mp4之后可以用ffprobe验证输出文件信息ffprobe -show_format -show_streams test_output.mp4如果输出里能看到format_namemov,mp4,m4a,3gp,3g2,mj2且视频流和音频流都完整说明转换成功。5. 常见问题与排查思路5.1 m3u8 下载超时或失败频繁问题现象常见原因解决思路单个分片下载超时目标服务器限速或网络不稳增加超时时间加入重试机制分片下载一半失败请求频繁被服务端拒绝加Referer请求头每几个分片做一次短延时M3U8 读取 403请求头不完整补充完整 UA 和浏览器 Cookie针对分片下载失败推荐在下载函数中加入重试逻辑import time from utils import download_file def robust_download(url: str, save_path: str, retries: int 3): for attempt in range(retries): try: download_file(url, save_path) return except Exception as e: print(f下载失败重试 {attempt 1}/{retries}: {e}) time.sleep(2) raise RuntimeError(f下载失败: {url})5.2 合并后没有声音或音画不同步如果 M3U8 中只有视频分片而没有音频分片合并出的 MP4 自然没有声音。DASH 场景中更常见视频轨和音频轨是独立的如果只下载视频轨输出文件就没有声音。解决思路检查 M3U8 文件中是否同时存在音频分组。DASH 场景下一定要分别下载视频轨和音频轨再合成。如果原始分片时间戳不一致不要使用-c copy改用重新编码的方式合并。ffmpeg -i video_stream.mp4 -i audio_stream.mp4 -c:v libx264 -c:a aac output.mp45.3 解密 KEY 获取失败如果 M3U8 中带有#EXT-X-KEY但 key 地址下载不了最可能的原因是服务器限制了 Referer 或 Cookie。这种情况下需要把鉴权信息加到请求头中并把 key 文件手动下载到本地再修改 M3U8 文件中的URI为本地路径。需要注意的是很多 HLS 流只是用 AES-128 做常规加密并不代表它有 DRM 版权保护。但如果你确认视频带有商业 DRM如 Widevine、FairPlay那任何开源工具都无法合法解密这种内容不在本文讨论范围内。5.4 TS 合并后播放卡顿播放卡顿通常不是合并的问题而是原始 TS 分片本身码率过高或者解码器不兼容。可以先确认视频编码ffprobe -show_streams output.mp4如果编码是 H.265而播放器解码能力有限重新编码为 H.264 即可ffmpeg -i output.mp4 -c:v libx264 -c:a aac -preset fast output_h264.mp45.5 转换失败Invalid data found when processing input这个报错说明 ffmpeg 无法识别输入文件。常见原因有两类一是输入文件并不是视频数据而是 HTML 错误页面二是 ffmpeg 版本过旧无法解析新版 HLS 或 DASH 流。排查思路# 查看文件真实类型 file seg_000.ts # 如果是 HTML 文件说明下载地址有误或触发了鉴权针对这类问题优先检查下载 URL 是否正确而不是直接怀疑 ffmpeg。6. 最佳实践与工程建议6.1 只处理有授权的视频内容这一点必须放在最前面。M3U8、DASH 只是一种技术协议不区分正版或盗版。作为开发者我们只能在自己有权限访问和下载的范围内使用这些工具。个人学习、离线备份、内部素材整理等场景都没有问题如果涉及商业用途请务必确认版权归属。6.2 并发下载与限速策略分片很多时逐个下载效率较低。可以考虑用concurrent.futures做多线程下载但要控制并发数避免给目标服务器造成压力。from concurrent.futures import ThreadPoolExecutor, as_completed def download_many(url_list, save_dir, max_workers5): os.makedirs(save_dir, exist_okTrue) with ThreadPoolExecutor(max_workersmax_workers) as executor: futures {} for idx, url in enumerate(url_list): futures[executor.submit(download_file, url, f{save_dir}/{idx:05d}.ts)] idx for future in as_completed(futures): future.result() print(全部下载完成)推荐并发数控制在 3 到 8 之间。过多的并发会导致服务器限流反而更容易失败。6.3 文件名与临时目录管理下载和合并过程会产生很多临时文件。建议统一管理临时分片统一放在ts_cache目录而不是散落在项目根目录。每次执行前先清空临时目录避免上次残留文件影响排序。输出文件名使用safe_filename清洗防止特殊字符问题。6.4 日志与断点续传生产环境使用下载脚本时建议加入日志模块。Python 标准库的logging就够用import logging logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s ) logger logging.getLogger(__name__) logger.info(开始下载 %s, m3u8_url)断点续传在分片级其实天然容易实现已经下载成功的分片文件如果存在且大小一致可以跳过。这样即使中途失败重新运行脚本已下载的部分不会重复拉取。6.5 明确自己的边界ffmpeg 能做的事非常多但它不是万能的。商业 DRM 加密的视频无法用 ffmpeg 解密低延迟直播协议如 WebRTC也不是 ffmpeg 的强项部分 HDR 或特殊色彩空间的内容重新编码后可能出现颜色偏差。遇到这些情况时调整预期或者寻找更专业的商业工具比强行折腾 ffmpeg 更有效。7. 总结与下一步学习路线到这里M3U8 下载、TS 合并、M3U8 转 MP4、DASH 音视频流合成这套链路已经完整走通了。你现在应该能看懂 M3U8 文件的基本结构能用 Python 脚本解析分片地址并批量下载能通过 ffmpeg 把分散的 TS 文件合并成 MP4也能理解 DASH 为什么需要单独下载音视频轨再合成。接下来可以继续深入的方向包括研究 HLS 协议的其他特性比如多码率自适应Master Playlist、EXT-X-DISCONTINUITY标签处理。了解 H.264 与 H.265 的编码差异掌握重新编码参数对画质和文件大小的影响。接触更多流媒体分发方案比如 SRT、WebRTC、LL-HLS理解不同协议的应用边界。把下载脚本封装成 Web 服务或命令行工具配合任务队列实现批量处理。实际项目里最值得关注的还是两个点一个是对目标资源是否有合法访问权限另一个是临时文件和并发策略是否设计得足够健壮。把这两个问题想清楚剩下的基本都是协议解析和 ffmpeg 参数切换的体力活。希望本文能帮你节省这些体力。