用纯 Go 实现一个 AES-128 加密 m3u8 视频下载器(不依赖 ffmpeg)

一、先认识一下 HLS 和 m3u8

1.1 HLS 基本概念

HLS 的核心思想很简单:

  1. 把视频流切成很多个小片段(segment),通常是 .ts 文件
  2. 用一个文本文件(.m3u8)列出这些分片的 URL 和相关信息
  3. 播放器顺序下载这些分片,拼在一起播放

典型的 m3u8 大概长这样:

m3u8 复制代码
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="https://xxx/crypt.key",IV=0x596df46cb......
#EXTINF:10.0,
0000.ts
#EXTINF:10.0,
0001.ts
#EXTINF:10.0,
0002.ts
...

对于加密的流,关键就是这行:

m3u8 复制代码
#EXT-X-KEY:METHOD=AES-128,URI="https://xxx/crypt.key",IV=0x596df46cb...

它说明了:

  • METHOD=AES-128:使用 AES-128 对称加密
  • URI="...":密钥文件的地址,下载回来就是 16 字节的 key
  • IV=0x...:初始化向量(IV),AES CBC 模式会用到

只要我们能拿到:

  • crypt.key(16 字节)
  • IV(16 字节)
  • 每个 TS 分片的密文数据

就可以按顺序解密并写入同一个文件中,实现"离线视频"。

二、工具整体设计思路

我们这个 Go 工具的目标:给一个 m3u8 URL,就输出一个本地 mp4 文件。

整体流程可以概括为:

  1. 解析命令行参数:m3u8 URL、输出文件名

  2. 下载 m3u8 文件内容

  3. 解析 m3u8:

    • 找到 #EXT-X-KEY 行,解析出加密信息(Method、URI、IV)
    • 收集所有 TS 分片 URL(支持相对路径 → 绝对路径)
  4. 下载密钥(.key 文件),确保长度 16 字节(AES-128)

  5. 创建输出文件

  6. 遍历每个分片:

    • 下载分片数据(cipherData)
    • 基于 key + IV 使用 AES-128 CBC 解密
    • 将解密后的明文追加写入输出文件
  7. 打印进度,结束。

对应到代码里,大致由这些部分组成:

  • main:参数解析 & 调度
  • runFromURL:核心流程控制
  • parseKeyLine:从 #EXT-X-KEY 行解析加密信息
  • 若干工具函数:resolveURL、parseIV、downloadKey
  • downloadAndDecryptSegment:下载 & 解密单个分片

下面我们逐段拆解。

三、主函数:命令行参数 & 入口

go 复制代码
func main() {
    playlistURL := flag.String("url", "", "m3u8 播放列表 URL(必填)")
    outPath := flag.String("out", "output.mp4", "输出文件名(默认 output.mp4)")
    flag.Parse()

    if *playlistURL == "" {
        fmt.Println("用法示例:")
        fmt.Println(`  go run main.go -url "https://xxx/xxx.m3u8?...auth_key=..." -out video.mp4`)
        os.Exit(1)
    }

    if err := runFromURL(*playlistURL, *outPath); err != nil {
        fmt.Fprintf(os.Stderr, "❌ 出错: %v\n", err)
        os.Exit(1)
    }
    fmt.Println("✅ 下载完成:", *outPath)
}

这里用标准库 flag 做命令行参数解析:

  • -url:必填,m3u8 地址
  • -out:可选,默认 output.mp4

如果 -url 为空,就打印使用说明并退出。真正干活的是 runFromURL。

四、核心流程:runFromURL

go 复制代码
func runFromURL(playlistURL, outPath string) error {
    client := &http.Client{
        Timeout: 30 * time.Second,
    }

    fmt.Println("📥 正在下载 m3u8:", playlistURL)
    resp, err := client.Get(playlistURL)
    ...

4.1 下载 m3u8

  • 创建一个自定义 http.Client,设置了 30 秒超时。
  • client.Get(playlistURL) 拉取 m3u8 文件,如果非 200 就返回错误。
go 复制代码
    body, err := io.ReadAll(resp.Body)
    ...
    baseURL, err := url.Parse(playlistURL)

把 m3u8 内容读到 body,同时用 url.Parse 解析出 baseURL,方便后面把相对路径转成绝对 URL。

4.2 解析 m3u8:找 KEY 和 TS 分片

go 复制代码
    var (
        keyInfo     *KeyInfo
        segmentURLs []string
    )

    scanner := bufio.NewScanner(strings.NewReader(string(body)))
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if line == "" {
            continue
        }
        if strings.HasPrefix(line, "#EXT-X-KEY:") {
            ki, err := parseKeyLine(line)
            if err != nil {
                return fmt.Errorf("解析 #EXT-X-KEY 失败: %w", err)
            }
            keyInfo = ki
        } else if strings.HasPrefix(line, "#") {
            continue
        } else {
            u, err := resolveURL(baseURL, line)
            if err != nil {
                return fmt.Errorf("解析分片 URL 失败 (%s): %w", line, err)
            }
            segmentURLs = append(segmentURLs, u)
        }
    }

这里用了一个简单但够用的解析策略:

  • 以 #EXT-X-KEY: 开头 → 当作密钥信息行,交给 parseKeyLine 处理
  • 以 # 开头但不是 #EXT-X-KEY → 各种标签(如 #EXTINF),忽略
  • 其他非空行 → 当作分片 URL(可能是相对路径),用 resolveURL 转成绝对 URL

最后做一些校验:

go 复制代码
    if len(segmentURLs) == 0 {
        return fmt.Errorf("没有解析到任何 ts 分片 URL")
    }

    if keyInfo == nil {
        return fmt.Errorf("未找到 #EXT-X-KEY,当前脚本只处理 AES-128 加密的情况")
    }
    if !strings.EqualFold(keyInfo.Method, "AES-128") {
        return fmt.Errorf("暂不支持加密方法: %s", keyInfo.Method)
    }

注意:这里是有意的限制 ------当前版本只处理带 AES-128 加密的流。如果想支持未加密的 m3u8,其实很简单:当 keyInfo == nil 时直接不解密,原样写入。

4.3 下载密钥

go 复制代码
    keyURL, err := resolveURL(baseURL, keyInfo.URI)
    ...
    fmt.Println("🔑 正在下载密钥:", keyURL)
    key, err := downloadKey(client, keyURL)
    ...
    if len(key) != 16 {
        return fmt.Errorf("密钥长度不是 16 字节 (AES-128),实际: %d", len(key))
    }
  • 用和分片一样的 resolveURL 把 keyInfo.URI 变成绝对 URL;

  • 下载密钥内容;

  • 强制要求长度为 16 字节,否则报错:

    • AES-128 的密钥长度是 128bit = 16 字节。

4.4 创建输出文件 & 下载所有分片

go 复制代码
    outFile, err := os.Create(outPath)
    ...
    total := len(segmentURLs)
    fmt.Printf("开始下载分片,共 %d 个...\n", total)

    for i, segURL := range segmentURLs {
        fmt.Printf("[%4d/%4d] %s\n", i+1, total, segURL)
        if err := downloadAndDecryptSegment(client, segURL, key, keyInfo.IV, outFile); err != nil {
            return fmt.Errorf("处理分片失败 (%s): %w", segURL, err)
        }
    }

这里采用最简单的方式:按顺序串行下载 ,每个分片解密后直接写到输出文件。

优点是实现简单,还保证了顺序;缺点是下载速度受限于单连接。

后面会讲如何扩展成并发版本。

五、解析加密信息:KeyInfo & parseKeyLine

5.1 KeyInfo 结构体

go 复制代码
type KeyInfo struct {
    Method string
    URI    string
    IV     []byte
}

对应 m3u8 中的 #EXT-X-KEY:

m3u8 复制代码
#EXT-X-KEY:METHOD=AES-128,URI="https://xxx/crypt.key",IV=0x596df46cb...
  • Method:通常是 AES-128
  • URI:密钥文件地址
  • IV:解析成 16 字节的二进制数组

5.2 parseKeyLine:将字符串解析成 KeyInfo

go 复制代码
func parseKeyLine(line string) (*KeyInfo, error) {
    idx := strings.Index(line, ":")
    if idx < 0 {
        return nil, fmt.Errorf("无效的 KEY 行: %s", line)
    }
    attrs := line[idx+1:]
    parts := strings.Split(attrs, ",")

    ki := &KeyInfo{}

    for _, p := range parts {
        p = strings.TrimSpace(p)
        if p == "" {
            continue
        }
        kv := strings.SplitN(p, "=", 2)
        if len(kv) != 2 {
            continue
        }
        k := strings.ToUpper(strings.TrimSpace(kv[0]))
        v := strings.TrimSpace(kv[1])
        switch k {
        case "METHOD":
            ki.Method = v
        case "URI":
            ki.URI = trimQuotes(v)
        case "IV":
            ivBytes, err := parseIV(v)
            if err != nil {
                return nil, fmt.Errorf("解析 IV 失败: %w", err)
            }
            ki.IV = ivBytes
        }
    }

    if ki.Method == "" || ki.URI == "" || len(ki.IV) == 0 {
        return nil, fmt.Errorf("KEY 信息不完整: %+v", ki)
    }
    return ki, nil
}

解析步骤:

  1. 先找到冒号,把前面的 #EXT-X-KEY 去掉
  2. 按逗号拆成多个 key=value 对
  3. 支持 METHOD / URI / IV 三个字段
  4. URI 可能带引号,用 trimQuotes 去掉
  5. IV 交给 parseIV 解析成字节数组
  6. 最后检查三个字段是否完整,否则认为 m3u8 有问题

trimQuotes 很简单:

go 复制代码
func trimQuotes(s string) string {
    s = strings.TrimSpace(s)
    if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) {
        return s[1 : len(s)-1]
    }
    return s
}

六、URL 处理:相对路径 → 绝对 URL

m3u8 里的分片一般是相对路径,比如:

m3u8 复制代码
#EXTINF:10.0,
0001.ts

而我们最终需要的是完整 URL。resolveURL 做的就是这件事:

go 复制代码
func resolveURL(base *url.URL, ref string) (string, error) {
    ref = strings.TrimSpace(ref)
    u, err := url.Parse(ref)
    if err != nil {
        return "", err
    }
    if u.IsAbs() {
        return u.String(), nil
    }
    return base.ResolveReference(u).String(), nil
}

逻辑很直接:

  • 如果本身就是绝对 URL(http:// / https:// 开头),直接用
  • 否则用 base.ResolveReference 和 m3u8 的地址拼接

同样的方法也用在 Key URI 上。

七、解析 IV:十六进制字符串 → 16 字节数组

m3u8 里的 IV 通常长这样:

m3u8 复制代码
IV=0x596df46cb...

parseIV 用来把它变成 []byte:

go 复制代码
func parseIV(s string) ([]byte, error) {
    s = strings.TrimSpace(s)
    if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
        s = s[2:]
    }
    if len(s)%2 != 0 {
        s = "0" + s
    }
    b, err := hex.DecodeString(s)
    if err != nil {
        return nil, err
    }
    if len(b) != aes.BlockSize {
        return nil, fmt.Errorf("IV 长度不是 %d 字节: %d", aes.BlockSize, len(b))
    }
    return b, nil
}

要点:

  • 支持 0x / 0X 前缀
  • 如果十六进制字符串长度为奇数,前面补一个 0,避免解析失败
  • 解码成字节后,必须保证长度为 aes.BlockSize(即 16 字节)

八、下载密钥:downloadKey

go 复制代码
func downloadKey(client *http.Client, uri string) ([]byte, error) {
    resp, err := client.Get(uri)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("HTTP 状态码: %d", resp.StatusCode)
    }
    return io.ReadAll(resp.Body)
}

这里没有做太复杂的处理,只是:

  • 直接 GET
  • 要求状态码 200
  • io.ReadAll 一次性读完

对于一般 .key 文件(只 16 字节)来说,这样完全没问题。

九、关键:下载并解密单个 TS 分片

go 复制代码
func downloadAndDecryptSegment(client *http.Client, segURL string, key, iv []byte, out io.Writer) error {
    resp, err := client.Get(segURL)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("HTTP 状态码: %d", resp.StatusCode)
    }

    cipherData, err := io.ReadAll(resp.Body)
    if err != nil {
        return err
    }

    if len(cipherData)%aes.BlockSize != 0 {
        return fmt.Errorf("分片长度不是 AES 块大小的整数倍: %d", len(cipherData))
    }

    block, err := aes.NewCipher(key)
    if err != nil {
        return err
    }

    ivCopy := make([]byte, len(iv))
    copy(ivCopy, iv)

    mode := cipher.NewCBCDecrypter(block, ivCopy)
    plain := make([]byte, len(cipherData))
    mode.CryptBlocks(plain, cipherData)

    _, err = out.Write(plain)
    return err
}

这里涉及到 AES-128 CBC 解密的关键点:

9.1 AES 块大小检查

go 复制代码
if len(cipherData)%aes.BlockSize != 0 {
    return fmt.Errorf("分片长度不是 AES 块大小的整数倍: %d", len(cipherData))
}

CBC 模式要求:

  • 密文长度必须是块大小(16 字节)的整数倍
  • 否则说明数据不完整或有 padding 方案不一致

HLS 常见的做法是:对 TS 流整体做 AES-128 CBC 加密,不额外做 PKCS#7 padding,因此 TS 本身通常已经对齐到 16 字节。

9.2 CBC 解密流程

go 复制代码
block, err := aes.NewCipher(key)
...
ivCopy := make([]byte, len(iv))
copy(ivCopy, iv)
mode := cipher.NewCBCDecrypter(block, ivCopy)
plain := make([]byte, len(cipherData))
mode.CryptBlocks(plain, cipherData)
  • aes.NewCipher(key):创建一个 AES 块密码器
  • cipher.NewCBCDecrypter:基于 AES 创建一个 CBC 模式的解密器
  • CryptBlocks:对所有分组进行解密

然后把明文直接写入输出文件,不做任何 padding 去除:

go 复制代码
_, err = out.Write(plain)

注意:规范上 CBC 模式通常配合 PKCS#7 等 padding 使用,这时解密后需要把最后一块的 padding 去掉。但在 HLS 的实践中,很多实现直接对 16 字节对齐的 TS 流加密,不额外添加 padding,所以这里也就不做处理。

十、使用示例

假设我们有一个加密的 m3u8 链接:

bash 复制代码
go run main.go \
  -url "https://hls.example.com/videos/abcdef/playlist.m3u8?auth_key=xxx" \
  -out my_video.mp4

执行过程中,你会看到类似输出:

text 复制代码
📥 正在下载 m3u8: https://hls.example.com/...
🔑 正在下载密钥: https://dncrf6pep7yrf.cloudfront.net/.../crypt.key?auth_key=...
开始下载分片,共 120 个...
[   1/ 120] https://hls.example.com/videos/.../0000.ts
[   2/ 120] https://hls.example.com/videos/.../0001.ts
...
[ 120/ 120] https://hls.example.com/videos/.../0119.ts
✅ 下载完成: my_video.mp4

生成的 my_video.mp4 虽然扩展名是 mp4,本质上是 TS 流,但绝大多数播放器(PotPlayer、VLC、ffplay、IINA 等)都能直接识别。

十一、可能的扩展 & 优化方向

当前版本的实现已经可以稳定地下载和解密加密 m3u8,但还有很多可以扩展的点:

11.1 并发下载分片

现在的实现是串行下载,如果网络延迟很高,下载会很慢。

可以考虑:

  • 使用一个 worker pool,并发拉取多个 TS 分片
  • 用一个 []byte slice 数组暂存各分片解密后的内容
  • 最后按顺序写入输出文件

或者更省内存一点:

  • 每个 worker 把解密后的分片写到一个临时文件夹里,命名为 index.ts
  • 全部下载完成后再按顺序拼接到最终文件中,最后删除临时文件

11.2 支持未加密 m3u8

现在遇到未加密流会直接报错(keyInfo == nil)。

可以修改为:

  • 如果找不到 #EXT-X-KEY,则认为是未加密流
  • downloadAndDecryptSegment 改名为 downloadSegment,根据情况选择解密或直接写入

11.3 支持多 KEY / KEY 轮换

有些长视频会在中途更换密钥,表现为 m3u8 中出现多个不同的 #EXT-X-KEY。

目前的解析逻辑默认只取到第一个 key。要支持多密钥轮换,可以:

  • 在解析时记录"当前 key",当遇到新的 #EXT-X-KEY 时更新 key/IV
  • 将分片与对应 key 做关联(例如构造一个包含 segURL + keyInfo 的切片)
  • 下载时根据分片对应的 key/IV 解密

11.4 加入重试机制和错误恢复

现在的实现是:只要某个分片失败,就直接终止整个下载。

可以改进为:

  • 失败时重试 N 次(如 3 次)
  • 记录失败的分片,下载结束后单独给出列表
  • 支持断点续传(例如记录已完成的分片索引,下次从某个 index 开始)

11.5 自定义 Header & 代理支持

现实中很多 CDN / 视频站都需要:

  • 特定的 Referer / User-Agent / Cookie
  • 或通过 HTTP 代理访问

可以在 http.Client + http.NewRequest 上扩展:

  • 提供配置文件 / 命令行参数,注入 header 和代理
  • 这样可以应对更多真实业务环境

十二、安全与合规说明

这类工具非常适合用来:

  • 学习和理解 HLS / AES-128 / CBC 的实现
  • 做内部系统的调试工具(比如视频服务的自测)

但在使用中务必要注意:

  • 遵守所在网站的 服务条款(ToS) 和 版权政策
  • 不要用于下载和传播未授权的受版权保护内容
  • 不要绕过你不应该绕过的访问限制

所有代码与示例仅供学习与技术研究使用。

十三、完整代码

最后附上本文解析的完整代码,方便复制使用:

go 复制代码
package main

import (
    "bufio"
    "crypto/aes"
    "crypto/cipher"
    "encoding/hex"
    "flag"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strings"
    "time"
)

type KeyInfo struct {
    Method string
    URI    string
    IV     []byte
}

func main() {
    playlistURL := flag.String("url", "", "m3u8 播放列表 URL(必填)")
    outPath := flag.String("out", "output.mp4", "输出文件名(默认 output.mp4)")
    flag.Parse()

    if *playlistURL == "" {
        fmt.Println("用法示例:")
        fmt.Println(`  go run main.go -url "https://xxx/xxx.m3u8?...auth_key=..." -out video.mp4`)
        os.Exit(1)
    }

    if err := runFromURL(*playlistURL, *outPath); err != nil {
        fmt.Fprintf(os.Stderr, "❌ 出错: %v\n", err)
        os.Exit(1)
    }
    fmt.Println("✅ 下载完成:", *outPath)
}

// 从 m3u8 URL 下载、解密、合并所有 ts 分片
func runFromURL(playlistURL, outPath string) error {
    client := &http.Client{
        Timeout: 30 * time.Second,
    }

    fmt.Println("📥 正在下载 m3u8:", playlistURL)
    resp, err := client.Get(playlistURL)
    if err != nil {
        return fmt.Errorf("获取 m3u8 失败: %w", err)
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("获取 m3u8 HTTP 状态码: %d", resp.StatusCode)
    }

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return fmt.Errorf("读取 m3u8 内容失败: %w", err)
    }

    baseURL, err := url.Parse(playlistURL)
    if err != nil {
        return fmt.Errorf("解析 m3u8 URL 失败: %w", err)
    }

    var (
        keyInfo     *KeyInfo
        segmentURLs []string
    )

    // 解析 m3u8
    scanner := bufio.NewScanner(strings.NewReader(string(body)))
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if line == "" {
            continue
        }
        if strings.HasPrefix(line, "#EXT-X-KEY:") {
            ki, err := parseKeyLine(line)
            if err != nil {
                return fmt.Errorf("解析 #EXT-X-KEY 失败: %w", err)
            }
            keyInfo = ki
        } else if strings.HasPrefix(line, "#") {
            // 其他标签忽略
            continue
        } else {
            // 分片 URL(相对或绝对)
            u, err := resolveURL(baseURL, line)
            if err != nil {
                return fmt.Errorf("解析分片 URL 失败 (%s): %w", line, err)
            }
            segmentURLs = append(segmentURLs, u)
        }
    }
    if err := scanner.Err(); err != nil {
        return fmt.Errorf("扫描 m3u8 失败: %w", err)
    }

    if len(segmentURLs) == 0 {
        return fmt.Errorf("没有解析到任何 ts 分片 URL")
    }

    if keyInfo == nil {
        return fmt.Errorf("未找到 #EXT-X-KEY,当前脚本只处理 AES-128 加密的情况")
    }
    if !strings.EqualFold(keyInfo.Method, "AES-128") {
        return fmt.Errorf("暂不支持加密方法: %s", keyInfo.Method)
    }

    // 计算 key 的绝对 URL
    keyURL, err := resolveURL(baseURL, keyInfo.URI)
    if err != nil {
        return fmt.Errorf("解析 key URI 失败: %w", err)
    }

    fmt.Println("🔑 正在下载密钥:", keyURL)
    key, err := downloadKey(client, keyURL)
    if err != nil {
        return fmt.Errorf("下载密钥失败: %w", err)
    }
    if len(key) != 16 {
        return fmt.Errorf("密钥长度不是 16 字节 (AES-128),实际: %d", len(key))
    }

    // 创建输出文件(这里直接叫 .mp4,实际内容是 TS 流,大部分播放器能正常识别播放)
    outFile, err := os.Create(outPath)
    if err != nil {
        return fmt.Errorf("创建输出文件失败: %w", err)
    }
    defer outFile.Close()

    total := len(segmentURLs)
    fmt.Printf("开始下载分片,共 %d 个...\n", total)

    for i, segURL := range segmentURLs {
        fmt.Printf("[%4d/%4d] %s\n", i+1, total, segURL)
        if err := downloadAndDecryptSegment(client, segURL, key, keyInfo.IV, outFile); err != nil {
            return fmt.Errorf("处理分片失败 (%s): %w", segURL, err)
        }
    }

    return nil
}

// 解析 KEY 行
func parseKeyLine(line string) (*KeyInfo, error) {
    // 形如:
    // #EXT-X-KEY:METHOD=AES-128,URI="https://xxx/crypt.key",IV=0x596df4...
    idx := strings.Index(line, ":")
    if idx < 0 {
        return nil, fmt.Errorf("无效的 KEY 行: %s", line)
    }
    attrs := line[idx+1:]

    parts := strings.Split(attrs, ",")

    ki := &KeyInfo{}

    for _, p := range parts {
        p = strings.TrimSpace(p)
        if p == "" {
            continue
        }
        kv := strings.SplitN(p, "=", 2)
        if len(kv) != 2 {
            continue
        }
        k := strings.ToUpper(strings.TrimSpace(kv[0]))
        v := strings.TrimSpace(kv[1])
        switch k {
        case "METHOD":
            ki.Method = v
        case "URI":
            ki.URI = trimQuotes(v)
        case "IV":
            ivBytes, err := parseIV(v)
            if err != nil {
                return nil, fmt.Errorf("解析 IV 失败: %w", err)
            }
            ki.IV = ivBytes
        }
    }

    if ki.Method == "" || ki.URI == "" || len(ki.IV) == 0 {
        return nil, fmt.Errorf("KEY 信息不完整: %+v", ki)
    }
    return ki, nil
}

// 相对 URL → 绝对 URL
func resolveURL(base *url.URL, ref string) (string, error) {
    ref = strings.TrimSpace(ref)
    u, err := url.Parse(ref)
    if err != nil {
        return "", err
    }
    if u.IsAbs() {
        return u.String(), nil
    }
    return base.ResolveReference(u).String(), nil
}

func trimQuotes(s string) string {
    s = strings.TrimSpace(s)
    if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) {
        return s[1 : len(s)-1]
    }
    return s
}

func parseIV(s string) ([]byte, error) {
    s = strings.TrimSpace(s)
    // 形如 0x596df46c...
    if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
        s = s[2:]
    }
    if len(s)%2 != 0 {
        s = "0" + s
    }
    b, err := hex.DecodeString(s)
    if err != nil {
        return nil, err
    }
    if len(b) != aes.BlockSize {
        return nil, fmt.Errorf("IV 长度不是 %d 字节: %d", aes.BlockSize, len(b))
    }
    return b, nil
}

func downloadKey(client *http.Client, uri string) ([]byte, error) {
    resp, err := client.Get(uri)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("HTTP 状态码: %d", resp.StatusCode)
    }
    return io.ReadAll(resp.Body)
}

// 下载并解密单个 ts 分片,追加写入 out
func downloadAndDecryptSegment(client *http.Client, segURL string, key, iv []byte, out io.Writer) error {
    resp, err := client.Get(segURL)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("HTTP 状态码: %d", resp.StatusCode)
    }

    cipherData, err := io.ReadAll(resp.Body)
    if err != nil {
        return err
    }

    if len(cipherData)%aes.BlockSize != 0 {
        return fmt.Errorf("分片长度不是 AES 块大小的整数倍: %d", len(cipherData))
    }

    block, err := aes.NewCipher(key)
    if err != nil {
        return err
    }

    // 每个分片都使用同一个 IV(来自 KEY 行)
    ivCopy := make([]byte, len(iv))
    copy(ivCopy, iv)

    mode := cipher.NewCBCDecrypter(block, ivCopy)
    plain := make([]byte, len(cipherData))
    mode.CryptBlocks(plain, cipherData)

    // HLS AES-128 通常 TS 数据本身就是 16 字节对齐,不再做额外 padding,直接写出
    _, err = out.Write(plain)
    return err
}
相关推荐
zslefour2 小时前
使用ffmpeg遍历获取音频文件相关信息
ffmpeg
AI视频工具指南2 小时前
AI视频的声音和画面怎么配合?先分清谁在传递信息
人工智能·aigc·音视频
小杨互联网3 小时前
Go 现代 CLI 工具离线镜像合集
开发语言·后端·golang·开源·go·命令行
辰辰观影5 小时前
无广告播放器推荐来啦
音视频
TrueDei5 小时前
GStreamer 使用 valve 分别控制音视频流的暂停与恢复
音视频
Android系统攻城狮5 小时前
Linux FFmpeg深度解析之avcodec_parameters_from_context调用流程与实战(十六)
linux·运维·ffmpeg·音视频进阶·ffmpeg音视频·车载音视频进阶
一条破秋裤6 小时前
基于 Hi3516CV610 的立定跳远视频测量系统调研与实施方案
arm开发·音视频
Android系统攻城狮6 小时前
Linux FFmpeg深度解析之avcodec_alloc_context3调用流程与实战(十四)
linux·运维·ffmpeg·车载音视频·音视频进阶·ffmpeg音视频进阶
2601_966827456 小时前
视频里的特效字幕怎么翻译?外挂字幕与AI字幕一键处理教程
音视频
microrain6 小时前
跟着一条属性报文走一遍 SagooIoT 的接入链路
物联网·架构·golang·sagooiot