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:
+173
-2
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user