1. 项目概述与核心价值
最近在折腾一些视频素材,发现很多在线视频平台,尤其是那些提供课程或者影视内容的网站,都喜欢用 m3u8 这种流媒体格式来分发视频。这玩意儿说白了就是一个播放列表文件,里面记录了一堆几秒钟到几十秒不等的视频小切片(ts文件)。这么做的好处对平台来说很明显:防盗链、便于CDN分发、能搞加密。对我们普通用户来说,想保存下来离线看或者做点二次创作,就有点麻烦了。特别是当这些 ts 切片还被 AES-128 加密的时候,直接下载下来就是一堆乱码,播放器根本不认。
网上找了一圈工具,不是要收费就是操作复杂,还得点点点。作为一个习惯用代码解决问题的程序员,我就琢磨着能不能用 Python 写个脚本,一键搞定从解析 m3u8 文件、下载加密切片、到最终解密合并成 MP4 的全过程。经过一番折腾和踩坑,终于搞出了一个稳定可用的方案。今天就把这个实战过程,连同完整的代码和避坑指南分享出来。如果你也遇到过“想下载个视频结果发现是加密 m3u8”的情况,或者对 Python 处理网络流媒体和加密解密感兴趣,那这篇内容应该能帮到你。整个过程思路清晰,代码量不大,即使你是 Python 新手,跟着步骤走,5分钟跑通核心流程绝对没问题。
2. 核心思路与技术选型解析
2.1 为什么是 m3u8 + AES-128?
在动手之前,我们得先搞清楚对手是谁。m3u8 是 HLS(HTTP Live Streaming)协议的标准播放列表格式。它把一个大视频文件切割成无数个小的 .ts(Transport Stream)文件,然后通过一个 .m3u8 的索引文件来组织播放顺序。这种“化整为零”的策略非常适合网络流式传输,可以根据用户的网速动态切换不同码率的切片。
而 AES-128 加密,则是平台为了保护这些切片内容不被轻易盗用而加上的“锁”。在 m3u8 文件中,如果包含了
#EXT-X-KEY
这个标签,就说明接下来的 ts 切片是被加密的。这个标签里会指明加密方法(通常是
METHOD=AES-128
)和密钥的获取方式(一个
URI
,可能是一个直接指向密钥文件的链接,也可能是一个需要额外请求的地址)。下载下来的每个 ts 切片,都需要用这个密钥进行解密,才能得到正常的视频数据。
所以,我们整个脚本的核心任务链条就非常清晰了:
- 获取并解析 m3u8 文件 :找到那个包含所有 ts 切片链接和加密信息的“地图”。
-
提取或获取解密密钥
:从
#EXT-X-KEY标签中拿到密钥 URI,并下载密钥内容。这是最关键的步骤,密钥错了,一切白费。 - 并发下载所有 ts 切片 :为了提高效率,我们需要同时下载多个切片,而不是傻傻地一个一个等。
- 解密每一个 ts 切片 :用上一步获取的密钥,对下载的加密数据进行 AES-128 CBC 模式解密。
- 合并解密后的 ts 文件 :将所有解密后的小文件,按顺序拼接成一个完整的视频文件,通常保存为 .mp4 格式。
2.2 工具库选型与考量
明确了步骤,接下来就是挑选趁手的“兵器”。Python 生态丰富,但选择不当也会踩坑。
-
网络请求:
requestsvsaiohttp下载 m3u8 文件和大量 ts 切片是典型的 I/O 密集型任务。requests库简单易用,但它是同步的,意味着下载100个切片要串行等待100次网络响应,效率极低。因此,我毫不犹豫地选择了aiohttp配合asyncio来实现异步并发下载。这能让脚本在等待一个切片下载的同时,去发起下一个切片的请求,速度有数量级的提升。虽然异步编程概念上稍微复杂一点,但aiohttp的 API 已经足够友好。 -
AES 解密:
pycryptodome这是 Python 里处理加密解密的权威库之一(Crypto模块)。我们需要用它来构建 AES 解密器。这里有个关键点:m3u8 的 AES-128 加密通常使用的是 CBC(Cipher Block Chaining)模式 ,并且 初始化向量(IV) 有时会直接在#EXT-X-KEY标签里通过IV=参数指定。如果没指定,则默认使用密钥的 MD5 哈希值(或全0)。pycryptodome的AES.new(key, AES.MODE_CBC, iv)接口正好满足需求。 -
文件与路径处理:
os,pathlib用于创建临时目录存放 ts 文件,以及最终合并文件。pathlib提供了更面向对象、更清晰的路径操作方式。 -
解析 m3u8:自定义解析器 市面上有
m3u8这个库可以专门解析 m3u8 文件,功能强大。但对于我们这个特定需求(主要是提取 ts URI 和 KEY 信息),自己写一个轻量级的解析器反而更直接、依赖更少。我们只需要按行读取,识别关键标签即可,代码更可控。
注意 :选择
aiohttp和asyncio意味着脚本的运行环境需要支持异步。在 Jupyter Notebook 或某些旧版 Python 交互环境中直接运行可能会遇到事件循环的问题。最稳妥的方式是保存为.py文件后用命令行执行。
3. 代码实现与分步详解
下面,我将结合完整代码,逐一拆解每个核心环节的实现细节和注意事项。你可以将代码复制到一个 Python 文件中(例如
m3u8_downloader.py
)运行。
3.1 环境准备与依赖安装
首先,确保你的 Python 环境是 3.7 及以上版本,因为我们会用到一些较新的异步特性。然后,通过 pip 安装必要的库:
pip install aiohttp pycryptodome
如果安装
pycryptodome
遇到问题,也可以尝试安装
cryptography
库,但本文代码基于
pycryptodome
实现。
接下来是完整的脚本代码,我会将其分块嵌入到各个章节进行讲解。
import asyncio
import aiohttp
import os
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import hashlib
import logging
from urllib.parse import urljoin
# 配置日志,方便查看进度和错误
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class M3U8Downloader:
def __init__(self, m3u8_url, output_file='output.mp4', max_concurrent=10):
"""
初始化下载器
:param m3u8_url: m3u8文件的完整URL
:param output_file: 最终输出的视频文件名
:param max_concurrent: 最大并发下载数,控制网络请求频率
"""
self.m3u8_url = m3u8_url
self.base_url = m3u8_url.rsplit('/', 1)[0] + '/' # 用于拼接相对路径
self.output_file = Path(output_file)
self.max_concurrent = max_concurrent
self.ts_urls = [] # 存储所有ts切片的绝对URL
self.key = None # 解密密钥(二进制)
self.iv = None # 初始化向量(二进制)
self.temp_dir = Path('temp_ts_files')
self.temp_dir.mkdir(exist_ok=True)
async def fetch_content(self, session, url):
"""通用的异步获取内容函数,处理重试和异常"""
for attempt in range(3): # 简单重试3次
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
resp.raise_for_status()
return await resp.read()
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
logger.warning(f"Attempt {attempt + 1} failed for {url}: {e}")
if attempt == 2: # 最后一次尝试也失败
logger.error(f"Failed to fetch {url} after 3 attempts.")
raise
await asyncio.sleep(1) # 等待1秒后重试
return None
async def parse_m3u8(self):
"""下载并解析m3u8文件,提取ts链接和加密密钥信息"""
async with aiohttp.ClientSession() as session:
content = await self.fetch_content(session, self.m3u8_url)
if not content:
raise ValueError("Failed to download m3u8 file.")
lines = content.decode('utf-8').splitlines()
self.ts_urls = []
current_key_uri = None
current_iv = None
for line in lines:
line = line.strip()
if not line or line.startswith('#'):
if line.startswith('#EXT-X-KEY'):
# 解析加密密钥信息,例如: #EXT-X-KEY:METHOD=AES-128,URI="key.key",IV=0x...
# 这里是一个简化解析,实际可能需要处理更复杂的情况
if 'URI=' in line:
# 提取URI,处理可能有的引号
uri_start = line.find('URI=') + 4
if line[uri_start] == '"':
uri_end = line.find('"', uri_start + 1)
key_uri = line[uri_start + 1:uri_end]
else:
# 如果没有引号,找到下一个逗号或行尾
uri_end = line.find(',', uri_start)
if uri_end == -1:
uri_end = len(line)
key_uri = line[uri_start:uri_end]
current_key_uri = urljoin(self.base_url, key_uri)
# 解析IV
if 'IV=' in line:
iv_start = line.find('IV=') + 3
iv_hex = line[iv_start:].split(',')[0].strip()
if iv_hex.startswith('0x') or iv_hex.startswith('0X'):
iv_hex = iv_hex[2:]
self.iv = bytes.fromhex(iv_hex)
continue
else:
# 这是一个ts文件的链接(可能是相对或绝对路径)
ts_url = urljoin(self.base_url, line)
self.ts_urls.append(ts_url)
logger.info(f"Parsed {len(self.ts_urls)} ts segments.")
if current_key_uri:
await self._fetch_key(current_key_uri)
else:
logger.warning("No encryption key found. Assuming segments are not encrypted.")
async def _fetch_key(self, key_uri):
"""获取解密密钥"""
async with aiohttp.ClientSession() as session:
key_content = await self.fetch_content(session, key_uri)
if key_content and len(key_content) == 16: # AES-128 密钥是16字节
self.key = key_content
logger.info("Encryption key fetched successfully.")
# 如果m3u8中没有指定IV,则使用密钥的MD5值(前16字节)作为IV,这是一种常见做法
if self.iv is None:
self.iv = hashlib.md5(self.key).digest()[:16]
logger.info(f"IV not specified, using MD5 of key: {self.iv.hex()}")
else:
raise ValueError(f"Invalid key fetched from {key_uri}. Length: {len(key_content) if key_content else 0}")
async def download_ts_segments(self):
"""并发下载所有ts切片到临时目录"""
semaphore = asyncio.Semaphore(self.max_concurrent) # 控制并发数
async def download_one(session, index, ts_url):
async with semaphore:
ts_data = await self.fetch_content(session, ts_url)
if ts_data:
ts_path = self.temp_dir / f"segment_{index:05d}.ts"
ts_path.write_bytes(ts_data)
logger.debug(f"Downloaded: {ts_path.name}")
return ts_data is not None
async with aiohttp.ClientSession() as session:
tasks = [download_one(session, i, url) for i, url in enumerate(self.ts_urls)]
results = await asyncio.gather(*tasks, return_exceptions=True)
success_count = sum(1 for r in results if r is True)
fail_count = sum(1 for r in results if isinstance(r, Exception))
logger.info(f"Download finished. Success: {success_count}, Failed: {fail_count}")
def decrypt_ts_files(self):
"""解密所有下载的ts文件(如果已获取密钥)"""
if not self.key:
logger.info("No encryption key, skipping decryption.")
return
ts_files = sorted(self.temp_dir.glob("segment_*.ts"))
for ts_file in ts_files:
encrypted_data = ts_file.read_bytes()
try:
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
# 注意:有些ts文件可能不是16字节的整数倍,需要处理填充,但实际中很多流不标准填充
# 这里先尝试解密,如果报错再尝试不处理填充
try:
decrypted_data = unpad(cipher.decrypt(encrypted_data), AES.block_size)
except ValueError:
# 可能没有标准PKCS7填充,直接解密
decrypted_data = cipher.decrypt(encrypted_data)
ts_file.write_bytes(decrypted_data)
logger.debug(f"Decrypted: {ts_file.name}")
except Exception as e:
logger.error(f"Failed to decrypt {ts_file.name}: {e}")
# 可以选择跳过或停止
def merge_ts_to_mp4(self):
"""将所有解密(或原始)的ts文件合并为单个mp4文件"""
ts_files = sorted(self.temp_dir.glob("segment_*.ts"))
if not ts_files:
logger.error("No TS files found to merge.")
return
with open(self.output_file, 'wb') as outfile:
for ts_file in ts_files:
outfile.write(ts_file.read_bytes())
logger.debug(f"Merged: {ts_file.name}")
logger.info(f"All segments merged into: {self.output_file}")
def cleanup(self):
"""清理临时目录"""
import shutil
if self.temp_dir.exists():
shutil.rmtree(self.temp_dir)
logger.info("Temporary directory cleaned up.")
async def run(self):
"""主运行流程"""
try:
await self.parse_m3u8()
await self.download_ts_segments()
self.decrypt_ts_files()
self.merge_ts_to_mp4()
logger.info("Process completed successfully!")
except Exception as e:
logger.error(f"Process failed with error: {e}")
raise
finally:
# 可以选择是否清理临时文件,调试时可注释掉
self.cleanup()
async def main():
# 使用示例:替换成你的m3u8文件地址
m3u8_url = "https://example.com/path/to/your/playlist.m3u8"
downloader = M3U8Downloader(m3u8_url, output_file='my_video.mp4', max_concurrent=5)
await downloader.run()
if __name__ == '__main__':
asyncio.run(main())
3.2 关键环节深度剖析
3.2.1 m3u8 文件解析的陷阱
在
parse_m3u8
方法中,我们做了几件关键事:
-
获取基础URL
:
self.base_url = m3u8_url.rsplit('/', 1)[0] + '/'。这行代码用于处理 m3u8 文件中 ts 切片使用相对路径的情况。例如,m3u8 地址是https://site.com/video/playlist.m3u8,里面 ts 链接写的是segment1.ts,那么完整的 URL 就需要拼接成https://site.com/video/segment1.ts。urljoin函数能更稳健地处理各种相对/绝对路径情况。 -
解析
#EXT-X-KEY:这是最易出错的地方。这个标签的格式可能有变体。我写的解析逻辑处理了URI="..."和URI=...(无引号)两种情况。但有些网站可能把密钥放在一个需要特定 Cookie 或 Header 才能访问的地址,甚至密钥是动态生成的。这时,简单的fetch_content可能拿不到密钥,需要你根据实际情况,在_fetch_key方法里添加相应的请求头(如Referer,Cookie)。 实操心得 :遇到解密失败,首先检查密钥是否成功获取且长度为16字节,其次检查IV是否正确。 -
初始化向量 IV 的处理
:IV 是 AES-CBC 模式必需的。如果 m3u8 里明确用
IV=0x...给出了,我们就用给出的。如果没有,常见的做法是使用密钥的 MD5 哈希值(取前16字节),或者使用全0的 IV。代码中我们采用了前者hashlib.md5(self.key).digest()[:16]。这一点需要根据目标网站的具体实现来调整,如果解密后视频开头花屏,可以尝试换用bytes(16)(全0 IV)。
3.2.2 异步下载的并发控制
在
download_ts_segments
方法中,我们使用了
asyncio.Semaphore
来限制最大并发数 (
max_concurrent
)。这是非常必要的,原因有二:
- 避免被封IP :对同一个服务器瞬间发起数百个连接,很容易触发对方的反爬机制。
- 保护本地资源 :过多的并发连接会消耗大量内存和网络带宽,可能导致程序崩溃或系统卡顿。
我通常将并发数设置在 5 到 20 之间,具体取决于你的网络环境和目标服务器的容忍度。代码中的
fetch_content
函数还加入了简单的重试机制,增强了鲁棒性。
3.2.3 AES 解密中的填充问题
在
decrypt_ts_files
方法中,解密部分有个细节:
try:
decrypted_data = unpad(cipher.decrypt(encrypted_data), AES.block_size)
except ValueError:
decrypted_data = cipher.decrypt(encrypted_data)
AES-CBC 要求明文长度是16字节(128位)的倍数。如果不是,就需要在加密前进行填充(Padding)。PKCS7 是一种常见的填充标准。但在实际的流媒体传输中,
很多 ts 切片并没有使用标准的填充
,或者其长度恰好是16的倍数。如果对没有填充的数据强行
unpad
,就会抛出
ValueError
。因此,这里我们尝试先解密并去除填充,如果失败,就认为数据没有标准填充,直接使用解密后的数据。这是处理“野路子”流媒体加密的一个实用技巧。
3.2.4 文件合并的注意事项
merge_ts_to_mp4
方法非常简单,就是按文件名排序后二进制拼接。这里有几个点:
-
排序
:
sorted(self.temp_dir.glob("segment_*.ts"))依赖于我们下载时按索引命名的文件(segment_00000.ts)。确保命名顺序和 m3u8 列表中的顺序一致,否则视频内容会错乱。 -
输出格式
:我们直接输出为
.mp4。实际上,将.ts文件简单拼接后,其编码格式(通常是 H.264/AAC)本身就可以被 MP4 容器封装,大多数播放器(如 VLC、PotPlayer)都能正常播放。如果追求更标准的封装,可以后续使用ffmpeg命令进行转封装:ffmpeg -i concat:filelist.txt -c copy output.mp4,其中filelist.txt包含了所有 ts 文件的路径。 - 内存管理 :代码是逐个读取 ts 文件并写入的,避免了将整个视频一次性加载到内存。对于超长视频,这很关键。
4. 实战演练与参数调整
现在,假设我们拿到了一个真实的加密 m3u8 地址:
https://your-video-site.com/2024/05/abc/index.m3u8
。我们该如何使用这个脚本?
第一步:修改运行参数
打开脚本,找到最下面的
main()
函数,修改
m3u8_url
和
output_file
。
async def main():
m3u8_url = "https://your-video-site.com/2024/05/abc/index.m3u8" # 替换为你的地址
downloader = M3U8Downloader(m3u8_url, output_file='我的解密视频.mp4', max_concurrent=8)
await downloader.run()
将并发数
max_concurrent
设为 8 是一个比较折中的开始,既不太激进,也能有效利用带宽。
第二步:处理可能的反爬
如果直接运行发现无法下载 m3u8 文件或密钥,很可能是网站检查了
User-Agent
或
Referer
。我们需要修改
fetch_content
函数或创建
ClientSession
时添加请求头。
修改
__init__
方法,增加 headers 属性:
def __init__(self, m3u8_url, output_file='output.mp4', max_concurrent=10):
...
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Referer': 'https://the-video-site.com/' # 通常设置为视频所在页面的域名
}
然后在
fetch_content
和
_fetch_key
中创建
session.get
请求时,传入
headers=self.headers
。
async with session.get(url, headers=self.headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
第三步:运行与观察 在命令行中执行:
python m3u8_downloader.py
观察日志输出。如果看到
Parsed XXX ts segments.
和
Encryption key fetched successfully.
,说明解析成功。接着会看到下载进度,最后是合并成功的提示。如果中途报错,请根据下一章的排查指南进行诊断。
5. 常见问题排查与进阶技巧
即使代码逻辑正确,在实际操作中也会遇到各种“坑”。下面是我总结的常见问题及解决方法。
5.1 问题排查速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 无法下载 m3u8 文件 |
1. URL 错误或失效
2. 需要特定 Headers(如 Referer) 3. 被服务器拒绝(403/404) |
1. 用浏览器开发者工具(Network标签)确认 m3u8 真实地址。
2. 在
fetch_content
中添加
User-Agent
,
Referer
等请求头。
3. 检查是否需要 Cookie(常用于会员视频)。 |
| 解析出 ts 链接数为 0 |
1. m3u8 文件内容格式非标准
2. 可能是多级 m3u8(Master Playlist) |
1. 打印出下载的 m3u8 内容,检查是否是有效的文本列表。
2. 如果内容以
#EXTM3U
开头且包含
#EXT-X-STREAM-INF
,说明是主列表,需要再次解析其中带宽最高的那个子列表 URL。
|
| 密钥获取失败或长度不对 |
1. 密钥 URI 是相对路径,拼接错误
2. 密钥需要带特定参数访问 3. 密钥是动态的,每次不同 |
1. 打印
current_key_uri
,用浏览器或
curl
手动访问测试。
2. 可能需要携带
session_id
等参数,需从页面或首次请求中提取。
3. 动态密钥通常无法简单下载,需分析页面JS逻辑,此类情况较复杂。 |
| 解密后视频无法播放或花屏 |
1. 密钥错误
2. IV 不正确 3. 加密模式非 AES-128-CBC 4. ts 文件本身损坏或下载不全 |
1. 确认密钥内容(16字节)。
2. 尝试更换 IV:用全0 IV (
bytes(16)
),或用密钥MD5值。
3. 检查
#EXT-X-KEY
中的
METHOD
字段。
4. 检查下载的 ts 文件大小是否正常,尝试用
ffmpeg -i segment_00000.ts
测试单个文件。
|
| 合并后的 MP4 只有声音没画面/无法拖动 |
1. ts 文件顺序错乱
2. 编码格式特殊,简单拼接不兼容 3. 缺少关键帧信息 |
1. 确保按
segment_00000.ts
格式命名并排序。
2. 强烈建议 :使用
ffmpeg
进行最终合并。先创建一个
filelist.txt
,内容如
file 'segment_00000.ts'
(每行一个),然后执行
ffmpeg -f concat -safe 0 -i filelist.txt -c copy final_output.mp4
。
-c copy
表示流复制,速度极快且无损。
|
程序报错
RuntimeError: Event loop is closed
|
在 Windows 系统上,
asyncio
的事件循环策略问题
|
在脚本入口处(
if __name__ == '__main__':
)修改为:
if __name__ == '__main__':
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
asyncio.run(main())
|
5.2 进阶技巧与优化建议
-
使用 FFmpeg 作为后备方案 :如果遇到极其复杂的加密或协议,Python 脚本搞不定,别忘了终极武器
ffmpeg。很多时候,一行命令就能解决:ffmpeg -i "https://.../playlist.m3u8" -c copy output.mp4。ffmpeg 内部自动处理了密钥获取、解密和合并。我们的脚本优势在于更可控、可定制(如添加特定请求头)、以及编程集成。 -
处理分片下载与断点续传 :对于超大型视频,可以考虑将下载任务分批次进行,并记录已下载的切片索引,实现简单的断点续传功能。
-
增加进度显示 :虽然有了日志,但一个直观的进度条(如使用
tqdm库)会让体验更好。可以在download_ts_segments中集成。 -
错误隔离与继续 :当前版本中,一个切片下载失败会导致整个
gather任务出现异常。可以优化错误处理,让单个切片失败不影响其他切片下载,最后报告哪些失败了,允许手动补下。 -
伪装成浏览器 :除了
User-Agent和Referer,有些网站还会检查Origin头。确保你的请求头尽可能模拟浏览器行为。
这个脚本提供了一个强大且可扩展的基础框架。掌握了它,你不仅能解决“下载加密 m3u8 视频”这个具体问题,更能深入理解网络流媒体、异步编程和对称加密在实际中的应用。遇到新的挑战,无非是在这个框架上添加新的“武器模块”而已。
2267

被折叠的 条评论
为什么被折叠?



