用纯 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 行解析加密信息
  • 若干工具函数:resolveURLparseIVdownloadKey
  • 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))
    }
  • 用和分片一样的 resolveURLkeyInfo.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
}
相关推荐
EasyCVR1 小时前
安防监控EasyCVR视频汇聚平台RTSP流播放异常的原因排查
音视频
DisonTangor2 小时前
Step-Audio-R1 首个成功实现测试时计算扩展的音频语言模型
人工智能·语言模型·开源·aigc·音视频
Zfox_2 小时前
【Go】异常处理、泛型和文件操作
开发语言·后端·golang
zhangyanfei012 小时前
谈谈 Golang 中的线程协程是如何管理栈内存的
开发语言·后端·golang
q***04632 小时前
[golang][MAC]Go环境搭建+VsCode配置
vscode·macos·golang
音视频牛哥3 小时前
从低延迟到高可用:RTMP与 HTTP/HTTPS-FLV在App播放体系中的角色重构
人工智能·音视频·音视频开发·http-flv播放器·https-flv播放器·ws-flv播放器·wss-flv播放器
Hommy883 小时前
如何利用剪映小助手实现视频批量剪辑?
aigc·音视频·批量剪辑·剪映
EasyGBS4 小时前
EasyGBS新版本(v3.7.168)发布!视频能力再度升级!
音视频
私人珍藏库4 小时前
[Android] 迅捷音频(2.9.00)
音视频