Author SHA1 Message Date
Joren d2fa098d69 feat: add configurable artist separator 2026-07-11 01:21:40 +02:00
Joren 537959b6ec feat: add beatport provider 2026-07-10 19:21:14 +02:00
Joren 2a7d259e9f Merge branch 'feat/yandex-music'
# Conflicts:
#	internal/audio/tag/tagger.go
#	internal/audio/tag/tagger_test.go
#	internal/urlparse/parse_test.go
2026-07-10 18:18:04 +02:00
Joren b65edb4cce yes 2026-06-15 17:18:47 +02:00
Joren db26a40415 fix qobuz interpreter artist urls 2026-06-10 12:34:37 +02:00
15 changed files with 1450 additions and 45 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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
@@ -138,6 +155,8 @@ saved_max_width = -1
set_playlist_to_album = true set_playlist_to_album = true
# Use playlist position as tracknumber for playlist items # Use playlist position as tracknumber for playlist items
renumber_playlist_tracks = true renumber_playlist_tracks = true
# Separator used when a provider exposes multiple artists as separate values
artist_separator = "; "
# Metadata fields to exclude from tagging # Metadata fields to exclude from tagging
exclude = [] exclude = []
+174 -2
View File
@@ -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"])
@@ -1361,12 +1470,14 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
Album: album, Album: album,
Artist: artist, Artist: artist,
AlbumArtist: albumArtist, AlbumArtist: albumArtist,
OmitDiscTags: opts.forPlaylist,
TrackNumber: trackNumber, TrackNumber: trackNumber,
DiscNumber: discNumber, DiscNumber: discNumber,
TrackTotal: trackTotal, TrackTotal: trackTotal,
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,
@@ -1383,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
+38
View File
@@ -525,6 +525,9 @@ func TestBuildTagMetadataPlaylistOmitsDiscTags(t *testing.T) {
if tags.DiscTotal != 0 { if tags.DiscTotal != 0 {
t.Fatalf("disc total = %d, want 0", tags.DiscTotal) t.Fatalf("disc total = %d, want 0", tags.DiscTotal)
} }
if !tags.OmitDiscTags {
t.Fatalf("omit disc tags = %v, want true", tags.OmitDiscTags)
}
} }
func TestTrackOutputPathFallsBackToDisc1(t *testing.T) { func TestTrackOutputPathFallsBackToDisc1(t *testing.T) {
@@ -861,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),
+12 -5
View File
@@ -14,12 +14,14 @@ type Metadata struct {
Album string Album string
Artist string Artist string
AlbumArtist string AlbumArtist string
OmitDiscTags bool
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
TrackTotal int TrackTotal int
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
@@ -102,6 +104,14 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext
} }
args = append(args, "-metadata", k+"="+v) args = append(args, "-metadata", k+"="+v)
} }
if meta.OmitDiscTags {
args = append(args,
"-metadata", "disc=",
"-metadata", "disk=",
"-metadata", "disctotal=",
"-metadata", "totaldiscs=",
)
}
if forceMP4Muxer { if forceMP4Muxer {
args = append(args, "-f", "mp4") args = append(args, "-f", "mp4")
} }
@@ -151,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,
@@ -166,11 +177,7 @@ func toTags(meta Metadata) map[string]string {
"source_artist_id": meta.SourceArtistID, "source_artist_id": meta.SourceArtistID,
} }
if meta.TrackNumber > 0 { if meta.TrackNumber > 0 {
if meta.TrackTotal > 0 { tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
tags["track"] = fmt.Sprintf("%02d/%02d", meta.TrackNumber, meta.TrackTotal)
} else {
tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
}
} }
if meta.TrackTotal > 0 { if meta.TrackTotal > 0 {
tags["tracktotal"] = strconv.Itoa(meta.TrackTotal) tags["tracktotal"] = strconv.Itoa(meta.TrackTotal)
+27 -1
View File
@@ -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",
@@ -37,7 +38,7 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
SourcePlatform: "qobuz", SourcePlatform: "qobuz",
SourceTrackID: "t1", SourceTrackID: "t1",
}) })
if tags["track"] != "03/12" { if tags["track"] != "03" {
t.Fatalf("track tag = %q", tags["track"]) t.Fatalf("track tag = %q", tags["track"])
} }
if tags["disc"] != "1/2" { if tags["disc"] != "1/2" {
@@ -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)
} }
@@ -96,6 +100,28 @@ func TestBuildFFmpegArgsSkipsCoverForUnsupportedContainer(t *testing.T) {
} }
} }
func TestBuildFFmpegArgsClearsDiscTagsWhenRequested(t *testing.T) {
args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x", OmitDiscTags: true}, "", "flac", false)
want := map[string]bool{
"disc=": false,
"disk=": false,
"disctotal=": false,
"totaldiscs=": false,
}
for i := 0; i < len(args)-1; i++ {
if args[i] == "-metadata" {
if _, ok := want[args[i+1]]; ok {
want[args[i+1]] = true
}
}
}
for tag, found := range want {
if !found {
t.Fatalf("missing clear tag %q in args: %v", tag, args)
}
}
}
func TestBuildFFmpegArgsForcesMP4Muxer(t *testing.T) { func TestBuildFFmpegArgsForcesMP4Muxer(t *testing.T) {
args := buildFFmpegArgs("in.m4a", "out.m4a", Metadata{Title: "x"}, "", "m4a", true) args := buildFFmpegArgs("in.m4a", "out.m4a", Metadata{Title: "x"}, "", "m4a", true)
for i := 0; i < len(args)-1; i++ { for i := 0; i < len(args)-1; i++ {
+17
View File
@@ -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"`
@@ -131,6 +142,7 @@ type ArtworkConfig struct {
type MetadataConfig struct { type MetadataConfig struct {
SetPlaylistToAlbum bool `toml:"set_playlist_to_album"` SetPlaylistToAlbum bool `toml:"set_playlist_to_album"`
RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"` RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"`
ArtistSeparator string `toml:"artist_separator"`
Exclude []string `toml:"exclude"` Exclude []string `toml:"exclude"`
} }
@@ -250,6 +262,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,
}, },
@@ -282,6 +298,7 @@ func DefaultConfigData() ConfigData {
Metadata: MetadataConfig{ Metadata: MetadataConfig{
SetPlaylistToAlbum: true, SetPlaylistToAlbum: true,
RenumberPlaylistTracks: true, RenumberPlaylistTracks: true,
ArtistSeparator: "; ",
Exclude: []string{}, Exclude: []string{},
}, },
Filepaths: FilepathsConfig{ Filepaths: FilepathsConfig{
+746
View File
@@ -0,0 +1,746 @@
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 c.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, c.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, c.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 := c.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 (c *Client) 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": c.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 (c *Client) 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, c.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, c.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 (c *Client) 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, artistSeparator(c.cfg.Session.Metadata.ArtistSeparator))
}
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 c.joinArtists(v)
}
func artistSeparator(separator string) string {
if separator == "" {
return "; "
}
return separator
}
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
}
+225
View File
@@ -0,0 +1,225 @@
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 TestArtistSeparatorCanBeConfigured(t *testing.T) {
cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()}
cfg.Session.Metadata.ArtistSeparator = " / "
c := New(cfg)
got := c.joinArtists([]any{
map[string]any{"name": "A"},
map[string]any{"name": "B"},
})
if got != "A / B" {
t.Fatalf("joinArtists() = %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)
}
}
+29 -22
View File
@@ -28,13 +28,13 @@ import (
) )
const ( const (
baseURL = "https://api.music.yandex.net" baseURL = "https://api.music.yandex.net"
desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2" desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2"
desktopOrigin = "music-application://desktop" desktopOrigin = "music-application://desktop"
requestAttempts = 3 requestAttempts = 3
desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5" desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5"
legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA" legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA"
defaultEstimatedKbps = 50000 defaultEstimatedKbps = 50000
) )
var ErrMissingYandexToken = errors.New("missing yandex access_token") var ErrMissingYandexToken = errors.New("missing yandex access_token")
@@ -183,7 +183,7 @@ func (c *Client) getTrackMetadata(ctx context.Context, item string) (map[string]
if len(items) == 0 { if len(items) == 0 {
return nil, errors.New("yandex track metadata missing result") return nil, errors.New("yandex track metadata missing result")
} }
return normalizeTrack(items[0], trackRef), nil return normalizeTrack(items[0], trackRef, c.artistSeparator()), nil
} }
func (c *Client) getAlbumMetadata(ctx context.Context, item string) (map[string]any, error) { func (c *Client) getAlbumMetadata(ctx context.Context, item string) (map[string]any, error) {
@@ -198,7 +198,7 @@ func (c *Client) getAlbumMetadata(ctx context.Context, item string) (map[string]
if len(result) == 0 { if len(result) == 0 {
return nil, errors.New("yandex album metadata missing result") return nil, errors.New("yandex album metadata missing result")
} }
return normalizeAlbum(result), nil return normalizeAlbum(result, c.artistSeparator()), nil
} }
func (c *Client) getArtistMetadata(ctx context.Context, item string) (map[string]any, error) { func (c *Client) getArtistMetadata(ctx context.Context, item string) (map[string]any, error) {
@@ -273,7 +273,7 @@ func (c *Client) normalizeSearchItems(mediaType string, result map[string]any) [
if !ok { if !ok {
continue continue
} }
appendItem(normalizeTrack(itm, canonicalTrackRefFromRaw(itm, ""))) appendItem(normalizeTrack(itm, canonicalTrackRefFromRaw(itm, ""), c.artistSeparator()))
} }
case "album": case "album":
for _, raw := range getResults("albums") { for _, raw := range getResults("albums") {
@@ -281,7 +281,7 @@ func (c *Client) normalizeSearchItems(mediaType string, result map[string]any) [
if !ok { if !ok {
continue continue
} }
appendItem(normalizeAlbumSearchItem(itm)) appendItem(normalizeAlbumSearchItem(itm, c.artistSeparator()))
} }
case "artist": case "artist":
for _, raw := range getResults("artists") { for _, raw := range getResults("artists") {
@@ -561,11 +561,11 @@ func resultMap(resp map[string]any) map[string]any {
return resp return resp
} }
func normalizeTrack(raw map[string]any, fallbackID string) map[string]any { func normalizeTrack(raw map[string]any, fallbackID string, artistSeparator string) map[string]any {
trackID := canonicalTrackRefFromRaw(raw, fallbackID) trackID := canonicalTrackRefFromRaw(raw, fallbackID)
albumRaw := firstAlbum(raw) albumRaw := firstAlbum(raw)
artistName := joinArtists(raw) artistName := joinArtists(raw, artistSeparator)
albumArtist := joinAlbumArtists(albumRaw) albumArtist := joinAlbumArtists(albumRaw, artistSeparator)
if albumArtist == "" { if albumArtist == "" {
albumArtist = artistName albumArtist = artistName
} }
@@ -599,10 +599,10 @@ func normalizeTrack(raw map[string]any, fallbackID string) map[string]any {
return meta return meta
} }
func normalizeAlbum(raw map[string]any) map[string]any { func normalizeAlbum(raw map[string]any, artistSeparator string) map[string]any {
artistName := joinArtists(raw) artistName := joinArtists(raw, artistSeparator)
if artistName == "" { if artistName == "" {
artistName = joinAlbumArtists(raw) artistName = joinAlbumArtists(raw, artistSeparator)
} }
volumes := albumVolumes(raw) volumes := albumVolumes(raw)
items := make([]any, 0) items := make([]any, 0)
@@ -698,8 +698,8 @@ func normalizeAlbumSummary(albumRaw map[string]any, artistName string) map[strin
} }
} }
func normalizeAlbumSearchItem(raw map[string]any) map[string]any { func normalizeAlbumSearchItem(raw map[string]any, artistSeparator string) map[string]any {
artistName := joinArtists(raw) artistName := joinArtists(raw, artistSeparator)
return map[string]any{ return map[string]any{
"id": jsonutil.StringFromAny(raw["id"]), "id": jsonutil.StringFromAny(raw["id"]),
"title": jsonutil.StringFromAny(raw["title"]), "title": jsonutil.StringFromAny(raw["title"]),
@@ -768,7 +768,7 @@ func firstArtistID(raw map[string]any) string {
return jsonutil.StringFromAny(artist["id"]) return jsonutil.StringFromAny(artist["id"])
} }
func joinArtists(raw map[string]any) string { func joinArtists(raw map[string]any, artistSeparator string) string {
artists, _ := raw["artists"].([]any) artists, _ := raw["artists"].([]any)
parts := make([]string, 0, len(artists)) parts := make([]string, 0, len(artists))
for _, entry := range artists { for _, entry := range artists {
@@ -780,10 +780,10 @@ func joinArtists(raw map[string]any) string {
parts = append(parts, name) parts = append(parts, name)
} }
} }
return strings.Join(parts, ", ") return strings.Join(parts, artistSeparator)
} }
func joinAlbumArtists(albumRaw map[string]any) string { func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string {
if len(albumRaw) == 0 { if len(albumRaw) == 0 {
return "" return ""
} }
@@ -798,7 +798,14 @@ func joinAlbumArtists(albumRaw map[string]any) string {
parts = append(parts, name) parts = append(parts, name)
} }
} }
return strings.Join(parts, ", ") return strings.Join(parts, artistSeparator)
}
func (c *Client) artistSeparator() string {
if c.cfg.Session.Metadata.ArtistSeparator == "" {
return "; "
}
return c.cfg.Session.Metadata.ArtistSeparator
} }
func trackNumbers(albumRaw map[string]any) (int, int) { func trackNumbers(albumRaw map[string]any) (int, int) {
+14 -8
View File
@@ -47,13 +47,13 @@ func TestGetDownloadableUsesModernGetFileInfo(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{ "result": map[string]any{
"downloadInfo": map[string]any{ "downloadInfo": map[string]any{
"trackId": "32038184", "trackId": "32038184",
"quality": "lossless", "quality": "lossless",
"codec": "flac-mp4", "codec": "flac-mp4",
"transport": "encraw", "transport": "encraw",
"key": "00112233445566778899aabbccddeeff", "key": "00112233445566778899aabbccddeeff",
"bitrate": 0, "bitrate": 0,
"url": "https://strm.example/music-v2/crypt/x/flac-mp4", "url": "https://strm.example/music-v2/crypt/x/flac-mp4",
}, },
}, },
}) })
@@ -115,13 +115,13 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
"id": "9442712", "id": "9442712",
"realId": "9442712", "realId": "9442712",
"title": "Nightcall", "title": "Nightcall",
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}}, "artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}},
"albums": []map[string]any{{ "albums": []map[string]any{{
"id": "1000856", "id": "1000856",
"title": "OutRun", "title": "OutRun",
"releaseDate": "2013-02-25T00:00:00+04:00", "releaseDate": "2013-02-25T00:00:00+04:00",
"trackCount": 13, "trackCount": 13,
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}}, "artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}},
"trackPosition": map[string]any{ "trackPosition": map[string]any{
"index": 0, "index": 0,
"volume": 1, "volume": 1,
@@ -155,6 +155,12 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
if album, _ := meta["album"].(map[string]any); jsonutil.StringFromAny(album["title"]) != "OutRun" { if album, _ := meta["album"].(map[string]any); jsonutil.StringFromAny(album["title"]) != "OutRun" {
t.Fatalf("unexpected album: %+v", album) t.Fatalf("unexpected album: %+v", album)
} }
if artist := jsonutil.NestedString(meta, "artist", "name"); artist != "Kavinsky; Lovefoxxx" {
t.Fatalf("artist = %q", artist)
}
if albumArtist := jsonutil.NestedString(meta, "album", "artist", "name"); albumArtist != "Kavinsky; Lovefoxxx" {
t.Fatalf("album artist = %q", albumArtist)
}
} }
func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) { func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {
+64 -1
View File
@@ -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
@@ -71,6 +127,9 @@ func parseQobuz(raw string, parts []string) *ParsedURL {
} }
mediaType := parts[0] mediaType := parts[0]
if mediaType == "interpreter" {
mediaType = "artist"
}
if !isSupportedMedia(mediaType) { if !isSupportedMedia(mediaType) {
return nil return nil
} }
@@ -219,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"
} }
@@ -233,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
+41
View File
@@ -27,6 +27,22 @@ func TestQobuzAlbumURL(t *testing.T) {
} }
} }
func TestQobuzInterpreterURLParsesAsArtist(t *testing.T) {
inputs := []string{
"https://www.qobuz.com/us-en/interpreter/odezenne/739874",
"https://play.qobuz.com/artist/739874",
}
for _, input := range inputs {
result := Parse(input)
if result == nil {
t.Fatalf("expected parsed url for %q", input)
}
if result.Source != "qobuz" || result.MediaType != "artist" || result.ID != "739874" {
t.Fatalf("unexpected parse result for %q: %+v", input, result)
}
}
}
func TestYandexURLs(t *testing.T) { func TestYandexURLs(t *testing.T) {
tests := []struct { tests := []struct {
url string url string
@@ -51,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",