[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 // 全局下载字节计数
currentSpeed float64 // 全局当前网速 (MB/s)
exeDir string // 程序所在目录
exeAbsPath string // 程序自身绝对路径
)
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 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) {
atomic.StoreInt64(&totalBytes, 0)
currentSpeed = 0
urlHash := getMD5(targetUrl)
fileNameBase := chooseFileName(fileName, urlHash)
// 创建带连接池和超时的 HTTP Client
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 并重算时长 (处理分片)...")
// 修正调用:传入 globalCfg.FullDownload
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("🎯 最终路径: %s\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)
stopSpeedometer := make(chan bool)
go func() {
var lastBytes int64
var history []float64
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
nowBytes := atomic.LoadInt64(&totalBytes)
delta := nowBytes - lastBytes
if delta < 0 {
delta = 0
}
instantSpeed := float64(delta) / 1024 / 1024
history = append(history, instantSpeed)
if len(history) > 3 {
history = history[1:]
}
var sum float64
for _, v := range history {
sum += v
}
currentSpeed = sum / float64(len(history))
lastBytes = nowBytes
case <-stopSpeedometer:
return
}
}
}()
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 mu sync.Mutex
var activeThreads int32
completed := 0
startTime := time.Now()
fmt.Printf("[2/3] 下载分片: %d | 时长: %s | 续传脚本: tmp\\%s\\resume_download.bat\n", total, durationTag, urlHash)
for i := 0; i < threads; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range taskChan {
atomic.AddInt32(&activeThreads, 1)
tsPath := filepath.Join(tmpDir, fmt.Sprintf("%s_%d.ts", urlHash, idx+1))
if info, err := os.Stat(tsPath); err == nil && info.Size() > 0 {
// 已存在则跳过
} else {
retryDownload(httpClient, segments[idx].URL, tsPath, headers, globalCfg.Retries)
}
atomic.AddInt32(&activeThreads, -1)
mu.Lock()
completed++
percentage := float64(completed) / float64(total)
var etaStr string = "--:--"
if percentage > 0 && currentSpeed > 0 {
estimatedTotalBytes := float64(atomic.LoadInt64(&totalBytes)) / percentage
remainingBytes := estimatedTotalBytes - float64(atomic.LoadInt64(&totalBytes))
etaSeconds := remainingBytes / (currentSpeed * 1024 * 1024)
if etaSeconds > 0 && etaSeconds < 360000 {
etaStr = fmt.Sprintf("%02d:%02d", int(etaSeconds)/60, int(etaSeconds)%60)
}
_ = time.Since(startTime).Seconds()
}
fmt.Printf("\r🚀 进度: [%d/%d] %.1f%% | 已下: %.1f MB | 速度: %.2f MB/s | 剩余: %s | 活动: %d/%d ",
completed, total, percentage*100, float64(atomic.LoadInt64(&totalBytes))/1024/1024, currentSpeed, etaStr, atomic.LoadInt32(&activeThreads), threads)
mu.Unlock()
}
}()
}
for i := 0; i < total; i++ {
taskChan <- i
}
close(taskChan)
wg.Wait()
stopSpeedometer <- true
currentSpeed = 0
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❌ 合并失败。")
}
}
// 1. 核心解析:只负责提取数据,不进行任何剔除
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)
// 处理 Master Playlist
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)
}
// 处理 Media Playlist
if listType == m3u8.MEDIA {
mpl := p.(*m3u8.MediaPlaylist)
var segs []SegmentMeta
curKeyURL, curKeyIV := "", ""
for _, v := range mpl.Segments {
if v == nil {
continue
}
// AES-128 密钥处理
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], "/")
}
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")
}
// 2. 智能过滤:根据重复、域名和路径特征进行分析
func filterAds(segs []SegmentMeta) []SegmentMeta {
if len(segs) <= 3 {
return 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("⚠️ [去重] 过滤了 %d 个重复分片\n", repeatCount)
}
patternDuration := make(map[string]float64)
for _, s := range uniqueSegs {
patternDuration[s.Pattern] += s.Duration
}
var mainPattern string
var maxDuration float64
for p, dur := range patternDuration {
if dur > maxDuration {
maxDuration = dur
mainPattern = p
}
}
var clean []SegmentMeta
adCount := 0
for _, s := range uniqueSegs {
if s.Pattern == mainPattern || patternDuration[s.Pattern] > (maxDuration*0.2) {
clean = append(clean, s)
} else {
adCount++
}
}
if adCount > 0 {
fmt.Printf("🎯 [智能拦截] 基于路径指纹和频率分析,已剔除 %d 个广告分片\n", adCount)
}
return clean
}
// 3. 入口函数:控制流程并汇总结果
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
}
func retryDownload(client *http.Client, u, path string, headers map[string]string, maxRetries int) {
tmpPath := path + ".tmp"
for i := 0; i <= maxRetries; i++ {
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 && resp.StatusCode == 200 {
f, err := os.Create(tmpPath)
if err != nil {
cancel()
continue
}
bufWriter := bufio.NewWriterSize(f, 256*1024)
tee := io.TeeReader(resp.Body, &progressWriter{})
n, copyErr := io.Copy(bufWriter, tee)
flushErr := bufWriter.Flush()
f.Close()
resp.Body.Close()
cancel()
if copyErr == nil && flushErr == nil {
if strings.HasSuffix(path, ".key") && n > 16 {
d, _ := os.ReadFile(tmpPath)
os.WriteFile(path, d[:16], 0644)
os.Remove(tmpPath)
} else {
os.Rename(tmpPath, path)
}
return
}
os.Remove(tmpPath)
}
if resp != nil {
resp.Body.Close()
}
cancel()
time.Sleep(time.Duration(i+1) * time.Second)
}
}
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.0 (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)
}
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,
}
if p != "" {
u, _ := url.Parse(p)
if strings.HasPrefix(u.Scheme, "http") {
t.Proxy = http.ProxyURL(u)
} else if strings.HasPrefix(u.Scheme, "socks5") {
d, _ := proxy.FromURL(u, proxy.Direct)
t.Dial = d.Dial
}
}
return &http.Client{
Transport: t,
}
}