add CLI parity flags and expand provider support

This brings the Go CLI closer to upstream behavior with global flag handling and clearer resolve failures, while adding Tidal video downloads plus initial Deezer and SoundCloud no-account flows for broader end-to-end coverage.
This commit is contained in:
2026-04-20 00:56:10 +02:00
parent 4da5114a70
commit b2688ce949
15 changed files with 1746 additions and 57 deletions

View File

@@ -0,0 +1,451 @@
package deezer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
"time"
"streamrip-go/internal/config"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
)
var baseURL = "https://api.deezer.com"
type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
type Client struct {
cfg *config.Config
http *http.Client
limiter *ratelimit.Limiter
loggedIn bool
bin string
run commandRunner
}
func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
bin: "yt-dlp",
run: runCommand,
}
}
func (c *Client) Source() string {
return "deezer"
}
func (c *Client) Login(context.Context) error {
c.loggedIn = true
return nil
}
func (c *Client) LoggedIn() bool {
return c.loggedIn
}
func (c *Client) Close() error {
return nil
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.loggedIn {
return nil, errors.New("deezer client not logged in")
}
if limit <= 0 {
limit = 25
}
pathType := mediaType
if mediaType == "playlist" {
pathType = "playlist"
}
params := url.Values{}
params.Set("q", query)
params.Set("limit", strconv.Itoa(limit))
resp, err := c.apiGet(ctx, "/search/"+pathType, params)
if err != nil {
return nil, err
}
data, _ := resp["data"].([]any)
if len(data) == 0 {
return []map[string]any{}, nil
}
bucket := map[string]any{"items": data}
return []map[string]any{{mediaType + "s": bucket}}, nil
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.loggedIn {
return nil, errors.New("deezer client not logged in")
}
switch mediaType {
case "track":
resp, err := c.apiGet(ctx, "/track/"+item, nil)
if err != nil {
return nil, err
}
enrichTrack(resp)
return resp, nil
case "album":
resp, err := c.apiGet(ctx, "/album/"+item, nil)
if err != nil {
return nil, err
}
items := make([]any, 0)
if tracks, ok := resp["tracks"].(map[string]any); ok {
if data, ok := tracks["data"].([]any); ok {
for _, raw := range data {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
enrichTrack(itm)
items = append(items, itm)
}
}
}
resp["tracks"] = map[string]any{"items": items}
enrichAlbumImage(resp)
return resp, nil
case "playlist":
resp, err := c.apiGet(ctx, "/playlist/"+item, nil)
if err != nil {
return nil, err
}
items := make([]any, 0)
if tracks, ok := resp["tracks"].(map[string]any); ok {
if data, ok := tracks["data"].([]any); ok {
for _, raw := range data {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
enrichTrack(itm)
items = append(items, itm)
}
}
}
resp["tracks"] = map[string]any{"items": items}
return resp, nil
case "artist":
resp, err := c.apiGet(ctx, "/artist/"+item+"/albums", nil)
if err != nil {
return nil, err
}
albums := make([]any, 0)
if data, ok := resp["data"].([]any); ok {
for _, raw := range data {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
enrichAlbumImage(itm)
albums = append(albums, itm)
}
}
return map[string]any{"name": "", "albums": map[string]any{"items": albums}}, nil
default:
return nil, fmt.Errorf("unsupported deezer media type: %s", mediaType)
}
}
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
meta, err := c.GetMetadata(ctx, item, "track")
if err != nil {
return nil, err
}
if c.shouldTryYtDlp() {
d, dlErr := c.getDownloadableViaYtDlp(ctx, item, meta)
if dlErr == nil {
return d, nil
}
if !c.cfg.Session.Deezer.LowerQualityIfNotAvailable {
return nil, dlErr
}
}
preview := strings.TrimSpace(stringFromAny(meta["preview"]))
if preview == "" {
return nil, errors.New("deezer track missing preview url")
}
return &provider.Downloadable{URL: preview, Extension: "mp3", Source: "deezer"}, nil
}
func (c *Client) shouldTryYtDlp() bool {
if c.cfg == nil {
return false
}
if c.cfg.Session.Deezer.UseDeezloader {
return true
}
return strings.TrimSpace(c.cfg.Session.Deezer.ARL) != ""
}
func (c *Client) getDownloadableViaYtDlp(ctx context.Context, trackID string, meta map[string]any) (*provider.Downloadable, error) {
if _, err := exec.LookPath(c.bin); err != nil {
return nil, fmt.Errorf("yt-dlp not found for deezer full-quality mode: %w", err)
}
target := strings.TrimSpace(stringFromAny(meta["link"]))
if target == "" {
target = "https://www.deezer.com/track/" + trackID
}
args := []string{"-J", "--no-playlist", "--skip-download", "--no-warnings"}
if arl := strings.TrimSpace(c.cfg.Session.Deezer.ARL); arl != "" {
args = append(args, "--add-header", "Cookie: arl="+arl)
}
args = append(args, target)
b, err := c.run(ctx, c.bin, args...)
if err != nil {
return nil, err
}
info := map[string]any{}
if err = json.Unmarshal(b, &info); err != nil {
return nil, err
}
f := selectDeezerFormat(info, c.cfg.Session.Deezer.Quality)
if f.url == "" {
return nil, errors.New("yt-dlp output missing downloadable format url")
}
ext := f.ext
if ext == "" {
ext = "mp3"
}
return &provider.Downloadable{URL: f.url, Extension: ext, Source: "deezer"}, nil
}
type deezerFormat struct {
url string
ext string
abr int
}
func selectDeezerFormat(info map[string]any, quality int) deezerFormat {
formats, _ := info["formats"].([]any)
selected := deezerFormat{}
pick := func(candidate deezerFormat, better func(cur, next deezerFormat) bool) {
if candidate.url == "" {
return
}
if selected.url == "" || better(selected, candidate) {
selected = candidate
}
}
for _, raw := range formats {
m, ok := raw.(map[string]any)
if !ok {
continue
}
if strings.TrimSpace(stringFromAny(m["vcodec"])) != "none" {
continue
}
cand := deezerFormat{
url: strings.TrimSpace(stringFromAny(m["url"])),
ext: strings.TrimSpace(stringFromAny(m["ext"])),
abr: intFromAny(m["abr"]),
}
if quality >= 2 {
pick(cand, func(cur, next deezerFormat) bool {
curFlac := strings.EqualFold(cur.ext, "flac")
nextFlac := strings.EqualFold(next.ext, "flac")
if curFlac != nextFlac {
return nextFlac
}
return next.abr > cur.abr
})
continue
}
if quality == 1 {
pick(cand, func(cur, next deezerFormat) bool {
curScore := abrScore(cur.abr, 320)
nextScore := abrScore(next.abr, 320)
if curScore == nextScore {
return next.abr > cur.abr
}
return nextScore > curScore
})
continue
}
pick(cand, func(cur, next deezerFormat) bool {
curScore := abrScore(cur.abr, 128)
nextScore := abrScore(next.abr, 128)
if curScore == nextScore {
if cur.abr == 0 {
return next.abr > 0
}
if next.abr == 0 {
return false
}
return next.abr < cur.abr
}
return nextScore > curScore
})
}
if selected.url != "" {
return selected
}
rootURL := strings.TrimSpace(stringFromAny(info["url"]))
if rootURL == "" {
return deezerFormat{}
}
return deezerFormat{url: rootURL, ext: strings.TrimSpace(stringFromAny(info["ext"])), abr: intFromAny(info["abr"])}
}
func abrScore(abr int, target int) int {
if abr <= 0 {
return -1
}
if abr > target {
return target - (abr-target)*2
}
return abr
}
func (c *Client) apiGet(ctx context.Context, path string, params url.Values) (map[string]any, error) {
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
}
u := strings.TrimSuffix(baseURL, "/") + "/" + strings.TrimPrefix(path, "/")
if len(params) > 0 {
u += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "streamrip-go/0.1")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
out := map[string]any{}
if len(body) > 0 {
if err = json.Unmarshal(body, &out); err != nil {
return nil, err
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("deezer api failed: status=%d body=%s", resp.StatusCode, string(body))
}
if e := stringFromAny(out["error"]); e != "" {
return nil, fmt.Errorf("deezer api error: %s", e)
}
return out, nil
}
func enrichTrack(track map[string]any) {
if artist, ok := track["artist"].(map[string]any); ok {
track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])}
}
if album, ok := track["album"].(map[string]any); ok {
enrichAlbumImage(album)
}
if _, ok := track["track_number"]; !ok {
if p := track["track_position"]; p != nil {
track["track_number"] = p
}
}
if _, ok := track["media_number"]; !ok {
if d := track["disk_number"]; d != nil {
track["media_number"] = d
}
}
if v := stringFromAny(track["explicit_lyrics"]); v == "true" {
track["explicit"] = true
}
}
func enrichAlbumImage(meta map[string]any) {
if _, ok := meta["image"].(map[string]any); ok {
return
}
cover := firstNonEmpty(
stringFromAny(meta["cover_xl"]),
stringFromAny(meta["cover_big"]),
stringFromAny(meta["cover_medium"]),
stringFromAny(meta["cover_small"]),
)
if cover == "" {
return
}
meta["image"] = map[string]any{
"small": cover,
"large": cover,
"extralarge": cover,
"original": cover,
}
}
func stringFromAny(v any) string {
switch t := v.(type) {
case string:
return t
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return ""
}
}
func firstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
return ""
}
func intFromAny(v any) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(t))
return i
default:
return 0
}
}
func runCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
b, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("command %s failed: %w: %s", name, err, string(b))
}
return b, nil
}

View File

@@ -0,0 +1,66 @@
package deezer
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"streamrip-go/internal/config"
)
func TestSearchTrack(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search/track":
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 1, "title": "Dreams", "artist": map[string]any{"name": "Fleetwood Mac"}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
orig := baseURL
baseURL = ts.URL
defer func() { baseURL = orig }()
pages, err := c.Search(context.Background(), "track", "dreams", 5)
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(pages) != 1 {
t.Fatalf("pages len = %d, want 1", len(pages))
}
}
func TestGetDownloadableUsesPreview(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/track/42":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "X", "preview": "https://cdn.example/p.mp3"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
orig := baseURL
baseURL = ts.URL
defer func() { baseURL = orig }()
d, err := c.GetDownloadable(context.Background(), "42", 0)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if d.URL != "https://cdn.example/p.mp3" || d.Extension != "mp3" {
t.Fatalf("unexpected downloadable: %+v", d)
}
}