mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
internal/download The ~40-line progress-bar decorator stack was copy-pasted into three download paths and had started to drift; it is now newDownloadBar, which returns a nil bar when progress is off (so callers use it unconditionally) plus a finish func to defer. The three hand-rolled read/write loops that differed only in whether they touched a bar become copyWithProgress. FileDeezerEncrypted and FileYandexEncrypted were ~90% identical: mkdir, GET, status check, create output, bar, cleanup-on-failure, Content-Length check, fsync. That scaffolding is now streamEncrypted, and each function supplies only its decryption step. Also switches the io.EOF/io.ErrUnexpectedEOF comparisons to errors.Is. internal/app Three copies of "coerce a JSON array that may be []any or []map[string]any" become anySlice, which also reports whether the value was an array at all so callers can still distinguish missing from empty. The worker-pool dispatch existed three times and the copies disagreed: two counted failures under a mutex and logged inline with the track id, while the third collected errors in a channel and logged them all after wg.Wait() without the id. runTrackWorkers unifies them on the inline form, which reports failures as they happen and names the track, and keeps the mutex held across the log so failure lines cannot interleave. Tests The progress-bar paths had no coverage at all, because progress is disabled when not attached to a terminal; four new tests force it on via the existing STREAMRIP_GO_FORCE_PROGRESS escape hatch and cover the determinate bar, the unknown-length spinner, Deezer decryption with a bar attached, and the failure path still removing the partial file. internal/download coverage 53% -> 63%. Three cancellation tests pin the Ctrl-C fix. Verified as genuine regression tests by reverting the guards: without them the rip walks all 40 tracks of a cancelled album, logs a failure for each, and Rip() returns nil. Adds store.SQLite.IsFailed, the read complement to MarkFailed, used by the bookkeeping test to confirm failure records still land under a cancelled context. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
793 lines
21 KiB
Go
793 lines
21 KiB
Go
package download
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"github.com/vbauerster/mpb/v8"
|
|
"github.com/vbauerster/mpb/v8/decor"
|
|
"golang.org/x/term"
|
|
|
|
"streamrip-go/internal/config"
|
|
"streamrip-go/internal/netutil"
|
|
"streamrip-go/internal/verbose"
|
|
|
|
"golang.org/x/crypto/blowfish"
|
|
)
|
|
|
|
type Downloader struct {
|
|
http *http.Client
|
|
showProgress bool
|
|
progress *mpb.Progress
|
|
barStarted atomic.Int32
|
|
|
|
// DeezerBlowfishSecret overrides the built-in Deezer protocol secret.
|
|
// Set from config by app.New; empty means use the default.
|
|
DeezerBlowfishSecret string
|
|
}
|
|
|
|
// downloadBufferSize sizes HTTP-to-disk copies. 1 MiB cuts read/write syscalls
|
|
// ~32x vs Go's default 32 KiB io.Copy buffer, which matters for multi-MB FLAC
|
|
// streams off CDNs that can sustain high per-connection throughput.
|
|
const downloadBufferSize = 1 << 20
|
|
|
|
func New() *Downloader {
|
|
return NewWithOptions(true, true, 0)
|
|
}
|
|
|
|
func NewWithOptions(verifySSL bool, showProgress bool, maxConnsPerHost int) *Downloader {
|
|
forceProgress := strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "1") || strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "true")
|
|
interactive := showProgress && (forceProgress || (term.IsTerminal(int(os.Stderr.Fd())) && strings.ToLower(os.Getenv("TERM")) != "dumb"))
|
|
d := &Downloader{http: netutil.NewHTTPClient(0, verifySSL, maxConnsPerHost), showProgress: interactive}
|
|
if interactive {
|
|
d.progress = mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr))
|
|
}
|
|
return d
|
|
}
|
|
|
|
func (d *Downloader) File(ctx context.Context, sourceURL, outputPath string) error {
|
|
return d.file(ctx, sourceURL, outputPath, true, false)
|
|
}
|
|
|
|
func (d *Downloader) FileNoProgress(ctx context.Context, sourceURL, outputPath string) error {
|
|
return d.file(ctx, sourceURL, outputPath, false, false)
|
|
}
|
|
|
|
func (d *Downloader) FileVideo(ctx context.Context, sourceURL, outputPath string) error {
|
|
return d.file(ctx, sourceURL, outputPath, true, true)
|
|
}
|
|
|
|
// streamEncrypted performs the shared scaffolding for an encrypted download:
|
|
// create the output directory, GET the URL, check the status, create the output
|
|
// file, attach a progress bar, clean up a partial file on failure, verify the
|
|
// byte count against Content-Length and fsync.
|
|
//
|
|
// decode does the format-specific work of turning the response body into plain
|
|
// audio on dst and returns the number of bytes written. FileDeezerEncrypted and
|
|
// FileYandexEncrypted were ~90% identical before this.
|
|
func (d *Downloader) streamEncrypted(
|
|
ctx context.Context,
|
|
sourceURL, outputPath string,
|
|
decode func(dst io.Writer, src io.Reader, bar *mpb.Bar) (int64, error),
|
|
) error {
|
|
logDownloadStart(sourceURL, outputPath)
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := d.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
|
|
}
|
|
|
|
out, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
success := false
|
|
defer func() {
|
|
_ = out.Close()
|
|
if !success {
|
|
_ = os.Remove(outputPath)
|
|
}
|
|
}()
|
|
|
|
bar, finishBar := d.newDownloadBar(outputPath, resp.ContentLength)
|
|
defer func() { finishBar(success) }()
|
|
|
|
written, err := decode(out, resp.Body, bar)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.ContentLength > 0 && written != resp.ContentLength {
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
if err = out.Sync(); err != nil {
|
|
return err
|
|
}
|
|
|
|
success = true
|
|
return nil
|
|
}
|
|
|
|
// FileDeezerEncrypted downloads a Deezer stream and decrypts it. Deezer
|
|
// encrypts every third 2048-byte chunk with Blowfish-CBC; a short final chunk
|
|
// is left as-is.
|
|
func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputPath, trackID string) error {
|
|
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(d.blowfishSecret(), trackID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return d.streamEncrypted(ctx, sourceURL, outputPath, func(dst io.Writer, src io.Reader, bar *mpb.Bar) (int64, error) {
|
|
buf := make([]byte, deezerBFChunkSize)
|
|
dec := make([]byte, deezerBFChunkSize)
|
|
chunkIndex := 0
|
|
total := int64(0)
|
|
for {
|
|
n, readErr := io.ReadFull(src, buf)
|
|
if errors.Is(readErr, io.EOF) {
|
|
return total, nil
|
|
}
|
|
if readErr != nil && !errors.Is(readErr, io.ErrUnexpectedEOF) {
|
|
return total, readErr
|
|
}
|
|
|
|
chunk := buf[:n]
|
|
if chunkIndex%3 == 0 && n == deezerBFChunkSize {
|
|
mode := cipher.NewCBCDecrypter(block, deezerBFIV)
|
|
mode.CryptBlocks(dec[:n], chunk)
|
|
chunk = dec[:n]
|
|
}
|
|
if _, writeErr := dst.Write(chunk); writeErr != nil {
|
|
return total, writeErr
|
|
}
|
|
total += int64(n)
|
|
if bar != nil {
|
|
bar.IncrBy(n)
|
|
}
|
|
chunkIndex++
|
|
|
|
if errors.Is(readErr, io.ErrUnexpectedEOF) {
|
|
return total, nil
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// FileYandexEncrypted downloads a Yandex stream and decrypts it. Yandex uses
|
|
// AES-CTR over the whole body with a zero IV.
|
|
func (d *Downloader) FileYandexEncrypted(ctx context.Context, sourceURL, outputPath, key string) error {
|
|
keyBytes, err := hex.DecodeString(strings.TrimSpace(key))
|
|
if err != nil {
|
|
return fmt.Errorf("invalid yandex key: %w", err)
|
|
}
|
|
if len(keyBytes) != 16 {
|
|
return fmt.Errorf("invalid yandex key length: %d", len(keyBytes))
|
|
}
|
|
block, err := aes.NewCipher(keyBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return d.streamEncrypted(ctx, sourceURL, outputPath, func(dst io.Writer, src io.Reader, bar *mpb.Bar) (int64, error) {
|
|
stream := cipher.NewCTR(block, make([]byte, aes.BlockSize))
|
|
return copyWithProgress(dst, &cipher.StreamReader{S: stream, R: src}, bar)
|
|
})
|
|
}
|
|
|
|
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error {
|
|
logDownloadStart(sourceURL, outputPath)
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := d.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
|
|
}
|
|
|
|
reader := bufio.NewReader(resp.Body)
|
|
peek, _ := reader.Peek(1024)
|
|
if isManifestResponse(resp.Header.Get("Content-Type"), peek) {
|
|
_ = resp.Body.Close()
|
|
return d.streamManifestWithFFmpeg(ctx, sourceURL, outputPath, includeVideo)
|
|
}
|
|
|
|
out, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
success := false
|
|
defer func() {
|
|
_ = out.Close()
|
|
if !success {
|
|
_ = os.Remove(outputPath)
|
|
}
|
|
}()
|
|
|
|
var bar *mpb.Bar
|
|
finishBar := func(bool) {}
|
|
if allowProgress {
|
|
bar, finishBar = d.newDownloadBar(outputPath, resp.ContentLength)
|
|
}
|
|
defer func() { finishBar(success) }()
|
|
|
|
written, err := copyWithProgress(out, reader, bar)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.ContentLength > 0 && written != resp.ContentLength {
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
if err = out.Sync(); err != nil {
|
|
return err
|
|
}
|
|
|
|
success = true
|
|
return nil
|
|
}
|
|
|
|
func (d *Downloader) Close() {
|
|
if d.progress != nil {
|
|
d.progress.Wait()
|
|
}
|
|
}
|
|
|
|
func (d *Downloader) ProgressEnabled() bool {
|
|
return d.showProgress && d.progress != nil
|
|
}
|
|
|
|
func (d *Downloader) Logf(format string, args ...any) {
|
|
msg := fmt.Sprintf(format, args...)
|
|
if d.ProgressEnabled() && d.barStarted.Load() == 1 {
|
|
_, _ = d.progress.Write([]byte(msg))
|
|
return
|
|
}
|
|
fmt.Print(msg)
|
|
}
|
|
|
|
// copyWithProgress copies src to dst, reporting progress to bar when it is
|
|
// non-nil. It replaces three hand-rolled read/write loops that differed only
|
|
// in whether they touched a progress bar.
|
|
func copyWithProgress(dst io.Writer, src io.Reader, bar *mpb.Bar) (int64, error) {
|
|
if bar == nil {
|
|
return io.CopyBuffer(dst, src, make([]byte, downloadBufferSize))
|
|
}
|
|
buf := make([]byte, downloadBufferSize)
|
|
total := int64(0)
|
|
for {
|
|
n, readErr := src.Read(buf)
|
|
if n > 0 {
|
|
if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
|
|
return total, writeErr
|
|
}
|
|
total += int64(n)
|
|
bar.IncrBy(n)
|
|
}
|
|
if readErr != nil {
|
|
if errors.Is(readErr, io.EOF) {
|
|
return total, nil
|
|
}
|
|
return total, readErr
|
|
}
|
|
}
|
|
}
|
|
|
|
// newDownloadBar creates the progress bar for a download: a determinate bar
|
|
// when the content length is known, a spinner when it is not. It returns a nil
|
|
// bar when progress display is off, so callers can use the result
|
|
// unconditionally, plus a finish func to defer.
|
|
//
|
|
// This block was copy-pasted into all three download paths; the decorator
|
|
// stacks had already started to drift apart.
|
|
func (d *Downloader) newDownloadBar(outputPath string, contentLength int64) (*mpb.Bar, func(success bool)) {
|
|
if !d.ProgressEnabled() {
|
|
return nil, func(bool) {}
|
|
}
|
|
d.barStarted.Store(1)
|
|
desc := shortenName(filepath.Base(outputPath), 54)
|
|
|
|
if contentLength > 0 {
|
|
bar := d.progress.AddBar(
|
|
contentLength,
|
|
mpb.PrependDecorators(
|
|
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
|
|
decor.Percentage(decor.WCSyncWidthR),
|
|
),
|
|
mpb.AppendDecorators(
|
|
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
|
|
decor.Name(" | ", decor.WCSyncWidth),
|
|
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
|
|
decor.Name(" | ETA ", decor.WCSyncWidth),
|
|
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
|
|
),
|
|
mpb.BarRemoveOnComplete(),
|
|
)
|
|
return bar, func(success bool) {
|
|
if !success {
|
|
bar.Abort(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
bar := d.progress.AddSpinner(
|
|
0,
|
|
mpb.PrependDecorators(
|
|
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
|
|
),
|
|
mpb.AppendDecorators(
|
|
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
|
|
decor.Name(" | ", decor.WCSyncWidth),
|
|
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
|
|
),
|
|
mpb.BarRemoveOnComplete(),
|
|
)
|
|
// Order matches the original defers, which ran LIFO: abort first, then
|
|
// finalise the spinner total.
|
|
return bar, func(success bool) {
|
|
if !success {
|
|
bar.Abort(true)
|
|
}
|
|
bar.SetTotal(-1, true)
|
|
}
|
|
}
|
|
|
|
// logDownloadStart emits the source URL and destination filename when the
|
|
// user passed -v or higher. The transport-level logger covers the same
|
|
// requests at -vv, but this line gives a friendlier per-track summary.
|
|
func logDownloadStart(sourceURL, outputPath string) {
|
|
if !verbose.Enabled(verbose.V) {
|
|
return
|
|
}
|
|
verbose.Printf(verbose.V, "download %s -> %s\n", sourceURL, filepath.Base(outputPath))
|
|
}
|
|
|
|
func shortenName(name string, max int) string {
|
|
if max <= 0 {
|
|
return name
|
|
}
|
|
r := []rune(name)
|
|
if len(r) <= max {
|
|
return name
|
|
}
|
|
if max <= 3 {
|
|
return string(r[:max])
|
|
}
|
|
return string(r[:max-3]) + "..."
|
|
}
|
|
|
|
func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, outputPath string, includeVideo bool) error {
|
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
return fmt.Errorf("ffmpeg not found for manifest stream: %w", err)
|
|
}
|
|
|
|
args := buildFFmpegStreamArgs(sourceURL, outputPath, includeVideo)
|
|
|
|
if !d.ProgressEnabled() {
|
|
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
_ = os.Remove(outputPath)
|
|
return fmt.Errorf("ffmpeg stream copy failed: %w: %s", err, string(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
d.barStarted.Store(1)
|
|
desc := shortenName(filepath.Base(outputPath), 54)
|
|
spinner := d.progress.AddSpinner(
|
|
0,
|
|
mpb.PrependDecorators(
|
|
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
|
|
),
|
|
mpb.AppendDecorators(
|
|
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
|
|
decor.Name(" | ", decor.WCSyncWidth),
|
|
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
|
|
),
|
|
mpb.BarRemoveOnComplete(),
|
|
)
|
|
|
|
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
spinner.SetTotal(-1, true)
|
|
return fmt.Errorf("ffmpeg stream setup failed: %w", err)
|
|
}
|
|
if err = cmd.Start(); err != nil {
|
|
spinner.SetTotal(-1, true)
|
|
return fmt.Errorf("ffmpeg stream setup failed: %w", err)
|
|
}
|
|
|
|
var ffOut strings.Builder
|
|
scanDone := make(chan scanState, 1)
|
|
go func() {
|
|
state := scanState{}
|
|
shownBytes := int64(0)
|
|
barTotalBytes := int64(0)
|
|
newEstimatedBar := func(totalBytes int64) *mpb.Bar {
|
|
return d.progress.AddBar(
|
|
totalBytes,
|
|
mpb.PrependDecorators(
|
|
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
|
|
decor.Percentage(decor.WCSyncWidthR),
|
|
),
|
|
mpb.AppendDecorators(
|
|
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
|
|
decor.Name(" | ", decor.WCSyncWidth),
|
|
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
|
|
decor.Name(" | ETA ", decor.WCSyncWidth),
|
|
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
|
|
),
|
|
mpb.BarRemoveOnComplete(),
|
|
)
|
|
}
|
|
var bar *mpb.Bar
|
|
scanner := bufio.NewScanner(stderr)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if ffOut.Len() < 256*1024 {
|
|
ffOut.WriteString(line)
|
|
ffOut.WriteByte('\n')
|
|
}
|
|
if totalMS, ok := parseFFmpegDurationLine(line); ok && totalMS > 0 {
|
|
state.totalMS = totalMS
|
|
}
|
|
if bitrateBPS, ok := parseFFmpegDurationBitrateBPS(line); ok && bitrateBPS > 0 {
|
|
state.bitrateBPS = bitrateBPS
|
|
}
|
|
if bitrateBPS, ok := parseFFmpegProgressBitrateBPS(line); ok && bitrateBPS > 0 {
|
|
state.bitrateBPS = bitrateBPS
|
|
}
|
|
if totalSize, ok := parseFFmpegTotalSize(line); ok && totalSize > state.currentBytes {
|
|
state.currentBytes = totalSize
|
|
}
|
|
if currentMS, ok := parseFFmpegOutTime(line); ok {
|
|
if currentMS > state.currentMS {
|
|
state.currentMS = currentMS
|
|
}
|
|
}
|
|
|
|
if bar == nil {
|
|
estimatedTotal := int64(0)
|
|
if state.totalMS > 0 && state.bitrateBPS > 0 {
|
|
estimatedTotal = estimateTotalBytesFromBitrate(state.totalMS, state.bitrateBPS)
|
|
}
|
|
if estimatedTotal == 0 {
|
|
estimatedTotal = estimateTotalBytesFromProgress(state.totalMS, state.currentMS, state.currentBytes)
|
|
}
|
|
if estimatedTotal > 0 {
|
|
if estimatedTotal < state.currentBytes {
|
|
estimatedTotal = state.currentBytes
|
|
}
|
|
barTotalBytes = estimatedTotal
|
|
spinner.SetTotal(-1, true)
|
|
bar = newEstimatedBar(barTotalBytes)
|
|
}
|
|
}
|
|
|
|
if state.currentBytes > shownBytes {
|
|
delta := state.currentBytes - shownBytes
|
|
if bar != nil {
|
|
bar.IncrBy(int(delta))
|
|
if state.currentBytes > barTotalBytes {
|
|
barTotalBytes = state.currentBytes
|
|
bar.SetTotal(barTotalBytes, false)
|
|
}
|
|
} else {
|
|
spinner.IncrBy(int(delta))
|
|
}
|
|
shownBytes = state.currentBytes
|
|
}
|
|
}
|
|
if bar != nil {
|
|
if shownBytes < barTotalBytes {
|
|
bar.IncrBy(int(barTotalBytes - shownBytes))
|
|
}
|
|
bar.SetTotal(barTotalBytes, true)
|
|
} else {
|
|
spinner.SetTotal(-1, true)
|
|
}
|
|
state.currentBytes = shownBytes
|
|
state.scanErr = scanner.Err()
|
|
scanDone <- state
|
|
}()
|
|
|
|
waitErr := cmd.Wait()
|
|
state := <-scanDone
|
|
|
|
if waitErr != nil {
|
|
_ = os.Remove(outputPath)
|
|
if out := strings.TrimSpace(ffOut.String()); out != "" {
|
|
return fmt.Errorf("ffmpeg stream copy failed: %w: %s", waitErr, out)
|
|
}
|
|
return fmt.Errorf("ffmpeg stream copy failed: %w", waitErr)
|
|
}
|
|
if state.scanErr != nil {
|
|
return fmt.Errorf("ffmpeg stream output read failed: %w", state.scanErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildFFmpegStreamArgs(sourceURL, outputPath string, includeVideo bool) []string {
|
|
args := []string{
|
|
"-y",
|
|
"-protocol_whitelist", "file,http,https,tcp,tls,crypto,data",
|
|
"-i", sourceURL,
|
|
}
|
|
if includeVideo {
|
|
args = append(args,
|
|
"-map", "0:v:0?",
|
|
"-map", "0:a:0?",
|
|
)
|
|
} else {
|
|
args = append(args, "-map", "0:a:0")
|
|
}
|
|
args = append(args, "-c", "copy", "-hide_banner", "-nostats", "-progress", "pipe:2", outputPath)
|
|
return args
|
|
}
|
|
|
|
type scanState struct {
|
|
totalMS int64
|
|
currentMS int64
|
|
bitrateBPS int64
|
|
currentBytes int64
|
|
scanErr error
|
|
}
|
|
|
|
func isManifestResponse(contentType string, peek []byte) bool {
|
|
ct := strings.ToLower(contentType)
|
|
if strings.Contains(ct, "dash+xml") || strings.Contains(ct, "mpegurl") || strings.Contains(ct, "vnd.apple.mpegurl") {
|
|
return true
|
|
}
|
|
s := strings.TrimSpace(strings.ToLower(string(peek)))
|
|
if (strings.HasPrefix(s, "<?xml") && strings.Contains(s, "<mpd")) || strings.HasPrefix(s, "<mpd") {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(s, "#extm3u") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseFFmpegDurationLine(line string) (int64, bool) {
|
|
idx := strings.Index(line, "Duration:")
|
|
if idx < 0 {
|
|
return 0, false
|
|
}
|
|
raw := strings.TrimSpace(line[idx+len("Duration:"):])
|
|
if raw == "" {
|
|
return 0, false
|
|
}
|
|
if comma := strings.Index(raw, ","); comma >= 0 {
|
|
raw = raw[:comma]
|
|
}
|
|
return parseClockDurationMS(raw)
|
|
}
|
|
|
|
func parseFFmpegOutTime(line string) (int64, bool) {
|
|
if !strings.HasPrefix(line, "out_time=") {
|
|
return 0, false
|
|
}
|
|
raw := strings.TrimSpace(strings.TrimPrefix(line, "out_time="))
|
|
if raw == "" || strings.EqualFold(raw, "N/A") {
|
|
return 0, false
|
|
}
|
|
return parseClockDurationMS(raw)
|
|
}
|
|
|
|
func parseFFmpegTotalSize(line string) (int64, bool) {
|
|
if !strings.HasPrefix(line, "total_size=") {
|
|
return 0, false
|
|
}
|
|
raw := strings.TrimSpace(strings.TrimPrefix(line, "total_size="))
|
|
if raw == "" || strings.EqualFold(raw, "N/A") {
|
|
return 0, false
|
|
}
|
|
v, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil || v < 0 {
|
|
return 0, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
func parseFFmpegDurationBitrateBPS(line string) (int64, bool) {
|
|
idx := strings.Index(strings.ToLower(line), "bitrate:")
|
|
if idx < 0 {
|
|
return 0, false
|
|
}
|
|
raw := strings.TrimSpace(line[idx+len("bitrate:"):])
|
|
if raw == "" {
|
|
return 0, false
|
|
}
|
|
return parseFFmpegBitrateBPS(raw)
|
|
}
|
|
|
|
func parseFFmpegProgressBitrateBPS(line string) (int64, bool) {
|
|
if !strings.HasPrefix(line, "bitrate=") {
|
|
return 0, false
|
|
}
|
|
raw := strings.TrimSpace(strings.TrimPrefix(line, "bitrate="))
|
|
if raw == "" || strings.EqualFold(raw, "N/A") {
|
|
return 0, false
|
|
}
|
|
return parseFFmpegBitrateBPS(raw)
|
|
}
|
|
|
|
func parseFFmpegBitrateBPS(raw string) (int64, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.EqualFold(raw, "N/A") {
|
|
return 0, false
|
|
}
|
|
parts := strings.Fields(strings.ToLower(raw))
|
|
if len(parts) == 0 {
|
|
return 0, false
|
|
}
|
|
numStr := parts[0]
|
|
unit := ""
|
|
if len(parts) > 1 {
|
|
unit = parts[1]
|
|
} else {
|
|
i := 0
|
|
for i < len(numStr) {
|
|
c := numStr[i]
|
|
if (c >= '0' && c <= '9') || c == '.' {
|
|
i++
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if i < len(numStr) {
|
|
unit = numStr[i:]
|
|
numStr = numStr[:i]
|
|
}
|
|
}
|
|
num, err := strconv.ParseFloat(strings.TrimSpace(numStr), 64)
|
|
if err != nil || num <= 0 {
|
|
return 0, false
|
|
}
|
|
unit = strings.TrimSpace(strings.Trim(unit, ","))
|
|
mult := float64(1)
|
|
switch unit {
|
|
case "kb/s", "kbits/s", "kbit/s", "kbps":
|
|
mult = 1000
|
|
case "mb/s", "mbits/s", "mbit/s", "mbps":
|
|
mult = 1000 * 1000
|
|
case "gb/s", "gbits/s", "gbit/s", "gbps":
|
|
mult = 1000 * 1000 * 1000
|
|
case "b/s", "bit/s", "bits/s", "":
|
|
mult = 1
|
|
default:
|
|
return 0, false
|
|
}
|
|
bps := int64(num * mult)
|
|
if bps <= 0 {
|
|
return 0, false
|
|
}
|
|
return bps, true
|
|
}
|
|
|
|
func estimateTotalBytesFromBitrate(totalMS, bitrateBPS int64) int64 {
|
|
if totalMS <= 0 || bitrateBPS <= 0 {
|
|
return 0
|
|
}
|
|
return (totalMS * bitrateBPS) / 8000
|
|
}
|
|
|
|
func estimateTotalBytesFromProgress(totalMS, currentMS, currentBytes int64) int64 {
|
|
if totalMS <= 0 || currentMS <= 0 || currentBytes <= 0 {
|
|
return 0
|
|
}
|
|
if currentMS > totalMS {
|
|
currentMS = totalMS
|
|
}
|
|
est := (currentBytes * totalMS) / currentMS
|
|
if est < currentBytes {
|
|
est = currentBytes
|
|
}
|
|
return est
|
|
}
|
|
|
|
func parseClockDurationMS(raw string) (int64, bool) {
|
|
parts := strings.Split(strings.TrimSpace(raw), ":")
|
|
if len(parts) != 3 {
|
|
return 0, false
|
|
}
|
|
hours, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil || hours < 0 {
|
|
return 0, false
|
|
}
|
|
minutes, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil || minutes < 0 {
|
|
return 0, false
|
|
}
|
|
secPart := parts[2]
|
|
secRaw, fracRaw, hasFrac := strings.Cut(secPart, ".")
|
|
seconds, err := strconv.ParseInt(secRaw, 10, 64)
|
|
if err != nil || seconds < 0 {
|
|
return 0, false
|
|
}
|
|
ms := ((hours*60+minutes)*60 + seconds) * 1000
|
|
if hasFrac {
|
|
if len(fracRaw) > 3 {
|
|
fracRaw = fracRaw[:3]
|
|
}
|
|
for len(fracRaw) < 3 {
|
|
fracRaw += "0"
|
|
}
|
|
fracMS, fracErr := strconv.ParseInt(fracRaw, 10, 64)
|
|
if fracErr != nil || fracMS < 0 {
|
|
return 0, false
|
|
}
|
|
ms += fracMS
|
|
}
|
|
return ms, true
|
|
}
|
|
|
|
const deezerBFChunkSize = 2048
|
|
|
|
var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7}
|
|
|
|
// defaultDeezerBlowfishSecret aliases the single definition in internal/config
|
|
// so the literal is not duplicated here.
|
|
const defaultDeezerBlowfishSecret = config.DefaultDeezerBlowfishSecret
|
|
|
|
// blowfishSecret returns the configured Deezer blowfish secret.
|
|
//
|
|
// The key derivation XORs 16 bytes of it, so a secret of any other length
|
|
// would panic; fall back to the built-in value rather than crash on a
|
|
// mistyped config entry.
|
|
func (d *Downloader) blowfishSecret() string {
|
|
if len(d.DeezerBlowfishSecret) == 16 {
|
|
return d.DeezerBlowfishSecret
|
|
}
|
|
if d.DeezerBlowfishSecret != "" {
|
|
verbose.Printf(verbose.V, "ignoring deezer.blowfish_secret: got %d bytes, want 16\n", len(d.DeezerBlowfishSecret))
|
|
}
|
|
return defaultDeezerBlowfishSecret
|
|
}
|
|
|
|
func deriveDeezerBlowfishKey(secret, trackID string) []byte {
|
|
sum := md5.Sum([]byte(trackID))
|
|
md5Hex := fmt.Sprintf("%x", sum)
|
|
key := make([]byte, 16)
|
|
for i := 0; i < 16; i++ {
|
|
key[i] = md5Hex[i] ^ md5Hex[i+16] ^ secret[i]
|
|
}
|
|
return key
|
|
}
|