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:
+1
-1
@@ -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)
|
fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind)
|
||||||
return false
|
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)
|
fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -294,7 +294,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
case "id":
|
case "id":
|
||||||
if len(os.Args) < 5 {
|
if len(os.Args) < 5 {
|
||||||
fmt.Println("usage: rip id <source> <track|album|playlist|artist|label|video> <id> [quality] [--force|--ignore-db]")
|
fmt.Println("usage: rip id <source> <track|album|playlist|artist|label|chart|video> <id> [quality] [--force|--ignore-db]")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +319,12 @@ func main() {
|
|||||||
cfg.Session.Tidal.Quality = opts.quality
|
cfg.Session.Tidal.Quality = opts.quality
|
||||||
case "yandex":
|
case "yandex":
|
||||||
cfg.Session.Yandex.Quality = opts.quality
|
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
|
var sopts searchOptions
|
||||||
if len(os.Args) < 5 {
|
if len(os.Args) < 5 {
|
||||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
fmt.Println("usage: rip search <qobuz|tidal|deezer|yandex|soundcloud> <track|album|playlist|artist|label|video> <query...> [--limit N] [--force|--ignore-db] [--no-download]")
|
fmt.Println("usage: rip search <qobuz|tidal|deezer|yandex|beatport|soundcloud> <track|album|playlist|artist|label|chart|video> <query...> [--limit N] [--force|--ignore-db] [--no-download]")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults)
|
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")
|
fmt.Fprintln(os.Stderr, "yandex search currently supports media types track, album, playlist, and artist")
|
||||||
os.Exit(2)
|
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 == "" {
|
if sopts.query == "" {
|
||||||
fmt.Fprintln(os.Stderr, "search query cannot be empty")
|
fmt.Fprintln(os.Stderr, "search query cannot be empty")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
|
|||||||
+31
-3
@@ -293,12 +293,12 @@ func writeSearchResultsToFile(source, mediaType string, results []searchResult,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isAllowedSearchSource(source string) bool {
|
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 {
|
func isAllowedMediaType(mediaType string) bool {
|
||||||
switch mediaType {
|
switch mediaType {
|
||||||
case "track", "album", "playlist", "artist", "label", "video":
|
case "track", "album", "playlist", "artist", "label", "video", "chart":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -318,7 +318,7 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
source, err := read("Source [qobuz/tidal/deezer/yandex/soundcloud]: ")
|
source, err := read("Source [qobuz/tidal/deezer/yandex/beatport/soundcloud]: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", searchOptions{}, err
|
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.")
|
fmt.Println("Yandex search supports track, album, playlist, and artist only.")
|
||||||
continue
|
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: ")
|
query, err := read("Query: ")
|
||||||
if err != nil {
|
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})
|
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
|
return results
|
||||||
|
|||||||
@@ -77,6 +77,23 @@ access_token = ""
|
|||||||
# Cached current account uid. Managed automatically when available.
|
# Cached current account uid. Managed automatically when available.
|
||||||
user_id = ""
|
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]
|
[soundcloud]
|
||||||
# Quality is currently provider-defined (keep 0)
|
# Quality is currently provider-defined (keep 0)
|
||||||
quality = 0
|
quality = 0
|
||||||
|
|||||||
+173
-2
@@ -21,6 +21,7 @@ import (
|
|||||||
"streamrip-go/internal/jsonutil"
|
"streamrip-go/internal/jsonutil"
|
||||||
"streamrip-go/internal/naming"
|
"streamrip-go/internal/naming"
|
||||||
"streamrip-go/internal/provider"
|
"streamrip-go/internal/provider"
|
||||||
|
beatportprovider "streamrip-go/internal/provider/beatport"
|
||||||
deezerprovider "streamrip-go/internal/provider/deezer"
|
deezerprovider "streamrip-go/internal/provider/deezer"
|
||||||
qobuzprovider "streamrip-go/internal/provider/qobuz"
|
qobuzprovider "streamrip-go/internal/provider/qobuz"
|
||||||
soundcloudprovider "streamrip-go/internal/provider/soundcloud"
|
soundcloudprovider "streamrip-go/internal/provider/soundcloud"
|
||||||
@@ -110,6 +111,7 @@ func New(cfg *config.Config) (*Main, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
providers := map[string]provider.Client{
|
providers := map[string]provider.Client{
|
||||||
|
"beatport": beatportprovider.New(cfg),
|
||||||
"qobuz": qobuzprovider.New(cfg),
|
"qobuz": qobuzprovider.New(cfg),
|
||||||
"tidal": tidalprovider.New(cfg),
|
"tidal": tidalprovider.New(cfg),
|
||||||
"deezer": deezerprovider.New(cfg),
|
"deezer": deezerprovider.New(cfg),
|
||||||
@@ -203,7 +205,12 @@ func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error
|
|||||||
case "artist":
|
case "artist":
|
||||||
return m.ripCollection(ctx, p, source, "Artist", id, meta)
|
return m.ripCollection(ctx, p, source, "Artist", id, meta)
|
||||||
case "label":
|
case "label":
|
||||||
|
if source == "beatport" {
|
||||||
|
return m.ripTrackCollection(ctx, p, source, "Label", id, meta, false)
|
||||||
|
}
|
||||||
return m.ripCollection(ctx, p, source, "Label", id, meta)
|
return m.ripCollection(ctx, p, source, "Label", id, meta)
|
||||||
|
case "chart":
|
||||||
|
return m.ripTrackCollection(ctx, p, source, "Chart", id, meta, true)
|
||||||
case "video":
|
case "video":
|
||||||
return m.ripVideo(ctx, p, source, id, meta)
|
return m.ripVideo(ctx, p, source, id, meta)
|
||||||
default:
|
default:
|
||||||
@@ -327,6 +334,98 @@ func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kin
|
|||||||
return nil
|
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 {
|
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)
|
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, videoID)
|
||||||
if err == nil && alreadyDownloaded && !m.IgnoreDB {
|
if err == nil && alreadyDownloaded && !m.IgnoreDB {
|
||||||
@@ -924,8 +1023,11 @@ downloaded:
|
|||||||
embedCoverPath = res.EmbedPath
|
embedCoverPath = res.EmbedPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if opts.albumFolder == "" {
|
} else if embedCoverPath == "" {
|
||||||
parent := filepath.Dir(outPath)
|
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, prepErr := artwork.Prepare(ctx, m.DL, parent, trackMetaAlbum(meta), m.Config.Session.Artwork, false); prepErr == nil {
|
||||||
if res.EmbedPath != "" {
|
if res.EmbedPath != "" {
|
||||||
embedCoverPath = res.EmbedPath
|
embedCoverPath = res.EmbedPath
|
||||||
@@ -971,6 +1073,8 @@ func (m *Main) qualityForSource(source string) int {
|
|||||||
return m.Config.Session.Deezer.Quality
|
return m.Config.Session.Deezer.Quality
|
||||||
case "yandex":
|
case "yandex":
|
||||||
return m.Config.Session.Yandex.Quality
|
return m.Config.Session.Yandex.Quality
|
||||||
|
case "beatport":
|
||||||
|
return m.Config.Session.Beatport.Quality
|
||||||
case "soundcloud":
|
case "soundcloud":
|
||||||
return m.Config.Session.Soundcloud.Quality
|
return m.Config.Session.Soundcloud.Quality
|
||||||
default:
|
default:
|
||||||
@@ -1313,6 +1417,11 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
if genre == "" {
|
if genre == "" {
|
||||||
genre = jsonutil.StringFromAny(trackMeta["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"])
|
comment := jsonutil.StringFromAny(trackMeta["comment"])
|
||||||
description := jsonutil.StringFromAny(trackMeta["description"])
|
description := jsonutil.StringFromAny(trackMeta["description"])
|
||||||
@@ -1368,6 +1477,7 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
DiscTotal: discTotal,
|
DiscTotal: discTotal,
|
||||||
Date: date,
|
Date: date,
|
||||||
Genre: genre,
|
Genre: genre,
|
||||||
|
InitialKey: initialKey,
|
||||||
Comment: comment,
|
Comment: comment,
|
||||||
Description: description,
|
Description: description,
|
||||||
Lyrics: lyrics,
|
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) {
|
func applyPlaylistMetadataOverrides(meta map[string]any, cfg config.MetadataConfig, playlistName string, position int) {
|
||||||
if cfg.RenumberPlaylistTracks && position > 0 {
|
if cfg.RenumberPlaylistTracks && position > 0 {
|
||||||
meta["track_number"] = position
|
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) {
|
func TestBuildTagMetadataReplayGainFallsBackToDeezerGain(t *testing.T) {
|
||||||
meta := map[string]any{
|
meta := map[string]any{
|
||||||
"gain": float64(-10),
|
"gain": float64(-10),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Metadata struct {
|
|||||||
DiscTotal int
|
DiscTotal int
|
||||||
Date string
|
Date string
|
||||||
Genre string
|
Genre string
|
||||||
|
InitialKey string
|
||||||
Comment string
|
Comment string
|
||||||
Description string
|
Description string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
@@ -160,6 +161,7 @@ func toTags(meta Metadata) map[string]string {
|
|||||||
"album_artist": meta.AlbumArtist,
|
"album_artist": meta.AlbumArtist,
|
||||||
"date": meta.Date,
|
"date": meta.Date,
|
||||||
"genre": meta.Genre,
|
"genre": meta.Genre,
|
||||||
|
"INITIALKEY": meta.InitialKey,
|
||||||
"comment": meta.Comment,
|
"comment": meta.Comment,
|
||||||
"description": meta.Description,
|
"description": meta.Description,
|
||||||
"lyrics": meta.Lyrics,
|
"lyrics": meta.Lyrics,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
|||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
DiscTotal: 2,
|
DiscTotal: 2,
|
||||||
ISRC: "USABC1234567",
|
ISRC: "USABC1234567",
|
||||||
|
InitialKey: "Em",
|
||||||
ReplaygainTrackGain: "-7.25 dB",
|
ReplaygainTrackGain: "-7.25 dB",
|
||||||
ReplaygainAlbumGain: "-8.1 dB",
|
ReplaygainAlbumGain: "-8.1 dB",
|
||||||
ReplaygainTrackPeak: "0.989",
|
ReplaygainTrackPeak: "0.989",
|
||||||
@@ -49,6 +50,9 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
|||||||
if tags["isrc"] != "USABC1234567" {
|
if tags["isrc"] != "USABC1234567" {
|
||||||
t.Fatalf("isrc missing: %+v", tags)
|
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" {
|
if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" {
|
||||||
t.Fatalf("source tags missing: %+v", tags)
|
t.Fatalf("source tags missing: %+v", tags)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type ConfigData struct {
|
|||||||
Tidal TidalConfig `toml:"tidal"`
|
Tidal TidalConfig `toml:"tidal"`
|
||||||
Deezer DeezerConfig `toml:"deezer"`
|
Deezer DeezerConfig `toml:"deezer"`
|
||||||
Yandex YandexConfig `toml:"yandex"`
|
Yandex YandexConfig `toml:"yandex"`
|
||||||
|
Beatport BeatportConfig `toml:"beatport"`
|
||||||
Soundcloud SoundcloudConfig `toml:"soundcloud"`
|
Soundcloud SoundcloudConfig `toml:"soundcloud"`
|
||||||
Youtube YoutubeConfig `toml:"youtube"`
|
Youtube YoutubeConfig `toml:"youtube"`
|
||||||
Database DatabaseConfig `toml:"database"`
|
Database DatabaseConfig `toml:"database"`
|
||||||
@@ -84,6 +85,16 @@ type YandexConfig struct {
|
|||||||
UserID string `toml:"user_id"`
|
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 {
|
type SoundcloudConfig struct {
|
||||||
Quality int `toml:"quality"`
|
Quality int `toml:"quality"`
|
||||||
ClientID string `toml:"client_id"`
|
ClientID string `toml:"client_id"`
|
||||||
@@ -250,6 +261,10 @@ func DefaultConfigData() ConfigData {
|
|||||||
Yandex: YandexConfig{
|
Yandex: YandexConfig{
|
||||||
Quality: 2,
|
Quality: 2,
|
||||||
},
|
},
|
||||||
|
Beatport: BeatportConfig{
|
||||||
|
Quality: 3,
|
||||||
|
VariousArtistsThreshold: 3,
|
||||||
|
},
|
||||||
Soundcloud: SoundcloudConfig{
|
Soundcloud: SoundcloudConfig{
|
||||||
Quality: 0,
|
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)
|
return parseQobuz(raw, parts)
|
||||||
case isYandexHost(host):
|
case isYandexHost(host):
|
||||||
return parseYandex(raw, parts)
|
return parseYandex(raw, parts)
|
||||||
|
case isBeatportHost(host):
|
||||||
|
return parseBeatport(raw, parts)
|
||||||
case isTidalHost(host):
|
case isTidalHost(host):
|
||||||
return parseTidal(raw, parts)
|
return parseTidal(raw, parts)
|
||||||
case isDeezerHost(host):
|
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 {
|
func parseQobuz(raw string, parts []string) *ParsedURL {
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return nil
|
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"
|
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 {
|
func isTidalHost(host string) bool {
|
||||||
return host == "tidal.com" || host == "open.tidal.com" || host == "listen.tidal.com"
|
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 {
|
func isSupportedMedia(mediaType string) bool {
|
||||||
switch mediaType {
|
switch mediaType {
|
||||||
case "album", "track", "playlist", "artist", "label", "video":
|
case "album", "track", "playlist", "artist", "label", "video", "chart":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
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) {
|
func TestTidalTrackURL(t *testing.T) {
|
||||||
inputs := []string{
|
inputs := []string{
|
||||||
"https://tidal.com/browse/track/3083287",
|
"https://tidal.com/browse/track/3083287",
|
||||||
|
|||||||
Reference in New Issue
Block a user