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