Files
djk/internal/app/downloader.go
T

327 lines
9.0 KiB
Go
Raw 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 app
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
type Downloader struct {
cfg Config
client *http.Client
huangdouDetails map[string]huangdouDetailEntry
huangdouDetailPending map[string]*huangdouDetailCall
providerMu sync.Mutex
providerHosts map[string]string
apiMu sync.Mutex
apiBase string
limiter *requestLimiter
proxyRouter *proxyRouter
ffmpegMu sync.Mutex
ffmpegInstaller *ffmpegInstaller
hongguoOnce sync.Once
hongguo *hongguoAppClient
legacyOnce sync.Once
legacy *legacyAPIClient
rankings rankingCache
diagnostics *diagnosticLog
}
func NewDownloader(cfg Config) *Downloader {
if cfg.OutputDir == "" {
cfg.OutputDir = defaultOutputDir()
}
if cfg.FFmpeg == "" {
cfg.FFmpeg = "ffmpeg"
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 2
}
if cfg.RequestConcurrency <= 0 {
cfg.RequestConcurrency = 2
}
if cfg.RequestIntervalMS <= 0 {
cfg.RequestIntervalMS = 500
}
loadRuntimeSettings(&cfg)
if absolute, err := filepath.Abs(cfg.dataDirectory()); err == nil {
cfg.dataDir = absolute
}
cfg.outputDirSetting = cfg.OutputDir
if absolute, err := filepath.Abs(cfg.OutputDir); err == nil {
cfg.OutputDir = absolute
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.InsecureTLS}
transport.MaxIdleConnsPerHost = 8
transport.ResponseHeaderTimeout = 20 * time.Second
router := &proxyRouter{}
router.configure(cfg.ProxyURL)
transport.Proxy = router.proxy
resolver := newSafeDNSDialer(transport)
transport.DialContext = resolver.DialContext
downloader := &Downloader{cfg: cfg, providerHosts: map[string]string{}, limiter: newRequestLimiter(cfg.RequestConcurrency, time.Duration(cfg.RequestIntervalMS)*time.Millisecond), proxyRouter: router, diagnostics: newDiagnosticLog(cfg.dataDirectory())}
images := newImageTransport(newHuangguoBrowserTransport(newCDNTransport(transport, resolver), downloader), transport)
images.lookup = func(ctx context.Context, host string) ([]string, error) {
if protectedCDNHost(host) {
return resolver.lookup(ctx, host)
}
return net.DefaultResolver.LookupHost(ctx, host)
}
images.fallback = func(ctx context.Context, host string) ([]string, error) {
entry, err := resolver.lookupEntry(ctx, host, cdnAlternateSubnet)
return entry.addresses, err
}
downloader.client = &http.Client{Transport: images, Timeout: 45 * time.Second}
return downloader
}
func (d *Downloader) DownloadEpisode(ctx context.Context, task Task) error {
return d.DownloadEpisodeWithProgress(ctx, task, nil)
}
func (d *Downloader) DownloadEpisodeWithProgress(ctx context.Context, task Task, callback func(DownloadProgress)) (resultErr error) {
defer func() {
if ctx.Err() == nil {
d.recordTaskFailure("download.failed", task, 0, resultErr)
}
}()
if isHuangguoProviderSource(task.Chapter.Source) || task.Chapter.PageURL != "" || isProviderHTTPMediaURL(task.Chapter.VideoURL) || strings.HasPrefix(task.Chapter.VideoURL, "hongguo-cenc://") {
return d.downloadHuangguoProviderMediaWithProgress(ctx, task, callback)
}
if task.Chapter.VideoURL == "" {
return errors.New("chapter videoUrl is empty")
}
if ok, size := existingGood(task.OutPath, d.cfg.SkipBytes); ok {
fmt.Printf(" 已存在,跳过:%s (%.1f MB)\n", task.OutPath, float64(size)/1024/1024)
if callback != nil {
total := task.Chapter.MediaSize
if total <= 0 {
total = size
}
callback(DownloadProgress{Percent: 100, DownloadedBytes: size, TotalBytes: total, Phase: "completed"})
}
return nil
}
partPath := strings.TrimSuffix(task.OutPath, filepath.Ext(task.OutPath)) + ".part.mp4"
_ = os.Remove(partPath)
defer os.Remove(partPath)
if callback != nil {
callback(DownloadProgress{Phase: "preparing"})
}
ffmpeg, err := d.ensureFFmpeg(ctx)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(task.OutPath), 0o755); err != nil {
return err
}
progress := newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback)
progress.report("downloading", true)
var lastErr error
for attempt := 1; attempt <= d.cfg.Retries; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
func() {
media, key, resolveErr := d.resolvePlaybackMedia(ctx, task)
if resolveErr != nil {
lastErr = resolveErr
return
}
media, resolveErr = d.selectDownloadQuality(ctx, task, media)
if resolveErr != nil {
lastErr = resolveErr
return
}
progress.setMediaTotal(media.Duration)
progress.report("downloading", true)
proxy, err := d.newHLSProxy(ctx, media, key)
if err != nil {
lastErr = err
return
}
defer proxy.Close()
args := []string{
"-hide_banner", "-loglevel", "error", "-nostats",
"-allowed_extensions", "ALL",
"-protocol_whitelist", "http,https,tcp,tls,crypto,httpproxy",
"-rw_timeout", "20000000",
"-i", proxy.root,
"-c", "copy",
"-progress", "pipe:1",
"-f", "mp4",
"-y", partPath,
}
cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
cmd := ffmpegMediaCommand(cmdCtx, ffmpeg, args...)
stdout, pipeErr := cmd.StdoutPipe()
if pipeErr != nil {
lastErr = pipeErr
return
}
stderr, pipeErr := cmd.StderrPipe()
if pipeErr != nil {
lastErr = pipeErr
return
}
if startErr := cmd.Start(); startErr != nil {
lastErr = startErr
return
}
stderrDone := make(chan string, 1)
go func() {
var stderrBuf cappedStringWriter
stderrBuf.limit = 64 * 1024
_, _ = io.Copy(&stderrBuf, stderr)
stderrDone <- stderrBuf.String()
}()
progressDone := make(chan struct{})
go func() {
defer close(progressDone)
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch key {
case "out_time_us", "out_time_ms":
value = strings.TrimSpace(value)
if value == "" || value == "N/A" {
continue
}
if micros, parseErr := strconv.ParseInt(value, 10, 64); parseErr == nil && micros >= 0 {
progress.setMediaElapsed(time.Duration(micros) * time.Microsecond)
progress.report("downloading", false)
}
case "progress":
if strings.TrimSpace(value) == "end" {
progress.report("downloading", true)
}
}
}
}()
tickerDone := make(chan struct{})
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
defer close(tickerDone)
for {
select {
case <-ticker.C:
progress.report("downloading", false)
case <-progressDone:
return
}
}
}()
waitErr := cmd.Wait()
<-progressDone
<-tickerDone
stderrText := <-stderrDone
if ctxErr := ctx.Err(); ctxErr != nil {
lastErr = ctxErr
return
}
if proxyErr := proxy.Err(); proxyErr != nil {
lastErr = proxyErr
return
}
if waitErr != nil {
lastErr = fmt.Errorf("ffmpeg failed: %v %s", waitErr, truncate(stderrText, 1000))
_ = os.Remove(partPath)
return
}
st, statErr := os.Stat(partPath)
if statErr != nil {
lastErr = statErr
return
}
if st.Size() < 100*1024 {
lastErr = fmt.Errorf("downloaded file too small: %d bytes", st.Size())
_ = os.Remove(partPath)
return
}
if renameErr := os.Rename(partPath, task.OutPath); renameErr != nil {
lastErr = renameErr
return
}
lastErr = nil
}()
if lastErr == nil {
progress.report("completed", true)
return nil
}
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
fmt.Printf(" 尝试 %d/%d 失败:%s:%v\n", attempt, d.cfg.Retries, filepath.Base(task.OutPath), lastErr)
if attempt < d.cfg.Retries {
select {
case <-time.After(time.Duration(attempt*2) * time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
}
return lastErr
}
func DownloadTasks(ctx context.Context, d *Downloader, tasks []Task) []Result {
if len(tasks) == 0 {
return nil
}
jobs := make(chan Task)
results := make(chan Result)
var done int64
workers := d.cfg.Concurrency
if workers > len(tasks) {
workers = len(tasks)
}
var wg sync.WaitGroup
for i := 1; i <= workers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for task := range jobs {
cur := atomic.AddInt64(&done, 1)
fmt.Printf(" [线程%d] [%d/%d] 《%s》 第%d/%d 集\n", id, cur, len(tasks), task.DramaTitle, task.Index, task.Total)
err := d.DownloadEpisode(ctx, task)
if err != nil {
results <- Result{Task: task, OK: false, Err: err.Error()}
} else {
results <- Result{Task: task, OK: true}
}
}
}(i)
}
go func() {
for _, task := range tasks {
jobs <- task
}
close(jobs)
wg.Wait()
close(results)
}()
var out []Result
for r := range results {
out = append(out, r)
}
return out
}