mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-07-27 23:42:28 +02:00
feat: add beatport provider
This commit is contained in:
@@ -0,0 +1,739 @@
|
||||
package beatport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/jsonutil"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://api.beatport.com/v4"
|
||||
clientID = "ryZ8LuyQVPqbK2mBX2Hwt4qSMtnWuTYSqBPO92yQ"
|
||||
tokenEndpoint = "/auth/o/token/"
|
||||
authEndpoint = "/auth/o/authorize/"
|
||||
loginEndpoint = "/auth/login/"
|
||||
)
|
||||
|
||||
var (
|
||||
errMissingCredentials = errors.New("missing beatport credentials")
|
||||
errNotLoggedIn = errors.New("beatport client not logged in")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
baseURL string
|
||||
loggedIn bool
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Client {
|
||||
h := netutil.NewHTTPClient(40*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections)
|
||||
h.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: h,
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Source() string { return "beatport" }
|
||||
|
||||
func (c *Client) LoggedIn() bool { return c.loggedIn }
|
||||
|
||||
func (c *Client) Login(ctx context.Context) error {
|
||||
b := &c.cfg.Session.Beatport
|
||||
b.Username = strings.TrimSpace(b.Username)
|
||||
b.Password = strings.TrimSpace(b.Password)
|
||||
if b.Username == "" || b.Password == "" {
|
||||
return errMissingCredentials
|
||||
}
|
||||
if strings.TrimSpace(b.AccessToken) != "" && time.Now().Unix()+300 < b.TokenExpiry {
|
||||
c.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(b.RefreshToken) != "" {
|
||||
if err := c.refresh(ctx); err == nil {
|
||||
c.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := c.loginPasswordFlow(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
c.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
switch mediaType {
|
||||
case "track":
|
||||
track, err := c.getMap(ctx, "/catalog/tracks/"+url.PathEscape(strings.TrimSpace(item))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if relID := jsonutil.NestedString(track, "release", "id"); relID != "" {
|
||||
if rel, relErr := c.getRelease(ctx, relID); relErr == nil {
|
||||
track["release"] = rel
|
||||
}
|
||||
}
|
||||
return c.normalizeTrack(track), nil
|
||||
case "album":
|
||||
return c.getAlbumMetadata(ctx, item)
|
||||
case "playlist":
|
||||
return c.getPlaylistMetadata(ctx, item)
|
||||
case "chart":
|
||||
return c.getChartMetadata(ctx, strings.TrimPrefix(strings.TrimSpace(item), "chart:"))
|
||||
case "artist":
|
||||
artist, err := c.getMap(ctx, "/catalog/artists/"+url.PathEscape(strings.TrimSpace(item))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
releases, err := c.getPaginated(ctx, "/catalog/releases/", url.Values{"artist_id": []string{strings.TrimSpace(item)}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.releaseCollectionMetadata(artist, releases), nil
|
||||
case "label":
|
||||
label, err := c.getMap(ctx, "/catalog/labels/"+url.PathEscape(strings.TrimSpace(item))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks, err := c.getTrackCollection(ctx, url.Values{"label_id": []string{strings.TrimSpace(item)}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return collectionMetadata(label, tracks), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported beatport media type %q", mediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
var key string
|
||||
switch mediaType {
|
||||
case "track":
|
||||
key = "tracks"
|
||||
case "album":
|
||||
key = "releases"
|
||||
case "label":
|
||||
key = "labels"
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported beatport search media type %q", mediaType)
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("order_by", "-publish_date")
|
||||
params.Set("is_available_for_streaming", "true")
|
||||
resp, err := c.getMap(ctx, "/catalog/search/", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := sliceAny(resp[key])
|
||||
if limit < len(items) {
|
||||
items = items[:limit]
|
||||
}
|
||||
return []map[string]any{{"items": c.normalizeSearchItems(mediaType, items)}}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
q := beatportQuality(quality)
|
||||
params := url.Values{"quality": []string{q}}
|
||||
resp, err := c.getMap(ctx, "/catalog/tracks/"+url.PathEscape(strings.TrimSpace(item))+"/download/", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
location := strings.TrimSpace(jsonutil.StringFromAny(resp["location"]))
|
||||
if location == "" {
|
||||
return nil, errors.New("beatport download response missing location")
|
||||
}
|
||||
streamQuality := strings.TrimSpace(jsonutil.StringFromAny(resp["stream_quality"]))
|
||||
profile, ext := audioProfile(q, streamQuality)
|
||||
return &provider.Downloadable{URL: location, Extension: ext, Source: "beatport", TrackID: strings.TrimSpace(item), Audio: profile}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error { return nil }
|
||||
|
||||
func (c *Client) getAlbumMetadata(ctx context.Context, id string) (map[string]any, error) {
|
||||
release, err := c.getRelease(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks, err := c.getReleaseTracks(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.normalizeRelease(release, tracks), nil
|
||||
}
|
||||
|
||||
func (c *Client) getPlaylistMetadata(ctx context.Context, id string) (map[string]any, error) {
|
||||
if strings.HasPrefix(strings.TrimSpace(id), "chart:") {
|
||||
return c.getChartMetadata(ctx, strings.TrimPrefix(strings.TrimSpace(id), "chart:"))
|
||||
}
|
||||
playlist, err := c.getMap(ctx, "/catalog/playlists/"+url.PathEscape(strings.TrimSpace(id))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := c.getPaginated(ctx, "/catalog/playlists/"+url.PathEscape(strings.TrimSpace(id))+"/tracks/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks := make([]any, 0, len(items))
|
||||
for _, raw := range items {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if track, ok := m["track"].(map[string]any); ok {
|
||||
tracks = append(tracks, normalizeTrackListItem(track))
|
||||
}
|
||||
}
|
||||
return playlistMetadata(playlist, tracks), nil
|
||||
}
|
||||
|
||||
func (c *Client) getChartMetadata(ctx context.Context, id string) (map[string]any, error) {
|
||||
chart, err := c.getMap(ctx, "/catalog/charts/"+url.PathEscape(strings.TrimSpace(id))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks, err := c.getPaginated(ctx, "/catalog/charts/"+url.PathEscape(strings.TrimSpace(id))+"/tracks/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := make([]any, 0, len(tracks))
|
||||
for _, raw := range tracks {
|
||||
if track, ok := raw.(map[string]any); ok {
|
||||
normalized = append(normalized, normalizeTrackListItem(track))
|
||||
}
|
||||
}
|
||||
return playlistMetadata(chart, normalized), nil
|
||||
}
|
||||
|
||||
func (c *Client) getRelease(ctx context.Context, id string) (map[string]any, error) {
|
||||
return c.getMap(ctx, "/catalog/releases/"+url.PathEscape(strings.TrimSpace(id))+"/", nil)
|
||||
}
|
||||
|
||||
func (c *Client) getReleaseTracks(ctx context.Context, id string) ([]any, error) {
|
||||
items, err := c.getPaginated(ctx, "/catalog/releases/"+url.PathEscape(strings.TrimSpace(id))+"/tracks/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, raw := range items {
|
||||
if track, ok := raw.(map[string]any); ok {
|
||||
track["release"] = map[string]any{"id": id}
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (c *Client) getTrackCollection(ctx context.Context, params url.Values) ([]any, error) {
|
||||
return c.getPaginated(ctx, "/catalog/tracks/", params)
|
||||
}
|
||||
|
||||
func (c *Client) getPaginated(ctx context.Context, endpoint string, params url.Values) ([]any, error) {
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
} else {
|
||||
params = cloneValues(params)
|
||||
}
|
||||
out := make([]any, 0)
|
||||
for page := 1; ; page++ {
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
resp, err := c.getMap(ctx, endpoint, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sliceAny(resp["results"])...)
|
||||
if strings.TrimSpace(jsonutil.StringFromAny(resp["next"])) == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) getMap(ctx context.Context, endpoint string, params url.Values) (map[string]any, error) {
|
||||
resp, err := c.apiRequest(ctx, http.MethodGet, endpoint, params, nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) apiRequest(ctx context.Context, method, endpoint string, params url.Values, payload any, contentType string) (map[string]any, error) {
|
||||
if err := c.ensureToken(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.rawRequest(ctx, method, endpoint, params, payload, contentType, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
c.cfg.Session.Beatport.TokenExpiry = 0
|
||||
if err = c.refresh(ctx); err != nil {
|
||||
if loginErr := c.loginPasswordFlow(ctx); loginErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
resp, status, err = c.rawRequest(ctx, method, endpoint, params, payload, contentType, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, fmt.Errorf("beatport request failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureToken(ctx context.Context) error {
|
||||
if strings.TrimSpace(c.cfg.Session.Beatport.AccessToken) != "" && time.Now().Unix()+300 < c.cfg.Session.Beatport.TokenExpiry {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(c.cfg.Session.Beatport.RefreshToken) != "" {
|
||||
if err := c.refresh(ctx); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return c.loginPasswordFlow(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) loginPasswordFlow(ctx context.Context) error {
|
||||
b := &c.cfg.Session.Beatport
|
||||
if strings.TrimSpace(b.Username) == "" || strings.TrimSpace(b.Password) == "" {
|
||||
return errMissingCredentials
|
||||
}
|
||||
loginResp, status, err := c.rawRequest(ctx, http.MethodPost, loginEndpoint, nil, map[string]string{"username": b.Username, "password": b.Password}, "application/json", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("beatport login failed: status=%d body=%v", status, loginResp)
|
||||
}
|
||||
if c.sessionID == "" {
|
||||
return errors.New("beatport login response missing sessionid cookie")
|
||||
}
|
||||
code, err := c.authorize(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.issueToken(ctx, map[string]string{"client_id": clientID, "grant_type": "authorization_code", "code": code})
|
||||
}
|
||||
|
||||
func (c *Client) authorize(ctx context.Context) (string, error) {
|
||||
params := url.Values{"client_id": []string{clientID}, "response_type": []string{"code"}}
|
||||
resp, status, err := c.rawRequest(ctx, http.MethodGet, authEndpoint, params, nil, "", false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status != http.StatusFound {
|
||||
return "", fmt.Errorf("beatport authorize failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
code := strings.TrimSpace(jsonutil.StringFromAny(resp["code"]))
|
||||
if code == "" {
|
||||
return "", errors.New("beatport authorize redirect missing code")
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (c *Client) refresh(ctx context.Context) error {
|
||||
b := &c.cfg.Session.Beatport
|
||||
return c.issueToken(ctx, map[string]string{"client_id": clientID, "grant_type": "refresh_token", "refresh_token": b.RefreshToken})
|
||||
}
|
||||
|
||||
func (c *Client) issueToken(ctx context.Context, payload map[string]string) error {
|
||||
resp, status, err := c.rawRequest(ctx, http.MethodPost, tokenEndpoint, nil, payload, "application/x-www-form-urlencoded", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("beatport token request failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
access := strings.TrimSpace(jsonutil.StringFromAny(resp["access_token"]))
|
||||
refresh := strings.TrimSpace(jsonutil.StringFromAny(resp["refresh_token"]))
|
||||
if access == "" {
|
||||
return errors.New("beatport token response missing access_token")
|
||||
}
|
||||
expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 3600
|
||||
}
|
||||
c.cfg.Session.Beatport.AccessToken = access
|
||||
if refresh != "" {
|
||||
c.cfg.Session.Beatport.RefreshToken = refresh
|
||||
}
|
||||
c.cfg.Session.Beatport.TokenExpiry = time.Now().Unix() + expiresIn
|
||||
c.cfg.File.Beatport.AccessToken = c.cfg.Session.Beatport.AccessToken
|
||||
c.cfg.File.Beatport.RefreshToken = c.cfg.Session.Beatport.RefreshToken
|
||||
c.cfg.File.Beatport.TokenExpiry = c.cfg.Session.Beatport.TokenExpiry
|
||||
_ = c.cfg.SaveFile()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) rawRequest(ctx context.Context, method, endpoint string, params url.Values, payload any, contentType string, auth bool) (map[string]any, int, error) {
|
||||
if c.limiter != nil {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
switch contentType {
|
||||
case "application/json":
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
case "application/x-www-form-urlencoded":
|
||||
vals := url.Values{}
|
||||
for k, v := range payload.(map[string]string) {
|
||||
vals.Set(k, v)
|
||||
}
|
||||
body = strings.NewReader(vals.Encode())
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("unsupported beatport content type %q", contentType)
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(strings.TrimRight(c.baseURL, "/") + endpoint)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(params) > 0 {
|
||||
u.RawQuery = params.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if auth && strings.TrimSpace(c.cfg.Session.Beatport.AccessToken) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.cfg.Session.Beatport.AccessToken))
|
||||
}
|
||||
if endpoint == authEndpoint && c.sessionID != "" {
|
||||
req.Header.Set("Cookie", "sessionid="+c.sessionID)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if endpoint == loginEndpoint {
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == "sessionid" {
|
||||
c.sessionID = cookie.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if endpoint == authEndpoint && resp.StatusCode == http.StatusFound {
|
||||
loc := resp.Header.Get("Location")
|
||||
parsed, parseErr := url.Parse(loc)
|
||||
if parseErr == nil && strings.TrimSpace(parsed.Query().Get("code")) != "" {
|
||||
return map[string]any{"code": parsed.Query().Get("code")}, resp.StatusCode, nil
|
||||
}
|
||||
return map[string]any{}, resp.StatusCode, nil
|
||||
}
|
||||
var out map[string]any
|
||||
if err = json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return map[string]any{}, resp.StatusCode, nil
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return map[string]any{}, resp.StatusCode, nil
|
||||
}
|
||||
return out, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *Client) normalizeRelease(raw map[string]any, tracks []any) map[string]any {
|
||||
id := jsonutil.StringFromAny(raw["id"])
|
||||
artist := c.releaseArtistName(raw["artists"])
|
||||
items := make([]any, 0, len(tracks))
|
||||
for _, entry := range tracks {
|
||||
track, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
track["release"] = raw
|
||||
items = append(items, c.normalizeTrack(track))
|
||||
}
|
||||
return map[string]any{
|
||||
"id": id,
|
||||
"title": jsonutil.StringFromAny(raw["name"]),
|
||||
"artist": map[string]any{"name": artist},
|
||||
"release_date_original": jsonutil.StringFromAny(raw["new_release_date"]),
|
||||
"tracks_count": firstPositiveInt(jsonutil.IntFromAny(raw["track_count"]), len(items)),
|
||||
"maximum_bit_depth": 16,
|
||||
"maximum_sampling_rate": "44.1",
|
||||
"image": imageMap(raw["image"]),
|
||||
"tracks": map[string]any{"items": items},
|
||||
"upc": jsonutil.StringFromAny(raw["upc"]),
|
||||
"label": raw["label"],
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) normalizeTrack(raw map[string]any) map[string]any {
|
||||
release := mapAny(raw["release"])
|
||||
artistName := joinArtists(raw["artists"])
|
||||
artistID := firstArtistID(raw["artists"])
|
||||
albumArtist := c.releaseArtistName(release["artists"])
|
||||
if albumArtist == "" {
|
||||
albumArtist = artistName
|
||||
}
|
||||
genreName := jsonutil.NestedString(raw, "genre", "name")
|
||||
if sub := jsonutil.NestedString(raw, "sub_genre", "name"); sub != "" {
|
||||
genreName = sub
|
||||
}
|
||||
date := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(release["new_release_date"]), jsonutil.StringFromAny(raw["publish_date"]))
|
||||
track := map[string]any{
|
||||
"id": jsonutil.StringFromAny(raw["id"]),
|
||||
"title": jsonutil.StringFromAny(raw["name"]),
|
||||
"version": jsonutil.StringFromAny(raw["mix_name"]),
|
||||
"artist": map[string]any{"id": artistID, "name": artistName},
|
||||
"performer": map[string]any{"id": artistID, "name": artistName},
|
||||
"album": map[string]any{"id": jsonutil.StringFromAny(release["id"]), "title": jsonutil.StringFromAny(release["name"]), "artist": map[string]any{"name": albumArtist}, "image": imageMap(release["image"]), "release_date_original": date},
|
||||
"track_number": jsonutil.IntFromAny(raw["number"]),
|
||||
"tracks_count": jsonutil.IntFromAny(release["track_count"]),
|
||||
"release_date_original": date,
|
||||
"genre": map[string]any{"name": genreName},
|
||||
"isrc": jsonutil.StringFromAny(raw["isrc"]),
|
||||
"bpm": jsonutil.IntFromAny(raw["bpm"]),
|
||||
"source_track_id": jsonutil.StringFromAny(raw["id"]),
|
||||
"source_album_id": jsonutil.StringFromAny(release["id"]),
|
||||
"source_artist_id": artistID,
|
||||
"maximum_bit_depth": 16,
|
||||
"maximum_sampling_rate": "44.1",
|
||||
}
|
||||
if keyName := jsonutil.NestedString(raw, "key", "name"); keyName != "" {
|
||||
track["key"] = keyName
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
func normalizeTrackListItem(raw map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"id": jsonutil.StringFromAny(raw["id"]),
|
||||
"title": jsonutil.StringFromAny(raw["name"]),
|
||||
"version": jsonutil.StringFromAny(raw["mix_name"]),
|
||||
"artist": map[string]any{"name": joinArtists(raw["artists"])},
|
||||
"album": map[string]any{"id": jsonutil.NestedString(raw, "release", "id"), "title": jsonutil.NestedString(raw, "release", "name")},
|
||||
"track_number": jsonutil.IntFromAny(raw["number"]),
|
||||
}
|
||||
}
|
||||
|
||||
func collectionMetadata(raw map[string]any, tracks []any) map[string]any {
|
||||
items := make([]any, 0, len(tracks))
|
||||
for _, entry := range tracks {
|
||||
track, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, normalizeTrackListItem(track))
|
||||
}
|
||||
return map[string]any{
|
||||
"id": jsonutil.StringFromAny(raw["id"]),
|
||||
"name": jsonutil.StringFromAny(raw["name"]),
|
||||
"title": jsonutil.StringFromAny(raw["name"]),
|
||||
"tracks_count": len(items),
|
||||
"tracks": map[string]any{"items": items},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) releaseCollectionMetadata(raw map[string]any, releases []any) map[string]any {
|
||||
items := make([]any, 0, len(releases))
|
||||
for _, entry := range releases {
|
||||
release, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(jsonutil.StringFromAny(release["id"]))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id,
|
||||
"title": jsonutil.StringFromAny(release["name"]),
|
||||
"artist": map[string]any{"name": c.releaseArtistName(release["artists"])},
|
||||
"tracks_count": jsonutil.IntFromAny(release["track_count"]),
|
||||
"release_date_original": jsonutil.StringFromAny(release["new_release_date"]),
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"id": jsonutil.StringFromAny(raw["id"]),
|
||||
"name": jsonutil.StringFromAny(raw["name"]),
|
||||
"title": jsonutil.StringFromAny(raw["name"]),
|
||||
"albums": map[string]any{"items": items},
|
||||
}
|
||||
}
|
||||
|
||||
func playlistMetadata(raw map[string]any, tracks []any) map[string]any {
|
||||
name := jsonutil.StringFromAny(raw["name"])
|
||||
return map[string]any{
|
||||
"id": jsonutil.StringFromAny(raw["id"]),
|
||||
"name": name,
|
||||
"title": name,
|
||||
"tracks_count": firstPositiveInt(jsonutil.IntFromAny(raw["track_count"]), len(tracks)),
|
||||
"image": imageMap(raw["image"]),
|
||||
"tracks": map[string]any{"items": tracks},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) normalizeSearchItems(mediaType string, items []any) []any {
|
||||
out := make([]any, 0, len(items))
|
||||
for _, raw := range items {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch mediaType {
|
||||
case "track":
|
||||
out = append(out, normalizeTrackListItem(m))
|
||||
case "album":
|
||||
out = append(out, map[string]any{"id": jsonutil.StringFromAny(m["id"]), "title": jsonutil.StringFromAny(m["name"]), "artist": map[string]any{"name": c.releaseArtistName(m["artists"])}, "tracks_count": jsonutil.IntFromAny(m["track_count"]), "release_date_original": jsonutil.StringFromAny(m["new_release_date"])})
|
||||
case "label":
|
||||
out = append(out, map[string]any{"id": jsonutil.StringFromAny(m["id"]), "title": jsonutil.StringFromAny(m["name"]), "name": jsonutil.StringFromAny(m["name"])})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func beatportQuality(q int) string {
|
||||
switch q {
|
||||
case 1:
|
||||
return "medium"
|
||||
case 2:
|
||||
return "high"
|
||||
default:
|
||||
return "lossless"
|
||||
}
|
||||
}
|
||||
|
||||
func audioProfile(quality, streamQuality string) (provider.AudioProfile, string) {
|
||||
s := strings.ToLower(strings.TrimSpace(streamQuality))
|
||||
switch {
|
||||
case strings.Contains(s, "flac") || quality == "lossless":
|
||||
return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}, "flac"
|
||||
case strings.Contains(s, "256") || quality == "high":
|
||||
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 256}, "m4a"
|
||||
default:
|
||||
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 128}, "m4a"
|
||||
}
|
||||
}
|
||||
|
||||
func joinArtists(v any) string {
|
||||
items := sliceAny(v)
|
||||
names := make([]string, 0, len(items))
|
||||
for _, raw := range items {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name := strings.TrimSpace(jsonutil.StringFromAny(m["name"])); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func (c *Client) releaseArtistName(v any) string {
|
||||
items := sliceAny(v)
|
||||
threshold := c.cfg.Session.Beatport.VariousArtistsThreshold
|
||||
if threshold >= 0 && len(items) > threshold {
|
||||
return "Various Artists"
|
||||
}
|
||||
return joinArtists(v)
|
||||
}
|
||||
|
||||
func firstArtistID(v any) string {
|
||||
items := sliceAny(v)
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
m, _ := items[0].(map[string]any)
|
||||
return jsonutil.StringFromAny(m["id"])
|
||||
}
|
||||
|
||||
func imageMap(v any) map[string]any {
|
||||
m := mapAny(v)
|
||||
dynamic := strings.TrimSpace(jsonutil.StringFromAny(m["dynamic_uri"]))
|
||||
uri := strings.TrimSpace(jsonutil.StringFromAny(m["uri"]))
|
||||
if dynamic != "" {
|
||||
return map[string]any{
|
||||
"original": strings.ReplaceAll(dynamic, "{w}x{h}", "1400x1400"),
|
||||
"extralarge": strings.ReplaceAll(dynamic, "{w}x{h}", "1000x1000"),
|
||||
"large": strings.ReplaceAll(dynamic, "{w}x{h}", "500x500"),
|
||||
"small": strings.ReplaceAll(dynamic, "{w}x{h}", "250x250"),
|
||||
"thumbnail": strings.ReplaceAll(dynamic, "{w}x{h}", "100x100"),
|
||||
}
|
||||
}
|
||||
if uri != "" {
|
||||
return map[string]any{"original": uri, "large": uri}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapAny(v any) map[string]any {
|
||||
m, _ := v.(map[string]any)
|
||||
if m == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func sliceAny(v any) []any {
|
||||
s, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstPositiveInt(vals ...int) int {
|
||||
for _, v := range vals {
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cloneValues(in url.Values) url.Values {
|
||||
out := url.Values{}
|
||||
for k, vals := range in {
|
||||
out[k] = append([]string(nil), vals...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user