303 lines
10 KiB
Go
303 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/schollz/progressbar/v3"
|
||
)
|
||
|
||
type M3u8Res struct {
|
||
IsMaster bool
|
||
NextUrl string
|
||
TsFile []string
|
||
}
|
||
|
||
// 创建一个连接池
|
||
// http.Client 是 net/http 包里的核心结构,专门用来处理 HTTP 请求。它包含了一个底层的并发安全连接池
|
||
var httpClient = &http.Client{
|
||
Transport: &http.Transport{
|
||
MaxIdleConnsPerHost: 100, // 💡 修复1:调大连接池(设为100),避免高并发下被耗尽导致重新握手
|
||
},
|
||
}
|
||
|
||
// 获取输入的m3u8链接
|
||
func getM3u8Path() (path string) {
|
||
for {
|
||
// fmt.Println 是格式化包 fmt 里的输出函数,会自动在末尾加换行
|
||
fmt.Println("请输入m3u8链接地址:")
|
||
// fmt.Scanln 等待你的终端输入,并把读取到的一行数据存入到 path 变量的内存地址(&path)中
|
||
_, err := fmt.Scanln(&path)
|
||
if err != nil {
|
||
if err.Error() == "unexpected newline" {
|
||
fmt.Println("你啥也没输入")
|
||
continue
|
||
}
|
||
fmt.Println(err)
|
||
continue
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// Get链接
|
||
func fetchM3u8Content(path string) string {
|
||
// 发起一个真正的 HTTP GET 网络请求。
|
||
res, err := httpClient.Get(path)
|
||
if err != nil {
|
||
fmt.Println(err)
|
||
return ""
|
||
}
|
||
// defer 用于注册延迟调用,确保这个函数无论怎样返回,都会在最后一步关闭连接释放网络资源,防止内存漏洞
|
||
defer res.Body.Close()
|
||
if res.StatusCode != 200 {
|
||
fmt.Println("出了点问题", res.StatusCode)
|
||
return ""
|
||
}
|
||
// io.ReadAll 是标准库 io(输入输出) 里的工具。它可以把网络流数据 (res.Body) 一次性全部读取到内存字节流
|
||
body, err := io.ReadAll(res.Body)
|
||
if err != nil {
|
||
fmt.Println(err)
|
||
return ""
|
||
}
|
||
return string(body)
|
||
}
|
||
|
||
func parseM3u8(content string, baseUrl string) M3u8Res {
|
||
// strings.Split 按照换行符 \n 把长长的文本打散成一个字符串数组,也就是一行一行的
|
||
lines := strings.Split(content, "\n")
|
||
// 优先级最高:检查是否包含 Master 标签
|
||
for i, line := range lines {
|
||
// strings.Contains 用于检索 line 这句话里面是否包含了某个特定字符串
|
||
if strings.Contains(line, "#EXT-X-STREAM-INF") {
|
||
return parseMainContent(lines, i, baseUrl)
|
||
}
|
||
}
|
||
return M3u8Res{
|
||
IsMaster: false,
|
||
TsFile: parseSubContent(lines),
|
||
}
|
||
}
|
||
|
||
func parseMainContent(lines []string, index int, baseUrl string) M3u8Res {
|
||
// 取匹配标签的下一行作为 URL
|
||
if index+1 < len(lines) {
|
||
// strings.TrimSpace 是去除字符串开头和结尾的空白字符(比如多余的空格或者换行回车符\r)
|
||
nextUrl := strings.TrimSpace(lines[index+1])
|
||
return M3u8Res{
|
||
IsMaster: true,
|
||
NextUrl: baseUrl + nextUrl,
|
||
}
|
||
}
|
||
return M3u8Res{IsMaster: false}
|
||
}
|
||
|
||
func parseSubContent(lines []string) []string {
|
||
var tsFiles []string
|
||
for _, line := range lines {
|
||
// 先去除两侧的空格回车
|
||
line = strings.TrimSpace(line)
|
||
// strings.HasPrefix 判断字符串 line 是不是以 "#" 号开头
|
||
if strings.HasPrefix(line, "#") || line == "" {
|
||
continue
|
||
}
|
||
// strings.HasSuffix 判断字符串的结尾,检查是不是这两种结尾形式
|
||
if strings.HasSuffix(line, ".jpeg") || strings.HasSuffix(line, ".ts") {
|
||
tsFiles = append(tsFiles, line)
|
||
}
|
||
}
|
||
return tsFiles
|
||
}
|
||
|
||
// 提取基础路径
|
||
func getBaseUrl(path string) string {
|
||
// strings.LastIndex 返回 "/" 在这个网址里最后一次出现的位置索引下标(数字)
|
||
lastSlash := strings.LastIndex(path, "/")
|
||
if lastSlash == -1 {
|
||
return path + "/"
|
||
}
|
||
return path[:lastSlash+1]
|
||
}
|
||
|
||
//func getNewBaseUrl(path string) string {
|
||
// lastSlash := strings.LastIndex(path, "/")
|
||
// if lastSlash == -1 {
|
||
// return path + "/"
|
||
// }
|
||
// subPath := path[:lastSlash]
|
||
// secondLastSlash := strings.LastIndex(subPath, "/")
|
||
// return path[:secondLastSlash+1]
|
||
//}
|
||
|
||
// 拼接下载地址
|
||
func buildUrl(baseUrl string, m3u8TsFile []string) []string {
|
||
var downLoadUrl []string
|
||
for _, tsUrl := range m3u8TsFile {
|
||
fullUrl := baseUrl + tsUrl
|
||
downLoadUrl = append(downLoadUrl, fullUrl)
|
||
}
|
||
return downLoadUrl
|
||
}
|
||
|
||
// 协程用 index int 是用来标识每个文件的序号,在并发下载中特别重要
|
||
func downLoadOne(Url string, index int, tempDir string) error {
|
||
maxRetry := 3 // 定义最大重试次数
|
||
// time.Second 是 time 库里的标准时长常量代表 1 秒钟
|
||
baseDelay := time.Second
|
||
for retry := 0; retry < maxRetry; retry++ {
|
||
if retry > 0 {
|
||
// time.Duration 是时间间隔类型,(1<<uint(retry-1)) 是位运算乘法。代表指数退避等待时间 (1s, 2s, 4s)
|
||
waitTime := baseDelay * time.Duration(1<<uint(retry-1)) // 1s, 2s, 4s
|
||
// fmt.Printf 是带占位符的格式化打印函数,%d等是占位符,用来将后面的变量安插进去打印
|
||
fmt.Printf("[%3d] 第 %d 次重试,等待 %v...\n", index, retry, waitTime)
|
||
// time.Sleep 会让执行到此的协程挂起(沉睡)指定的时间,以留出时间重连并不给服务器造成大负担
|
||
time.Sleep(waitTime)
|
||
}
|
||
resp, err := httpClient.Get(Url)
|
||
if err != nil {
|
||
fmt.Printf("连接失败: %v\n", err)
|
||
if retry < maxRetry-1 {
|
||
continue // 重试
|
||
}
|
||
return fmt.Errorf("(%d)连接失败 %d次后: %v", index, maxRetry, err)
|
||
}
|
||
if resp.StatusCode != 200 {
|
||
resp.Body.Close()
|
||
if retry < maxRetry-1 {
|
||
continue // 重试
|
||
}
|
||
return fmt.Errorf("服务器状态异常: %d", resp.StatusCode)
|
||
}
|
||
// fmt.Sprintf 不直接抛出屏幕,而是把变量合并并返回字符串表示,我们将它当作文件名
|
||
fileName := fmt.Sprintf("%s/%d.ts", tempDir, index) // 💡 修复2:存入临时目录,同时加上 .ts 后缀
|
||
// os.Create 在硬盘上对应路径下创建一个新文件,如果存在同名文件则会被从头清空截断为0!
|
||
file, err := os.Create(fileName)
|
||
if err != nil {
|
||
resp.Body.Close()
|
||
return err
|
||
}
|
||
|
||
// io.Copy 核心函数:它可以源源不断地把 resp.Body (网络传输过来的字节流) 搬运到 file (刚才创的空文件) 中,极其省内存,不卡电脑
|
||
_, err = io.Copy(file, resp.Body)
|
||
closeErr := file.Close()
|
||
resp.Body.Close()
|
||
if err != nil || closeErr != nil {
|
||
// os.Remove 直接让操作系统把这个产生坏结果的文件删掉清理掉
|
||
os.Remove(fileName)
|
||
if err != nil {
|
||
if retry < maxRetry-1 {
|
||
continue // 重试
|
||
}
|
||
return fmt.Errorf("下载中断: %v %v", err, index)
|
||
}
|
||
return fmt.Errorf("文件关闭失败: %v", closeErr)
|
||
}
|
||
return nil
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 并行下载函数
|
||
func downLoadALL(fullUrl []string, maxConcurrent int, tempDir string) {
|
||
// sync.WaitGroup 是极经典的同步原语。本质上是个高级计数器,用于等待一群分派出去的并发任务全军凯旋
|
||
var wait sync.WaitGroup
|
||
total := len(fullUrl)
|
||
bar := progressbar.Default(int64(total), "正在下载(%d)")
|
||
// make 函数专门用于创建 channel、map 或 slice,这里构建了一个带最大缓冲容量的消息信道
|
||
maxT := make(chan struct{}, maxConcurrent) //定义一个长度为maxConcurrent的信道
|
||
// wait.Add 把总任务数一次性全加进计数器里
|
||
wait.Add(len(fullUrl))
|
||
for i := range fullUrl {
|
||
// 当信道满载 10 个时,这行代码会被迫卡住(阻塞)等别人拿出来。达到限制峰值避免超额的并发控制
|
||
maxT <- struct{}{} // 写入一个信号
|
||
// 关键字 go,代表直接分出一个独立的执行线(协程,极低开销)去运行内部代码块
|
||
go func(url string, index int) {
|
||
defer func() {
|
||
// <-maxT 事情干完或出错了,从信道抽走一个信号释放位置,这会让刚才卡住的地方马上填装信道创建下一个兵
|
||
<-maxT
|
||
bar.Add(1)
|
||
}()
|
||
// wait.Done 给总计任务器数量减一。表示单个子业务已经闭环
|
||
defer wait.Done()
|
||
err := downLoadOne(url, index, tempDir)
|
||
if err != nil {
|
||
fmt.Printf("文件 %d 失败: %v ", index, err)
|
||
}
|
||
}(fullUrl[i], i)
|
||
}
|
||
wait.Wait()
|
||
bar.Finish()
|
||
}
|
||
|
||
// 碎片拼接
|
||
func videoBuild(num int, outputpath string, deleteAfter bool, tempDir string) error {
|
||
// 💡 修复3:通过相对路径调用 ffmpeg 混流
|
||
listFileName := fmt.Sprintf("%s/mylist.txt", tempDir)
|
||
listFile, err := os.Create(listFileName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for i := 0; i < num; i++ {
|
||
listFile.WriteString(fmt.Sprintf("file '%d.ts'\n", i))
|
||
}
|
||
listFile.Close()
|
||
|
||
ffmpegPath := "./ffmpeg/ffmpeg.exe" // 如果是 Mac/Linux,把 .exe 去掉
|
||
// 组装合并命令
|
||
cmd := exec.Command(ffmpegPath, "-f", "concat", "-safe", "0", "-i", listFileName, "-c", "copy", outputpath)
|
||
|
||
fmt.Println("\n正在调用 FFmpeg 进行无损合并...")
|
||
err = cmd.Run()
|
||
if err != nil {
|
||
return fmt.Errorf("FFmpeg 混流失败 (请确认当前目录下放入了 ffmpeg/ffmpeg.exe): %v\n", err)
|
||
}
|
||
|
||
fmt.Println("🎉 FFmpeg 处理完成!")
|
||
|
||
if deleteAfter {
|
||
os.RemoveAll(tempDir) // 直接清空整个临时目录(包含所有 .ts 和 mylist.txt)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func main() {
|
||
m3u8Path := getM3u8Path() // 输入链接
|
||
m3u8Body := fetchM3u8Content(m3u8Path) // get到链接文件
|
||
m3u8BaseUrl := getBaseUrl(m3u8Path) // 获取基础路径
|
||
m3u8TsFile := parseM3u8(m3u8Body, m3u8BaseUrl)
|
||
|
||
// 💡 修复2:创建临时存文件的目录
|
||
tempDir := "./temp_download"
|
||
os.MkdirAll(tempDir, os.ModePerm)
|
||
|
||
if m3u8TsFile.IsMaster {
|
||
fmt.Println("检测到主播放列表,准备跳转:", m3u8TsFile.NextUrl)
|
||
baseNew := getBaseUrl(m3u8TsFile.NextUrl) // 新的基础路径
|
||
m3u8Body = fetchM3u8Content(m3u8TsFile.NextUrl) // get新的内容
|
||
m3u8TsFileNew := parseM3u8(m3u8Body, baseNew)
|
||
fullUrl := buildUrl(baseNew, m3u8TsFileNew.TsFile)
|
||
downLoadALL(fullUrl, 10, tempDir)
|
||
fmt.Printf("\n下载完成?开始合并...\n")
|
||
videoBuild(len(m3u8TsFileNew.TsFile), "test.mp4", true, tempDir)
|
||
} else {
|
||
fmt.Println("获得分片列表,准备下载:", len(m3u8TsFile.TsFile), "个文件")
|
||
fullUrl := buildUrl(m3u8BaseUrl, m3u8TsFile.TsFile)
|
||
downLoadALL(fullUrl, 10, tempDir)
|
||
fmt.Printf("\n下载完成?开始合并...\n")
|
||
videoBuild(len(m3u8TsFile.TsFile), "test.mp4", true, tempDir)
|
||
}
|
||
}
|
||
|
||
//http://devimages.apple.com/iphone/samples/bipbop/gear1/prog_index.m3u8
|
||
//https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8
|
||
//https://test-streams.mux.dev/x36xhzz/url_0/193039199_mp4_h264_aac_hd_7.m3u8
|
||
//https://surrit.com/0831edf7-98aa-49ff-a865-42777726e282/720p/video.m3u8
|