implement native Deezer download/decrypt pipeline

Replace Deezer yt-dlp usage with native ARL session + media.get_url resolution, add BF_CBC_STRIPE decryption in downloader, and wire cipher-aware Deezer downloads through the main rip pipeline. Includes validation hardening and metadata/source-id improvements used by tagging flows.
This commit is contained in:
2026-04-21 00:48:07 +02:00
parent 0ba8faa943
commit 26c9d50fac
10 changed files with 569 additions and 260 deletions

View File

@@ -3,12 +3,15 @@ package download
import (
"bufio"
"context"
"crypto/cipher"
"crypto/md5"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
@@ -17,6 +20,8 @@ import (
"golang.org/x/term"
"streamrip-go/internal/netutil"
"golang.org/x/crypto/blowfish"
)
type Downloader struct {
@@ -56,6 +61,33 @@ func (d *Downloader) FileVideo(ctx context.Context, sourceURL, outputPath string
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)
}
encrypted, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
plain, err := decryptDeezerBFCBCStripe(encrypted, trackID)
if err != nil {
return err
}
return os.WriteFile(outputPath, plain, 0o644)
}
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
@@ -204,3 +236,60 @@ func isManifestResponse(contentType string, peek []byte) bool {
}
return false
}
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
}

View File

@@ -2,11 +2,14 @@ package download
import (
"context"
"crypto/cipher"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"golang.org/x/crypto/blowfish"
)
func TestDownloaderHasNoClientTimeout(t *testing.T) {
@@ -51,3 +54,33 @@ func TestManifestDetection(t *testing.T) {
t.Fatalf("did not expect flac to be manifest")
}
}
func TestNormalizeDeezerTrackID(t *testing.T) {
if got := normalizeDeezerTrackID("https://www.deezer.com/track/3135556"); got != "3135556" {
t.Fatalf("normalize track id = %q, want 3135556", got)
}
}
func TestDecryptDeezerBFCBCStripe(t *testing.T) {
trackID := "3135556"
plain := make([]byte, deezerBFChunkSize*2)
for i := range plain {
plain[i] = byte(i % 251)
}
enc := make([]byte, len(plain))
copy(enc, plain)
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
if err != nil {
t.Fatalf("cipher error: %v", err)
}
cbc := cipher.NewCBCEncrypter(block, deezerBFIV)
cbc.CryptBlocks(enc[:deezerBFChunkSize], enc[:deezerBFChunkSize])
dec, err := decryptDeezerBFCBCStripe(enc, trackID)
if err != nil {
t.Fatalf("decrypt error: %v", err)
}
if len(dec) != len(plain) || string(dec) != string(plain) {
t.Fatalf("decrypted data mismatch")
}
}