使用 Go 编写一个 M3U8 下载工具
一年多没有写博文了,之前太忙,也不知道写什么内容。前几天突然萌生一个想法:用 Go 编写一个 M3U8 下载器。由于工作上很少用 Go,好多知识都忘了,这次想重新复习回来,就当做是再次入门 Go 的练习吧。
1、M3U8 介绍
M3U8 视频在视频网中很常见,很多视频小站采用这种视频播放格式。什么是 M3U8 呢?其实在编写 工具 之前,我自己也不太懂,毕竟没有做过音视频处理相关的业务。我偶尔上一些小站看电影、电视剧,有时习惯性地打开浏览器开发者工具查看网络请求,观察播放器请求的视频链接,可以看到很多网站使用的视频链接都带有 .m3u8
这个关键后缀,但直接把链接下载下来就会发现内容只有几十几百 B 大小。直接打开 M3U8 文件可以看到类似以下内容:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:8
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:5.004,
/20190319/DnYZi3eA/800kb/hls/imaOxa8299000.ts
#EXTINF:4.17,
/20190319/DnYZi3eA/800kb/hls/imaOxa8299001.ts
#EXTINF:6.005,
/20190319/DnYZi3eA/800kb/hls/imaOxa8299002.ts
....
这些内容代表什么含义呢?我学习了一下:
M3U8 —— Unicode 版本的 M3U(Moving Picture Experts Group Audio Layer 3 Uniform Resource Locator),使用了 UTF-8 编码,是 HLS(HTTP Living Stream,苹果公司基于 HTTP 实现的媒体流传输协议)协议的一部分,作为媒体文件描述清单,另外一部分为 TS(Transport Stream,传输流) 媒体文件。
M3U8 文件使用特定标签描述了媒体流的详细信息,包括时长、版本、编码、音频、字幕、播放列表、加密等。M3U8 媒体播放列表中保存了 TS 媒体文件的路径列表:
...
#EXTINF:5.004,
/20190319/DnYZi3eA/800kb/hls/imaOxa8299000.ts
#EXTINF:4.17,
/20190319/DnYZi3eA/800kb/hls/imaOxa8299001.ts
#EXTINF:6.005,
/20190319/DnYZi3eA/800kb/hls/imaOxa8299002.ts
...
播放器按照播放列表的索引顺序请求 TS 文件,就能获取到完整的视频片段,如果你在网上看电影时,打开浏览器开发者工具查看网络请求,有时可能会发现有连续的 .ts
文件请求。
视频在经过切片后得到多个 TS 文件和索引 TS 的 M3U8 文件,TS 文件的体积相比整个媒体文件小得多,每个 TS 文件都可独立解码,避免由于部分数据损坏而造成整个媒体文件无法播放。可使用 FFmpeg 对视频进行切片。
好了,以上是我经过搜索大概了解的内容,更高深的概念我也讲不出来了,大家可以自行了解,接下来聊聊怎么使用 Go 编写下载器。
2、解析 M3U8
要解析 M3U8 文件内容,我们需要了解 M3U8 标签的含义,我介绍一下本工具涉及到的相关标签。
2.1、#EXTM3U
标准的 M3U8 文件在第一行都会是 #EXTM3U
,可以用这个特征来检验 M3U8 文件的合法性。
2.2、#EXT-X-STREAM-INF
该标签定义了多不同码率的播放源,供用户选择不同的码率播放。我们在播放视频时选择不同的视频分辨率就是来源这个配置。
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=240000,RESOLUTION=416x234,CODECS="avc1.42e00a,mp4a.40.2"
https://video.com/hd/index1.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=2,BANDWIDTH=1280000,RESOLUTION=1920x1080,CODECS="avc1.42e00a,mp4a.40.2"
https://video.com/hd/index2.m3u8
当 M3U8 存在 EXT-X-STREAM-INF
标签时,说明它还不是最终的媒体播放清单文件,即称之为 Master playlist。我们需要再次从多码率列表中选择一个源来请求媒体播放列表(Media playlist),这个类型的 M3U8 才会有具体的 TS 文件索引信息。
他们的关系:
Master Playlist -> Media Playlist -> Segment
2.3、#EXT-X-KEY
媒体文件加密方式和解密秘钥信息。
#EXT-X-KEY:METHOD=AES-128,URI="key.key"
#EXT-X-KEY:METHOD=AES-128,URI="faxs://faxs.adobe.com",IV=0X99b74007b6254e4bd1c6e03631cad15b
有些 TS 文件是经过加密处理的,下载下来无法直接播放,需要对 TS 数据进行解密, METHOD
为加密方式,一般为 AES-128
或者 NONE
。如果为 AES-128
则有 URI
给定秘钥的存放位置,部分加密还是用了 IV
偏移向量,因此在解密的时候需要格外注意,记得一起使用 IV
来进行解密。如果 METHOD
为 NONE
则表示没有加密,默认可以不声明 #EXT-X-KEY
, NONE
的情况下不能出现 URI
和 IV
。
一个 M3U8 媒体播放列表中可以有多个 #EXT-X-KEY
定义,每个 segment 使用的解密 key 为在它之前定义的 key。
2.4、Segment 片段
#EXT-X-KEY:METHOD=AES-128,URI="key.key"
#EXTINF:5.004,
/20190319/DnYZi3eA/800kb/hls/imaOxa8299000.ts
#EXTINF:4.17,
/20190319/DnYZi3eA/800kb/hls/imaOxa8299001.ts
#EXT-X-KEY:METHOD=AES-128,URI="faxs://faxs.adobe.com",IV=0X99b74007b6254e4bd1c6e03631cad15b
#EXTINF:6.005,
/20190319/DnYZi3eA/800kb/hls/imaOxa8299002.ts
以上内容包含有 3 个 segment, #EXTINF
后面为该片段的时长,格式如下:
#EXTINF:duration,<title>
title
为标题,可选。在 #EXTINF
下面有一个 URI 定义,为 TS 文件的路径,这才是真正的媒体文件。
仔细观察,上面定义了两个 #EXT-X-KEY
,就像之前所说的一样, #EXT-X-KEY
可以定义多个, #EXT-X-KEY
之后的所有 segment 都是用该 key 解密,直到遇到新的 #EXT-X-KEY
。因此不是所有的 TS 都用同一个秘钥进行解密。
2.5、编码解析
实际上 M3U8 有很多标签,我只是列举了程序中用到的部分。M3U8 的多个标签组合起来能定义不同的播放列表类型,目前程序仅支持 VOD 点播类的视频。
下面为 M3U8 数据结构定义结构体:
const (
CryptMethodAES CryptMethod = "AES-128"
CryptMethodNONE CryptMethod = "NONE"
)
var lineParameterPattern = regexp.MustCompile(`([a-zA-Z-]+)=("[^"]+"|[^",]+)`)
type CryptMethod string
type M3u8 struct {
Segments []*Segment
MasterPlaylistURIs []string
}
type Segment struct {
URI string
Key *Key
}
type Key struct {
URI string
IV string
key string
Method CryptMethod
}
type Result struct {
URL *url.URL
M3u8 *M3u8
Keys map[*Key]string
}
写一个通用(当然达不到…)的 M3U8 内容解析函数,传入的参数为内容按行分割后的得到的切片:
func parseLines(lines []string) (*M3u8, error) {
var (
i = 0
lineLen = len(lines)
m3u8 = &M3u8{}
key *Key
seg *Segment
)
for ; i < lineLen; i++ {
line := strings.TrimSpace(lines[i])
if i == 0 {
if "#EXTM3U" != line {
return nil, fmt.Errorf("invalid m3u8, missing #EXTM3U in line 1")
}
continue
}
switch {
case line == "":
continue
// Master playlist 解析
case strings.HasPrefix(line, "#EXT-X-STREAM-INF:"):
i++
m3u8.MasterPlaylistURIs = append(m3u8.MasterPlaylistURIs, lines[i])
continue
// TS URI 解析
case !strings.HasPrefix(line, "#"):
seg = new(Segment)
seg.URI = line
m3u8.Segments = append(m3u8.Segments, seg)
seg.Key = key
continue
// 解密秘钥解析
case strings.HasPrefix(line, "#EXT-X-KEY"):
params := parseLineParameters(line)
if len(params) == 0 {
return nil, fmt.Errorf("invalid EXT-X-KEY: %s, line: %d", line, i+1)
}
key = new(Key)
method := CryptMethod(params["METHOD"])
if method != "" && method != CryptMethodAES && method != CryptMethodNONE {
return nil, fmt.Errorf("invalid EXT-X-KEY method: %s, line: %d", method, i+1)
}
key.Method = method
key.URI = params["URI"]
key.IV = params["IV"]
default:
continue
}
}
return m3u8, nil
}
// parseLineParameters 把 key:value,key=value 形式的字符串转成 map
func parseLineParameters(line string) map[string]string {
r := lineParameterPattern.FindAllStringSubmatch(line, -1)
params := make(map[string]string)
for _, arr := range r {
params[arr[1]] = strings.Trim(arr[2], "\"")
}
return params
}
以上解析逻辑基本满足这个小工具了,实际上 M3U8 的标签很多,要想完全解析,得需要对 M3U8 非常了解。
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic:", r)
os.Exit(-1)
}
}()
m3u8URL := "http://devimages.apple.com/iphone/samples/bipbop/bipbopall.m3u8"
u, err := url.Parse(m3u8URL)
if err != nil {
panic(err)
}
m3u8URL = u.String()
body, err := tool.Get(m3u8URL)
if err != nil {
panic(err)
}
//noinspection GoUnhandledErrorResult
defer body.Close()
s := bufio.NewScanner(body)
var lines []string
for s.Scan() {
lines = append(lines, s.Text())
}
m3u8, err := parseLines(lines)
if err != nil {
panic(err)
}
jsonBytes, err := json.MarshalIndent(m3u8, "", "\t")
if err != nil {
panic(err)
}
fmt.Println(string(jsonBytes))
}
以下是三种类型 M3U8 内容解析后的结果,由于影视资源存在版权关系,对应的 URL 我就不贴出来了。
没有加密且为 Media Playlist 解析输出的结果(省略了部分重复内容):
{
"Segments": [
{
"URI": "/20190319/DnYZi3eA/800kb/hls/imaOxa8299000.ts",
"Key": null
},
{
"URI": "/20190319/DnYZi3eA/800kb/hls/imaOxa8299001.ts",
"Key": null
}
],
"MasterPlaylistURIs": null
}
Master Playlist 类型的解析结果:
{
"Segments": null,
"MasterPlaylistURIs": [
"1000k/hls/index.m3u8"
]
}
Master playlist 的 URI 可能有多个,我们只挑选第一个来再次请求:
// BaseURL + m3u8.MasterPlaylistURIs[0]
BaseURL + "1000k/hls/index.m3u8"
加密的 M3U8 解析之后:
{
"Segments": [
{
"URI": "89ec30e2be4300a614b371ffa8821c50-000.ts",
"Key": {
"URI": "https://xx.xxxxx.com/drm?Action=GetExplicitKey\u0026VideoId=20114714\u0026App=vms-m3u8-v1.0",
"IV": "0x8956858436434929e266210657d68e69",
"Method": "AES-128"
}
}
]
}
为了方便下载模块直接调用数据下载,我们还需要包装一下解析结果,直接把 Media Playlist 和解密 key 返回。
type Result struct {
URL *url.URL
M3u8 *M3u8
Keys map[*Key]string
}
func fromURL(link string) (*Result, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
link = u.String()
body, err := tool.Get(link)
if err != nil {
return nil, fmt.Errorf("request m3u8 URL failed: %s", err.Error())
}
//noinspection GoUnhandledErrorResult
defer body.Close()
s := bufio.NewScanner(body)
var lines []string
for s.Scan() {
lines = append(lines, s.Text())
}
m3u8, err := parseLines(lines)
if err != nil {
return nil, err
}
// 若为 Master playlist,则再次请求获取 Media playlist
if m3u8.MasterPlaylistURIs != nil {
return fromURL(tool.ResolveURL(u, m3u8.MasterPlaylistURIs[0]))
}
if len(m3u8.Segments) == 0 {
return nil, errors.New("no segments")
}
result := &Result{
URL: u,
M3u8: m3u8,
Keys: make(map[*Key]string),
}
// 请求解密秘钥
for _, seg := range m3u8.Segments {
switch {
case seg.Key == nil || seg.Key.Method == "" || seg.Key.Method == CryptMethodNONE:
continue
case seg.Key.Method == CryptMethodAES:
// 如果已经请求过了,就不再请求
if _, ok := result.Keys[seg.Key]; ok {
continue
}
keyURL := seg.Key.URI
keyURL = tool.ResolveURL(u, keyURL)
resp, err := tool.Get(keyURL)
if err != nil {
return nil, fmt.Errorf("extract key failed: %s", err.Error())
}
keyByte, err := ioutil.ReadAll(resp)
_ = resp.Close()
if err != nil {
return nil, err
}
fmt.Println("decryption key: ", string(keyByte))
result.Keys[seg.Key] = string(keyByte)
default:
return nil, fmt.Errorf("unknown or unsupported cryption method: %s", seg.Key.Method)
}
}
return result, nil
}
解密方式使用了 AES-128 CBC,相关 tool
包的工具函数后面给出。
3、下载 TS 文件
解析之后,我们得到了 TS 列表,可以用 HTTP 将 TS 逐个下载到本地,为了加快下载速度,充分利用 Go 的优势,我们可以开启多个协程并发下载 TS。
需要注意的是。有些 TS URI 采用相对路径,有些则是 URL,在下载前需要对 TS 的 URI 进行拼接处理,这里使用了 tool.ResolveURL
函数进行处理。
因为 TS 分段文件很多,可能会有上千个,因此我们需要控制一下协程的生成数量,协程的数量当然不是越多越好,使用有一定缓冲数限制的 chan
来控制协程的生成速度。
为了等待所有协程执行完毕再合并 TS 文件,我们使用了 sync.WaitGroup
。
func main() {
// ....
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic:", r)
}
}()
m3u8URL := "http://devimages.apple.com/iphone/samples/bipbop/bipbopall.m3u8"
result, err := fromURL(m3u8URL)
if err != nil {
panic(err)
}
storeFolder := "/Users/oopsguy/m3u8_down/s"
if err := os.MkdirAll(storeFolder, 0777); err != nil {
panic(err)
}
var wg sync.WaitGroup
// 防止协程启动过多,限制频率
limitChan := make(chan byte, 20)
// 开启协程请求
for idx, seg := range result.M3u8.Segments {
wg.Add(1)
go func(i int, s *Segment) {
defer func() {
wg.Done()
<-limitChan
}()
// 以需要命名文件
fullURL := tool.ResolveURL(result.URL, s.URI)
body, err := tool.Get(fullURL)
if err != nil {
fmt.Printf("Download failed [%s] %s\n", err.Error(), fullURL)
return
}
defer body.Close()
// 创建存在 TS 数据的文件
tsFile := filepath.Join(storeFolder, strconv.Itoa(i)+".ts")
tsFileTmpPath := tsFile + "_tmp"
tsFileTmp, err := os.Create(tsFileTmpPath)
if err != nil {
fmt.Printf("Create TS file failed: %s\n", err.Error())
return
}
//noinspection GoUnhandledErrorResult
defer tsFileTmp.Close()
bytes, err := ioutil.ReadAll(body)
if err != nil {
fmt.Printf("Read TS file failed: %s\n", err.Error())
return
}
// 解密 TS 数据
if s.Key != nil {
key := result.Keys[s.Key]
if key != "" {
bytes, err = tool.AES128Decrypt(bytes, []byte(key), []byte(s.Key.IV))
if err != nil {
fmt.Printf("decryt TS failed: %s\n", err.Error())
}
}
}
if _, err := tsFileTmp.Write(bytes); err != nil {
fmt.Printf("Save TS file failed:%s\n", err.Error())
return
}
_ = tsFileTmp.Close()
// 重命名为正式文件
if err = os.Rename(tsFileTmpPath, tsFile); err != nil {
fmt.Printf("Rename TS file failed: %s\n", err.Error())
return
}
fmt.Printf("下载成功:%s\n", fullURL)
}(idx, seg)
limitChan <- 1
}
wg.Wait()
// ....
}
代码片段中使用 tool.AES128Decrypt
对 TS 的字节切片进行解密处理,后面给出该函数的代码。
4、合并 TS 文件
4.1、合并
所有 TS 文件都下载下来了,合并还不简单?来个 for 循环将每个 TS 文件的二进制数据统统写到同一个文件中,so easy 😄。
func main() {
// ...
// 按 ts 文件名顺序合并文件
// 由于是从 0 开始计算,只需要递增到 len(result.M3u8.Segments)-1 即可
mainFile, err := os.Create(filepath.Join(storeFolder, "main.ts"))
if err != nil {
panic(err)
}
//noinspection GoUnhandledErrorResult
defer mainFile.Close()
for i := 0; i < len(result.M3u8.Segments); i++ {
bytes, err := ioutil.ReadFile(filepath.Join(storeFolder, strconv.Itoa(i)+".ts"))
if err != nil {
fmt.Println(err.Error())
continue
}
if _, err := mainFile.Write(bytes); err != nil {
fmt.Println(err.Error())
continue
}
}
_ = mainFile.Sync()
//...
}
如果 TS 片段文件比较多,可以使用协程来分批合并,最后把小合并的文件合成一个文件,这样效率会更快。合并完成之后,可以把 TS 片段文件删除,释放磁盘空间,鉴于篇幅这里就不说了。
合并完成,执行测试,下载后的视频确实合并成一个文件,并且可以播放。
4.2、加密 TS 的陷阱
很好,可以播放了。
但我尝试下载了几个 M3U8 后,就高兴不起来了,因为有些 TS 合并之后,播放不了,有些可以播放,但是画质很差,偶尔还会出现万恶的马赛克 🐎。我排查了很久,首先是怀疑自己写入文件的方式有误,因此我换了多种写入文件的写法,甚至使用了网上所说的命令行命令方式合并( cat
或 copy
),但问题依然存在;然后我又对解密方式怀疑,找了多份 AES 算法源码交替测试,问题依旧存在!开始怀疑人生,百思不得其解。
我写了个单元测试,将合并流程拆分,只合并几个连续的文件,有些可以从编号 10 合并到最后一个文件,但 1-9 合并之后就有问题,播放不了,仔细想想,这些文件都是经过加密后的 TS,解密之后独立的 TS 片段是可以播放的,但合并起来就有问题,我开始怀疑是 TS 文件数据的问题。
我下载了 Hex Fiend 十六进制查看工具对 TS 文件进行分析,碰碰运气兴许能发现什么规律。
还是看不懂这些 乱码 ,随后我搜索了 TS 文件格式解析相关的内容,了解到每个 TS 流分成多个包,每个包都是等长,而每个包开头都以一个 同步字节 (SyncByte)开始,即一个十六进制魔术值: 0x47
用十六进制查看软件观察了几个 TS 文件,他们的数据都没有以 47
开头,而是一些看似 无用 的数据,看起来像是 FFmpeg 的描述信息,我尝试找到最近的一个 47
,然后把 47
之前的所数据都删除,保存再次打开,视频还可以播放,我再尝试打开一个未经加密的 TS 片段,发现它是直接以 47
开头,此时我的心终于放下了。到此,我坚信肯定是这部分的数据影响了 TS 合并,我需要对这部分的数据进行删除。
删除操作很简单,程序中已经得到了 TS 文件的数据,我们仅需要对字节切片进行遍历,直到找到等于十六进制数 47
对应十进制值 71
这个值即可,然后删除该数前面的数据。
0x47 转为十进制: 4*16+7 = 71
//...
syncByte := uint8(71) // 0x47 的十进制
bLen := len(bytes)
for j := 0; j < bLen; j++ {
if bytes[j] == syncByte {
bytes = bytes[j:]
break
}
}
if _, err := tsFileTmp.Write(bytes); err != nil {
fmt.Printf("Save TS file failed:%s\n", err.Error())
return
}
//...
经过数据处理,合并之后的 TS 文件可以正常打开,播放的时候也没有偶尔出现了马赛克了 ☕️。
了解 M3U8 的朋友可能知道这个 陷阱 ,我作为一个门外汉,不知道前面这段数据代表啥意思,看内容,像是 FFmpeg 的描述信息,我只能把它当做坑了……。
5、其他合并方式
当然,如果你觉得这种程序合并方式并不好,你可以使用网上所说的命令行方式或者靠谱的 FFmpeg 。
5.1、命令行
- Linux & MacOS
cat 1.ts 2.ts > out.ts
- Windows CMD
copy /b E:\ts\*.ts E:\ts\out.ts
使用命令行合并方式,TS 文件需要按有规律的需要命名,如:1.ts、2.ts、3.ts…
我没在 Windows 上试过,在 MacOS 尝试合并之前合并失败的 TS,解决不了那个同步位问题,当然在程序中在下载 TS 时已经对字节进行偏移处理,因此理论上命令行应该是能合并成功的。
我自己也没对比过命令行合并和程序合并的速度到底哪个快,你也可以在程序中使用 cmd.Execute
调用命令行命令执行合并操作。
5.2、 FFmpeg
ffmpeg -i "http://m3u8url.com/index.m3u8" -c copy ./out.ts
这条命令直接帮你解析 M3U8 并下载整个 TS 下来,合并工作对 FFmpeg 简直是小儿科,毕竟这些切片都是使用 FFmpeg 切出来的。
6、示例源码
为了方便分析和运行,我把源码精简了,本文的源码为精简过的可运行源码,完整效果的源码放在 Github 上。
package main
import (
"bufio"
"errors"
"fmt"
"github.com/oopsguy/m3u8/tool"
"io/ioutil"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
)
const (
CryptMethodAES CryptMethod = "AES-128"
CryptMethodNONE CryptMethod = "NONE"
)
var lineParameterPattern = regexp.MustCompile(`([a-zA-Z-]+)=("[^"]+"|[^",]+)`)
type CryptMethod string
type M3u8 struct {
Segments []*Segment
MasterPlaylistURIs []string
}
type Segment struct {
URI string
Key *Key
}
type Key struct {
URI string
IV string
key string
Method CryptMethod
}
type Result struct {
URL *url.URL
M3u8 *M3u8
Keys map[*Key]string
}
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic:", r)
}
}()
m3u8URL := "http://devimages.apple.com/iphone/samples/bipbop/bipbopall.m3u8"
result, err := fromURL(m3u8URL)
if err != nil {
panic(err)
}
storeFolder := "/Users/oopsguy/m3u8_down/s"
if err := os.MkdirAll(storeFolder, 0777); err != nil {
panic(err)
}
var wg sync.WaitGroup
// 防止协程启动过多,限制频率
limitChan := make(chan byte, 20)
// 开启协程请求
for idx, seg := range result.M3u8.Segments {
wg.Add(1)
go func(i int, s *Segment) {
defer func() {
wg.Done()
<-limitChan
}()
// 以需要命名文件
fullURL := tool.ResolveURL(result.URL, s.URI)
body, err := tool.Get(fullURL)
if err != nil {
fmt.Printf("Download failed [%s] %s\n", err.Error(), fullURL)
return
}
defer body.Close()
// 创建存在 TS 数据的文件
tsFile := filepath.Join(storeFolder, strconv.Itoa(i)+".ts")
tsFileTmpPath := tsFile + "_tmp"
tsFileTmp, err := os.Create(tsFileTmpPath)
if err != nil {
fmt.Printf("Create TS file failed: %s\n", err.Error())
return
}
//noinspection GoUnhandledErrorResult
defer tsFileTmp.Close()
bytes, err := ioutil.ReadAll(body)
if err != nil {
fmt.Printf("Read TS file failed: %s\n", err.Error())
return
}
// 解密 TS 数据
if s.Key != nil {
key := result.Keys[s.Key]
if key != "" {
bytes, err = tool.AES128Decrypt(bytes, []byte(key), []byte(s.Key.IV))
if err != nil {
fmt.Printf("decryt TS failed: %s\n", err.Error())
}
}
}
syncByte := uint8(71) //0x47
bLen := len(bytes)
for j := 0; j < bLen; j++ {
if bytes[j] == syncByte {
bytes = bytes[j:]
break
}
}
if _, err := tsFileTmp.Write(bytes); err != nil {
fmt.Printf("Save TS file failed:%s\n", err.Error())
return
}
_ = tsFileTmp.Close()
// 重命名为正式文件
if err = os.Rename(tsFileTmpPath, tsFile); err != nil {
fmt.Printf("Rename TS file failed: %s\n", err.Error())
return
}
fmt.Printf("下载成功:%s\n", fullURL)
}(idx, seg)
limitChan <- 1
}
wg.Wait()
// 按 ts 文件名顺序合并文件
// 由于是从 0 开始计算,只需要递增到 len(result.M3u8.Segments)-1 即可
mainFile, err := os.Create(filepath.Join(storeFolder, "main.ts"))
if err != nil {
panic(err)
}
//noinspection GoUnhandledErrorResult
defer mainFile.Close()
for i := 0; i < len(result.M3u8.Segments); i++ {
bytes, err := ioutil.ReadFile(filepath.Join(storeFolder, strconv.Itoa(i)+".ts"))
if err != nil {
fmt.Println(err.Error())
continue
}
if _, err := mainFile.Write(bytes); err != nil {
fmt.Println(err.Error())
continue
}
}
_ = mainFile.Sync()
fmt.Println("下载完成")
}
func fromURL(link string) (*Result, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
link = u.String()
body, err := tool.Get(link)
if err != nil {
return nil, fmt.Errorf("request m3u8 URL failed: %s", err.Error())
}
//noinspection GoUnhandledErrorResult
defer body.Close()
s := bufio.NewScanner(body)
var lines []string
for s.Scan() {
lines = append(lines, s.Text())
}
m3u8, err := parseLines(lines)
if err != nil {
return nil, err
}
// 若为 Master playlist,则再次请求获取 Media playlist
if m3u8.MasterPlaylistURIs != nil {
return fromURL(tool.ResolveURL(u, m3u8.MasterPlaylistURIs[0]))
}
if len(m3u8.Segments) == 0 {
return nil, errors.New("can not found any segment")
}
result := &Result{
URL: u,
M3u8: m3u8,
Keys: make(map[*Key]string),
}
// 请求解密秘钥
for _, seg := range m3u8.Segments {
switch {
case seg.Key == nil || seg.Key.Method == "" || seg.Key.Method == CryptMethodNONE:
continue
case seg.Key.Method == CryptMethodAES:
// 如果已经请求过了,就不在请求
if _, ok := result.Keys[seg.Key]; ok {
continue
}
keyURL := seg.Key.URI
keyURL = tool.ResolveURL(u, keyURL)
resp, err := tool.Get(keyURL)
if err != nil {
return nil, fmt.Errorf("extract key failed: %s", err.Error())
}
keyByte, err := ioutil.ReadAll(resp)
_ = resp.Close()
if err != nil {
return nil, err
}
fmt.Println("decryption key: ", string(keyByte))
result.Keys[seg.Key] = string(keyByte)
default:
return nil, fmt.Errorf("unknown or unsupported cryption method: %s", seg.Key.Method)
}
}
return result, nil
}
func parseLines(lines []string) (*M3u8, error) {
var (
i = 0
lineLen = len(lines)
m3u8 = &M3u8{}
key *Key
seg *Segment
)
for ; i < lineLen; i++ {
line := strings.TrimSpace(lines[i])
if i == 0 {
if "#EXTM3U" != line {
return nil, fmt.Errorf("invalid m3u8, missing #EXTM3U in line 1")
}
continue
}
switch {
case line == "":
continue
case strings.HasPrefix(line, "#EXT-X-STREAM-INF:"):
i++
m3u8.MasterPlaylistURIs = append(m3u8.MasterPlaylistURIs, lines[i])
continue
case !strings.HasPrefix(line, "#"):
seg = new(Segment)
seg.URI = line
m3u8.Segments = append(m3u8.Segments, seg)
seg.Key = key
continue
case strings.HasPrefix(line, "#EXT-X-KEY"):
params := parseLineParameters(line)
if len(params) == 0 {
return nil, fmt.Errorf("invalid EXT-X-KEY: %s, line: %d", line, i+1)
}
key = new(Key)
method := CryptMethod(params["METHOD"])
if method != "" && method != CryptMethodAES && method != CryptMethodNONE {
return nil, fmt.Errorf("invalid EXT-X-KEY method: %s, line: %d", method, i+1)
}
key.Method = method
key.URI = params["URI"]
key.IV = params["IV"]
default:
continue
}
}
return m3u8, nil
}
func parseLineParameters(line string) map[string]string {
r := lineParameterPattern.FindAllStringSubmatch(line, -1)
params := make(map[string]string)
for _, arr := range r {
params[arr[1]] = strings.Trim(arr[2], "\"")
}
return params
}
package tool
import (
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"bytes"
"crypto/aes"
"crypto/cipher"
)
func Get(url string) (io.ReadCloser, error) {
c := http.Client{
Timeout: time.Duration(60) * time.Second,
}
resp, err := c.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("http error: status code %d", resp.StatusCode)
}
return resp.Body, nil
}
func ResolveURL(u *url.URL, p string) string {
if strings.HasPrefix(p, "https://") || strings.HasPrefix(p, "http://") {
return p
}
var baseURL string
if strings.Index(p, "/") == 0 {
baseURL = u.Scheme + "://" + u.Host
} else {
tU := u.String()
baseURL = tU[0:strings.LastIndex(tU, "/")]
}
return baseURL + path.Join("/", p)
}
func AES128Encrypt(origData, key, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
blockSize := block.BlockSize()
if len(iv) == 0 {
iv = key
}
origData = pkcs5Padding(origData, blockSize)
blockMode := cipher.NewCBCEncrypter(block, iv[:blockSize])
crypted := make([]byte, len(origData))
blockMode.CryptBlocks(crypted, origData)
return crypted, nil
}
func AES128Decrypt(crypted, key, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
blockSize := block.BlockSize()
if len(iv) == 0 {
iv = key
}
blockMode := cipher.NewCBCDecrypter(block, iv[:blockSize])
origData := make([]byte, len(crypted))
blockMode.CryptBlocks(origData, crypted)
origData = pkcs5UnPadding(origData)
return origData, nil
}
func pkcs5Padding(cipherText []byte, blockSize int) []byte {
padding := blockSize - len(cipherText)%blockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(cipherText, padText...)
}
func pkcs5UnPadding(origData []byte) []byte {
length := len(origData)
unPadding := int(origData[length-1])
return origData[:(length - unPadding)]
}
7、总结
到此,一个简陋但实用的迷你 M3U8 下载工具就算完成了,虽然比不上那些专业的处理软件,但用来下载常见的 M3U8 还是可行的。本人编写这个小工具也不是为了下载 M3U8,而是想通过分析过程、编写思路来达到学习 Go 的目的。
如果你想要更靠谱的 M3U8 处理工具,建议还是使用 FFmpeg 。
本文的代码仅仅是简单的示例,我根据这上述思路做了个比较完整的程序,完整源码链接在文末。
本人对 M3U8 和 Go 还不太熟悉,如果大家发现文中思路和代码逻辑有错误的地方,欢迎指出。