Files
streamrip-go/internal/download/downloader.go
JorenandClaude Opus 5 b5d4d5688d refactor: consolidate the duplicated download and worker-pool code
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>
2026-10-03 17:23:24 +02:00

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
}