内容简介:一年多没有写博文了,之前太忙,也不知道写什么内容。前几天突然萌生一个想法:用 Go 编写一个 M3U8 下载器。由于工作上很少用 Go,好多知识都忘了,这次想重新复习回来,就当做是再次入门 Go 的练习吧。M3U8 视频在视频网中很常见,很多视频小站采用这种视频播放格式。什么是 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 :smile:。
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 合并之后,播放不了,有些可以播放,但是画质很差,偶尔还会出现万恶的马赛克 :racehorse:。我排查了很久,首先是怀疑自己写入文件的方式有误,因此我换了多种写入文件的写法,甚至使用了网上所说的命令行命令方式合并( 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 文件可以正常打开,播放的时候也没有偶尔出现了马赛克了 :coffee:️。
了解 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 还不太熟悉,如果大家发现文中思路和代码逻辑有错误的地方,欢迎指出。
以上所述就是小编给大家介绍的《使用 Go 编写一个 M3U8 下载工具》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- Motrix-开源下载工具
- qBittorrent - 优秀的种子下载工具
- Motrix:一款全能的下载工具
- 15大安全工具和下载黑客工具
- Linux下载工具——cURL使用入门
- cURL 7.56.1 发布,字符界面下载工具
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
RGB转16进制工具
RGB HEX 互转工具
图片转BASE64编码
在线图片转Base64编码工具