返回 Python
Python
11 分钟阅读

M3U8 Python 下载脚本

基于 HLS 协议的流媒体分片抓取、AES-128 解密与多线程合并的 Python 实现

现代在线视频平台普遍采用 HLS(HTTP Live Streaming) 作为点播与直播的传输协议:服务端将视频切分为若干 .ts 分片,客户端通过 .m3u8 播放列表按序拉取并播放。对开发者而言,理解这套「清单 + 分片」的结构,是编写下载工具的前提。

本文介绍一个轻量 Python 脚本,完成 M3U8 解析 → 多线程分片下载 → AES-128 解密 → FFmpeg 合并 的完整流水线,适用于自有内容归档、内网测试流调试等场景。关于 M3U 播放列表与 IPTV 生态的背景,可参考 IPTV 入门

HLS / M3U8 流媒体架构:Master Playlist 选择清晰度,Media Playlist 列出 TS 分片,EXT-X-KEY 声明 AES-128 密钥


一、HLS 协议要点

HLS 由 Apple 提出,现已成为 RFC 8216 标准。与一次性下载完整 MP4 不同,HLS 将媒体流拆成短时长分片(通常 2–10 秒),通过 M3U8 文本清单描述分片 URL、加密方式与码率变体。

概念说明
Master Playlist多码率入口,含 #EXT-X-STREAM-INFBANDWIDTH 字段,指向各清晰度子清单
Media Playlist实际分片列表,每行一个 .ts URL,可含 #EXT-X-KEY 加密声明
Segment(.tsMPEG-TS 容器分片,下载后需按序合并
#EXT-X-KEY声明加密方式(常见 METHOD=AES-128)及密钥 URI、IV

脚本的处理逻辑与上述结构一一对应:先判断是否为 Master Playlist 并选取最高带宽变体,再解析 Media Playlist 中的分片 URL 与密钥信息,最后并发下载并合并。


二、脚本能力概览

M3U8 Python 下载流水线:解析清单 → 多线程下载 → AES-128 解密 → FFmpeg 合并为 MP4

模块能力
清单解析支持 Master / Media 两级 Playlist;自动选取 BANDWIDTH 最高的 variant
并发下载ThreadPoolExecutor 多线程拉取分片,默认 16 线程,可配置
断点续传已存在的分片文件自动跳过,适合中断后重试
AES-128 解密解析 #EXT-X-KEY,通过 pycryptodome 做 CBC 解密
合并输出优先调用 ffmpeg -f concat -c copy 封装为 MP4;无 FFmpeg 时退化为二进制拼接 .ts
进度反馈集成 tqdm 显示分片下载进度

依赖安装

pip install requests tqdm pycryptodome
# 合并为 MP4 还需系统安装 ffmpeg
brew install ffmpeg   # macOS

三、核心流程

输入 m3u8 URL
    │
    ├─ Master Playlist? ──是──▶ 选取最高 BANDWIDTH variant
    │                         │
    └─ Media Playlist ◀───────┘
            │
            ├─ 解析 segments[] + key_info (AES-128)
            │
            ├─ ThreadPoolExecutor 并发下载 → tmp/{000000.ts, 000001.ts, ...}
            │     └─ 已存在分片跳过;失败分片记录后中止
            │
            └─ 合并
                  ├─ ffmpeg 可用 → concat demuxer → output.mp4
                  └─ 否则 → 二进制拼接 → output.ts

Variant 选取:遍历 #EXT-X-STREAM-INF 行,解析 BANDWIDTH 数值,取最大值对应的子清单 URL。适用于自适应码率(ABR)流,确保下载最高清晰度版本。

AES-128 解密:从 #EXT-X-KEY 提取 URIIV(支持 0x 前缀十六进制),首次解密时拉取密钥字节并缓存;若 Playlist 未声明 IV,脚本回退为零 IV(部分流可能因此解密异常,需结合具体源验证)。

原子写入:每个分片先写入 .part 临时文件,完成后 os.replace 落盘,避免中断产生损坏分片。


四、完整代码

import os
import re
import sys
import shutil
import argparse
import tempfile
import subprocess
from urllib.parse import urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
 
try:
    import requests
except ImportError:
    print("请先安装依赖: pip install requests tqdm")
    sys.exit(1)
 
try:
    from tqdm import tqdm
except Exception:
    tqdm = None
 
try:
    from Crypto.Cipher import AES
    have_crypto = True
except Exception:
    have_crypto = False
 
HEADERS = {"User-Agent": "m3u8-downloader/1.0"}
 
 
def fetch_text(url, session=None, **kwargs):
    s = session or requests
    resp = s.get(url, headers=HEADERS, timeout=15, **kwargs)
    resp.raise_for_status()
    return resp.text
 
 
def pick_variant(m3u8_text, base_url):
    """若是 Master Playlist,选取 BANDWIDTH 最大的 variant。"""
    lines = m3u8_text.strip().splitlines()
    variant_infos = []
    for i, line in enumerate(lines):
        if line.startswith("#EXT-X-STREAM-INF"):
            m = re.search(r"BANDWIDTH=(\d+)", line)
            bw = int(m.group(1)) if m else 0
            for j in range(i + 1, len(lines)):
                u = lines[j].strip()
                if u and not u.startswith("#"):
                    variant_infos.append((bw, urljoin(base_url, u)))
                    break
    if not variant_infos:
        return None
    variant_infos.sort(key=lambda x: x[0], reverse=True)
    return variant_infos[0][1]
 
 
def parse_segments(m3u8_text, base_url):
    """解析分片 URL 列表与 AES-128 密钥信息。"""
    lines = m3u8_text.strip().splitlines()
    segments = []
    key = None
 
    for line in lines:
        line = line.strip()
        if line.startswith("#EXT-X-KEY"):
            m_method = re.search(r"METHOD=([^,]+)", line)
            m_uri = re.search(r'URI="([^"]+)"', line)
            m_iv = re.search(r"IV=([^,]+)", line)
            method = m_method.group(1) if m_method else None
            uri = urljoin(base_url, m_uri.group(1)) if m_uri else None
            iv = None
            if m_iv:
                ivtxt = m_iv.group(1)
                if ivtxt.startswith(("0x", "0X")):
                    iv = bytes.fromhex(ivtxt[2:])
                else:
                    try:
                        iv = bytes.fromhex(ivtxt)
                    except Exception:
                        iv = None
            key = {"method": method, "uri": uri, "iv": iv}
        if line and not line.startswith("#"):
            segments.append(urljoin(base_url, line))
 
    return segments, key
 
 
def download_segment(idx, url, dest_path, session, retries=3, key=None):
    """下载单个分片,支持 AES-128 解密。"""
    tmp_path = dest_path + ".part"
    if os.path.exists(dest_path):
        return True, dest_path
 
    for attempt in range(1, retries + 1):
        try:
            r = session.get(url, headers=HEADERS, timeout=30, stream=True)
            r.raise_for_status()
            data = r.content
 
            if key and key.get("method", "").upper() == "AES-128":
                if not have_crypto:
                    raise RuntimeError("需要 pycryptodome 支持 AES 解密,但未安装。")
                if not key.get("_key_bytes"):
                    kb = session.get(key["uri"], headers=HEADERS, timeout=15)
                    kb.raise_for_status()
                    key["_key_bytes"] = kb.content
                iv = key.get("iv") or (b"\x00" * 16)
                cipher = AES.new(key["_key_bytes"], AES.MODE_CBC, iv=iv)
                data = cipher.decrypt(data)
 
            with open(tmp_path, "wb") as f:
                f.write(data)
            os.replace(tmp_path, dest_path)
            return True, dest_path
        except Exception as e:
            if attempt == retries:
                return False, str(e)
    return False, "unknown error"
 
 
def merge_with_ffmpeg(segment_files, output_file):
    """通过 ffmpeg concat demuxer 无损合并分片。"""
    tmpdir = os.path.dirname(output_file) or "."
    list_file = os.path.join(tmpdir, "ff_concat_list.txt")
    with open(list_file, "w", encoding="utf-8") as f:
        for seg in segment_files:
            f.write(f"file '{os.path.abspath(seg)}'\n")
    cmd = [
        "ffmpeg", "-hide_banner", "-loglevel", "error",
        "-f", "concat", "-safe", "0", "-i", list_file,
        "-c", "copy", output_file,
    ]
    try:
        subprocess.check_call(cmd)
        os.remove(list_file)
        return True, None
    except subprocess.CalledProcessError as e:
        return False, str(e)
 
 
def download_m3u8(url, out, threads=16, tmp=None, keep_ts=True, session=None):
    session = session or requests.Session()
    text = fetch_text(url, session=session)
 
    variant = pick_variant(text, url)
    if variant:
        print(f"检测到 Master Playlist,选择 variant: {variant}")
        text = fetch_text(variant, session=session)
        base_for_join = variant.rsplit("/", 1)[0] + "/"
    else:
        base_for_join = url.rsplit("/", 1)[0] + "/"
 
    segments, key = parse_segments(text, base_for_join)
    if not segments:
        raise RuntimeError("未找到任何分片 —— 请确认 URL 为有效的 Media Playlist。")
 
    print(f"共 {len(segments)} 个分片,并发线程数={threads}")
 
    tmpdir = tmp or tempfile.mkdtemp(prefix="m3u8_dl_")
    os.makedirs(tmpdir, exist_ok=True)
    seg_files = [os.path.join(tmpdir, f"{i:06d}.ts") for i in range(len(segments))]
 
    if key and key.get("method", "").upper() == "AES-128":
        if not have_crypto:
            print("警告:Playlist 使用 AES-128,但未安装 pycryptodome。")
        else:
            print(f"检测到 AES-128 加密,key URI: {key.get('uri')}")
 
    failures = []
    pbar = tqdm(total=len(segments), desc="下载", unit="seg") if tqdm else None
 
    with ThreadPoolExecutor(max_workers=threads) as exe:
        futures = {
            exe.submit(download_segment, i, seg, seg_files[i], session, 5, key): i
            for i, seg in enumerate(segments)
        }
        for fut in as_completed(futures):
            ok, info = fut.result()
            if not ok:
                failures.append((futures[fut], info))
            if pbar:
                pbar.update(1)
    if pbar:
        pbar.close()
 
    if failures:
        print(f"{len(failures)} 个分片下载失败,中止合并。")
        raise RuntimeError("存在下载失败的分片。")
 
    if shutil.which("ffmpeg"):
        print("使用 ffmpeg 合并...")
        success, err = merge_with_ffmpeg(seg_files, out)
        if success:
            print(f"完成 → {out}")
            if not keep_ts:
                shutil.rmtree(tmpdir, ignore_errors=True)
            return out
        print(f"ffmpeg 合并失败: {err},退化为二进制拼接。")
 
    out_ts = out if out.lower().endswith(".ts") else out + ".ts"
    with open(out_ts, "wb") as outf:
        for seg in seg_files:
            with open(seg, "rb") as f:
                shutil.copyfileobj(f, outf)
    print(f"拼接完成 → {out_ts}")
    if not keep_ts:
        shutil.rmtree(tmpdir, ignore_errors=True)
    return out_ts
 
 
def cli(url: str, output_base: str, threads: int = 32):
    parser = argparse.ArgumentParser(description="多线程下载 M3U8 (HLS) 视频并合并")
    parser.add_argument("--keep-ts", action="store_true", help="保留临时 ts 文件")
    args = parser.parse_args()
 
    output = output_base + ".mp4"
    temp = output_base + "_temp"
 
    try:
        out = download_m3u8(url, output, threads=threads, tmp=temp, keep_ts=args.keep_ts)
        print("完成:", out)
    except Exception as e:
        print("出错:", e)
        sys.exit(1)
 
 
if __name__ == "__main__":
    videos = [
        # {"url": "https://example.com/stream.m3u8", "name": "output-filename"},
    ]
 
    if not videos:
        print("请在 videos 列表中配置待下载资源。")
    else:
        for video in videos:
            cli(url=video["url"], output_base=video["name"])

五、使用方式

videos 列表中填入目标 URL 与输出文件名(不含扩展名),直接运行脚本;或通过命令行封装调用 cli()

python m3u8_downloader.py
# 输出 → {name}.mp4,临时分片存放在 {name}_temp/

批量下载时,在 videos 数组中追加多条 {url, name} 即可顺序执行。


六、已知局限

局限说明
加密范围仅支持 AES-128SAMPLE-AES、DRM(Widevine / FairPlay)无法处理
IV 回退Playlist 未提供 IV 时使用零 IV,部分 CDN 源可能解密失败
直播流#EXT-X-ENDLIST 缺失的直播清单会持续更新,本脚本面向点播(VOD)设计
合并兼容性无 FFmpeg 时输出裸 .ts,需播放器支持 TS 容器
合规性仅下载有权访问的内容;请遵守目标站点的服务条款与版权法规

七、与 IPTV / 爬虫场景的关系

  • IPTV 场景:直播源通常为滚动更新的 Media Playlist,本脚本更适合点播回放或固定清单的归档下载。频道列表获取方式见 IPTV 部署指南
  • 爬虫场景:部分 Web 平台的视频地址以 .m3u8 形式存在于页面接口或 DevTools 网络面板中,抓取逻辑可参考 Python 爬虫 先定位清单 URL,再交由本脚本完成分片层下载。

两者分工明确:爬虫负责「找到 m3u8 链接」,本脚本负责「把 HLS 流还原为本地文件」。