mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
608 lines
15 KiB
Go
608 lines
15 KiB
Go
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, "<?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 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
|
|
}
|