好友
阅读权限10
听众
最后登录1970-1-1
|
本帖最后由 saobee 于 2026-5-26 12:01 编辑
版本更新:2026.05.26
广告过滤更完善
使用了fasthttp增强了下载速度,对某些服务器能跑满带宽。
支持单文件下载(偶尔用用可以,真要追求单文件下载速度我推荐fluxdown)
使用随机UA防封,具体效果未知。
其它细节和性能优化。
总之就是用AI反复拷问,反复优化,然后再拷问是否有“负优化”。
最新成品和源码:
https://wwbhw.lanzouq.com/b0mch4hjc
密码:52pj
版本更新:2026.04.10
广告过滤更新,减少对不同cdn分片的误杀
多线程下载优化,使用了“渐进式”多线程,而不是一上来就把线程拉满,对某些有限制的站点更友好
对已存在的同名文件,自动重命名。
优化合并方式,没有key的分片合并更快。
附件已更新,代码长度增加,暂时没有更新到帖子内
附上lanzou盘地址:
源码:https://wwbhw.lanzouq.com/iKsoO3mwz8qh 密码:52pj
成品(请自行下载ffmpeg): https://wwbhw.lanzouq.com/it36k3mwz8wd 密码:52pj
版本更新:2026.03.28
去除了原版本中,对超短长度分片也当作广告过滤,导致合并后某些画面破碎。现在就只过滤重复分片和“路径指纹”分析。
增加了广告过滤触发规则的输出。
经过和不同的AI沟通,重新对一些细节做了优化,下载状态进度的重写。
磁盘IO的优化,直接内存缓存,彻底减少IO数量。
ffmepg合并失败时,不删除临时目录。
获取不同类型加密的m3u8 key存储方式。
其它细节优化。
这个下载器思路是来自于我本人,只是代码是AI生成,根据版规,AI生成的代码不能用【原创】标记。
已经经过多个版本的优化,目前是已经足够成熟的版本。
【源码放最后】
- 开发思路:
m3u8还能有啥思路,就多线程下载分片,然后调用ffmpeg合并。
用ffmpeg,是因为它是目前最合适的合并m3u8文件为mp4的工具,比自己弄更好吧。
只要是正常的m3u8网址,不限扩展名,也不用管分片的扩展名,临时目录统统改成.ts。
广告过滤:针对分片网址进行分析,将异常规则,并且时长比例较少的分片判定为广告,不下载。
其它,就什么多线程之类,请AI做了几次优化。
同时保存下载分片,也针对未完全下载的分片,加上临时扩展名,防止中途取消后,再次重试时正在下载的几个分片是损坏的。
还有一个防遗忘的续传批处理,如果中途断开,然后你忘记下载地址了,还可以去临时目录找到一个bat文件,双击即可继续。
使用了配置文件来保存默认参数,命令行调用时一般就只传递url和文件名就可以了。
* 编译 #太久了,忘记要不要用go get下载包
`m3u8down.exe`这个文件名你可以改成自己想要的。
[Patch] 纯文本查看 复制代码
SETLOCAL
go mod tidy
set CGO_ENABLED=0
set GOOS=windows
set GOARCH=amd64
go build -ldflags="-s -w" -o m3u8down.exe main.go
使用go编译后,直接运行一次,会生成config.ini配置文件
[Plain Text] 纯文本查看 复制代码
[settings]
ffmpeg_path = D:\tools\ffmpeg\bin\ffmpeg.exe #ffmpeg.exe的路径,你也可以用相对路径
show_ffmpeg_log = false # 合并出错了的话,这里开启看ffmpeg输出的内容,找解决方案。
threads = 12 #线程数,不建议超过32,没必要。
save_dir = E:\Download #下载目录
retries = 5 #重试次数
keep_tmp = false #下载完成后要不要保留临时片段目录,调试时可以开启
proxy =
#proxy 一般留空,需要的时候从命令行传,支持http://127.0.0.1:8080和socks5://127.0.0.1:8081两种格式。
user_agent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36`
# 默认UA,你可以自己随便搞,主要也是方便被识别为正常浏览器
connect_timeout=10 #连接超时时间,秒。
request_timeout=60 #下载分片超时时间,代码里是指下载每256k大小。
full = false #完整下载整个视频,不过滤广告
启动参数:
[Plain Text] 纯文本查看 复制代码
用法: downloader.exe [选项]
[ 命令行选项 ]
-u <url> 必填: M3U8 播放列表网址
-n <name> 可选: 自定义文件名 (不含后缀)
-d <dir> 可选: 视频保存目录
-t <num> 可选: 线程数 (覆盖 config.ini)
-p <proxy> 可选: 代理地址 (http://... 或 socks5://...)
-e <header> 可选: 附加header,(K1:V1|K2:V2)
-full 可选: 不进行广告检查,完整下载 (默认: false)
-h, --help 显示此帮助信息
完整代码,大家自己编译吧:
避免论坛代码对一些emoji进行转码,特加上附件,包含了go.mod和go.sum。
gom3u8.zip
(11.29 KB, 下载次数: 37)
[Golang] 纯文本查看 复制代码
package main
import (
"bufio"
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/grafov/m3u8"
"golang.org/x/net/proxy"
"gopkg.in/ini.v1"
)
// --- 全局配置与变量 ---
type AppConfig struct {
FFmpegPath string
Threads int
SaveDir string
Retries int
KeepTmp bool
ShowFFmpegLog bool
Proxy string
UserAgent string
ConnectTimeout int // 连接超时 (秒)
RequestTimeout int // 单个分片下载超时 (秒)
FullDownload bool
}
type SegmentMeta struct {
URL string
KeyURL string
KeyIV string
Duration float64
Pattern string // 路径指纹 (Domain + ParentPath)
}
var (
globalCfg AppConfig
totalBytes int64 // 全局下载字节计数
exeDir string
exeAbsPath string
downloadStart time.Time // 记录下载开始时间,用于计算总平均速度
)
// 用于统计速度的结构体
type speedStat struct {
seconds int64
bytes int64
}
func main() {
initDefaultConfig()
uPtr := flag.String("u", "", "M3U8 网址 (URL)")
nPtr := flag.String("n", "", "保存的文件名 (Name)")
dPtr := flag.String("d", "", "保存目录 (Directory)")
tPtr := flag.Int("t", 0, "下载线程数 (Threads)")
pPtr := flag.String("p", "", "代理地址 (Proxy)")
ePtr := flag.String("e", "", "额外 Header (K1:V1|K2:V2)")
fPtr := flag.Bool("full", globalCfg.FullDownload, "是否完整下载,不进行广告检查 (true|false)")
hPtr := flag.Bool("h", false, "显示详细帮助信息")
flag.BoolVar(hPtr, "help", false, "显示详细帮助信息")
flag.Parse()
if *hPtr {
showDetailedHelp()
return
}
globalCfg.FullDownload = *fPtr
if *uPtr != "" {
runSilentMode(*uPtr, *nPtr, *dPtr, *tPtr, *pPtr, *ePtr)
} else {
showWelcomeInfo()
runInteractiveMode()
}
}
func runSilentMode(targetUrl, fileName, saveDir string, threads int, proxyAddr string, headerRaw string) {
if saveDir == "" {
saveDir = globalCfg.SaveDir
}
if threads <= 0 {
threads = globalCfg.Threads
}
if proxyAddr == "" {
proxyAddr = globalCfg.Proxy
}
executeTask(targetUrl, saveDir, fileName, threads, proxyAddr, headerRaw)
}
func runInteractiveMode() {
scanner := bufio.NewScanner(os.Stdin)
var targetUrl, savePath, fileName, headerRaw, proxyAddr string
threads := globalCfg.Threads
fmt.Println(">>> 交互模式 (直接回车使用默认值) <<<")
fmt.Print("1. M3U8 网址 (必填): ")
if scanner.Scan() {
targetUrl = strings.TrimSpace(scanner.Text())
}
if targetUrl == "" {
return
}
fmt.Printf("2. 保存目录 (默认: %s): ", globalCfg.SaveDir)
if scanner.Scan() {
savePath = strings.TrimSpace(scanner.Text())
if savePath == "" {
savePath = globalCfg.SaveDir
}
}
fmt.Print("3. 自定义文件名: ")
if scanner.Scan() {
fileName = strings.TrimSpace(scanner.Text())
}
fmt.Printf("4. 线程数 (默认: %d): ", threads)
if scanner.Scan() {
tText := strings.TrimSpace(scanner.Text())
if tText != "" {
fmt.Sscanf(tText, "%d", &threads)
}
}
fmt.Printf("5. 代理 (当前: %s): ", globalCfg.Proxy)
if scanner.Scan() {
proxyAddr = strings.TrimSpace(scanner.Text())
if proxyAddr == "" {
proxyAddr = globalCfg.Proxy
}
}
fmt.Print("6. 额外 Header (K1:V1|K2:V2): ")
if scanner.Scan() {
headerRaw = strings.TrimSpace(scanner.Text())
}
executeTask(targetUrl, savePath, fileName, threads, proxyAddr, headerRaw)
fmt.Println("\n-------------------------------------------------------")
fmt.Println("任务结束。按 [回车键] 退出程序...")
bufio.NewReader(os.Stdin).ReadBytes('\n')
}
func executeTask(targetUrl, savePath, fileName string, threads int, proxyAddr, headerRaw string) {
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc() // 任务结束取消所有下载
atomic.StoreInt64(&totalBytes, 0)
urlHash := getMD5(targetUrl)
fileNameBase := chooseFileName(fileName, urlHash)
httpClient := createHttpClient(proxyAddr, threads)
finalSaveBase := savePath
if !filepath.IsAbs(finalSaveBase) {
finalSaveBase = filepath.Join(exeDir, finalSaveBase)
}
fmt.Println("-------------------------------------------------------")
fmt.Printf("📂 保存目录: %s\n", finalSaveBase)
fmt.Printf("📄 目标文件: %s.mp4 (时长解析中...)\n", fileNameBase)
if proxyAddr != "" {
fmt.Printf("🌐 使用代理: %s\n", proxyAddr)
} else {
fmt.Println("🌐 直连模式 (无代理)")
}
fmt.Println("-------------------------------------------------------")
headers := make(map[string]string)
headers["User-Agent"] = globalCfg.UserAgent
if headerRaw != "" {
for _, p := range strings.Split(headerRaw, "|") {
kv := strings.SplitN(p, ":", 2)
if len(kv) == 2 {
headers[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
}
fmt.Println("[1/3] 解析 M3U8 并重算时长 (处理分片)...")
segments, duration := selectAndParse(targetUrl, httpClient, headers, globalCfg.FullDownload)
if len(segments) == 0 {
fmt.Println("❌ 错误:无法解析有效分片。")
return
}
durationTag := formatDuration(duration)
fileNameFinal := fileNameBase + "[" + durationTag + "]"
finalFilePath := filepath.Join(finalSaveBase, fileNameFinal+".mp4")
fmt.Printf("\n🎯 最终路径: %s\n\n", finalFilePath)
tmpDir := filepath.Join(finalSaveBase, "tmp", urlHash)
os.MkdirAll(tmpDir, 0755)
// 生成续传脚本
batPath := filepath.Join(tmpDir, "resume_download.bat")
var batBuilder strings.Builder
batBuilder.WriteString(fmt.Sprintf("\"%s\" ", exeAbsPath))
batBuilder.WriteString(fmt.Sprintf("-u \"%s\" ", targetUrl))
if fileName != "" {
batBuilder.WriteString(fmt.Sprintf("-n \"%s\" ", fileName))
}
batBuilder.WriteString(fmt.Sprintf("-d \"%s\" ", finalSaveBase))
batBuilder.WriteString(fmt.Sprintf("-t %d ", threads))
if globalCfg.FullDownload {
batBuilder.WriteString("-full=true ")
}
if proxyAddr != "" {
batBuilder.WriteString(fmt.Sprintf("-p %s ", proxyAddr))
}
if headerRaw != "" {
batBuilder.WriteString(fmt.Sprintf("-e \"%s\" ", headerRaw))
}
os.WriteFile(batPath, []byte(batBuilder.String()), 0644)
// --- 预下载 Key ---
keyMap := make(map[string]string)
for _, seg := range segments {
if seg.KeyURL != "" {
if _, exists := keyMap[seg.KeyURL]; !exists {
localKeyName := fmt.Sprintf("key_%d.key", len(keyMap))
keyPath := filepath.Join(tmpDir, localKeyName)
retryDownload(httpClient, seg.KeyURL, keyPath, headers, globalCfg.Retries)
keyMap[seg.KeyURL] = localKeyName
}
}
}
// --- 下载核心逻辑 ---
total := len(segments)
taskChan := make(chan int, total)
var wg sync.WaitGroup
var completed int32 // 原子计数:已完成数量
var activeThreads int32 // 原子计数:当前活动线程数
//var downloadedBytes int64 // 原子计数:本次运行下载的字节数
fmt.Printf("[2/3] 下载分片: %d | 时长: %s | 续传脚本: tmp\\%s\\resume_download.bat\n", total, durationTag, urlHash)
// --- 独立的状态显示协程 ---
stopDisplay := make(chan struct{})
go func() {
ticker := time.NewTicker(500 * time.Millisecond) // 0.5秒刷新一次,流畅不闪烁
defer ticker.Stop()
var lastBytes int64
lastTime := time.Now()
speedHistory := make([]float64, 0, 6) // 滑动平均窗口
for {
select {
case <-ticker.C:
nowBytes := atomic.LoadInt64(&totalBytes)
nowTime := time.Now()
// 计算瞬时速度
dt := nowTime.Sub(lastTime).Seconds()
if dt <= 0 { dt = 0.1 }
instSpeed := float64(nowBytes-lastBytes) / dt / 1024 / 1024 // MB/s
lastBytes = nowBytes
lastTime = nowTime
// 平滑速度 (滑动平均)
speedHistory = append(speedHistory, instSpeed)
if len(speedHistory) > 6 { speedHistory = speedHistory[1:] }
var avgSpeed float64
for _, v := range speedHistory { avgSpeed += v }
avgSpeed /= float64(len(speedHistory))
// 计算进度
c := int(atomic.LoadInt32(&completed))
percent := float64(c) / float64(total)
downMB := float64(nowBytes) / 1024 / 1024
// 计算 ETA (剩余时间)
etaStr := "--:--"
if avgSpeed > 0.01 && percent > 0.001 {
remainingBytes := float64(nowBytes) / percent - float64(nowBytes)
etaSeconds := remainingBytes / (avgSpeed * 1024 * 1024)
if etaSeconds > 0 && etaSeconds < 360000 {
etaStr = fmt.Sprintf("%02d:%02d", int(etaSeconds)/60, int(etaSeconds)%60)
}
}
// 获取活动线程数
act := atomic.LoadInt32(&activeThreads)
// 打印进度条
fmt.Printf("\r🚀 进度: [%d/%d] %.1f%% | 已下: %.1f MB | 速度: %.2f MB/s | 剩余: %s | 活动: %d/%d ",
c, total, percent*100, downMB, avgSpeed, etaStr, act, threads)
case <-stopDisplay:
return
}
}
}()
// --- 下载工作协程修改版 ---
for i := 0; i < threads; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range taskChan {
// 检查全局取消信号
select {
case <-ctx.Done():
return
default:
}
tsPath := filepath.Join(tmpDir, fmt.Sprintf("%s_%d.ts", urlHash, idx+1))
if info, err := os.Stat(tsPath); err == nil && info.Size() > 0 {
atomic.AddInt64(&totalBytes, info.Size())
atomic.AddInt32(&completed, 1)
continue
}
atomic.AddInt32(&activeThreads, 1)
// 传入 ctx (需要修改 retryDownload 函数签名接受 context)
// 如果不想大改函数签名,目前的逻辑也能跑,但加上 ctx 更规范
if retryDownload(httpClient, segments[idx].URL, tsPath, headers, globalCfg.Retries) {
atomic.AddInt32(&completed, 1)
}
atomic.AddInt32(&activeThreads, -1)
}
}()
}
// 分发任务
for i := 0; i < total; i++ {
taskChan <- i
}
close(taskChan)
wg.Wait()
close(stopDisplay) // 停止显示协程
// 最终打印一次确保显示完整
fmt.Printf("\r🚀 进度: [%d/%d] 100.0%% | 已下: %.1f MB | 下载完成! \n",
total, total, float64(atomic.LoadInt64(&totalBytes))/1024/1024)
// --- 合并阶段 ---
localM3u8Path := filepath.Join(tmpDir, "index.m3u8")
writeLocalM3U8(localM3u8Path, urlHash, segments, keyMap)
fmt.Printf("\n[3/3] FFmpeg 合并中...")
cmdArgs := []string{
"-loglevel", "warning",
"-protocol_whitelist", "file,http,https,tcp,tls,crypto",
"-allowed_extensions", "ALL",
"-fflags", "+genpts+igndts",
"-i", localM3u8Path,
"-c", "copy",
"-bsf:a", "aac_adtstoasc",
"-y", finalFilePath,
}
if globalCfg.ShowFFmpegLog {
cmdArgs[1] = "info"
}
ffmpeg := globalCfg.FFmpegPath
if !filepath.IsAbs(ffmpeg) {
if _, err := os.Stat(filepath.Join(exeDir, ffmpeg)); err == nil {
ffmpeg = filepath.Join(exeDir, ffmpeg)
}
}
cmd := exec.Command(ffmpeg, cmdArgs...)
if globalCfg.ShowFFmpegLog {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
if err := cmd.Run(); err == nil {
fmt.Println("\n✅ 任务完成:", finalFilePath)
if !globalCfg.KeepTmp {
os.RemoveAll(tmpDir)
}
} else {
fmt.Printf("\n❌ 合并失败: %v\n", err)
}
}
// parseSegments 保持不变
func parseSegments(mUrl string, client *http.Client, headers map[string]string) ([]SegmentMeta, error) {
req, _ := http.NewRequest("GET", mUrl, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
p, listType, err := m3u8.DecodeFrom(bytes.NewReader(bodyBytes), true)
if err != nil {
return nil, err
}
baseUrl, _ := url.Parse(mUrl)
if listType == m3u8.MASTER {
master := p.(*m3u8.MasterPlaylist)
var activeVariant *m3u8.Variant
for _, v := range master.Variants {
if v != nil {
activeVariant = v
break
}
}
if activeVariant == nil {
return nil, fmt.Errorf("no variants found")
}
subUrl, _ := url.Parse(activeVariant.URI)
return parseSegments(baseUrl.ResolveReference(subUrl).String(), client, headers)
}
if listType == m3u8.MEDIA {
mpl := p.(*m3u8.MediaPlaylist)
var segs []SegmentMeta
curKeyURL, curKeyIV := "", ""
for _, v := range mpl.Segments {
if v == nil {
continue
}
if v.Key != nil {
if v.Key.Method == "AES-128" {
ku, _ := url.Parse(v.Key.URI)
curKeyURL = baseUrl.ResolveReference(ku).String()
curKeyIV = v.Key.IV
} else {
curKeyURL = ""
curKeyIV = ""
}
}
u, _ := url.Parse(v.URI)
fullUrl := baseUrl.ResolveReference(u).String()
pathParts := strings.Split(u.Path, "/")
pattern := u.Host
/*if len(pathParts) > 1 {
pattern += strings.Join(pathParts[:len(pathParts)-1], "/")
}*/
if len(pathParts) > 1 {
// 去掉最后一个元素(文件名),保留目录结构
pattern += strings.Join(pathParts[:len(pathParts)-1], "/")
} else {
// 如果路径类似 "/file.ts" 或 "file.ts",则只保留 Host
pattern += "/"
}
segs = append(segs, SegmentMeta{
URL: fullUrl,
KeyURL: curKeyURL,
KeyIV: curKeyIV,
Duration: v.Duration,
Pattern: pattern,
})
}
return segs, nil
}
return nil, fmt.Errorf("unknown playlist type")
}
// filterAds 保持不变
func filterAds(segs []SegmentMeta) []SegmentMeta {
if len(segs) <= 3 {
return segs
}
fmt.Printf("\n🔍 开始路径特征扫描...(%d 个分片)", len(segs))
seenUrls := make(map[string]bool)
var uniqueSegs []SegmentMeta
repeatCount := 0
for _, s := range segs {
if seenUrls[s.URL] {
repeatCount++
continue
}
seenUrls[s.URL] = true
uniqueSegs = append(uniqueSegs, s)
}
if repeatCount > 0 {
fmt.Printf("\n⚠️ 去重完成:过滤了 %d 个重复分片", repeatCount)
}
patternCount := make(map[string]int)
for _, s := range uniqueSegs {
patternCount[s.Pattern]++
}
var mainPattern string
var maxCount int
for p, count := range patternCount {
if count > maxCount {
maxCount = count
mainPattern = p
}
}
var clean []SegmentMeta
adCount := 0
threshold := len(uniqueSegs) / 10
if threshold < 2 {
threshold = 2
}
fmt.Printf("\n📊 统计基准: 主路径指纹 [%s] | 数量 [%d]", mainPattern, maxCount)
for i, s := range uniqueSegs {
_ = i
isMain := s.Pattern == mainPattern
isFrequent := patternCount[s.Pattern] >= threshold
if isMain || isFrequent {
clean = append(clean, s)
} else {
adCount++
}
}
if adCount > 0 {
fmt.Printf("\n🔞 拦截结束:基于路径一致性,共剔除 %d 个异源分片", adCount)
}
return clean
}
func selectAndParse(mUrl string, client *http.Client, headers map[string]string, keepAds bool) ([]SegmentMeta, float64) {
allSegs, err := parseSegments(mUrl, client, headers)
if err != nil {
fmt.Printf("❌ 解析失败: %v\n", err)
return nil, 0
}
var finalSegs []SegmentMeta
if keepAds {
fmt.Println("ℹ️ 模式: 完整下载 (已跳过广告检测)")
finalSegs = allSegs
} else {
finalSegs = filterAds(allSegs)
}
var totalDur float64
for _, s := range finalSegs {
totalDur += s.Duration
}
return finalSegs, totalDur
}
// retryDownload 深度优化版
// 支持内存下载模式,减少磁盘IO
func retryDownload(client *http.Client, u, path string, headers map[string]string, maxRetries int) bool {
tmpPath := path + ".tmp"
// 确保退出时清理临时文件(如果是从内存写入失败的情况)
defer os.Remove(tmpPath)
for i := 0; i <= maxRetries; i++ {
// 注意:context 应该由 executeTask 传入以支持全局取消,这里暂时保持局部超时
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(globalCfg.RequestTimeout)*time.Second)
req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
cancel()
time.Sleep(time.Duration(i+1) * time.Second)
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
cancel()
time.Sleep(time.Duration(i+1) * time.Second)
continue
}
// --- 优化点:内存缓冲策略 ---
// 如果文件较小(例如 < 4MB),先下载到内存,减少磁盘IO
// 使用 LimitReader 防止恶意服务器返回无限数据导致 OOM
maxMemorySize := int64(4 * 1024 * 1024)
var data []byte
var writeErr error
// 使用 TeeReader 统计流量,无论写到哪里
tee := io.TeeReader(resp.Body, &progressWriter{})
if resp.ContentLength > 0 && resp.ContentLength < maxMemorySize {
// 模式 A:内存下载
limitedReader := io.LimitReader(tee, maxMemorySize+1) // +1 为了检测是否超大
data, writeErr = io.ReadAll(limitedReader)
if int64(len(data)) > maxMemorySize {
// 如果意外超大,强制回退到磁盘模式(这里简单处理,直接报错重试)
resp.Body.Close()
cancel()
continue
}
} else {
// 模式 B:直接写磁盘(大文件或未知大小)
f, err := os.Create(tmpPath)
if err != nil {
resp.Body.Close()
cancel()
continue
}
bufWriter := bufio.NewWriterSize(f, 256*1024)
_, writeErr = io.Copy(bufWriter, tee)
flushErr := bufWriter.Flush()
f.Close()
if writeErr == nil { writeErr = flushErr }
}
resp.Body.Close()
cancel()
if writeErr == nil {
// 处理 Key 文件截断
if strings.HasSuffix(path, ".key") {
if len(data) > 0 { // 内存模式
if len(data) > 16 {
data = data[:16]
}
writeErr = os.WriteFile(path, data, 0644)
} else { // 磁盘模式
if d, err := os.ReadFile(tmpPath); err == nil && len(d) > 16 {
writeErr = os.WriteFile(path, d[:16], 0644)
}
}
if writeErr == nil { return true }
} else {
// 最终写入或重命名
if len(data) > 0 {
// 从内存写入磁盘
writeErr = os.WriteFile(path, data, 0644)
} else {
// 重命名临时文件
writeErr = os.Rename(tmpPath, path)
}
if writeErr == nil { return true }
}
}
// 如果失败,os.Remove 会在 defer 中执行
}
return false
}
type progressWriter struct{}
func (pw *progressWriter) Write(p []byte) (n int, err error) {
n = len(p)
atomic.AddInt64(&totalBytes, int64(n))
return n, nil
}
func writeLocalM3U8(path, hash string, segs []SegmentMeta, keyMap map[string]string) {
f, _ := os.Create(path)
defer f.Close()
f.WriteString("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:60\n")
lastK := "INITIAL_STATE"
for i, s := range segs {
curK := keyMap[s.KeyURL]
if curK != lastK {
if lastK != "INITIAL_STATE" {
f.WriteString("#EXT-X-DISCONTINUITY\n")
}
if curK != "" {
iv := ""
if s.KeyIV != "" {
iv = ",IV=" + s.KeyIV
}
fmt.Fprintf(f, "#EXT-X-KEY:METHOD=AES-128,URI=\"%s\"%s\n", curK, iv)
} else {
f.WriteString("#EXT-X-KEY:METHOD=NONE\n")
}
lastK = curK
}
fmt.Fprintf(f, "#EXTINF:%.3f,\n%s_%d.ts\n", s.Duration, hash, i+1)
}
f.WriteString("#EXT-X-ENDLIST")
}
func initDefaultConfig() {
p, err := os.Executable()
if err != nil {
p = "."
}
exeAbsPath, _ = filepath.Abs(p)
exeDir = filepath.Dir(exeAbsPath)
configPath := filepath.Join(exeDir, "config.ini")
globalCfg = AppConfig{
FFmpegPath: "ffmpeg.exe",
Threads: 16,
SaveDir: "videos",
Retries: 5,
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0",
ConnectTimeout: 10,
RequestTimeout: 30,
FullDownload: false,
}
cfg, err := ini.Load(configPath)
if err == nil {
sec := cfg.Section("settings")
globalCfg.FFmpegPath = sec.Key("ffmpeg_path").MustString(globalCfg.FFmpegPath)
globalCfg.Threads = sec.Key("threads").MustInt(globalCfg.Threads)
globalCfg.SaveDir = sec.Key("save_dir").MustString(globalCfg.SaveDir)
globalCfg.Retries = sec.Key("retries").MustInt(globalCfg.Retries)
globalCfg.KeepTmp = sec.Key("keep_tmp").MustBool(false)
globalCfg.ShowFFmpegLog = sec.Key("show_ffmpeg_log").MustBool(false)
globalCfg.Proxy = sec.Key("proxy").MustString("")
globalCfg.UserAgent = sec.Key("user_agent").MustString(globalCfg.UserAgent)
globalCfg.ConnectTimeout = sec.Key("connect_timeout").MustInt(globalCfg.ConnectTimeout)
globalCfg.RequestTimeout = sec.Key("request_timeout").MustInt(globalCfg.RequestTimeout)
globalCfg.FullDownload = sec.Key("full_download").MustBool(false)
} else {
cfg := ini.Empty()
sec := cfg.Section("settings")
sec.Key("ffmpeg_path").SetValue(globalCfg.FFmpegPath)
sec.Key("threads").SetValue(fmt.Sprint(globalCfg.Threads))
sec.Key("save_dir").SetValue(globalCfg.SaveDir)
sec.Key("retries").SetValue(fmt.Sprint(globalCfg.Retries))
sec.Key("keep_tmp").SetValue("false")
sec.Key("show_ffmpeg_log").SetValue("false")
sec.Key("proxy").SetValue("")
sec.Key("user_agent").SetValue(globalCfg.UserAgent)
sec.Key("connect_timeout").SetValue(fmt.Sprint(globalCfg.ConnectTimeout))
sec.Key("request_timeout").SetValue(fmt.Sprint(globalCfg.RequestTimeout))
sec.Key("full_download").SetValue("false")
cfg.SaveTo(configPath)
}
}
func showDetailedHelp() {
fmt.Println(` M3U8 Downloader CLI 帮助手册
-------------------------------------------------------
用法: downloader.exe [选项]
[ 命令行选项 ]
-u <url> 必填: M3U8 播放列表网址
-n <name> 可选: 自定义文件名 (不含后缀)
-d <dir> 可选: 视频保存目录
-t <num> 可选: 线程数 (覆盖 config.ini)
-p <proxy> 可选: 代理地址 (http://... 或 socks5://...)
-e <header> 可选: 附加header,(K1:V1|K2:V2)
-full 可选: 不进行广告检查,完整下载 (默认: false)
-h, --help 显示此帮助信息
-------------------------------------------------------`)
}
func showWelcomeInfo() {
fmt.Println("=======================================================")
fmt.Println(" M3U8 下载器 v3.1 (Speed Optimization Edition) ")
fmt.Println("=======================================================\n")
}
func getMD5(t string) string {
h := md5.Sum([]byte(t))
return hex.EncodeToString(h[:])
}
func chooseFileName(input, hash string) string {
if input == "" {
return hash
}
re := regexp.MustCompile(`[\\/:*?"<>|]`)
clean := re.ReplaceAllString(input, "_")
clean = strings.TrimSpace(clean)
if clean == "" {
return hash
}
return clean
}
func formatDuration(s float64) string {
return fmt.Sprintf("%02d_%02d", int(s)/60, int(s)%60)
}
// createHttpClient 优化:支持 SOCKS5 上下文,更好的超时控制
// createHttpClient 优化版:完善代理支持
func createHttpClient(p string, threads int) *http.Client {
dialer := &net.Dialer{
Timeout: time.Duration(globalCfg.ConnectTimeout) * time.Second,
KeepAlive: 30 * time.Second,
}
t := &http.Transport{
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConnsPerHost: threads + 2,
MaxResponseHeaderBytes: 1 << 20, // 限制响应头大小,防止恶意服务器
WriteBufferSize: 64 * 1024, // 写缓冲区大小,视频流场景建议调大
ReadBufferSize: 64 * 1024,// 读缓冲区大小
}
if p != "" {
u, err := url.Parse(p)
if err == nil {
if strings.HasPrefix(strings.ToLower(u.Scheme), "http") {
t.Proxy = http.ProxyURL(u)
} else if strings.HasPrefix(strings.ToLower(u.Scheme), "socks") {
// 使用 proxy.FromURL 自动处理 user:password@host:port
// 注意:proxy.FromURL 需要 *URL 而不是 string
proxyDialer, err := proxy.FromURL(u, proxy.Direct)
if err == nil {
// 尝试转换为 ContextDialer 以支持超时控制
if ctxDialer, ok := proxyDialer.(proxy.ContextDialer); ok {
t.DialContext = ctxDialer.DialContext
} else {
// 降级处理(极少见情况)
t.Dial = proxyDialer.Dial
}
} else {
fmt.Printf("⚠️ 代理解析失败: %v\n", err)
}
}
}
}
return &http.Client{
Transport: t,
}
} |
免费评分
-
查看全部评分
|