mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
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:
451
internal/provider/deezer/client.go
Normal file
451
internal/provider/deezer/client.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user