只处理有权访问的流

M3U8/HLS 很适合学习流媒体结构:一个 playlist 指向很多 .ts.m4s 片段,可能还有多清晰度、嵌套 playlist 和 AES-128 加密信息。这里的边界也很明确:只处理自己有权访问和保存的内容,不绕过登录、付费、DRM 或版权限制。

Shark-Tools 里既有 Qt 播放器,也有早期下载脚本。把真实站点和资源地址去掉后,剩下的其实是一条很通用的工程链路。

解析嵌套 playlist

有些页面里拿到的 m3u8 不是最终片段列表,而是再指向一个子 playlist。第一步要解析相对路径:

from urllib.parse import urljoin

def resolve_nested_playlist(m3u8_url: str, content: str) -> str:
    lines = [
        line.strip()
        for line in content.splitlines()
        if line.strip() and not line.startswith("#")
    ]
    nested = next((line for line in lines if ".m3u8" in line), "")
    if not nested:
        return m3u8_url
    return nested if nested.startswith("http") else urljoin(m3u8_url, nested)

这一步看似简单,但很关键。很多下载失败不是网络问题,而是把 master playlist 当成 media playlist 处理了。

读取 EXT-X-KEY

普通 AES-128 HLS 会在 playlist 里出现 #EXT-X-KEY。工程上应该把 key URL、IV、是否加密这些信息放进一个独立结构里:

import re
from urllib.parse import urljoin

def parse_hls_key(m3u8_url: str, content: str) -> dict:
    info = {
        "encrypted": False,
        "key_url": None,
        "iv": bytes(16),
    }

    match = re.search(r'#EXT-X-KEY:METHOD=AES-128,URI="(.*?)"', content)
    if not match:
        return info

    key_uri = match.group(1)
    info["encrypted"] = True
    info["key_url"] = key_uri if key_uri.startswith("http") else urljoin(m3u8_url, key_uri)

    iv_match = re.search(r"IV=0x([0-9a-fA-F]+)", content)
    if iv_match:
        info["iv"] = bytes.fromhex(iv_match.group(1).zfill(32))

    return info

如果没有 key,代码应该明确标记“加密但缺 key”,而不是偷偷保存一堆无法播放的片段。

片段下载与解密

片段下载最容易写成无上限并发。我的经验是分批下载、保留序号、失败可重试,但不要无限打:

import asyncio
from Crypto.Cipher import AES

def decrypt_segment(data: bytes, key: bytes, iv: bytes) -> bytes:
    cipher = AES.new(key, AES.MODE_CBC, iv)
    return cipher.decrypt(data)

async def download_segment(session, url, index, *, key=None, iv=None):
    async with session.get(url, timeout=30) as response:
        if response.status in {401, 403}:
            raise PermissionError("访问被拒绝,停止下载")
        response.raise_for_status()
        data = await response.read()

    if key:
        data = decrypt_segment(data, key, iv or bytes(16))

    return index, data

async def download_in_batches(tasks, batch_size=20):
    results = []
    for offset in range(0, len(tasks), batch_size):
        batch = tasks[offset:offset + batch_size]
        results.extend(await asyncio.gather(*batch, return_exceptions=True))
    return results

我不喜欢“失败就疯狂重试一万次”的写法。遇到 401/403 应该停止,遇到网络抖动可以少量重试,遇到片段缺失则让用户决定是否继续合并。

合并清单

FFmpeg concat 模式需要一个顺序文件。只把成功下载且非空的片段写进去,能避免把坏文件硬塞进合并流程:

from pathlib import Path

def write_concat_list(segment_dir: Path, count: int) -> Path:
    list_file = segment_dir / "file.txt"
    with list_file.open("w", encoding="utf-8") as handle:
        for index in range(count):
            segment = segment_dir / f"{index:05d}.ts"
            if segment.exists() and segment.stat().st_size > 0:
                handle.write(f"file '{segment.name}'\n")
    return list_file

合并不是下载的结束,而是质量检查的开始。片段数量、文件大小、时长、音视频流信息都值得再校验一次。

播放器侧的小结

如果只是想预览,Qt 的 QMediaPlayer 可以直接吃一些 MP4 或 m3u8 链接,但对不同平台 HLS 的兼容性不稳定。播放器适合“快速验证链接能不能播”,下载器适合“分析 playlist 和片段结构”,两者不要混成一个大函数。

我现在更倾向于把 HLS 处理拆成四个模块:playlist 解析、key 解析、片段下载、合并/校验。每一层都有输入输出,后面接 GUI、CLI 或 Agent 工具都会更舒服。