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 }