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 }