吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 2072|回复: 28
上一主题 下一主题
收起左侧

[其他转载] Go开发的命令行多线程m3u8下载器,带广告分片过滤功能。

[复制链接]
跳转到指定楼层
楼主
saobee 发表于 2026-3-17 14:23 回帖奖励
本帖最后由 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("&#128194; 保存目录: %s\n", finalSaveBase)
        fmt.Printf("&#128196; 目标文件: %s.mp4 (时长解析中...)\n", fileNameBase)
        if proxyAddr != "" {
                fmt.Printf("&#127760; 使用代理: %s\n", proxyAddr)
        } else {
                fmt.Println("&#127760; 直连模式 (无代理)")
        }
        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("&#10060; 错误:无法解析有效分片。")
                return
        }

        durationTag := formatDuration(duration)
        fileNameFinal := fileNameBase + "[" + durationTag + "]"
        finalFilePath := filepath.Join(finalSaveBase, fileNameFinal+".mp4")
        fmt.Printf("\n&#127919; 最终路径: %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&#128640; 进度: [%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&#128640; 进度: [%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&#9989; 任务完成:", finalFilePath)
                if !globalCfg.KeepTmp {
                        os.RemoveAll(tmpDir)
                }
        } else {
                fmt.Printf("\n&#10060; 合并失败: %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&#128269; 开始路径特征扫描...(%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&#9888;&#65039; 去重完成:过滤了 %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&#128202; 统计基准: 主路径指纹 [%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&#128286; 拦截结束:基于路径一致性,共剔除 %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("&#10060; 解析失败: %v\n", err)
                return nil, 0
        }
        var finalSegs []SegmentMeta
        if keepAds {
                fmt.Println("&#8505;&#65039; 模式: 完整下载 (已跳过广告检测)")
                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("&#9888;&#65039; 代理解析失败: %v\n", err)
                                }
                        }
                }
        }

        return &http.Client{
                Transport: t,
        }
}

免费评分

参与人数 8吾爱币 +13 热心值 +6 收起 理由
douche2021 + 1 我很赞同!
mancong122 + 1 我很赞同!
苏紫方璇 + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
timmit + 1 + 1 我很赞同!
hurric + 1 + 1 谢谢@Thanks!
Dzhy + 1 鼓励转贴优秀软件安全工具和文档!
enjoylifenow + 1 + 1 热心回复!
KKBon + 1 + 1 谢谢@Thanks!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
 楼主| saobee 发表于 2026-5-29 19:42 |楼主
WXJYXLWMH 发表于 2026-5-29 19:04
m3u8down -u "htpps:\\www.xxxxx.m3u8"  直接闪退
m3u8down -u ".m3u8" -n "视频名称" -d "Download" -t " ...

测试没问题啊,下面是输出,你可以直接下载源码编译看看

m3u8down.exe -u "https://文件网址/video.m3u8" -n "标题"
-------------------------------------------------------
&#128194; 保存目录: E:\QQDownload
&#128196; 目标文件: 标题.mp4 (时长解析中...)
&#127760; 直连模式 [fasthttp 零分配引擎]
-------------------------------------------------------
[1/3] 解析 M3U8 并重算时长...

&#127919; 最终路径: E:\QQDownload\标题[21_26].mp4

[2/3] 极速下载模式: 固定 16 线程 | 实际待下载: 322 | 时长: 21_26
&#128640; 下载分片 7.00 MB/s [00:09] T16  84% |█████████████████████████████████       | (271/322, 11 ts/s)
推荐
汇成河流 发表于 2026-5-30 12:25
工具非常好用,感谢楼主,我增加了批量下载模式,直接类似:
https://vip.lz-cdn17.com/20230414/8238_4f22216d/2000k/hls/mixed.m3u8----茶啊二中001
https://vip.lz-cdn17.com/20230414/8236_69d48f34/2000k/hls/mixed.m3u8----茶啊二中002
原来的只输入地址下载的模式没变,但是识别到----这样的四个减号就变为批量模式,从上到下逐个下载。
这样的复制进去一路回车就可以了,而且下载完成后默认回车依然是退出,同时增加了可以继续进行的选项。
gom3u8_source.rar (13.14 KB, 下载次数: 1)
沙发
dork 发表于 2026-3-18 08:11
3#
wangcd 发表于 2026-3-18 08:15
感谢分享,但是是不是有些网站下载需要配合cookie之类的?
4#
你是神 发表于 2026-3-18 08:40
带广告分片过滤功能,有没有成品
5#
lypxynok 发表于 2026-3-18 08:40
学习一下下载思路,感谢楼主分享
6#
goodyang 发表于 2026-3-18 08:46
学习一下,感谢分享
7#
zhaoyuxuan 发表于 2026-3-18 08:56
不错不错 顶!
8#
hurric 发表于 2026-3-18 08:59
学习一下 感谢分享
9#
royswift 发表于 2026-3-18 09:11
这种工具下载在线视频特别实用,感谢!
10#
suiyuebuguomeng 发表于 2026-3-19 10:24
带广告分片过滤 强
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-6-19 06:43

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表