Author SHA1 Message Date
Joren 8590a5b6b6 fix: playlist cover art, compilation tag, and year
- ripPlaylist/ripTrackCollection: pre-fetch playlist artwork once, embed in all tracks
- ripTrack: skip per-track album cover when playlist embed is provided
- buildTagMetadata: set COMPILATION=1 and year from playlist publish_date
- beatport: normalize playlistMetadata to include date fields for year extraction
- qobuz: normalize playlist image_rectangle to standard image map
2026-08-05 23:16:37 +02:00
Joren eb7854bac3 fix: use all album artists for folder naming 2026-07-22 22:34:42 +02:00
Joren 5f61b1a3cf fix: use exact qobuz download quality 2026-07-12 21:44:01 +02:00
Joren e336bb96f1 fix: set album track totals 2026-07-12 03:28:27 +02:00
Joren 99c531928e feat: add cli help menus 2026-07-12 00:06:18 +02:00
Joren 150b5b5d85 fix: omit original mix from titles 2026-07-11 23:43:03 +02:00
Joren 80bfbe0ecb feat: infer beatport release artist 2026-07-11 16:40:04 +02:00
Joren 3413de1daa feat: format multi-artist credits 2026-07-11 04:44:54 +02:00
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 0ae8c7e008 feat yandex desktop downloads 2026-06-10 12:58:04 +02:00
Joren db26a40415 fix qobuz interpreter artist urls 2026-06-10 12:34:37 +02:00
Joren fa39582849 fix tidal playlist metadata and retries 2026-05-21 23:03:27 +02:00
25 changed files with 4112 additions and 125 deletions
+4
View File
@@ -25,6 +25,7 @@ type globalOptions struct {
noProgress bool noProgress bool
noSSLVerify bool noSSLVerify bool
verbose int verbose int
help bool
command string command string
commandArgs []string commandArgs []string
} }
@@ -45,6 +46,9 @@ func parseGlobalArgs(args []string) (globalOptions, error) {
} }
switch { switch {
case isHelpArg(arg):
opts.help = true
return opts, nil
case arg == "-ndb" || arg == "--no-db": case arg == "-ndb" || arg == "--no-db":
opts.noDB = true opts.noDB = true
case arg == "--no-progress": case arg == "--no-progress":
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"fmt"
"io"
"strings"
)
func isHelpArg(arg string) bool {
return arg == "-h" || arg == "--help"
}
func commandWantsHelp(args []string) bool {
for _, arg := range args {
if isHelpArg(arg) {
return true
}
}
return false
}
func printMainHelp(w io.Writer) {
fmt.Fprint(w, `streamrip-go
Usage:
rip [global options] <command> [command options]
rip help [command]
Commands:
url Rip one or more URLs
file Rip URLs or IDs from a file
id Rip by source, media type, and ID
search Search a provider and download selected results
lastfm Import a Last.fm playlist
config Manage configuration
database Inspect download databases
Global options:
-f, --folder <path> Override downloads folder
-q, --quality <0-4> Override provider quality
-c, --codec <codec> Convert after download: ALAC, FLAC, OGG, MP3, AAC
-ndb, --no-db Ignore download database
--no-progress Disable progress bars
--no-ssl-verify Disable TLS verification
--config-path <path> Use a custom config file
-v, -vv Verbose logging
-h, --help Show help
Run 'rip help <command>' for command-specific help.
`)
}
func printCommandHelp(w io.Writer, command string) bool {
switch strings.ToLower(strings.TrimSpace(command)) {
case "url":
fmt.Fprint(w, `Usage:
rip url <url...> [--force|--ignore-db]
Rip one or more provider URLs.
Examples:
rip url https://www.beatport.com/release/example/123
rip url https://play.qobuz.com/album/abc --force
Options:
--force, --ignore-db Redownload even if already in the database
`)
case "file":
fmt.Fprint(w, `Usage:
rip file <path> [--force|--ignore-db]
Rip URLs or JSON ID entries from a file.
Options:
--force, --ignore-db Redownload even if already in the database
`)
case "id":
fmt.Fprint(w, `Usage:
rip id <source> <media-type> <id> [quality] [--force|--ignore-db]
Rip an item by provider ID.
Sources:
qobuz, tidal, deezer, yandex, beatport, soundcloud
Media types:
track, album, playlist, artist, label, chart, video
Options:
quality Override quality for this rip, 0-4
--force, --ignore-db Redownload even if already in the database
`)
case "search":
fmt.Fprint(w, `Usage:
rip search <source> <media-type> <query...> [options]
Search a provider and optionally download selected results.
Sources:
qobuz, tidal, deezer, yandex, beatport, soundcloud
Media types:
track, album, playlist, artist, label, chart, video
Options:
--limit N Maximum results to show
--first Download the first result without prompting
--no-download Show results only
--output-file <path> Write results as JSON
--force, --ignore-db Redownload even if already in the database
`)
case "lastfm":
fmt.Fprint(w, `Usage:
rip lastfm [--source SOURCE] [--fallback-source SOURCE] <playlist_url>
Import a Last.fm playlist and resolve tracks through configured providers.
Options:
--source SOURCE Primary lookup source
--fallback-source SOURCE Fallback lookup source
`)
case "config":
fmt.Fprint(w, `Usage:
rip config <open|reset|path> [options]
Manage configuration.
Commands:
open Open the config file in an editor
reset Reset config to defaults
path Print the config path
Options:
-v, --vim Use Vim for 'rip config open'
-y, --yes Confirm 'rip config reset' without prompting
`)
case "database":
fmt.Fprint(w, `Usage:
rip database browse <downloads|failed>
Inspect local download databases.
`)
case "dev-help":
printDeveloperHelp(w)
default:
return false
}
return true
}
func printDeveloperHelp(w io.Writer) {
fmt.Fprint(w, `Developer smoke commands:
soundcloud-smoke
qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke
qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke
qobuz-search-smoke
tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke
tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke
`)
}
+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 != "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
} }
+51 -17
View File
@@ -25,10 +25,34 @@ func main() {
fmt.Fprintf(os.Stderr, "option error: %v\n", err) fmt.Fprintf(os.Stderr, "option error: %v\n", err)
os.Exit(2) os.Exit(2)
} }
if gopts.help {
printMainHelp(os.Stdout)
return
}
if gopts.command == "" { if gopts.command == "" {
fmt.Println("usage: rip <command>") printMainHelp(os.Stdout)
fmt.Println("commands: url, file, config, database, id, search, lastfm") os.Exit(2)
fmt.Println("tip: run `rip dev-help` to list developer smoke commands") }
if gopts.command == "help" {
if len(gopts.commandArgs) == 0 || isHelpArg(gopts.commandArgs[0]) {
printMainHelp(os.Stdout)
return
}
if printCommandHelp(os.Stdout, gopts.commandArgs[0]) {
return
}
fmt.Fprintf(os.Stderr, "unknown help topic: %s\n", gopts.commandArgs[0])
os.Exit(2)
}
if gopts.command == "dev-help" {
printDeveloperHelp(os.Stdout)
return
}
if commandWantsHelp(gopts.commandArgs) {
if printCommandHelp(os.Stdout, gopts.command) {
return
}
fmt.Fprintf(os.Stderr, "unknown command: %s\n", gopts.command)
os.Exit(2) os.Exit(2)
} }
@@ -64,17 +88,11 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "dev-help": case "dev-help":
fmt.Println("developer smoke commands:") printDeveloperHelp(os.Stdout)
fmt.Println(" soundcloud-smoke")
fmt.Println(" qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke")
fmt.Println(" qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke")
fmt.Println(" qobuz-search-smoke")
fmt.Println(" tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke")
fmt.Println(" tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke")
return return
case "url": case "url":
if len(os.Args) < 3 { if len(os.Args) < 3 {
fmt.Println("usage: rip url <url...> [--force|--ignore-db]") printCommandHelp(os.Stdout, "url")
os.Exit(2) os.Exit(2)
} }
@@ -119,7 +137,7 @@ func main() {
fmt.Printf("url rip complete (%d item(s))\n", added) fmt.Printf("url rip complete (%d item(s))\n", added)
case "file": case "file":
if len(os.Args) < 3 { if len(os.Args) < 3 {
fmt.Println("usage: rip file <path> [--force|--ignore-db]") printCommandHelp(os.Stdout, "file")
os.Exit(2) os.Exit(2)
} }
@@ -192,7 +210,7 @@ func main() {
fmt.Printf("file rip complete (%d item(s))\n", added) fmt.Printf("file rip complete (%d item(s))\n", added)
case "config": case "config":
if len(os.Args) < 3 { if len(os.Args) < 3 {
fmt.Println("usage: rip config <open|reset|path> [options]") printCommandHelp(os.Stdout, "config")
os.Exit(2) os.Exit(2)
} }
switch os.Args[2] { switch os.Args[2] {
@@ -258,7 +276,7 @@ func main() {
} }
case "database": case "database":
if len(os.Args) < 4 || os.Args[2] != "browse" { if len(os.Args) < 4 || os.Args[2] != "browse" {
fmt.Println("usage: rip database browse <downloads|failed>") printCommandHelp(os.Stdout, "database")
os.Exit(2) os.Exit(2)
} }
table := strings.ToLower(strings.TrimSpace(os.Args[3])) table := strings.ToLower(strings.TrimSpace(os.Args[3]))
@@ -294,7 +312,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]") printCommandHelp(os.Stdout, "id")
os.Exit(2) os.Exit(2)
} }
@@ -317,6 +335,14 @@ func main() {
cfg.Session.Qobuz.Quality = opts.quality cfg.Session.Qobuz.Quality = opts.quality
case "tidal": case "tidal":
cfg.Session.Tidal.Quality = opts.quality cfg.Session.Tidal.Quality = opts.quality
case "yandex":
cfg.Session.Yandex.Quality = opts.quality
case "beatport":
if opts.quality < 1 {
fmt.Fprintf(os.Stderr, "quality error: beatport quality must be 1-4\n")
os.Exit(2)
}
cfg.Session.Beatport.Quality = opts.quality
} }
} }
@@ -346,7 +372,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|soundcloud> <track|album|playlist|artist|label|video> <query...> [--limit N] [--force|--ignore-db] [--no-download]") printCommandHelp(os.Stdout, "search")
os.Exit(2) os.Exit(2)
} }
source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults)
@@ -380,6 +406,14 @@ func main() {
fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist") fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist")
os.Exit(2) os.Exit(2)
} }
if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" {
fmt.Fprintln(os.Stderr, "yandex search currently supports media types track, album, playlist, and artist")
os.Exit(2)
}
if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" {
fmt.Fprintln(os.Stderr, "beatport search currently supports media types track, album, and label")
os.Exit(2)
}
if sopts.query == "" { 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)
@@ -533,7 +567,7 @@ func main() {
opts, parseErr := parseLastFMArgs(os.Args[2:], cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource) opts, parseErr := parseLastFMArgs(os.Args[2:], cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource)
if parseErr != nil { if parseErr != nil {
fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr) fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr)
fmt.Println("usage: rip lastfm [--source SOURCE] [--fallback-source SOURCE] <playlist_url>") printCommandHelp(os.Stdout, "lastfm")
os.Exit(2) os.Exit(2)
} }
+53
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
@@ -206,6 +207,58 @@ func TestParseGlobalArgsNoDBBeforeCommand(t *testing.T) {
} }
} }
func TestParseGlobalArgsHelp(t *testing.T) {
opts, err := parseGlobalArgs([]string{"--help"})
if err != nil {
t.Fatalf("parseGlobalArgs() error = %v", err)
}
if !opts.help {
t.Fatalf("expected help=true")
}
}
func TestCommandWantsHelp(t *testing.T) {
if !commandWantsHelp([]string{"https://example.com", "-h"}) {
t.Fatalf("expected command help")
}
if commandWantsHelp([]string{"https://example.com"}) {
t.Fatalf("did not expect command help")
}
}
func TestMainHelpHidesDeveloperCommands(t *testing.T) {
var buf bytes.Buffer
printMainHelp(&buf)
out := buf.String()
if !strings.Contains(out, "rip help [command]") || !strings.Contains(out, "Commands:") {
t.Fatalf("main help missing expected text: %s", out)
}
if strings.Contains(out, "dev-help") || strings.Contains(out, "smoke") {
t.Fatalf("main help should hide developer commands: %s", out)
}
}
func TestCommandHelpURL(t *testing.T) {
var buf bytes.Buffer
if !printCommandHelp(&buf, "url") {
t.Fatalf("expected url help")
}
out := buf.String()
if !strings.Contains(out, "rip url <url...>") || !strings.Contains(out, "--ignore-db") {
t.Fatalf("url help missing expected text: %s", out)
}
}
func TestPrintCommandHelpRejectsUnknown(t *testing.T) {
var buf bytes.Buffer
if printCommandHelp(&buf, "nope") {
t.Fatalf("unexpected help for unknown command")
}
if buf.Len() != 0 {
t.Fatalf("unexpected output for unknown command: %s", buf.String())
}
}
func TestParseGlobalArgsAllOfficialFlags(t *testing.T) { func TestParseGlobalArgsAllOfficialFlags(t *testing.T) {
opts, err := parseGlobalArgs([]string{ opts, err := parseGlobalArgs([]string{
"--config-path", "/tmp/custom.toml", "--config-path", "/tmp/custom.toml",
+77 -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 == "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/soundcloud]: ") source, err := read("Source [qobuz/tidal/deezer/yandex/beatport/soundcloud]: ")
if err != nil { if err != nil {
return "", "", searchOptions{}, err return "", "", searchOptions{}, err
} }
@@ -341,6 +341,14 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e
fmt.Println("SoundCloud search supports track and playlist only.") fmt.Println("SoundCloud search supports track and playlist only.")
continue continue
} }
if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" {
fmt.Println("Yandex search supports track, album, playlist, and artist only.")
continue
}
if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" {
fmt.Println("Beatport search supports track, album, and label only.")
continue
}
query, err := read("Query: ") query, err := read("Query: ")
if err != nil { if err != nil {
@@ -544,6 +552,72 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
) )
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Date: date, TrackCount: trackCount}) appendUnique(searchResult{ID: id, Title: title, Artist: artist, Date: date, TrackCount: trackCount})
} }
case "yandex":
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"])
}
artist := nestedSearchString(itm, "artist", "name")
if artist == "" {
artist = nestedSearchString(itm, "performer", "name")
}
album := nestedSearchString(itm, "album", "title")
trackCount := firstPositiveInt(
searchInt(itm["trackCount"]),
searchInt(itm["track_count"]),
searchInt(itm["tracks_count"]),
)
explicit := searchBool(itm["explicit"])
date := firstNonEmpty(
asString(itm["release_date"]),
asString(itm["releaseDate"]),
nestedSearchString(itm, "album", "release_date"),
nestedSearchString(itm, "album", "releaseDate"),
)
releases := 0
if mediaType == "artist" {
releases = firstPositiveInt(
searchInt(itm["albums_count"]),
searchInt(itm["numberOfAlbums"]),
nestedSearchInt(itm, "albums", "total"),
)
}
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
+28
View File
@@ -68,6 +68,32 @@ password = ""
# Optional cached Deezer refresh token. Managed automatically when available. # Optional cached Deezer refresh token. Managed automatically when available.
refresh_token = "" refresh_token = ""
[yandex]
# Quality ladder:
# 0 = LOW (HE-AAC/AAC when available), 1 = HIGH (AAC/MP3 192), 2/3/4 = LOSSLESS when available
quality = 2
# OAuth access token for api.music.yandex.net
access_token = ""
# Cached current account uid. Managed automatically when available.
user_id = ""
[beatport]
# Quality ladder:
# 1 = medium (AAC 128), 2 = high (AAC 256), 3/4 = lossless (FLAC 16/44.1)
# medium-hls is intentionally not supported.
quality = 3
# If a release lists more artists than this, use "Various Artists" as album artist.
# Set to -1 to disable this collapse.
various_artists_threshold = 3
# Beatport Streaming account credentials
username = ""
password = ""
# Session values are managed automatically. Do not modify manually.
access_token = ""
refresh_token = ""
# Unix timestamp when access_token expires
token_expiry = 0
[soundcloud] [soundcloud]
# Quality is currently provider-defined (keep 0) # Quality is currently provider-defined (keep 0)
quality = 0 quality = 0
@@ -129,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 = []
+296 -15
View File
@@ -11,6 +11,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time"
"streamrip-go/internal/artwork" "streamrip-go/internal/artwork"
"streamrip-go/internal/audio/convert" "streamrip-go/internal/audio/convert"
@@ -21,10 +22,12 @@ 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"
tidalprovider "streamrip-go/internal/provider/tidal" tidalprovider "streamrip-go/internal/provider/tidal"
yandexprovider "streamrip-go/internal/provider/yandex"
"streamrip-go/internal/store" "streamrip-go/internal/store"
"streamrip-go/internal/verbose" "streamrip-go/internal/verbose"
) )
@@ -56,6 +59,7 @@ type ripTrackOptions struct {
forPlaylist bool forPlaylist bool
playlistName string playlistName string
playlistPos int playlistPos int
playlistYear int
} }
type folderAudioValues struct { type folderAudioValues struct {
@@ -109,9 +113,11 @@ 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),
"yandex": yandexprovider.New(cfg),
"soundcloud": soundcloudprovider.New(cfg), "soundcloud": soundcloudprovider.New(cfg),
} }
@@ -201,7 +207,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:
@@ -325,6 +336,107 @@ 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)
}
}
var artRes artwork.Result
var playlistYear int
if playlistLike {
artRes, _ = artwork.Prepare(ctx, m.DL, folder, meta, m.Config.Session.Artwork, true)
playlistYear = extractYear(meta)
}
m.logf("%s: %s (%d tracks)\n", kind, name, len(ids))
failures := 0
runOne := func(i int, trackID string) {
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, index: i, total: len(ids)}
if playlistLike {
opts.forPlaylist = true
opts.playlistName = name
opts.playlistPos = i
opts.playlistYear = playlistYear
}
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, albumEmbedCover: artRes.EmbedPath, index: pos, total: len(ids)}
if playlistLike {
opts.forPlaylist = true
opts.playlistName = name
opts.playlistPos = pos
opts.playlistYear = playlistYear
}
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 {
@@ -547,10 +659,7 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
} }
albumTitle := titleFromMetadata(albumMeta, albumID) albumTitle := titleFromMetadata(albumMeta, albumID)
albumArtist := jsonutil.NestedString(albumMeta, "artist", "name") albumArtist := extractAlbumArtist(albumMeta)
if albumArtist == "" {
albumArtist = "Unknown"
}
releaseDate := jsonutil.StringFromAny(albumMeta["release_date_original"]) releaseDate := jsonutil.StringFromAny(albumMeta["release_date_original"])
if releaseDate == "" { if releaseDate == "" {
releaseDate = jsonutil.StringFromAny(albumMeta["release_date"]) releaseDate = jsonutil.StringFromAny(albumMeta["release_date"])
@@ -708,6 +817,10 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
} }
} }
artRes, _ := artwork.Prepare(ctx, m.DL, folder, playlistMeta, m.Config.Session.Artwork, true)
playlistYear := extractYear(playlistMeta)
total := len(ids) total := len(ids)
m.logf("Playlist: %s (%d tracks)\n", name, total) m.logf("Playlist: %s (%d tracks)\n", name, total)
failures := 0 failures := 0
@@ -715,11 +828,13 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
runOne := func(i int, id string) { runOne := func(i int, id string) {
opts := ripTrackOptions{ opts := ripTrackOptions{
albumFolder: folder, albumFolder: folder,
albumEmbedCover: artRes.EmbedPath,
index: i, index: i,
total: total, total: total,
forPlaylist: true, forPlaylist: true,
playlistName: name, playlistName: name,
playlistPos: i, playlistPos: i,
playlistYear: playlistYear,
} }
if err := m.ripTrack(ctx, p, source, id, "", opts); err != nil { if err := m.ripTrack(ctx, p, source, id, "", opts); err != nil {
failures++ failures++
@@ -745,7 +860,7 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
go func(pos int, tid string) { go func(pos int, tid string) {
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
opts := ripTrackOptions{albumFolder: folder, index: pos, total: total, forPlaylist: true, playlistName: name, playlistPos: pos} opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, index: pos, total: total, forPlaylist: true, playlistName: name, playlistPos: pos, playlistYear: playlistYear}
if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil { if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil {
mu.Lock() mu.Lock()
failures++ failures++
@@ -888,6 +1003,9 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
} }
return m.DL.FileDeezerEncrypted(ctx, d.URL, outPath, trackID) return m.DL.FileDeezerEncrypted(ctx, d.URL, outPath, trackID)
} }
if d.Source == "yandex" && strings.EqualFold(strings.TrimSpace(d.Cipher), "AES_CTR") {
return m.DL.FileYandexEncrypted(ctx, d.URL, outPath, d.Key)
}
return m.DL.File(ctx, d.URL, outPath) return m.DL.File(ctx, d.URL, outPath)
} }
if err = downloadOnce(); err != nil { if err = downloadOnce(); err != nil {
@@ -909,7 +1027,7 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
downloaded: downloaded:
embedCoverPath := opts.albumEmbedCover embedCoverPath := opts.albumEmbedCover
if opts.forPlaylist { if opts.forPlaylist && embedCoverPath == "" {
parent := opts.albumFolder parent := opts.albumFolder
if parent == "" { if parent == "" {
parent = filepath.Dir(outPath) parent = filepath.Dir(outPath)
@@ -919,8 +1037,11 @@ downloaded:
embedCoverPath = res.EmbedPath embedCoverPath = res.EmbedPath
} }
} }
} else if opts.albumFolder == "" { } else if !opts.forPlaylist && 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
@@ -964,6 +1085,10 @@ func (m *Main) qualityForSource(source string) int {
return m.Config.Session.Tidal.Quality return m.Config.Session.Tidal.Quality
case "deezer": case "deezer":
return m.Config.Session.Deezer.Quality return m.Config.Session.Deezer.Quality
case "yandex":
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:
@@ -994,6 +1119,8 @@ func (m *Main) qualityProfileForSource(source string) (int, string) {
default: default:
return 16, "44.1" return 16, "44.1"
} }
case "yandex":
return 16, "44.1"
default: default:
return 16, "44.1" return 16, "44.1"
} }
@@ -1111,10 +1238,7 @@ func (m *Main) trackOutputPath(source, id, title, ext string, d *provider.Downlo
if albumID == "" { if albumID == "" {
albumID = id albumID = id
} }
albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name") albumArtist := extractAlbumArtist(trackMetaAlbum(trackMeta))
if albumArtist == "" {
albumArtist = jsonutil.NestedString(trackMeta, "performer", "name")
}
albumYear := naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date_original"])) albumYear := naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date_original"]))
if albumYear == "Unknown" { if albumYear == "Unknown" {
albumYear = naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date"])) albumYear = naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date"]))
@@ -1197,7 +1321,7 @@ func titleFromMetadata(meta map[string]any, fallback string) string {
if title, ok := meta["title"].(string); ok { if title, ok := meta["title"].(string); ok {
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
version := strings.TrimSpace(jsonutil.StringFromAny(meta["version"])) version := strings.TrimSpace(jsonutil.StringFromAny(meta["version"]))
if version != "" { if version != "" && !isOriginalMix(version) {
return title + " (" + version + ")" return title + " (" + version + ")"
} }
if title != "" { if title != "" {
@@ -1207,6 +1331,10 @@ func titleFromMetadata(meta map[string]any, fallback string) string {
return fallback return fallback
} }
func isOriginalMix(version string) bool {
return strings.EqualFold(strings.TrimSpace(version), "Original Mix")
}
func replaygainGainFromAny(v any) string { func replaygainGainFromAny(v any) string {
s := strings.TrimSpace(jsonutil.StringFromAny(v)) s := strings.TrimSpace(jsonutil.StringFromAny(v))
if s == "" { if s == "" {
@@ -1245,6 +1373,7 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
if artist == "" { if artist == "" {
artist = jsonutil.NestedString(trackMeta, "artist", "name") artist = jsonutil.NestedString(trackMeta, "artist", "name")
} }
artistNames := stringSliceFromAny(trackMeta["artist_names"])
albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name") albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
if albumArtist == "" { if albumArtist == "" {
albumArtist = artist albumArtist = artist
@@ -1281,6 +1410,9 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
if trackTotal == 0 { if trackTotal == 0 {
trackTotal = jsonutil.IntFromAny(trackMeta["track_total"]) trackTotal = jsonutil.IntFromAny(trackMeta["track_total"])
} }
if trackTotal == 0 && opts.total > 0 {
trackTotal = opts.total
}
if opts.forPlaylist && opts.total > 0 { if opts.forPlaylist && opts.total > 0 {
trackTotal = opts.total trackTotal = opts.total
} }
@@ -1289,11 +1421,12 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
if discTotal == 0 { if discTotal == 0 {
discTotal = jsonutil.IntFromAny(trackMeta["numberOfVolumes"]) discTotal = jsonutil.IntFromAny(trackMeta["numberOfVolumes"])
} }
if discTotal == 0 && opts.albumDiscTotal > 0 { if !opts.forPlaylist && discTotal == 0 && opts.albumDiscTotal > 0 {
discTotal = opts.albumDiscTotal discTotal = opts.albumDiscTotal
} }
if opts.forPlaylist { if opts.forPlaylist {
discTotal = 1 discNumber = 0
discTotal = 0
} }
if !opts.forPlaylist && discNumber == 0 { if !opts.forPlaylist && discNumber == 0 {
discNumber = 1 discNumber = 1
@@ -1303,6 +1436,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"])
@@ -1350,13 +1488,18 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
Title: title, Title: title,
Album: album, Album: album,
Artist: artist, Artist: artist,
Artists: artistNames,
AlbumArtist: albumArtist, AlbumArtist: albumArtist,
Compilation: opts.forPlaylist,
OmitDiscTags: opts.forPlaylist,
Year: opts.playlistYear,
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,
@@ -1373,6 +1516,85 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
} }
} }
func stringSliceFromAny(v any) []string {
items, ok := v.([]string)
if ok {
return append([]string(nil), items...)
}
rawItems, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(rawItems))
for _, raw := range rawItems {
if s := strings.TrimSpace(jsonutil.StringFromAny(raw)); s != "" {
out = append(out, s)
}
}
return out
}
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
@@ -1404,3 +1626,62 @@ func isFFmpegMissingError(err error) bool {
} }
return strings.Contains(strings.ToLower(err.Error()), "ffmpeg not found") return strings.Contains(strings.ToLower(err.Error()), "ffmpeg not found")
} }
func extractAlbumArtist(albumMeta map[string]any) string {
if artistsRaw, ok := albumMeta["artists"].([]any); ok {
names := make([]string, 0, len(artistsRaw))
for _, a := range artistsRaw {
artist, ok := a.(map[string]any)
if !ok {
continue
}
if name := jsonutil.StringFromAny(artist["name"]); name != "" {
names = append(names, name)
}
}
if len(names) > 0 {
return displayArtistNames(names)
}
}
if names := stringSliceFromAny(albumMeta["artist_names"]); len(names) > 0 {
return displayArtistNames(names)
}
artist := jsonutil.NestedString(albumMeta, "artist", "name")
if artist != "" {
return artist
}
return "Unknown"
}
func displayArtistNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " & " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1]
}
}
func extractYear(meta map[string]any) int {
date := jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(meta["publish_date"]),
jsonutil.StringFromAny(meta["creation_date"]),
jsonutil.StringFromAny(meta["created_at"]),
jsonutil.StringFromAny(meta["release_date"]),
jsonutil.StringFromAny(meta["new_release_date"]),
)
if date == "" {
return 0
}
if t, err := time.Parse(time.RFC3339, date); err == nil {
return t.Year()
}
if t, err := time.Parse("2006-01-02", date); err == nil {
return t.Year()
}
return 0
}
+121
View File
@@ -490,6 +490,62 @@ func TestBuildTagMetadataUsesAlbumArtistOverride(t *testing.T) {
} }
} }
func TestBuildTagMetadataUsesAlbumContextTrackTotal(t *testing.T) {
meta := map[string]any{
"title": "ideal world (feat. higma)",
"track_number": float64(2),
"performer": map[string]any{"name": "Seren Azuma"},
"album": map[string]any{
"title": "YUKIHASU",
"artist": map[string]any{"name": "Seren Azuma"},
},
}
tags := buildTagMetadata(meta, "ideal world (feat. higma)", "qobuz", "295525879", ripTrackOptions{total: 11})
if tags.TrackTotal != 11 {
t.Fatalf("track total = %d, want 11", tags.TrackTotal)
}
}
func TestBuildTagMetadataPlaylistOmitsDiscTags(t *testing.T) {
meta := map[string]any{
"title": "One Step Too Far",
"track_number": float64(15),
"media_number": float64(2),
"numberOfVolumes": float64(2),
"numberOfTracks": float64(18),
"performer": map[string]any{"name": "Faithless"},
"artist": map[string]any{"name": "Faithless"},
"release_date": "2005-01-01",
"release_date_original": "2005-01-01",
"album": map[string]any{
"id": "23324600",
"title": "Greatest Hits (Deluxe)",
"artist": map[string]any{"name": "Faithless"},
},
}
playlistCfg := config.DefaultConfigData().Metadata
applyPlaylistMetadataOverrides(meta, playlistCfg, "Road Trip", 3)
tags := buildTagMetadata(meta, "One Step Too Far", "tidal", "23324615", ripTrackOptions{forPlaylist: true, playlistName: "Road Trip", playlistPos: 3, total: 20})
if tags.Album != "Road Trip" {
t.Fatalf("album = %q, want Road Trip", tags.Album)
}
if tags.TrackNumber != 3 {
t.Fatalf("track number = %d, want 3", tags.TrackNumber)
}
if tags.TrackTotal != 20 {
t.Fatalf("track total = %d, want 20", tags.TrackTotal)
}
if tags.DiscNumber != 0 {
t.Fatalf("disc number = %d, want 0", tags.DiscNumber)
}
if tags.DiscTotal != 0 {
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) {
tmp := t.TempDir() tmp := t.TempDir()
d := config.DefaultConfigData() d := config.DefaultConfigData()
@@ -824,6 +880,71 @@ 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 TestBuildTagMetadataArtistList(t *testing.T) {
meta := map[string]any{
"artist_names": []string{"Lost Frequencies", "Calum Scott", "Kungs"},
"performer": map[string]any{"name": "Lost Frequencies, Calum Scott & Kungs"},
"album": map[string]any{"title": "Album"},
}
tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{})
if tags.Artist != "Lost Frequencies, Calum Scott & Kungs" {
t.Fatalf("artist=%q", tags.Artist)
}
if got := strings.Join(tags.Artists, ";"); got != "Lost Frequencies;Calum Scott;Kungs" {
t.Fatalf("artists=%q", got)
}
}
func TestTitleFromMetadataSkipsOriginalMix(t *testing.T) {
meta := map[string]any{"title": "Dance Done", "version": "Original Mix"}
if got := titleFromMetadata(meta, "42"); got != "Dance Done" {
t.Fatalf("titleFromMetadata()=%q", got)
}
}
func TestTitleFromMetadataIncludesNonOriginalMix(t *testing.T) {
meta := map[string]any{"title": "Dance Done", "version": "Extended Mix"}
if got := titleFromMetadata(meta, "42"); got != "Dance Done (Extended Mix)" {
t.Fatalf("titleFromMetadata()=%q", got)
}
}
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),
+190 -6
View File
@@ -1,6 +1,7 @@
package tag package tag
import ( import (
"encoding/binary"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -13,13 +14,18 @@ type Metadata struct {
Title string Title string
Album string Album string
Artist string Artist string
Artists []string
AlbumArtist string AlbumArtist string
Compilation bool
OmitDiscTags bool
Year int
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
@@ -47,9 +53,10 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
} }
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
forceMP4Muxer := shouldForceMP4Muxer(path, ext)
tmpPath := taggedTempPath(path) tmpPath := taggedTempPath(path)
runTag := func(cover string) ([]byte, error) { runTag := func(cover string) ([]byte, error) {
args := buildFFmpegArgs(path, tmpPath, meta, cover, ext) args := buildFFmpegArgs(path, tmpPath, meta, cover, ext, forceMP4Muxer)
cmd := exec.Command("ffmpeg", args...) cmd := exec.Command("ffmpeg", args...)
return cmd.CombinedOutput() return cmd.CombinedOutput()
} }
@@ -68,11 +75,14 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
return err return err
} }
if err = applyMultiValueFLACTags(path, meta); err != nil {
return err
}
return nil return nil
} }
func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext string) []string { func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext string, forceMP4Muxer bool) []string {
args := []string{"-y", "-i", inputPath} args := []string{"-y", "-i", inputPath}
withCover := coverPath != "" && fileExists(coverPath) && supportsAttachedPicture(ext) withCover := coverPath != "" && fileExists(coverPath) && supportsAttachedPicture(ext)
if withCover { if withCover {
@@ -101,11 +111,38 @@ 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 {
args = append(args, "-f", "mp4")
}
args = append(args, outputPath) args = append(args, outputPath)
return args return args
} }
func shouldForceMP4Muxer(path, ext string) bool {
switch strings.TrimPrefix(strings.ToLower(ext), ".") {
case "m4a", "mp4":
default:
return false
}
if _, err := exec.LookPath("ffprobe"); err != nil {
return false
}
out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", path).Output()
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(string(out)), "flac")
}
func taggedTempPath(path string) string { func taggedTempPath(path string) string {
ext := filepath.Ext(path) ext := filepath.Ext(path)
if ext == "" { if ext == "" {
@@ -131,6 +168,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,
@@ -146,12 +184,8 @@ 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/%02d", meta.TrackNumber, meta.TrackTotal)
} else {
tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber) 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)
} }
@@ -165,9 +199,159 @@ func toTags(meta Metadata) map[string]string {
if meta.DiscTotal > 0 { if meta.DiscTotal > 0 {
tags["disctotal"] = strconv.Itoa(meta.DiscTotal) tags["disctotal"] = strconv.Itoa(meta.DiscTotal)
} }
if meta.Compilation {
tags["COMPILATION"] = "1"
}
if meta.Year > 0 {
tags["year"] = strconv.Itoa(meta.Year)
}
return tags return tags
} }
func applyMultiValueFLACTags(path string, meta Metadata) error {
if strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) != "flac" || len(meta.Artists) == 0 {
return nil
}
st, err := os.Stat(path)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
updated, err := replaceFLACVorbisComments(data, "ARTISTS", meta.Artists)
if err != nil {
return err
}
if updated == nil {
return nil
}
return os.WriteFile(path, updated, st.Mode())
}
type flacMetadataBlock struct {
isLast bool
blockType byte
data []byte
}
func replaceFLACVorbisComments(data []byte, key string, values []string) ([]byte, error) {
if len(data) < 4 || string(data[:4]) != "fLaC" {
return nil, fmt.Errorf("not a FLAC file")
}
blocks := []flacMetadataBlock{}
pos := 4
vorbisIndex := -1
for {
if pos+4 > len(data) {
return nil, fmt.Errorf("truncated FLAC metadata header")
}
header := data[pos]
blockType := header & 0x7f
length := int(data[pos+1])<<16 | int(data[pos+2])<<8 | int(data[pos+3])
pos += 4
if pos+length > len(data) {
return nil, fmt.Errorf("truncated FLAC metadata block")
}
if blockType == 4 {
vorbisIndex = len(blocks)
}
blocks = append(blocks, flacMetadataBlock{isLast: header&0x80 != 0, blockType: blockType, data: data[pos : pos+length]})
pos += length
if header&0x80 != 0 {
break
}
}
if vorbisIndex < 0 {
return nil, nil
}
commentBlock, err := replaceVorbisCommentValues(blocks[vorbisIndex].data, key, values)
if err != nil {
return nil, err
}
blocks[vorbisIndex].data = commentBlock
out := make([]byte, 0, len(data)+len(commentBlock)-len(blocks[vorbisIndex].data))
out = append(out, data[:4]...)
for _, block := range blocks {
if len(block.data) > 0xffffff {
return nil, fmt.Errorf("FLAC metadata block too large")
}
header := block.blockType
if block.isLast {
header |= 0x80
}
out = append(out, header, byte(len(block.data)>>16), byte(len(block.data)>>8), byte(len(block.data)))
out = append(out, block.data...)
}
out = append(out, data[pos:]...)
return out, nil
}
func replaceVorbisCommentValues(data []byte, key string, values []string) ([]byte, error) {
if len(data) < 8 {
return nil, fmt.Errorf("truncated Vorbis comment block")
}
pos := 0
vendorLength := int(binary.LittleEndian.Uint32(data[pos:]))
pos += 4
if pos+vendorLength+4 > len(data) {
return nil, fmt.Errorf("truncated Vorbis vendor string")
}
vendor := data[pos : pos+vendorLength]
pos += vendorLength
commentCount := int(binary.LittleEndian.Uint32(data[pos:]))
pos += 4
comments := make([][]byte, 0, commentCount+len(values))
for i := 0; i < commentCount; i++ {
if pos+4 > len(data) {
return nil, fmt.Errorf("truncated Vorbis comment length")
}
commentLength := int(binary.LittleEndian.Uint32(data[pos:]))
pos += 4
if pos+commentLength > len(data) {
return nil, fmt.Errorf("truncated Vorbis comment")
}
comment := data[pos : pos+commentLength]
pos += commentLength
if !vorbisCommentKeyEqual(comment, key) {
comments = append(comments, comment)
}
}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
comments = append(comments, []byte(key+"="+value))
}
}
out := make([]byte, 0, len(data))
out = appendUint32LE(out, uint32(len(vendor)))
out = append(out, vendor...)
out = appendUint32LE(out, uint32(len(comments)))
for _, comment := range comments {
out = appendUint32LE(out, uint32(len(comment)))
out = append(out, comment...)
}
return out, nil
}
func vorbisCommentKeyEqual(comment []byte, key string) bool {
idx := strings.IndexByte(string(comment), '=')
if idx < 0 {
return false
}
return strings.EqualFold(string(comment[:idx]), key)
}
func appendUint32LE(out []byte, v uint32) []byte {
return append(out, byte(v), byte(v>>8), byte(v>>16), byte(v>>24))
}
func normalizeCopyright(in string) string { func normalizeCopyright(in string) string {
out := strings.ReplaceAll(in, "(c)", "©") out := strings.ReplaceAll(in, "(c)", "©")
out = strings.ReplaceAll(out, "(C)", "©") out = strings.ReplaceAll(out, "(C)", "©")
+136 -3
View File
@@ -1,6 +1,7 @@
package tag package tag
import ( import (
"encoding/binary"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -30,6 +31,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 +39,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 +51,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)
} }
@@ -60,13 +65,109 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
} }
} }
func TestToTagsArtistList(t *testing.T) {
tags := toTags(Metadata{Artist: "A, B & C", Artists: []string{"A", "B", "C"}})
if tags["artist"] != "A, B & C" {
t.Fatalf("artist tags = %+v", tags)
}
if _, ok := tags["ARTISTS"]; ok {
t.Fatalf("ARTISTS should be written as repeated FLAC comments, got %+v", tags)
}
}
func TestReplaceVorbisCommentValuesWritesRepeatedTags(t *testing.T) {
block := vorbisCommentBlock("vendor", []string{
"ARTIST=A, B & C",
"ARTISTS=A;B;C",
"TITLE=Song",
})
updated, err := replaceVorbisCommentValues(block, "ARTISTS", []string{"A", "B", "C"})
if err != nil {
t.Fatalf("replaceVorbisCommentValues() error = %v", err)
}
comments := readVorbisComments(t, updated)
want := []string{"ARTIST=A, B & C", "TITLE=Song", "ARTISTS=A", "ARTISTS=B", "ARTISTS=C"}
if len(comments) != len(want) {
t.Fatalf("comments=%#v want %#v", comments, want)
}
for i := range want {
if comments[i] != want[i] {
t.Fatalf("comments=%#v want %#v", comments, want)
}
}
}
func TestReplaceFLACVorbisComments(t *testing.T) {
vorbis := vorbisCommentBlock("vendor", []string{"ARTISTS=A;B", "TITLE=Song"})
flac := append([]byte("fLaC"), flacBlockHeader(true, 4, len(vorbis))...)
flac = append(flac, vorbis...)
flac = append(flac, []byte("audio")...)
updated, err := replaceFLACVorbisComments(flac, "ARTISTS", []string{"A", "B"})
if err != nil {
t.Fatalf("replaceFLACVorbisComments() error = %v", err)
}
if string(updated[len(updated)-5:]) != "audio" {
t.Fatalf("audio payload not preserved")
}
length := int(updated[5])<<16 | int(updated[6])<<8 | int(updated[7])
comments := readVorbisComments(t, updated[8:8+length])
want := []string{"TITLE=Song", "ARTISTS=A", "ARTISTS=B"}
if len(comments) != len(want) {
t.Fatalf("comments=%#v want %#v", comments, want)
}
for i := range want {
if comments[i] != want[i] {
t.Fatalf("comments=%#v want %#v", comments, want)
}
}
}
func vorbisCommentBlock(vendor string, comments []string) []byte {
out := []byte{}
out = appendUint32LE(out, uint32(len(vendor)))
out = append(out, vendor...)
out = appendUint32LE(out, uint32(len(comments)))
for _, comment := range comments {
out = appendUint32LE(out, uint32(len(comment)))
out = append(out, comment...)
}
return out
}
func flacBlockHeader(last bool, blockType byte, length int) []byte {
header := blockType
if last {
header |= 0x80
}
return []byte{header, byte(length >> 16), byte(length >> 8), byte(length)}
}
func readVorbisComments(t *testing.T, block []byte) []string {
t.Helper()
pos := 0
vendorLength := int(binary.LittleEndian.Uint32(block[pos:]))
pos += 4 + vendorLength
count := int(binary.LittleEndian.Uint32(block[pos:]))
pos += 4
comments := make([]string, 0, count)
for i := 0; i < count; i++ {
length := int(binary.LittleEndian.Uint32(block[pos:]))
pos += 4
comments = append(comments, string(block[pos:pos+length]))
pos += length
}
return comments
}
func TestBuildFFmpegArgsWithCover(t *testing.T) { func TestBuildFFmpegArgsWithCover(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
cover := filepath.Join(tmp, "cover.jpg") cover := filepath.Join(tmp, "cover.jpg")
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil { if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
t.Fatalf("write cover: %v", err) t.Fatalf("write cover: %v", err)
} }
args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover, "flac") args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover, "flac", false)
foundInput2 := false foundInput2 := false
foundAttach := false foundAttach := false
for i := 0; i < len(args)-1; i++ { for i := 0; i < len(args)-1; i++ {
@@ -88,7 +189,7 @@ func TestBuildFFmpegArgsSkipsCoverForUnsupportedContainer(t *testing.T) {
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil { if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
t.Fatalf("write cover: %v", err) t.Fatalf("write cover: %v", err)
} }
args := buildFFmpegArgs("in.opus", "out.opus", Metadata{Title: "x"}, cover, "opus") args := buildFFmpegArgs("in.opus", "out.opus", Metadata{Title: "x"}, cover, "opus", false)
for i := 0; i < len(args)-1; i++ { for i := 0; i < len(args)-1; i++ {
if args[i] == "-i" && args[i+1] == cover { if args[i] == "-i" && args[i+1] == cover {
t.Fatalf("unexpected cover input for opus: %v", args) t.Fatalf("unexpected cover input for opus: %v", args)
@@ -96,6 +197,38 @@ 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) {
args := buildFFmpegArgs("in.m4a", "out.m4a", Metadata{Title: "x"}, "", "m4a", true)
for i := 0; i < len(args)-1; i++ {
if args[i] == "-f" && args[i+1] == "mp4" {
return
}
}
t.Fatalf("missing forced mp4 muxer args: %v", args)
}
func TestTaggedTempPathPreservesExtension(t *testing.T) { func TestTaggedTempPathPreservesExtension(t *testing.T) {
if got := taggedTempPath("/tmp/song.flac"); got != "/tmp/song.tmp.flac" { if got := taggedTempPath("/tmp/song.flac"); got != "/tmp/song.tmp.flac" {
t.Fatalf("taggedTempPath(flac)=%q", got) t.Fatalf("taggedTempPath(flac)=%q", got)
+27
View File
@@ -24,6 +24,8 @@ type ConfigData struct {
Qobuz QobuzConfig `toml:"qobuz"` Qobuz QobuzConfig `toml:"qobuz"`
Tidal TidalConfig `toml:"tidal"` Tidal TidalConfig `toml:"tidal"`
Deezer DeezerConfig `toml:"deezer"` Deezer DeezerConfig `toml:"deezer"`
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"`
@@ -77,6 +79,22 @@ type DeezerConfig struct {
RefreshToken string `toml:"refresh_token"` RefreshToken string `toml:"refresh_token"`
} }
type YandexConfig struct {
Quality int `toml:"quality"`
AccessToken string `toml:"access_token"`
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"`
@@ -124,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"`
} }
@@ -240,6 +259,13 @@ func DefaultConfigData() ConfigData {
Quality: 2, Quality: 2,
LowerQualityIfNotAvailable: true, LowerQualityIfNotAvailable: true,
}, },
Yandex: YandexConfig{
Quality: 2,
},
Beatport: BeatportConfig{
Quality: 3,
VariousArtistsThreshold: 3,
},
Soundcloud: SoundcloudConfig{ Soundcloud: SoundcloudConfig{
Quality: 0, Quality: 0,
}, },
@@ -272,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{
+116
View File
@@ -3,8 +3,10 @@ package download
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/md5" "crypto/md5"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -182,6 +184,120 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
return nil return nil
} }
func (d *Downloader) FileYandexEncrypted(ctx context.Context, sourceURL, outputPath, key string) error {
logDownloadStart(sourceURL, outputPath)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
keyBytes, err := hex.DecodeString(strings.TrimSpace(key))
if err != nil {
return fmt.Errorf("invalid yandex key: %w", err)
}
if len(keyBytes) != 16 {
return fmt.Errorf("invalid yandex key length: %d", len(keyBytes))
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return err
}
resp, err := d.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
success := false
defer func() {
_ = out.Close()
if !success {
_ = os.Remove(outputPath)
}
}()
var bar *mpb.Bar
if d.ProgressEnabled() {
d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54)
if resp.ContentLength > 0 {
bar = d.progress.AddBar(
resp.ContentLength,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
} else {
bar = d.progress.AddSpinner(
0,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
),
mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
defer bar.SetTotal(-1, true)
}
defer func() {
if !success && bar != nil {
bar.Abort(true)
}
}()
}
stream := cipher.NewCTR(block, make([]byte, aes.BlockSize))
reader := &cipher.StreamReader{S: stream, R: resp.Body}
buf := make([]byte, downloadBufferSize)
totalWritten := int64(0)
for {
n, readErr := reader.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
totalWritten += int64(n)
if bar != nil {
bar.IncrBy(n)
}
}
if readErr != nil {
if readErr == io.EOF {
break
}
return readErr
}
}
if resp.ContentLength > 0 && totalWritten != resp.ContentLength {
return io.ErrUnexpectedEOF
}
if err = out.Sync(); err != nil {
return err
}
success = true
return nil
}
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error { func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error {
logDownloadStart(sourceURL, outputPath) logDownloadStart(sourceURL, outputPath)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
+50
View File
@@ -2,8 +2,10 @@ package download
import ( import (
"context" "context"
"crypto/aes"
"crypto/cipher" "crypto/cipher"
"errors" "errors"
"encoding/hex"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -110,6 +112,45 @@ func TestFileDeezerEncrypted(t *testing.T) {
} }
} }
func TestFileYandexEncrypted(t *testing.T) {
plain := make([]byte, 8192+333)
for i := range plain {
plain[i] = byte((i * 11) % 251)
}
keyHex := "00112233445566778899aabbccddeeff"
key, err := hex.DecodeString(keyHex)
if err != nil {
t.Fatalf("DecodeString() error = %v", err)
}
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("NewCipher() error = %v", err)
}
enc := make([]byte, len(plain))
copy(enc, plain)
stream := cipher.NewCTR(block, make([]byte, aes.BlockSize))
stream.XORKeyStream(enc, enc)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(enc)
}))
defer ts.Close()
d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.m4a")
if err = d.FileYandexEncrypted(context.Background(), ts.URL, out, keyHex); err != nil {
t.Fatalf("FileYandexEncrypted() error = %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(got) != string(plain) {
t.Fatalf("decrypted file mismatch")
}
}
func TestDownloaderFileTruncatedResponseRemovesPartialFile(t *testing.T) { func TestDownloaderFileTruncatedResponseRemovesPartialFile(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "10") w.Header().Set("Content-Length", "10")
@@ -160,6 +201,15 @@ func TestFileDeezerEncryptedBadStatus(t *testing.T) {
} }
} }
func TestFileYandexEncryptedBadKey(t *testing.T) {
d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.m4a")
err := d.FileYandexEncrypted(context.Background(), "https://example.com/file", out, "abcd")
if err == nil || !strings.Contains(err.Error(), "invalid yandex key length") {
t.Fatalf("expected key length error, got %v", err)
}
}
func TestDownloaderFileContextCancellationRemovesPartialFile(t *testing.T) { func TestDownloaderFileContextCancellationRemovesPartialFile(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
+828
View File
@@ -0,0 +1,828 @@
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.releaseArtistNameForTracks(raw["artists"], tracks)
trackRelease := cloneMap(raw)
trackRelease["album_artist_name"] = artist
items := make([]any, 0, len(tracks))
for _, entry := range tracks {
track, ok := entry.(map[string]any)
if !ok {
continue
}
track["release"] = trackRelease
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"])
trackArtistNames := artistNames(raw["artists"])
artistName := displayArtistNames(trackArtistNames)
artistID := firstArtistID(raw["artists"])
albumArtist := strings.TrimSpace(jsonutil.StringFromAny(release["album_artist_name"]))
if albumArtist == "" {
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},
"artist_names": trackArtistNames,
"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 {
names := artistNames(raw["artists"])
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": displayArtistNames(names)},
"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"]),
"publish_date": jsonutil.StringFromAny(raw["publish_date"]),
"new_release_date": jsonutil.StringFromAny(raw["new_release_date"]),
"release_date": jsonutil.StringFromAny(raw["release_date"]),
"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 artistNames(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 names
}
func displayArtistNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " & " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1]
}
}
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 displayArtistNames(artistNames(v))
}
func (c *Client) releaseArtistNameForTracks(releaseArtists any, tracks []any) string {
common := commonReleaseArtists(releaseArtists, tracks)
if len(common) > 0 {
return displayArtistNames(artistNames(common))
}
return c.releaseArtistName(releaseArtists)
}
func commonReleaseArtists(releaseArtists any, tracks []any) []any {
items := sliceAny(releaseArtists)
if len(items) == 0 || len(tracks) == 0 {
return nil
}
counts := map[string]int{}
trackCount := 0
for _, rawTrack := range tracks {
track, ok := rawTrack.(map[string]any)
if !ok {
continue
}
trackCount++
seen := map[string]bool{}
for _, rawArtist := range sliceAny(track["artists"]) {
if key := artistIdentity(rawArtist); key != "" {
seen[key] = true
}
}
for key := range seen {
counts[key]++
}
}
if trackCount == 0 {
return nil
}
common := make([]any, 0)
for _, rawArtist := range items {
if key := artistIdentity(rawArtist); key != "" && counts[key] == trackCount {
common = append(common, rawArtist)
}
}
return common
}
func artistIdentity(raw any) string {
m, ok := raw.(map[string]any)
if !ok {
return ""
}
if id := strings.TrimSpace(jsonutil.StringFromAny(m["id"])); id != "" {
return "id:" + id
}
if name := strings.TrimSpace(jsonutil.StringFromAny(m["name"])); name != "" {
return "name:" + strings.ToLower(name)
}
return ""
}
func cloneMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
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
}
+267
View File
@@ -0,0 +1,267 @@
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": 2,
"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 A", "number": 1, "artists": []any{map[string]any{"id": 1, "name": "A"}}},
map[string]any{"id": 43, "name": "Track B", "number": 2, "artists": []any{map[string]any{"id": 2, "name": "B"}}},
},
})
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 TestAlbumMetadataUsesArtistPresentOnEveryTrack(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": "Artist Album",
"track_count": 3,
"artists": []any{
map[string]any{"id": 1, "name": "Main"},
map[string]any{"id": 2, "name": "Guest A"},
map[string]any{"id": 3, "name": "Guest B"},
map[string]any{"id": 4, "name": "Guest C"},
},
})
case "/catalog/releases/7/tracks/":
writeJSON(t, w, map[string]any{
"next": nil,
"results": []any{
map[string]any{"id": 42, "name": "Track A", "number": 1, "artists": []any{map[string]any{"id": 1, "name": "Main"}, map[string]any{"id": 2, "name": "Guest A"}}},
map[string]any{"id": 43, "name": "Track B", "number": 2, "artists": []any{map[string]any{"id": 1, "name": "Main"}, map[string]any{"id": 3, "name": "Guest B"}}},
map[string]any{"id": 44, "name": "Track C", "number": 3, "artists": []any{map[string]any{"id": 1, "name": "Main"}, map[string]any{"id": 4, "name": "Guest C"}}},
},
})
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"] != "Main" {
t.Fatalf("album artist = %q, want Main", 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"] != "Main" {
t.Fatalf("track album artist = %q, want Main", 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 TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) {
got := displayArtistNames(artistNames([]any{
map[string]any{"name": "A"},
map[string]any{"name": "B"},
map[string]any{"name": "C"},
}))
if got != "A, B & C" {
t.Fatalf("displayArtistNames() = %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)
}
}
+1
View File
@@ -7,6 +7,7 @@ type Downloadable struct {
Extension string Extension string
Source string Source string
Cipher string Cipher string
Key string
TrackID string TrackID string
Audio AudioProfile Audio AudioProfile
} }
+62 -8
View File
@@ -364,40 +364,60 @@ func qobuzDownloadExtension(resp map[string]any, quality int, streamURL string)
} }
func qobuzAudioProfile(resp map[string]any, requestedQuality int, ext string) provider.AudioProfile { func qobuzAudioProfile(resp map[string]any, requestedQuality int, ext string) provider.AudioProfile {
exactBitDepth, _ := intValue(firstNonNil(resp["bit_depth"], resp["bits_depth"], resp["maximum_bit_depth"]))
exactSampling, _ := floatValue(firstNonNil(resp["sampling_rate"], resp["sample_rate"], resp["maximum_sampling_rate"]))
if formatID, ok := intValue(resp["format_id"]); ok { if formatID, ok := intValue(resp["format_id"]); ok {
switch formatID { switch formatID {
case 5: case 5:
if exactBitDepth == 0 {
exactBitDepth = 16
}
if exactSampling == 0 {
exactSampling = 44.1
}
return provider.AudioProfile{ return provider.AudioProfile{
Container: "MP3", Container: "MP3",
Codec: "MP3", Codec: "MP3",
Quality: "HIGH", Quality: "HIGH",
BitDepth: 16, BitDepth: exactBitDepth,
SamplingRate: "44.1", SamplingRate: formatSamplingRate(exactSampling),
BitrateKbps: 320, BitrateKbps: 320,
} }
case 6: case 6:
if exactBitDepth == 0 {
exactBitDepth = 16
}
if exactSampling == 0 {
exactSampling = 44.1
}
return provider.AudioProfile{ return provider.AudioProfile{
Container: "FLAC", Container: "FLAC",
Codec: "FLAC", Codec: "FLAC",
Quality: "LOSSLESS", Quality: "LOSSLESS",
BitDepth: 16, BitDepth: exactBitDepth,
SamplingRate: "44.1", SamplingRate: formatSamplingRate(exactSampling),
} }
case 7: case 7:
if exactBitDepth == 0 {
exactBitDepth = 24
}
return provider.AudioProfile{ return provider.AudioProfile{
Container: "FLAC", Container: "FLAC",
Codec: "FLAC", Codec: "FLAC",
Quality: "HI_RES", Quality: "HI_RES",
BitDepth: 24, BitDepth: exactBitDepth,
SamplingRate: "96", SamplingRate: formatSamplingRate(exactSampling),
} }
case 27: case 27:
if exactBitDepth == 0 {
exactBitDepth = 24
}
return provider.AudioProfile{ return provider.AudioProfile{
Container: "FLAC", Container: "FLAC",
Codec: "FLAC", Codec: "FLAC",
Quality: "HI_RES", Quality: "HI_RES",
BitDepth: 24, BitDepth: exactBitDepth,
SamplingRate: "192", SamplingRate: formatSamplingRate(exactSampling),
} }
} }
} }
@@ -438,6 +458,22 @@ func qobuzAudioProfile(resp map[string]any, requestedQuality int, ext string) pr
} }
} }
func firstNonNil(vals ...any) any {
for _, v := range vals {
if v != nil {
return v
}
}
return nil
}
func formatSamplingRate(v float64) string {
if v <= 0 {
return ""
}
return strconv.FormatFloat(v, 'f', -1, 64)
}
func (c *Client) Close() error { func (c *Client) Close() error {
return nil return nil
} }
@@ -577,11 +613,13 @@ func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string
total, _ := intValue(resp["tracks_count"]) total, _ := intValue(resp["tracks_count"])
if total <= pageLimit { if total <= pageLimit {
normalizePlaylistImage(resp)
return resp, nil return resp, nil
} }
tracksObj, ok := mapValue(resp["tracks"]) tracksObj, ok := mapValue(resp["tracks"])
if !ok { if !ok {
normalizePlaylistImage(resp)
return resp, nil return resp, nil
} }
items, ok := tracksObj["items"].([]any) items, ok := tracksObj["items"].([]any)
@@ -617,9 +655,25 @@ func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string
tracksObj["items"] = items tracksObj["items"] = items
resp["tracks"] = tracksObj resp["tracks"] = tracksObj
normalizePlaylistImage(resp)
return resp, nil return resp, nil
} }
func normalizePlaylistImage(resp map[string]any) {
if resp["image"] != nil {
return
}
rect, ok := resp["image_rectangle"].([]any)
if !ok || len(rect) == 0 {
return
}
url, ok := rect[0].(string)
if !ok || url == "" {
return
}
resp["image"] = map[string]any{"original": url, "large": url}
}
func (c *Client) getLabel(ctx context.Context, labelID string) (map[string]any, error) { func (c *Client) getLabel(ctx context.Context, labelID string) (map[string]any, error) {
pageLimit := 500 pageLimit := 500
params := url.Values{} params := url.Values{}
+18
View File
@@ -371,6 +371,24 @@ func TestGetDownloadableUsesReturnedURLExtension(t *testing.T) {
} }
} }
func TestQobuzAudioProfileUsesExactReturnedQuality(t *testing.T) {
profile := qobuzAudioProfile(map[string]any{
"format_id": float64(7),
"bit_depth": float64(24),
"sampling_rate": float64(44.1),
}, 4, "flac")
if profile.BitDepth != 24 || profile.SamplingRate != "44.1" || profile.Quality != "HI_RES" {
t.Fatalf("unexpected profile: %+v", profile)
}
}
func TestQobuzAudioProfileAvoidsGuessingHiResSampleRate(t *testing.T) {
profile := qobuzAudioProfile(map[string]any{"format_id": float64(7)}, 4, "flac")
if profile.BitDepth != 24 || profile.SamplingRate != "" || profile.Quality != "HI_RES" {
t.Fatalf("unexpected profile: %+v", profile)
}
}
func qobuzSecretSig(requestTS, secret string) string { func qobuzSecretSig(requestTS, secret string) string {
raw := "trackgetFileUrlformat_id27intentstreamtrack_id19512574" + requestTS + secret raw := "trackgetFileUrlformat_id27intentstreamtrack_id19512574" + requestTS + secret
hash := md5.Sum([]byte(raw)) hash := md5.Sum([]byte(raw))
+110 -43
View File
@@ -28,6 +28,7 @@ const (
authURL = "https://auth.tidal.com/v1/oauth2" authURL = "https://auth.tidal.com/v1/oauth2"
clientID = "fX2JxdmntZWK0ixT" clientID = "fX2JxdmntZWK0ixT"
clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=" clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="
tidalRequestAttempts = 3
) )
var qualityMap = map[int]string{ var qualityMap = map[int]string{
@@ -843,11 +844,111 @@ func resolvePlaylistURL(baseRaw, refRaw string) string {
return baseURL.ResolveReference(refURL).String() return baseURL.ResolveReference(refURL).String()
} }
func (c *Client) apiRequest(ctx context.Context, path string, params url.Values, base string) (map[string]any, int, error) { func shouldRetryStatus(status int) bool {
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
}
func retryDelay(retryAfter string, attempt int) time.Duration {
retryAfter = strings.TrimSpace(retryAfter)
if retryAfter != "" {
if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(retryAfter); err == nil {
if delay := time.Until(when); delay > 0 {
return delay
}
return 0
}
}
return time.Duration(attempt+1) * 500 * time.Millisecond
}
func waitRetry(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func parseAPIResponseBody(body []byte, status int) (map[string]any, error) {
out := map[string]any{}
if len(body) == 0 {
return out, nil
}
if err := json.Unmarshal(body, &out); err != nil {
if status < http.StatusOK || status >= http.StatusMultipleChoices {
if raw := strings.TrimSpace(string(body)); raw != "" {
out["raw"] = raw
}
return out, nil
}
return nil, err
}
return out, nil
}
func readAPIResponse(resp *http.Response) (map[string]any, int, string, error) {
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, resp.Header.Get("Retry-After"), err
}
parsed, err := parseAPIResponseBody(body, resp.StatusCode)
return parsed, resp.StatusCode, resp.Header.Get("Retry-After"), err
}
func (c *Client) doJSONWithRetry(ctx context.Context, newRequest func() (*http.Request, error)) (map[string]any, int, error) {
var lastStatus int
for attempt := 0; attempt < tidalRequestAttempts; attempt++ {
if err := c.limiter.Wait(ctx); err != nil { if err := c.limiter.Wait(ctx); err != nil {
return nil, 0, err return nil, 0, err
} }
req, err := newRequest()
if err != nil {
return nil, 0, err
}
resp, err := c.http.Do(req)
if err != nil {
if attempt+1 < tidalRequestAttempts {
if waitErr := waitRetry(ctx, retryDelay("", attempt)); waitErr != nil {
return nil, 0, waitErr
}
continue
}
return nil, 0, err
}
parsed, status, retryAfter, err := readAPIResponse(resp)
lastStatus = status
if err != nil {
if attempt+1 < tidalRequestAttempts {
if waitErr := waitRetry(ctx, retryDelay(retryAfter, attempt)); waitErr != nil {
return nil, 0, waitErr
}
continue
}
return nil, status, err
}
if shouldRetryStatus(status) && attempt+1 < tidalRequestAttempts {
if waitErr := waitRetry(ctx, retryDelay(retryAfter, attempt)); waitErr != nil {
return nil, 0, waitErr
}
continue
}
return parsed, status, nil
}
return map[string]any{}, lastStatus, nil
}
func (c *Client) apiRequest(ctx context.Context, path string, params url.Values, base string) (map[string]any, int, error) {
if params == nil { if params == nil {
params = url.Values{} params = url.Values{}
} }
@@ -863,41 +964,22 @@ func (c *Client) apiRequest(ctx context.Context, path string, params url.Values,
reqURL += "?" + params.Encode() reqURL += "?" + params.Encode()
} }
return c.doJSONWithRetry(ctx, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil { if err != nil {
return nil, 0, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+c.cfg.Session.Tidal.AccessToken) req.Header.Set("Authorization", "Bearer "+c.cfg.Session.Tidal.AccessToken)
req.Header.Set("User-Agent", "streamrip-go/0.1") req.Header.Set("User-Agent", "streamrip-go/0.1")
return req, nil
resp, err := c.http.Do(req) })
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
parsed := map[string]any{}
if len(body) > 0 {
if err = json.Unmarshal(body, &parsed); err != nil {
return nil, resp.StatusCode, err
}
}
return parsed, resp.StatusCode, nil
} }
func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values, basicAuth bool) (map[string]any, int, error) { func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values, basicAuth bool) (map[string]any, int, error) {
if err := c.limiter.Wait(ctx); err != nil { return c.doJSONWithRetry(ctx, func() (*http.Request, error) {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode())) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode()))
if err != nil { if err != nil {
return nil, 0, err return nil, err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "streamrip-go/0.1") req.Header.Set("User-Agent", "streamrip-go/0.1")
@@ -905,23 +987,8 @@ func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values,
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec)) auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec))
req.Header.Set("Authorization", "Basic "+auth) req.Header.Set("Authorization", "Basic "+auth)
} }
return req, nil
resp, err := c.http.Do(req) })
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
out := map[string]any{}
if len(body) > 0 {
if err = json.Unmarshal(body, &out); err != nil {
return nil, resp.StatusCode, err
}
}
return out, resp.StatusCode, nil
} }
func stringify(v any) string { func stringify(v any) string {
+78
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"reflect" "reflect"
"strconv" "strconv"
"testing" "testing"
@@ -238,6 +239,83 @@ func TestGetMetadataTrackIgnoresLyricsEndpointFailure(t *testing.T) {
} }
} }
func TestAPIRequestRetriesTooManyRequests(t *testing.T) {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/tracks/42" {
w.WriteHeader(http.StatusNotFound)
return
}
calls++
if calls == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte("slow down"))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "Song"})
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Downloads.RequestsPerMinute = 0
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.baseURL = ts.URL + "/v1"
resp, status, err := c.apiRequest(context.Background(), "tracks/42", nil, c.baseURL)
if err != nil {
t.Fatalf("apiRequest() err = %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d, want %d", status, http.StatusOK)
}
if calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}
if stringify(resp["title"]) != "Song" {
t.Fatalf("title = %q, want Song", stringify(resp["title"]))
}
}
func TestAPIPostRetriesTooManyRequests(t *testing.T) {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/token" {
w.WriteHeader(http.StatusNotFound)
return
}
calls++
if calls == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte("slow down"))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "fresh-token"})
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Downloads.RequestsPerMinute = 0
c := New(&config.Config{File: cfgData, Session: cfgData})
resp, status, err := c.apiPost(context.Background(), ts.URL+"/token", url.Values{"grant_type": []string{"refresh_token"}}, false)
if err != nil {
t.Fatalf("apiPost() err = %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d, want %d", status, http.StatusOK)
}
if calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}
if stringify(resp["access_token"]) != "fresh-token" {
t.Fatalf("access_token = %q, want fresh-token", stringify(resp["access_token"]))
}
}
func TestGetDownloadablePrefersAtmosWhenEnabled(t *testing.T) { func TestGetDownloadablePrefersAtmosWhenEnabled(t *testing.T) {
var calls []string var calls []string
allImmersive := true allImmersive := true
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
package yandex
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
)
func TestYandexDownloadSignMatchesCapturedFormat(t *testing.T) {
sign, ts := yandexDownloadSign("32038184", "lossless", []string{"flac", "aac", "he-aac", "mp3", "flac-mp4", "aac-mp4", "he-aac-mp4"}, "raw")
if ts <= 0 {
t.Fatalf("timestamp = %d", ts)
}
if strings.TrimSpace(sign) == "" {
t.Fatalf("decoded sign is empty")
}
if strings.Contains(sign, "=") {
t.Fatalf("sign unexpectedly contains base64 padding: %q", sign)
}
if strings.Contains(sign, " ") {
t.Fatalf("sign unexpectedly contains space: %q", sign)
}
}
func TestGetDownloadableUsesModernGetFileInfo(t *testing.T) {
var gotPath string
var gotQuery url.Values
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.Query()
if r.URL.Path == "/account/about" {
_ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"uid": "123"}})
return
}
if r.URL.Path != "/get-file-info" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{
"downloadInfo": map[string]any{
"trackId": "32038184",
"quality": "lossless",
"codec": "flac-mp4",
"transport": "encraw",
"key": "00112233445566778899aabbccddeeff",
"bitrate": 0,
"url": "https://strm.example/music-v2/crypt/x/flac-mp4",
},
},
})
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = 0
d.Yandex.AccessToken = "token"
c := New(&config.Config{File: d, Session: d})
c.baseURL = ts.URL
c.loggedIn = true
dl, err := c.GetDownloadable(context.Background(), "32038184:1683700", 2)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if gotPath != "/get-file-info" {
t.Fatalf("path = %q, want /get-file-info", gotPath)
}
if gotQuery.Get("trackId") != "32038184" {
t.Fatalf("trackId = %q, want 32038184", gotQuery.Get("trackId"))
}
if gotQuery.Get("quality") != "lossless" {
t.Fatalf("quality = %q, want lossless", gotQuery.Get("quality"))
}
if gotQuery.Get("transports") != "encraw" {
t.Fatalf("transports = %q, want encraw", gotQuery.Get("transports"))
}
if dl.Extension != "m4a" {
t.Fatalf("extension = %q, want m4a", dl.Extension)
}
if dl.Audio.Codec != "FLAC" || dl.Audio.Quality != "LOSSLESS" {
t.Fatalf("unexpected audio profile: %+v", dl.Audio)
}
if dl.TrackID != "32038184" {
t.Fatalf("track id = %q, want 32038184", dl.TrackID)
}
if dl.Cipher != "AES_CTR" || dl.Key == "" {
t.Fatalf("expected yandex cipher metadata, got cipher=%q key=%q", dl.Cipher, dl.Key)
}
}
func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
var gotMethod string
var gotPath string
var gotBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if r.URL.Path != "/tracks" {
w.WriteHeader(http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
_ = json.NewEncoder(w).Encode(map[string]any{
"result": []map[string]any{{
"id": "9442712",
"realId": "9442712",
"title": "Nightcall",
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}},
"albums": []map[string]any{{
"id": "1000856",
"title": "OutRun",
"releaseDate": "2013-02-25T00:00:00+04:00",
"trackCount": 13,
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}},
"trackPosition": map[string]any{
"index": 0,
"volume": 1,
},
}},
}},
})
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = 0
d.Yandex.AccessToken = "token"
c := New(&config.Config{File: d, Session: d})
c.baseURL = ts.URL
c.loggedIn = true
meta, err := c.GetMetadata(context.Background(), "9442712:1000856", "track")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if gotMethod != http.MethodPost || gotPath != "/tracks" {
t.Fatalf("unexpected request: %s %s", gotMethod, gotPath)
}
if !strings.Contains(gotBody, "trackIds=9442712%3A1000856") {
t.Fatalf("body = %q", gotBody)
}
if meta["id"] != "9442712:1000856" {
t.Fatalf("id = %v", meta["id"])
}
if album, _ := meta["album"].(map[string]any); jsonutil.StringFromAny(album["title"]) != "OutRun" {
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)
}
artists, _ := meta["artist_names"].([]string)
if strings.Join(artists, ";") != "Kavinsky;Lovefoxxx" {
t.Fatalf("artist_names = %#v", artists)
}
}
func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) {
got := displayArtistNames([]string{"A", "B", "C"})
if got != "A, B & C" {
t.Fatalf("displayArtistNames() = %q", got)
}
}
func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {
url, err := legacyDirectURL(&legacyDownloadInfoXML{
Host: "example.test",
Path: "/abc123",
TS: "1234567890",
S: "tailxyz",
})
if err != nil {
t.Fatalf("legacyDirectURL() error = %v", err)
}
want := "https://example.test/get-mp3/248c1c6ff5daf481560d3bd9f24e8058/1234567890/abc123"
if url != want {
t.Fatalf("legacyDirectURL() = %q, want %q", url, want)
}
}
+106 -1
View File
@@ -45,6 +45,10 @@ func Parse(raw string) *ParsedURL {
switch { switch {
case isQobuzHost(host): case isQobuzHost(host):
return parseQobuz(raw, parts) return parseQobuz(raw, parts)
case isYandexHost(host):
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):
@@ -56,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
@@ -69,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
} }
@@ -80,6 +141,42 @@ func parseQobuz(raw string, parts []string) *ParsedURL {
return &ParsedURL{OriginalURL: raw, Source: "qobuz", MediaType: mediaType, ID: id, Kind: KindGeneric} return &ParsedURL{OriginalURL: raw, Source: "qobuz", MediaType: mediaType, ID: id, Kind: KindGeneric}
} }
func parseYandex(raw string, parts []string) *ParsedURL {
if len(parts) < 2 {
return nil
}
switch parts[0] {
case "track":
if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" {
return nil
}
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "track", ID: parts[1], Kind: KindGeneric}
case "album":
if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" {
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "album", ID: parts[1], Kind: KindGeneric}
}
if len(parts) == 4 && parts[2] == "track" && strings.TrimSpace(parts[1]) != "" && strings.TrimSpace(parts[3]) != "" {
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "track", ID: parts[3] + ":" + parts[1], Kind: KindGeneric}
}
case "artist":
if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" {
return nil
}
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "artist", ID: parts[1], Kind: KindGeneric}
case "users":
if len(parts) == 4 && parts[2] == "playlists" && strings.TrimSpace(parts[1]) != "" && strings.TrimSpace(parts[3]) != "" {
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "playlist", ID: parts[1] + ":" + parts[3], Kind: KindGeneric}
}
case "playlists":
if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" {
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "playlist", ID: parts[1], Kind: KindGeneric}
}
}
return nil
}
func parseTidal(raw string, parts []string) *ParsedURL { func parseTidal(raw string, parts []string) *ParsedURL {
if len(parts) < 2 { if len(parts) < 2 {
return nil return nil
@@ -177,6 +274,14 @@ func isQobuzHost(host string) bool {
return host == "qobuz.com" || host == "open.qobuz.com" || host == "play.qobuz.com" return host == "qobuz.com" || host == "open.qobuz.com" || host == "play.qobuz.com"
} }
func isYandexHost(host string) bool {
return host == "music.yandex.ru" || host == "music.yandex.com" || host == "music.yandex.kz" || host == "music.yandex.by"
}
func isBeatportHost(host string) bool {
return host == "beatport.com" || host == "api.beatport.com"
}
func isTidalHost(host string) bool { 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"
} }
@@ -191,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
+65
View File
@@ -27,6 +27,71 @@ 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) {
tests := []struct {
url string
mediaType string
id string
}{
{url: "https://music.yandex.ru/track/9442712", mediaType: "track", id: "9442712"},
{url: "https://music.yandex.ru/album/1000856", mediaType: "album", id: "1000856"},
{url: "https://music.yandex.ru/album/1000856/track/9442712", mediaType: "track", id: "9442712:1000856"},
{url: "https://music.yandex.ru/artist/1433871", mediaType: "artist", id: "1433871"},
{url: "https://music.yandex.ru/users/yandexmusic/playlists/1635", mediaType: "playlist", id: "yandexmusic:1635"},
{url: "https://music.yandex.ru/playlists/4ae45ac1-0972-734f-8537-769490399170", mediaType: "playlist", id: "4ae45ac1-0972-734f-8537-769490399170"},
}
for _, tc := range tests {
result := Parse(tc.url)
if result == nil {
t.Fatalf("expected parse for %q", tc.url)
}
if result.Source != "yandex" || result.MediaType != tc.mediaType || result.ID != tc.id {
t.Fatalf("unexpected parse result for %q: %+v", tc.url, result)
}
}
}
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",