package download import ( "bufio" "context" "crypto/cipher" "crypto/md5" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "sync/atomic" "time" "github.com/vbauerster/mpb/v8" "github.com/vbauerster/mpb/v8/decor" "golang.org/x/term" "streamrip-go/internal/netutil" "golang.org/x/crypto/blowfish" ) type Downloader struct { http *http.Client showProgress bool progress *mpb.Progress barStarted atomic.Int32 } func New() *Downloader { return NewWithOptions(true, true) } func NewWithVerifySSL(verifySSL bool) *Downloader { return NewWithOptions(verifySSL, true) } func NewWithOptions(verifySSL bool, showProgress bool) *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), 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 { 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) } } block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(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) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error { 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) } buf := make([]byte, 256*1024) 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.Copy(out, reader) 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) } 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 { stopSpinner := func() {} if d.ProgressEnabled() { d.barStarted.Store(1) desc := shortenName(filepath.Base(outputPath), 54) spin := d.progress.AddSpinner( 0, mpb.PrependDecorators( decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), ), mpb.AppendDecorators( decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), decor.Name(" | ffmpeg stream", decor.WCSyncWidth), ), mpb.BarRemoveOnComplete(), ) done := make(chan struct{}) go func() { ticker := time.NewTicker(120 * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: spin.IncrBy(1) case <-done: return } } }() stopSpinner = func() { close(done) spin.SetTotal(-1, true) } } defer stopSpinner() if _, err := exec.LookPath("ffmpeg"); err != nil { return fmt.Errorf("ffmpeg not found for manifest stream: %w", err) } args := []string{ "-y", "-protocol_whitelist", "file,http,https,tcp,tls,crypto,data", "-i", sourceURL, } if includeVideo { args = append(args, "-map", "0") } else { args = append(args, "-map", "0:a:0") } args = append(args, "-c", "copy", outputPath) 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 } 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, " len(in) { end = len(in) } chunk := in[start:end] if i%3 == 0 && len(chunk) == deezerBFChunkSize { dec := make([]byte, len(chunk)) mode := cipher.NewCBCDecrypter(block, deezerBFIV) mode.CryptBlocks(dec, chunk) copy(out[start:end], dec) } else { copy(out[start:end], chunk) } } return out, nil } func deriveDeezerBlowfishKey(trackID string) []byte { sum := md5.Sum([]byte(trackID)) md5Hex := fmt.Sprintf("%x", sum) secret := "g4el58wc0zvf9na1" key := make([]byte, 16) for i := 0; i < 16; i++ { key[i] = md5Hex[i] ^ md5Hex[i+16] ^ secret[i] } return key } func normalizeDeezerTrackID(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } if _, err := strconv.Atoi(trimmed); err == nil { return trimmed } parts := strings.Split(strings.Trim(trimmed, "/"), "/") for i := len(parts) - 1; i >= 0; i-- { if _, err := strconv.Atoi(parts[i]); err == nil { return parts[i] } } return trimmed }