diff --git a/cmd/rip/helpers.go b/cmd/rip/helpers.go index 2591a6a..c430bb6 100644 --- a/cmd/rip/helpers.go +++ b/cmd/rip/helpers.go @@ -65,7 +65,7 @@ func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool { fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind) return false } - if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "yandex" && parsed.Source != "soundcloud" { + if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "yandex" && parsed.Source != "beatport" && parsed.Source != "soundcloud" { fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw) return false } diff --git a/cmd/rip/main.go b/cmd/rip/main.go index 7240a16..c93eced 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -294,7 +294,7 @@ func main() { } case "id": if len(os.Args) < 5 { - fmt.Println("usage: rip id [quality] [--force|--ignore-db]") + fmt.Println("usage: rip id [quality] [--force|--ignore-db]") os.Exit(2) } @@ -319,6 +319,12 @@ func main() { cfg.Session.Tidal.Quality = opts.quality case "yandex": cfg.Session.Yandex.Quality = opts.quality + case "beatport": + if opts.quality < 1 { + fmt.Fprintf(os.Stderr, "quality error: beatport quality must be 1-4\n") + os.Exit(2) + } + cfg.Session.Beatport.Quality = opts.quality } } @@ -348,7 +354,7 @@ func main() { var sopts searchOptions if len(os.Args) < 5 { if !term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Println("usage: rip search [--limit N] [--force|--ignore-db] [--no-download]") + fmt.Println("usage: rip search [--limit N] [--force|--ignore-db] [--no-download]") os.Exit(2) } source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) @@ -386,6 +392,10 @@ func main() { fmt.Fprintln(os.Stderr, "yandex search currently supports media types track, album, playlist, and artist") os.Exit(2) } + if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" { + fmt.Fprintln(os.Stderr, "beatport search currently supports media types track, album, and label") + os.Exit(2) + } if sopts.query == "" { fmt.Fprintln(os.Stderr, "search query cannot be empty") os.Exit(2) diff --git a/cmd/rip/search.go b/cmd/rip/search.go index 9770c83..9744baf 100644 --- a/cmd/rip/search.go +++ b/cmd/rip/search.go @@ -293,12 +293,12 @@ func writeSearchResultsToFile(source, mediaType string, results []searchResult, } func isAllowedSearchSource(source string) bool { - return source == "qobuz" || source == "tidal" || source == "deezer" || source == "yandex" || source == "soundcloud" + return source == "qobuz" || source == "tidal" || source == "deezer" || source == "yandex" || source == "beatport" || source == "soundcloud" } func isAllowedMediaType(mediaType string) bool { switch mediaType { - case "track", "album", "playlist", "artist", "label", "video": + case "track", "album", "playlist", "artist", "label", "video", "chart": return true default: return false @@ -318,7 +318,7 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e } for { - source, err := read("Source [qobuz/tidal/deezer/yandex/soundcloud]: ") + source, err := read("Source [qobuz/tidal/deezer/yandex/beatport/soundcloud]: ") if err != nil { return "", "", searchOptions{}, err } @@ -345,6 +345,10 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e fmt.Println("Yandex search supports track, album, playlist, and artist only.") continue } + if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" { + fmt.Println("Beatport search supports track, album, and label only.") + continue + } query, err := read("Query: ") if err != nil { @@ -590,6 +594,30 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) [] } appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit}) } + case "beatport": + items, ok := page["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + id := asString(itm["id"]) + title := asString(itm["title"]) + if title == "" { + title = asString(itm["name"]) + } + if version := asString(itm["version"]); version != "" { + title += " (" + version + ")" + } + artist := nestedSearchString(itm, "artist", "name") + album := nestedSearchString(itm, "album", "title") + trackCount := firstPositiveInt(searchInt(itm["tracks_count"]), searchInt(itm["track_count"])) + date := firstNonEmpty(asString(itm["release_date_original"]), asString(itm["release_date"]), nestedSearchString(itm, "album", "release_date_original")) + appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, TrackCount: trackCount}) + } } } return results diff --git a/config.toml.example b/config.toml.example index 5747e70..a2e6712 100644 --- a/config.toml.example +++ b/config.toml.example @@ -77,6 +77,23 @@ access_token = "" # Cached current account uid. Managed automatically when available. user_id = "" +[beatport] +# Quality ladder: +# 1 = medium (AAC 128), 2 = high (AAC 256), 3/4 = lossless (FLAC 16/44.1) +# medium-hls is intentionally not supported. +quality = 3 +# If a release lists more artists than this, use "Various Artists" as album artist. +# Set to -1 to disable this collapse. +various_artists_threshold = 3 +# Beatport Streaming account credentials +username = "" +password = "" +# Session values are managed automatically. Do not modify manually. +access_token = "" +refresh_token = "" +# Unix timestamp when access_token expires +token_expiry = 0 + [soundcloud] # Quality is currently provider-defined (keep 0) quality = 0 diff --git a/internal/app/app.go b/internal/app/app.go index 9d7e940..9a5e165 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,6 +21,7 @@ import ( "streamrip-go/internal/jsonutil" "streamrip-go/internal/naming" "streamrip-go/internal/provider" + beatportprovider "streamrip-go/internal/provider/beatport" deezerprovider "streamrip-go/internal/provider/deezer" qobuzprovider "streamrip-go/internal/provider/qobuz" soundcloudprovider "streamrip-go/internal/provider/soundcloud" @@ -110,6 +111,7 @@ func New(cfg *config.Config) (*Main, error) { } providers := map[string]provider.Client{ + "beatport": beatportprovider.New(cfg), "qobuz": qobuzprovider.New(cfg), "tidal": tidalprovider.New(cfg), "deezer": deezerprovider.New(cfg), @@ -203,7 +205,12 @@ func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error case "artist": return m.ripCollection(ctx, p, source, "Artist", id, meta) case "label": + if source == "beatport" { + return m.ripTrackCollection(ctx, p, source, "Label", id, meta, false) + } return m.ripCollection(ctx, p, source, "Label", id, meta) + case "chart": + return m.ripTrackCollection(ctx, p, source, "Chart", id, meta, true) case "video": return m.ripVideo(ctx, p, source, id, meta) default: @@ -327,6 +334,98 @@ func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kin return nil } +func (m *Main) ripTrackCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any, playlistLike bool) error { + if err := m.requireSourceDownloadAuth(source); err != nil { + return err + } + name := titleFromMetadata(meta, id) + if n := jsonutil.StringFromAny(meta["name"]); n != "" { + name = n + } + base := m.Config.Session.Downloads.Folder + if m.Config.Session.Downloads.SourceSubdirectories { + base = filepath.Join(base, jsonutil.TitleCase(source)) + } + folder := filepath.Join(base, naming.CleanName(name, naming.Config{RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, TruncateTo: m.Config.Session.Filepaths.TruncateTo})) + tracksMap, ok := meta["tracks"].(map[string]any) + if !ok { + return fmt.Errorf("%s missing tracks data", strings.ToLower(kind)) + } + rawItems := make([]any, 0) + switch items := tracksMap["items"].(type) { + case []any: + rawItems = items + case []map[string]any: + for _, item := range items { + rawItems = append(rawItems, item) + } + default: + return fmt.Errorf("%s tracks missing items", strings.ToLower(kind)) + } + ids := make([]string, 0, len(rawItems)) + for _, raw := range rawItems { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + if id := jsonutil.StringFromAny(itm["id"]); id != "" { + ids = append(ids, id) + } + } + m.logf("%s: %s (%d tracks)\n", kind, name, len(ids)) + failures := 0 + runOne := func(i int, trackID string) { + opts := ripTrackOptions{albumFolder: folder, index: i, total: len(ids)} + if playlistLike { + opts.forPlaylist = true + opts.playlistName = name + opts.playlistPos = i + } + if err := m.ripTrack(ctx, p, source, trackID, "", opts); err != nil { + failures++ + m.logf("track failed: id=%s reason=%v\n", trackID, err) + } + } + if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 { + for i, trackID := range ids { + runOne(i+1, trackID) + } + } else { + maxWorkers := m.Config.Session.Downloads.MaxConnections + if maxWorkers <= 0 { + maxWorkers = 6 + } + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + var mu sync.Mutex + for i, trackID := range ids { + wg.Add(1) + sem <- struct{}{} + go func(pos int, tid string) { + defer wg.Done() + defer func() { <-sem }() + opts := ripTrackOptions{albumFolder: folder, index: pos, total: len(ids)} + if playlistLike { + opts.forPlaylist = true + opts.playlistName = name + opts.playlistPos = pos + } + if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil { + mu.Lock() + failures++ + m.logf("track failed: id=%s reason=%v\n", tid, err) + mu.Unlock() + } + }(i+1, trackID) + } + wg.Wait() + } + if failures > 0 { + m.logf("%s done with %d failed track(s)\n", kind, failures) + } + return nil +} + func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID string, meta map[string]any) error { alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, videoID) if err == nil && alreadyDownloaded && !m.IgnoreDB { @@ -924,8 +1023,11 @@ downloaded: embedCoverPath = res.EmbedPath } } - } else if opts.albumFolder == "" { - parent := filepath.Dir(outPath) + } else if embedCoverPath == "" { + parent := opts.albumFolder + if parent == "" { + parent = filepath.Dir(outPath) + } if res, prepErr := artwork.Prepare(ctx, m.DL, parent, trackMetaAlbum(meta), m.Config.Session.Artwork, false); prepErr == nil { if res.EmbedPath != "" { embedCoverPath = res.EmbedPath @@ -971,6 +1073,8 @@ func (m *Main) qualityForSource(source string) int { return m.Config.Session.Deezer.Quality case "yandex": return m.Config.Session.Yandex.Quality + case "beatport": + return m.Config.Session.Beatport.Quality case "soundcloud": return m.Config.Session.Soundcloud.Quality default: @@ -1313,6 +1417,11 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o if genre == "" { genre = jsonutil.StringFromAny(trackMeta["genre"]) } + initialKey := jsonutil.FirstNonEmpty( + jsonutil.StringFromAny(trackMeta["key"]), + jsonutil.StringFromAny(trackMeta["initialkey"]), + ) + initialKey = normalizeInitialKey(initialKey) comment := jsonutil.StringFromAny(trackMeta["comment"]) description := jsonutil.StringFromAny(trackMeta["description"]) @@ -1368,6 +1477,7 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o DiscTotal: discTotal, Date: date, Genre: genre, + InitialKey: initialKey, Comment: comment, Description: description, Lyrics: lyrics, @@ -1384,6 +1494,67 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o } } +func normalizeInitialKey(in string) string { + s := strings.TrimSpace(in) + if s == "" { + return "" + } + parts := strings.Fields(s) + if len(parts) >= 2 { + switch strings.ToLower(parts[1]) { + case "major", "maj": + if root := normalizeMajorKeyRoot(parts[0]); root != "" { + return root + } + case "minor", "min": + if root := normalizeMinorKeyRoot(parts[0]); root != "" { + return root + "m" + } + } + } + if strings.HasSuffix(s, "m") && len(s) > 1 { + root := normalizeMinorKeyRoot(strings.TrimSuffix(s, "m")) + if root != "" { + return root + "m" + } + } + if root := normalizeMajorKeyRoot(s); root != "" { + return root + } + return s +} + +func normalizeMajorKeyRoot(root string) string { + s := normalizeKeyRootCase(root) + switch s { + case "C", "Db", "D", "Eb", "E", "F", "F#", "Gb", "G", "Ab", "A", "Bb", "B": + return s + default: + return "" + } +} + +func normalizeMinorKeyRoot(root string) string { + s := normalizeKeyRootCase(root) + switch s { + case "C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B": + return s + default: + return "" + } +} + +func normalizeKeyRootCase(root string) string { + s := strings.TrimSpace(root) + if s == "" { + return "" + } + if len(s) >= 1 { + s = strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) + } + return s +} + func applyPlaylistMetadataOverrides(meta map[string]any, cfg config.MetadataConfig, playlistName string, position int) { if cfg.RenumberPlaylistTracks && position > 0 { meta["track_number"] = position diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a0ba5ca..f60c1fd 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -864,6 +864,41 @@ func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) { } } +func TestBuildTagMetadataInitialKey(t *testing.T) { + meta := map[string]any{ + "key": "E Minor", + "album": map[string]any{"title": "Album"}, + "performer": map[string]any{"name": "Artist"}, + } + + tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{}) + if tags.InitialKey != "Em" { + t.Fatalf("InitialKey=%q", tags.InitialKey) + } +} + +func TestNormalizeInitialKey(t *testing.T) { + tests := map[string]string{ + "C Major": "C", + "Db Major": "Db", + "F# Major": "F#", + "E Minor": "Em", + "C# Minor": "C#m", + "Bb Minor": "Bbm", + "g# minor": "G#m", + "F#m": "F#m", + "Ab": "Ab", + "Not A Key": "Not A Key", + "C# Major": "C# Major", + "Db Minor": "Db Minor", + } + for input, want := range tests { + if got := normalizeInitialKey(input); got != want { + t.Fatalf("normalizeInitialKey(%q)=%q want %q", input, got, want) + } + } +} + func TestBuildTagMetadataReplayGainFallsBackToDeezerGain(t *testing.T) { meta := map[string]any{ "gain": float64(-10), diff --git a/internal/audio/tag/tagger.go b/internal/audio/tag/tagger.go index 7faee64..9d2f217 100644 --- a/internal/audio/tag/tagger.go +++ b/internal/audio/tag/tagger.go @@ -21,6 +21,7 @@ type Metadata struct { DiscTotal int Date string Genre string + InitialKey string Comment string Description string Lyrics string @@ -160,6 +161,7 @@ func toTags(meta Metadata) map[string]string { "album_artist": meta.AlbumArtist, "date": meta.Date, "genre": meta.Genre, + "INITIALKEY": meta.InitialKey, "comment": meta.Comment, "description": meta.Description, "lyrics": meta.Lyrics, diff --git a/internal/audio/tag/tagger_test.go b/internal/audio/tag/tagger_test.go index 9746942..828d037 100644 --- a/internal/audio/tag/tagger_test.go +++ b/internal/audio/tag/tagger_test.go @@ -30,6 +30,7 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) { DiscNumber: 1, DiscTotal: 2, ISRC: "USABC1234567", + InitialKey: "Em", ReplaygainTrackGain: "-7.25 dB", ReplaygainAlbumGain: "-8.1 dB", ReplaygainTrackPeak: "0.989", @@ -49,6 +50,9 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) { if tags["isrc"] != "USABC1234567" { t.Fatalf("isrc missing: %+v", tags) } + if tags["INITIALKEY"] != "Em" { + t.Fatalf("INITIALKEY missing: %+v", tags) + } if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" { t.Fatalf("source tags missing: %+v", tags) } diff --git a/internal/config/config.go b/internal/config/config.go index 5b979dc..80994e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ type ConfigData struct { Tidal TidalConfig `toml:"tidal"` Deezer DeezerConfig `toml:"deezer"` Yandex YandexConfig `toml:"yandex"` + Beatport BeatportConfig `toml:"beatport"` Soundcloud SoundcloudConfig `toml:"soundcloud"` Youtube YoutubeConfig `toml:"youtube"` Database DatabaseConfig `toml:"database"` @@ -84,6 +85,16 @@ type YandexConfig struct { UserID string `toml:"user_id"` } +type BeatportConfig struct { + Quality int `toml:"quality"` + VariousArtistsThreshold int `toml:"various_artists_threshold"` + Username string `toml:"username"` + Password string `toml:"password"` + AccessToken string `toml:"access_token"` + RefreshToken string `toml:"refresh_token"` + TokenExpiry int64 `toml:"token_expiry"` +} + type SoundcloudConfig struct { Quality int `toml:"quality"` ClientID string `toml:"client_id"` @@ -250,6 +261,10 @@ func DefaultConfigData() ConfigData { Yandex: YandexConfig{ Quality: 2, }, + Beatport: BeatportConfig{ + Quality: 3, + VariousArtistsThreshold: 3, + }, Soundcloud: SoundcloudConfig{ Quality: 0, }, diff --git a/internal/provider/beatport/client.go b/internal/provider/beatport/client.go new file mode 100644 index 0000000..11fc8b6 --- /dev/null +++ b/internal/provider/beatport/client.go @@ -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 +} diff --git a/internal/provider/beatport/client_test.go b/internal/provider/beatport/client_test.go new file mode 100644 index 0000000..86e0f82 --- /dev/null +++ b/internal/provider/beatport/client_test.go @@ -0,0 +1,212 @@ +package beatport + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "streamrip-go/internal/config" +) + +func newTestClient(t *testing.T, handler http.Handler) (*Client, func()) { + t.Helper() + ts := httptest.NewServer(handler) + cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()} + cfg.Session.Beatport.Username = "user" + cfg.Session.Beatport.Password = "pass" + cfg.Session.Beatport.AccessToken = "token" + cfg.Session.Beatport.TokenExpiry = time.Now().Add(time.Hour).Unix() + c := New(cfg) + c.baseURL = ts.URL + c.http = ts.Client() + c.loggedIn = true + return c, ts.Close +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("encode json: %v", err) + } +} + +func TestGetDownloadableMapsQuality(t *testing.T) { + c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/catalog/tracks/42/download/" { + t.Fatalf("path = %q", r.URL.Path) + } + if got := r.URL.Query().Get("quality"); got != "high" { + t.Fatalf("quality = %q, want high", got) + } + writeJSON(t, w, map[string]any{"location": "https://cdn.example/42.m4a", "stream_quality": ".256k.aac.mp4"}) + })) + defer closeServer() + + d, err := c.GetDownloadable(context.Background(), "42", 2) + if err != nil { + t.Fatalf("GetDownloadable() error = %v", err) + } + if d.URL != "https://cdn.example/42.m4a" || d.Extension != "m4a" || d.Audio.BitrateKbps != 256 || d.Audio.Quality != "HIGH" { + t.Fatalf("unexpected downloadable: %+v", d) + } +} + +func TestAlbumMetadataNormalizesReleaseTracks(t *testing.T) { + c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/catalog/releases/7/": + writeJSON(t, w, map[string]any{ + "id": 7, + "name": "Release Name", + "new_release_date": "2024-01-02", + "track_count": 1, + "artists": []any{map[string]any{"id": 10, "name": "Album Artist"}}, + "image": map[string]any{"dynamic_uri": "https://img.example/{w}x{h}.jpg"}, + }) + case "/catalog/releases/7/tracks/": + writeJSON(t, w, map[string]any{ + "next": nil, + "results": []any{map[string]any{ + "id": 42, + "name": "Track Name", + "mix_name": "Original Mix", + "number": 1, + "artists": []any{map[string]any{"id": 11, "name": "Track Artist"}}, + "genre": map[string]any{"name": "House"}, + "isrc": "USABC1234567", + }}, + }) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer closeServer() + + meta, err := c.GetMetadata(context.Background(), "7", "album") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + if meta["title"] != "Release Name" || meta["release_date_original"] != "2024-01-02" { + t.Fatalf("unexpected album meta: %+v", meta) + } + tracks := meta["tracks"].(map[string]any)["items"].([]any) + if len(tracks) != 1 { + t.Fatalf("tracks len = %d", len(tracks)) + } + track := tracks[0].(map[string]any) + if track["id"] != "42" || track["title"] != "Track Name" || track["version"] != "Original Mix" { + t.Fatalf("unexpected track meta: %+v", track) + } + album := track["album"].(map[string]any) + if album["title"] != "Release Name" { + t.Fatalf("unexpected track album: %+v", album) + } +} + +func TestAlbumMetadataCollapsesManyReleaseArtists(t *testing.T) { + c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/catalog/releases/7/": + writeJSON(t, w, map[string]any{ + "id": 7, + "name": "Compilation", + "track_count": 1, + "artists": []any{ + map[string]any{"id": 1, "name": "A"}, + map[string]any{"id": 2, "name": "B"}, + map[string]any{"id": 3, "name": "C"}, + map[string]any{"id": 4, "name": "D"}, + }, + }) + case "/catalog/releases/7/tracks/": + writeJSON(t, w, map[string]any{ + "next": nil, + "results": []any{map[string]any{ + "id": 42, + "name": "Track Name", + "number": 1, + "artists": []any{map[string]any{"id": 1, "name": "A"}}, + }}, + }) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer closeServer() + + meta, err := c.GetMetadata(context.Background(), "7", "album") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + artist := meta["artist"].(map[string]any) + if artist["name"] != "Various Artists" { + t.Fatalf("album artist = %q, want Various Artists", artist["name"]) + } + track := meta["tracks"].(map[string]any)["items"].([]any)[0].(map[string]any) + album := track["album"].(map[string]any) + albumArtist := album["artist"].(map[string]any) + if albumArtist["name"] != "Various Artists" { + t.Fatalf("track album artist = %q, want Various Artists", albumArtist["name"]) + } +} + +func TestReleaseArtistCollapseCanBeDisabled(t *testing.T) { + cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()} + cfg.Session.Beatport.VariousArtistsThreshold = -1 + c := New(cfg) + got := c.releaseArtistName([]any{ + map[string]any{"name": "A"}, + map[string]any{"name": "B"}, + map[string]any{"name": "C"}, + map[string]any{"name": "D"}, + }) + if got != "A, B, C, D" { + t.Fatalf("releaseArtistName() = %q", got) + } +} + +func TestArtistMetadataUsesReleases(t *testing.T) { + c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/catalog/artists/908468/": + writeJSON(t, w, map[string]any{"id": 908468, "name": "D'ort"}) + case "/catalog/releases/": + if got := r.URL.Query().Get("artist_id"); got != "908468" { + t.Fatalf("artist_id = %q", got) + } + writeJSON(t, w, map[string]any{ + "next": nil, + "results": []any{map[string]any{ + "id": 123, + "name": "An Artisan's Exhibition", + "new_release_date": "2026-01-01", + "track_count": 10, + "artists": []any{map[string]any{"id": 908468, "name": "D'ort"}}, + }}, + }) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer closeServer() + + meta, err := c.GetMetadata(context.Background(), "908468", "artist") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + if meta["name"] != "D'ort" { + t.Fatalf("artist name = %q", meta["name"]) + } + albums := meta["albums"].(map[string]any)["items"].([]any) + if len(albums) != 1 { + t.Fatalf("albums len = %d", len(albums)) + } + album := albums[0].(map[string]any) + if album["id"] != "123" || album["title"] != "An Artisan's Exhibition" || album["tracks_count"] != 10 { + t.Fatalf("unexpected album: %+v", album) + } +} diff --git a/internal/urlparse/parse.go b/internal/urlparse/parse.go index 686cd71..c98e746 100644 --- a/internal/urlparse/parse.go +++ b/internal/urlparse/parse.go @@ -47,6 +47,8 @@ func Parse(raw string) *ParsedURL { return parseQobuz(raw, parts) case isYandexHost(host): return parseYandex(raw, parts) + case isBeatportHost(host): + return parseBeatport(raw, parts) case isTidalHost(host): return parseTidal(raw, parts) case isDeezerHost(host): @@ -58,6 +60,60 @@ func Parse(raw string) *ParsedURL { } } +func parseBeatport(raw string, parts []string) *ParsedURL { + if len(parts) < 2 { + return nil + } + if len(parts[0]) == 2 { + parts = parts[1:] + } + if len(parts) > 0 && parts[0] == "catalog" { + parts = parts[1:] + } + if len(parts) < 2 { + return nil + } + + mediaType := "" + idIndex := 1 + switch parts[0] { + case "track", "tracks": + mediaType = "track" + if parts[0] == "track" { + idIndex = 2 + } + case "release", "releases": + mediaType = "album" + if parts[0] == "release" { + idIndex = 2 + } + case "library": + if len(parts) < 3 || (parts[1] != "playlists" && parts[1] != "playlist") { + return nil + } + mediaType = "playlist" + idIndex = 2 + case "playlists": + mediaType = "playlist" + idIndex = 2 + case "chart", "playlist": + mediaType = "chart" + idIndex = 2 + case "artist": + mediaType = "artist" + idIndex = 2 + case "label": + mediaType = "label" + idIndex = 2 + default: + return nil + } + if idIndex >= len(parts) || strings.TrimSpace(parts[idIndex]) == "" { + return nil + } + return &ParsedURL{OriginalURL: raw, Source: "beatport", MediaType: mediaType, ID: parts[idIndex], Kind: KindGeneric} +} + func parseQobuz(raw string, parts []string) *ParsedURL { if len(parts) < 2 { return nil @@ -222,6 +278,10 @@ func isYandexHost(host string) bool { return host == "music.yandex.ru" || host == "music.yandex.com" || host == "music.yandex.kz" || host == "music.yandex.by" } +func isBeatportHost(host string) bool { + return host == "beatport.com" || host == "api.beatport.com" +} + func isTidalHost(host string) bool { return host == "tidal.com" || host == "open.tidal.com" || host == "listen.tidal.com" } @@ -236,7 +296,7 @@ func isSoundcloudHost(host string) bool { func isSupportedMedia(mediaType string) bool { switch mediaType { - case "album", "track", "playlist", "artist", "label", "video": + case "album", "track", "playlist", "artist", "label", "video", "chart": return true default: return false diff --git a/internal/urlparse/parse_test.go b/internal/urlparse/parse_test.go index 53844e7..01b943a 100644 --- a/internal/urlparse/parse_test.go +++ b/internal/urlparse/parse_test.go @@ -67,6 +67,31 @@ func TestYandexURLs(t *testing.T) { } } +func TestBeatportURLs(t *testing.T) { + tests := []struct { + url string + mediaType string + id string + }{ + {url: "https://www.beatport.com/track/strobe/1696999", mediaType: "track", id: "1696999"}, + {url: "https://www.beatport.com/release/random-album/12345", mediaType: "album", id: "12345"}, + {url: "https://www.beatport.com/library/playlists/67890", mediaType: "playlist", id: "67890"}, + {url: "https://www.beatport.com/chart/some-chart/111", mediaType: "chart", id: "111"}, + {url: "https://www.beatport.com/artist/deadmau5/24078", mediaType: "artist", id: "24078"}, + {url: "https://www.beatport.com/label/mau5trap/1234", mediaType: "label", id: "1234"}, + {url: "https://api.beatport.com/v4/catalog/tracks/1696999/", mediaType: "track", id: "1696999"}, + } + for _, tc := range tests { + result := Parse(tc.url) + if result == nil { + t.Fatalf("expected parse for %q", tc.url) + } + if result.Source != "beatport" || result.MediaType != tc.mediaType || result.ID != tc.id { + t.Fatalf("unexpected parse result for %q: %+v", tc.url, result) + } + } +} + func TestTidalTrackURL(t *testing.T) { inputs := []string{ "https://tidal.com/browse/track/3083287",