跳至主要內容

使用 Go 编写一个 M3U8 下载工具

OrangBus大约 18 分钟

一年多没有写博文了,之前太忙,也不知道写什么内容。前几天突然萌生一个想法:用 Goopen in new window 编写一个 M3U8 下载器。由于工作上很少用 Go,好多知识都忘了,这次想重新复习回来,就当做是再次入门 Go 的练习吧。

使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具

1、M3U8 介绍

M3U8 视频在视频网中很常见,很多视频小站采用这种视频播放格式。什么是 M3U8 呢?其实在编写 工具open in new window 之前,我自己也不太懂,毕竟没有做过音视频处理相关的业务。我偶尔上一些小站看电影、电视剧,有时习惯性地打开浏览器开发者工具查看网络请求,观察播放器请求的视频链接,可以看到很多网站使用的视频链接都带有 .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 文件都可独立解码,避免由于部分数据损坏而造成整个媒体文件无法播放。可使用 FFmpegopen in new window 对视频进行切片。

好了,以上是我经过搜索大概了解的内容,更高深的概念我也讲不出来了,大家可以自行了解,接下来聊聊怎么使用 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 来进行解密。如果 METHODNONE 则表示没有加密,默认可以不声明 #EXT-X-KEYNONE 的情况下不能出现 URIIV

一个 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 的字节切片进行解密处理,后面给出该函数的代码。

使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具

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 片段文件删除,释放磁盘空间,鉴于篇幅这里就不说了。

合并完成,执行测试,下载后的视频确实合并成一个文件,并且可以播放。

使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具

4.2、加密 TS 的陷阱

很好,可以播放了。

但我尝试下载了几个 M3U8 后,就高兴不起来了,因为有些 TS 合并之后,播放不了,有些可以播放,但是画质很差,偶尔还会出现万恶的马赛克 🐎。我排查了很久,首先是怀疑自己写入文件的方式有误,因此我换了多种写入文件的写法,甚至使用了网上所说的命令行命令方式合并( catcopy ),但问题依然存在;然后我又对解密方式怀疑,找了多份 AES 算法源码交替测试,问题依旧存在!开始怀疑人生,百思不得其解。

我写了个单元测试,将合并流程拆分,只合并几个连续的文件,有些可以从编号 10 合并到最后一个文件,但 1-9 合并之后就有问题,播放不了,仔细想想,这些文件都是经过加密后的 TS,解密之后独立的 TS 片段是可以播放的,但合并起来就有问题,我开始怀疑是 TS 文件数据的问题。

我下载了 Hex Fiendopen in new window 十六进制查看工具对 TS 文件进行分析,碰碰运气兴许能发现什么规律。

使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具

还是看不懂这些 乱码 ,随后我搜索了 TS 文件格式解析相关的内容,了解到每个 TS 流分成多个包,每个包都是等长,而每个包开头都以一个 同步字节 (SyncByte)开始,即一个十六进制魔术值: 0x47

使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具

用十六进制查看软件观察了几个 TS 文件,他们的数据都没有以 47 开头,而是一些看似 无用 的数据,看起来像是 FFmpeg 的描述信息,我尝试找到最近的一个 47 ,然后把 47 之前的所数据都删除,保存再次打开,视频还可以播放,我再尝试打开一个未经加密的 TS 片段,发现它是直接以 47 开头,此时我的心终于放下了。到此,我坚信肯定是这部分的数据影响了 TS 合并,我需要对这部分的数据进行删除。

使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具
使用 Go 编写一个 M3U8 下载工具

删除操作很简单,程序中已经得到了 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、其他合并方式

当然,如果你觉得这种程序合并方式并不好,你可以使用网上所说的命令行方式或者靠谱的 FFmpegopen in new window

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、 FFmpegopen in new window

ffmpeg -i "http://m3u8url.com/index.m3u8" -c copy ./out.ts

这条命令直接帮你解析 M3U8 并下载整个 TS 下来,合并工作对 FFmpeg 简直是小儿科,毕竟这些切片都是使用 FFmpeg 切出来的。

6、示例源码

为了方便分析和运行,我把源码精简了,本文的源码为精简过的可运行源码,完整效果的源码放在 Githubopen in new window 上。

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 处理工具,建议还是使用 FFmpegopen in new window

本文的代码仅仅是简单的示例,我根据这上述思路做了个比较完整的程序,完整源码链接在文末。

本人对 M3U8 和 Go 还不太熟悉,如果大家发现文中思路和代码逻辑有错误的地方,欢迎指出。