Files
Downloader/main.go
2026-03-06 15:54:32 +08:00

303 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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