一、先认识一下 HLS 和 m3u8
1.1 HLS 基本概念
HLS 的核心思想很简单:
- 把视频流切成很多个小片段(segment),通常是
.ts文件 - 用一个文本文件(
.m3u8)列出这些分片的 URL 和相关信息 - 播放器顺序下载这些分片,拼在一起播放
典型的 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 字节的 keyIV=0x...:初始化向量(IV),AES CBC 模式会用到
只要我们能拿到:
crypt.key(16 字节)- IV(16 字节)
- 每个 TS 分片的密文数据
就可以按顺序解密并写入同一个文件中,实现"离线视频"。
二、工具整体设计思路
我们这个 Go 工具的目标:给一个 m3u8 URL,就输出一个本地 mp4 文件。
整体流程可以概括为:
-
解析命令行参数:m3u8 URL、输出文件名
-
下载 m3u8 文件内容
-
解析 m3u8:
- 找到
#EXT-X-KEY行,解析出加密信息(Method、URI、IV) - 收集所有 TS 分片 URL(支持相对路径 → 绝对路径)
- 找到
-
下载密钥(
.key文件),确保长度 16 字节(AES-128) -
创建输出文件
-
遍历每个分片:
- 下载分片数据(cipherData)
- 基于 key + IV 使用 AES-128 CBC 解密
- 将解密后的明文追加写入输出文件
-
打印进度,结束。
对应到代码里,大致由这些部分组成:
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-128URI:密钥文件地址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
}
解析步骤:
- 先找到冒号,把前面的
#EXT-X-KEY去掉 - 按逗号拆成多个
key=value对 - 支持
METHOD/URI/IV三个字段 - URI 可能带引号,用
trimQuotes去掉 - IV 交给
parseIV解析成字节数组 - 最后检查三个字段是否完整,否则认为 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 分片 - 用一个
[]byteslice 数组暂存各分片解密后的内容 - 最后按顺序写入输出文件
或者更省内存一点:
- 每个 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
}