This commit is contained in:
YL
2026-03-06 08:52:55 +08:00
parent 97eed68d88
commit 0cb5a71908
8 changed files with 329 additions and 0 deletions

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/

7
.idea/dictionaries/project.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>outputpath</w>
</words>
</dictionary>
</component>

11
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

9
.idea/m3u8.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/m3u8.iml" filepath="$PROJECT_DIR$/.idea/m3u8.iml" />
</modules>
</component>
</project>

11
go.mod Normal file
View File

@@ -0,0 +1,11 @@
module m3u8-downloader
go 1.25.0
require (
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/schollz/progressbar/v3 v3.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
)

10
go.sum Normal file
View File

@@ -0,0 +1,10 @@
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=

263
main.go Normal file
View File

@@ -0,0 +1,263 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/schollz/progressbar/v3"
)
type M3u8Res struct {
IsMaster bool
NextUrl string
TsFile []string
}
// 创建一个连接池
var httpClient = &http.Client{
Transport: &http.Transport{
MaxIdleConnsPerHost: 5,
},
}
// 获取输入的m3u8链接
func getM3u8Path() (path string) {
for {
fmt.Println("请输入m3u8链接地址:")
_, 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 {
res, err := httpClient.Get(path)
if err != nil {
fmt.Println(err)
return ""
}
defer res.Body.Close()
if res.StatusCode != 200 {
fmt.Println("出了点问题", res.StatusCode)
return ""
}
body, err := io.ReadAll(res.Body)
if err != nil {
fmt.Println(err)
return ""
}
return string(body)
}
func parseM3u8(content string, baseUrl string) M3u8Res {
lines := strings.Split(content, "\n")
// 优先级最高:检查是否包含 Master 标签
for i, line := range lines {
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) {
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)
if strings.HasPrefix(line, "#") || line == "" {
continue
}
if strings.HasSuffix(line, ".jpeg") || strings.HasSuffix(line, ".ts") {
tsFiles = append(tsFiles, line)
}
}
return tsFiles
}
// 提取基础路径
func getBaseUrl(path string) string {
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) error {
maxRetry := 3 // 定义最大重试次数
baseDelay := time.Second
for retry := 0; retry < maxRetry; retry++ {
if retry > 0 {
waitTime := baseDelay * time.Duration(1<<uint(retry-1)) // 1s, 2s, 4s
fmt.Printf("[%3d] 第 %d 次重试,等待 %v...\n", index, retry, waitTime)
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)
}
fileName := fmt.Sprintf("%d", index)
file, err := os.Create(fileName)
if err != nil {
resp.Body.Close()
return err
}
_, err = io.Copy(file, resp.Body)
closeErr := file.Close()
resp.Body.Close()
if err != nil || closeErr != nil {
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) {
var wait sync.WaitGroup
total := len(fullUrl)
bar := progressbar.Default(int64(total), "正在下载(%d)")
maxT := make(chan struct{}, maxConcurrent) //定义一个长度为maxConcurrent的信道
wait.Add(len(fullUrl))
for i := range fullUrl {
maxT <- struct{}{} // 写入一个信号
go func(url string, index int) {
defer func() {
<-maxT
bar.Add(1)
}()
defer wait.Done()
err := downLoadOne(url, index)
if err != nil {
fmt.Printf("文件 %d 失败: %v ", index, err)
}
}(fullUrl[i], i)
}
wait.Wait()
bar.Finish()
}
// 碎片拼接
func videoBuild(num int, outputpath string, deleteAfter bool) error {
output, err := os.Create(outputpath)
if err != nil {
return err
}
defer output.Close()
for i := 0; i < num; i++ {
fileName := fmt.Sprintf("%d", i)
input, err := os.Open(fileName)
if err != nil {
return fmt.Errorf("打开文件失败\n")
}
writtenBytes, err := io.Copy(output, input)
input.Close()
if err != nil {
return fmt.Errorf("合并文件失败\n")
}
if deleteAfter {
os.Remove(fileName)
}
fmt.Printf("合并成功,%d\n", writtenBytes)
}
return nil
}
func main() {
m3u8Path := getM3u8Path() // 输入链接
m3u8Body := fetchM3u8Content(m3u8Path) // get到链接文件
m3u8BaseUrl := getBaseUrl(m3u8Path) // 获取基础路径
m3u8TsFile := parseM3u8(m3u8Body, m3u8BaseUrl)
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)
fmt.Printf("下载完成?开始合并...\n")
videoBuild(len(m3u8TsFileNew.TsFile), "test.mp4", true)
} else {
fmt.Println("获得分片列表,准备下载:", len(m3u8TsFile.TsFile), "个文件")
fullUrl := buildUrl(m3u8BaseUrl, m3u8TsFile.TsFile)
downLoadALL(fullUrl, 10)
fmt.Printf("下载完成?开始合并...\n")
videoBuild(len(m3u8TsFile.TsFile), "test.mp4", true)
}
}
//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