package download import ( "bufio" "context" "crypto/cipher" "crypto/md5" "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/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 { 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.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), decor.Name(" | probing stream", decor.WCSyncWidth), ), 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{} shownMS := int64(0) newFFmpegBar := func(totalMS int64) *mpb.Bar { return d.progress.AddBar( totalMS, mpb.PrependDecorators( decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), decor.Percentage(decor.WCSyncWidthR), ), mpb.AppendDecorators( decor.Name(" | ", decor.WCSyncWidth), decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), decor.Name(" | ffmpeg stream", decor.WCSyncWidth), ), 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 state.totalMS == 0 { if totalMS, ok := parseFFmpegDurationLine(line); ok && totalMS > 0 { state.totalMS = totalMS spinner.SetTotal(-1, true) bar = newFFmpegBar(state.totalMS) if state.currentMS > state.totalMS { state.currentMS = state.totalMS } if state.currentMS > shownMS { bar.IncrBy(int(state.currentMS - shownMS)) shownMS = state.currentMS } } } if currentMS, ok := parseFFmpegOutTime(line); ok { if currentMS > state.currentMS { state.currentMS = currentMS } if bar != nil { targetMS := state.currentMS if targetMS > state.totalMS { targetMS = state.totalMS } if targetMS > shownMS { bar.IncrBy(int(targetMS - shownMS)) shownMS = targetMS } } } } if bar != nil { if shownMS < state.totalMS { bar.IncrBy(int(state.totalMS - shownMS)) } bar.SetTotal(state.totalMS, true) } else { spinner.SetTotal(-1, true) } state.currentMS = shownMS 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 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, "= 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 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} func decryptDeezerBFCBCStripe(in []byte, trackID string) ([]byte, error) { block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID)) if err != nil { return nil, err } out := make([]byte, len(in)) for i := 0; i*deezerBFChunkSize < len(in); i++ { start := i * deezerBFChunkSize end := start + deezerBFChunkSize if end > 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 }