228 lines
5.6 KiB
Go
228 lines
5.6 KiB
Go
package app
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func (d *Downloader) downloadMediaWithProgress(ctx context.Context, task Task, callback func(DownloadProgress)) error {
|
|
if ok, size := existingGood(task.OutPath, d.cfg.SkipBytes); ok {
|
|
if callback != nil {
|
|
callback(DownloadProgress{Percent: 100, DownloadedBytes: size, TotalBytes: size, Phase: "completed"})
|
|
}
|
|
return nil
|
|
}
|
|
if callback != nil {
|
|
callback(DownloadProgress{Phase: "preparing"})
|
|
}
|
|
partPath := strings.TrimSuffix(task.OutPath, filepath.Ext(task.OutPath)) + ".part.mp4"
|
|
if err := os.MkdirAll(filepath.Dir(task.OutPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
progress := newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback)
|
|
progress.report("resolving", true)
|
|
retries := d.cfg.Retries
|
|
if retries <= 0 {
|
|
retries = 3
|
|
}
|
|
var lastErr error
|
|
for attempt := 1; attempt <= retries; attempt++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if attempt > 1 {
|
|
_ = os.Remove(partPath)
|
|
progress = newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback)
|
|
progress.report("retrying", true)
|
|
select {
|
|
case <-time.After(time.Duration(attempt*2) * time.Second):
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
progress.report("resolving", true)
|
|
media, err := d.resolveProviderMedia(ctx, task)
|
|
if err != nil {
|
|
lastErr = publicError(err)
|
|
continue
|
|
}
|
|
media, err = d.selectDownloadQuality(ctx, task, media)
|
|
if err != nil {
|
|
lastErr = publicError(err)
|
|
continue
|
|
}
|
|
if len(media.CENCKey) != 0 && (len(media.CENCKey) != 16 || media.Playlist != "") {
|
|
return errors.New("CENC 媒体的密钥或格式无效")
|
|
}
|
|
if handled, directErr := d.downloadDirectProviderMedia(ctx, task, media, nil, progress); handled {
|
|
if directErr == nil {
|
|
return nil
|
|
}
|
|
lastErr = directErr
|
|
continue
|
|
}
|
|
ffmpeg, err := d.ensureFFmpeg(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = os.Remove(partPath)
|
|
progress.setMediaTotal(media.Duration)
|
|
proxy, err := d.newHLSProxy(ctx, media, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if media.Playlist != "" {
|
|
if cache, cacheErr := prepareDownloadMediaCache(partPath+".segments", media, nil); cacheErr == nil {
|
|
proxy.cache = cache
|
|
} else {
|
|
proxy.Close()
|
|
lastErr = cacheErr
|
|
continue
|
|
}
|
|
}
|
|
inputURL := proxy.root
|
|
origin, _ := url.Parse(media.Referer)
|
|
headers := fmt.Sprintf("User-Agent: %s\r\nReferer: %s\r\n", userAgent, media.Referer)
|
|
if origin != nil && origin.Host != "" {
|
|
headers += "Origin: " + origin.Scheme + "://" + origin.Host + "\r\n"
|
|
}
|
|
cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
|
args := []string{
|
|
"-hide_banner", "-loglevel", "error", "-nostats",
|
|
"-rw_timeout", "20000000",
|
|
"-protocol_whitelist", "http,https,tcp,tls,crypto,httpproxy",
|
|
"-headers", headers,
|
|
}
|
|
if media.Playlist != "" {
|
|
args = append(args, "-allowed_extensions", "ALL")
|
|
}
|
|
if len(media.CENCKey) > 0 {
|
|
args = append(args, "-decryption_key", hex.EncodeToString(media.CENCKey))
|
|
}
|
|
args = append(args,
|
|
"-i", inputURL,
|
|
"-c", "copy",
|
|
"-progress", "pipe:1",
|
|
"-f", "mp4",
|
|
"-y", partPath,
|
|
)
|
|
cmd := ffmpegMediaCommand(cmdCtx, ffmpeg, args...)
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
if proxy != nil {
|
|
proxy.Close()
|
|
}
|
|
cancel()
|
|
return err
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
if proxy != nil {
|
|
proxy.Close()
|
|
}
|
|
cancel()
|
|
return err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
if proxy != nil {
|
|
proxy.Close()
|
|
}
|
|
cancel()
|
|
lastErr = err
|
|
continue
|
|
}
|
|
progress.report("downloading", true)
|
|
stderrDone := make(chan string, 1)
|
|
go func() {
|
|
var b cappedStringWriter
|
|
b.limit = 64 * 1024
|
|
_, _ = io.Copy(&b, stderr)
|
|
stderrDone <- b.String()
|
|
}()
|
|
progressDone := make(chan struct{})
|
|
go func() {
|
|
defer close(progressDone)
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
key, val, ok := strings.Cut(scanner.Text(), "=")
|
|
if ok && (key == "out_time_us" || key == "out_time_ms") {
|
|
if micros, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64); err == nil && micros >= 0 {
|
|
progress.setMediaElapsed(time.Duration(micros) * time.Microsecond)
|
|
progress.report("downloading", false)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
tickerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(tickerDone)
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
progress.report("downloading", false)
|
|
case <-progressDone:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
waitErr := cmd.Wait()
|
|
<-progressDone
|
|
<-tickerDone
|
|
stderrText := <-stderrDone
|
|
commandErr := cmdCtx.Err()
|
|
cancel()
|
|
var proxyErr error
|
|
if proxy != nil {
|
|
proxyErr = proxy.Err()
|
|
proxy.Close()
|
|
}
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return ctxErr
|
|
}
|
|
if commandErr != nil {
|
|
lastErr = commandErr
|
|
_ = os.Remove(partPath)
|
|
continue
|
|
}
|
|
if proxyErr != nil {
|
|
lastErr = proxyErr
|
|
continue
|
|
}
|
|
if waitErr != nil {
|
|
lastErr = fmt.Errorf("ffmpeg failed: %v %s", waitErr, truncate(stderrText, 1000))
|
|
_ = os.Remove(partPath)
|
|
continue
|
|
}
|
|
st, err := os.Stat(partPath)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if st.Size() < 100*1024 {
|
|
lastErr = fmt.Errorf("downloaded file too small: %d bytes", st.Size())
|
|
_ = os.Remove(partPath)
|
|
continue
|
|
}
|
|
if err := os.Rename(partPath, task.OutPath); err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
_ = os.RemoveAll(partPath + ".segments")
|
|
progress.report("completed", true)
|
|
return nil
|
|
}
|
|
return lastErr
|
|
}
|