mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
The provider apps' own identifiers and protocol keys were scattered as constants across six packages. They are not user secrets and stay baked in so the tool still works out of the box, but they are now mirrored into the config so that when a provider rotates one, a user can fix their install with a config edit instead of waiting for a release. Moved, each with the current value as the default in internal/config: qobuz mobile_app_id, mobile_app_secret tidal app_client_id, app_client_secret deezer api_key, gateway_key, blowfish_secret yandex sign_key, legacy_mp3_sign_salt beatport app_client_id applyVendorDefaults backfills any empty value, so configs written before these keys existed keep working and the clients never have to nil-check them. Each client reads through a small accessor; the package-level signing helpers (mobileSignRequest, yandexDownloadSign, legacyDirectURL, deriveGatewayAuthToken and friends, deriveDeezerBlowfishKey) now take the key as a parameter instead of closing over a constant. The blowfish secret reaches internal/download through a field on Downloader set by app.New. The blowfish key derivation XORs 16 bytes of the secret, so a mistyped override would have panicked; a wrong-length value is now rejected with a warning and the built-in value is used. Tests pin the length and that the qobuz mobile secret is valid hex, since the HKDF step requires it. Also drops soundcloud.client_id and soundcloud.app_version: soundcloud goes through yt-dlp, which supplies its own, so nothing read them. config.toml.example documents each new key as "leave empty unless the vendor rotates theirs". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
867 lines
22 KiB
Go
867 lines
22 KiB
Go
package download
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"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)
|
|
}
|
|
|
|
func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputPath, trackID string) 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)
|
|
}
|
|
}()
|
|
|
|
var bar *mpb.Bar
|
|
if d.ProgressEnabled() {
|
|
d.barStarted.Store(1)
|
|
desc := shortenName(filepath.Base(outputPath), 54)
|
|
if resp.ContentLength > 0 {
|
|
bar = d.progress.AddBar(
|
|
resp.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(),
|
|
)
|
|
} else {
|
|
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(),
|
|
)
|
|
defer bar.SetTotal(-1, true)
|
|
}
|
|
defer func() {
|
|
if !success && bar != nil {
|
|
bar.Abort(true)
|
|
}
|
|
}()
|
|
}
|
|
|
|
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(d.blowfishSecret(), trackID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buf := make([]byte, deezerBFChunkSize)
|
|
dec := make([]byte, deezerBFChunkSize)
|
|
chunkIndex := 0
|
|
totalRead := int64(0)
|
|
for {
|
|
n, readErr := io.ReadFull(resp.Body, buf)
|
|
if readErr == io.EOF {
|
|
break
|
|
}
|
|
if readErr != nil && readErr != io.ErrUnexpectedEOF {
|
|
return readErr
|
|
}
|
|
chunk := buf[:n]
|
|
if chunkIndex%3 == 0 && n == deezerBFChunkSize {
|
|
mode := cipher.NewCBCDecrypter(block, deezerBFIV)
|
|
mode.CryptBlocks(dec[:n], chunk)
|
|
chunk = dec[:n]
|
|
}
|
|
if _, err = out.Write(chunk); err != nil {
|
|
return err
|
|
}
|
|
totalRead += int64(n)
|
|
if bar != nil {
|
|
bar.IncrBy(n)
|
|
}
|
|
chunkIndex++
|
|
if readErr == io.ErrUnexpectedEOF {
|
|
break
|
|
}
|
|
}
|
|
if resp.ContentLength > 0 && totalRead != resp.ContentLength {
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
if err = out.Sync(); err != nil {
|
|
return err
|
|
}
|
|
success = true
|
|
return nil
|
|
}
|
|
|
|
func (d *Downloader) FileYandexEncrypted(ctx context.Context, sourceURL, outputPath, key string) error {
|
|
logDownloadStart(sourceURL, outputPath)
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
}()
|
|
|
|
var bar *mpb.Bar
|
|
if d.ProgressEnabled() {
|
|
d.barStarted.Store(1)
|
|
desc := shortenName(filepath.Base(outputPath), 54)
|
|
if resp.ContentLength > 0 {
|
|
bar = d.progress.AddBar(
|
|
resp.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(),
|
|
)
|
|
} else {
|
|
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(),
|
|
)
|
|
defer bar.SetTotal(-1, true)
|
|
}
|
|
defer func() {
|
|
if !success && bar != nil {
|
|
bar.Abort(true)
|
|
}
|
|
}()
|
|
}
|
|
|
|
stream := cipher.NewCTR(block, make([]byte, aes.BlockSize))
|
|
reader := &cipher.StreamReader{S: stream, R: resp.Body}
|
|
buf := make([]byte, downloadBufferSize)
|
|
totalWritten := int64(0)
|
|
for {
|
|
n, readErr := reader.Read(buf)
|
|
if n > 0 {
|
|
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
|
return writeErr
|
|
}
|
|
totalWritten += int64(n)
|
|
if bar != nil {
|
|
bar.IncrBy(n)
|
|
}
|
|
}
|
|
if readErr != nil {
|
|
if readErr == io.EOF {
|
|
break
|
|
}
|
|
return readErr
|
|
}
|
|
}
|
|
if resp.ContentLength > 0 && totalWritten != resp.ContentLength {
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
if err = out.Sync(); err != nil {
|
|
return err
|
|
}
|
|
success = true
|
|
return nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
}()
|
|
|
|
if d.ProgressEnabled() && allowProgress {
|
|
d.barStarted.Store(1)
|
|
desc := shortenName(filepath.Base(outputPath), 54)
|
|
var bar *mpb.Bar
|
|
if resp.ContentLength > 0 {
|
|
bar = d.progress.AddBar(
|
|
resp.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(),
|
|
)
|
|
} else {
|
|
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(),
|
|
)
|
|
defer bar.SetTotal(-1, true)
|
|
}
|
|
defer func() {
|
|
if !success && bar != nil {
|
|
bar.Abort(true)
|
|
}
|
|
}()
|
|
buf := make([]byte, downloadBufferSize)
|
|
totalWritten := int64(0)
|
|
for {
|
|
n, readErr := reader.Read(buf)
|
|
if n > 0 {
|
|
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
|
return writeErr
|
|
}
|
|
totalWritten += int64(n)
|
|
bar.IncrBy(n)
|
|
}
|
|
if readErr != nil {
|
|
if readErr == io.EOF {
|
|
break
|
|
}
|
|
return readErr
|
|
}
|
|
}
|
|
if resp.ContentLength > 0 && totalWritten != resp.ContentLength {
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
if err = out.Sync(); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
written, copyErr := io.CopyBuffer(out, reader, make([]byte, downloadBufferSize))
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|