Author SHA1 Message Date
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
lb-a 3bc965db77 Merge branch 'feat/verbose' 2026-05-07 23:56:42 +02:00
lb-a 3909ba5113 feat: implement second level of verbosity 2026-05-07 18:45:36 +02:00
lb-a 04cc56040b feat: add log hooks 2026-05-07 18:45:05 +02:00
lb-a ef741434cb feat: ad verbose package 2026-05-07 18:42:24 +02:00
Joren ef72aad14e Merge branch 'fix/qobuz-mobile-fallback-eof' 2026-05-05 21:51:19 +02:00
Joren 59b476034e fix qobuz EOF downloads with mobile fallback flow 2026-05-01 17:23:52 +02:00
lb-a 9618108f2a performance: exclude lyrics if they are not enabled 2026-04-30 12:00:58 +02:00
lb-a 7a27845e75 performance: change I/O buffer size 2026-04-29 23:56:05 +02:00
lb-a 945695cea7 performance: tune shared HTTP transport for concurrent CDN downloads 2026-04-29 23:53:51 +02:00
Joren 9e27ba842f fix deezer track pagination and decryption track id resolution 2026-04-26 22:23:23 +02:00
Joren 63e1f20e04 fix tidal lyrics fetch host and synced lyric tagging 2026-04-25 01:24:11 +02:00
28 changed files with 4924 additions and 160 deletions
+8 -2
View File
@@ -24,7 +24,7 @@ type globalOptions struct {
codec string codec string
noProgress bool noProgress bool
noSSLVerify bool noSSLVerify bool
verbose bool verbose int
command string command string
commandArgs []string commandArgs []string
} }
@@ -52,7 +52,13 @@ func parseGlobalArgs(args []string) (globalOptions, error) {
case arg == "--no-ssl-verify": case arg == "--no-ssl-verify":
opts.noSSLVerify = true opts.noSSLVerify = true
case arg == "-v" || arg == "--verbose": case arg == "-v" || arg == "--verbose":
opts.verbose = true if opts.verbose < 2 {
opts.verbose++
}
case arg == "-vv":
if opts.verbose < 2 {
opts.verbose = 2
}
case arg == "-f" || arg == "--folder": case arg == "-f" || arg == "--folder":
if i+1 >= len(args) { if i+1 >= len(args) {
return globalOptions{}, fmt.Errorf("%s requires a value", arg) return globalOptions{}, fmt.Errorf("%s requires a value", arg)
+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
} }
+3 -3
View File
@@ -86,7 +86,7 @@ func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string
if !isValidLastFMPlaylistURL(playlistURL) { if !isValidLastFMPlaylistURL(playlistURL) {
return "", nil, fmt.Errorf("invalid playlist url") return "", nil, fmt.Errorf("invalid playlist url")
} }
client := netutil.NewHTTPClient(30*time.Second, verifySSL) client := netutil.NewHTTPClient(30*time.Second, verifySSL, 0)
page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1) page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1)
if err != nil { if err != nil {
@@ -123,7 +123,7 @@ func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string
} }
func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) { func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) {
client := netutil.NewHTTPClient(30*time.Second, verifySSL) client := netutil.NewHTTPClient(30*time.Second, verifySSL, 0)
all := make([]lastFMTrack, 0, 200) all := make([]lastFMTrack, 0, 200)
title := "" title := ""
@@ -376,7 +376,7 @@ func fetchSoundcloudOEmbed(ctx context.Context, verifySSL bool, trackURL string)
q.Set("url", trackURL) q.Set("url", trackURL)
endpoint := "https://soundcloud.com/oembed?" + q.Encode() endpoint := "https://soundcloud.com/oembed?" + q.Encode()
client := netutil.NewHTTPClient(20*time.Second, verifySSL) client := netutil.NewHTTPClient(20*time.Second, verifySSL, 0)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return nil, err return nil, err
+24 -4
View File
@@ -14,6 +14,7 @@ import (
"streamrip-go/internal/app" "streamrip-go/internal/app"
"streamrip-go/internal/config" "streamrip-go/internal/config"
"streamrip-go/internal/provider" "streamrip-go/internal/provider"
"streamrip-go/internal/verbose"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -49,8 +50,11 @@ func main() {
os.Exit(1) os.Exit(1)
} }
applyGlobalConfigOverrides(cfg, gopts) applyGlobalConfigOverrides(cfg, gopts)
if gopts.verbose { verbose.SetLevel(gopts.verbose)
fmt.Fprintln(os.Stderr, "verbose mode enabled") if gopts.verbose >= 2 {
fmt.Fprintln(os.Stderr, "verbose mode enabled (level 2: downloads + http)")
} else if gopts.verbose >= 1 {
fmt.Fprintln(os.Stderr, "verbose mode enabled (level 1: downloads)")
} }
os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...) os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...)
@@ -290,7 +294,7 @@ func main() {
} }
case "id": case "id":
if len(os.Args) < 5 { if len(os.Args) < 5 {
fmt.Println("usage: rip id <source> <track|album|playlist|artist|label|video> <id> [quality] [--force|--ignore-db]") fmt.Println("usage: rip id <source> <track|album|playlist|artist|label|chart|video> <id> [quality] [--force|--ignore-db]")
os.Exit(2) os.Exit(2)
} }
@@ -313,6 +317,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
} }
} }
@@ -342,7 +354,7 @@ func main() {
var sopts searchOptions var sopts searchOptions
if len(os.Args) < 5 { if len(os.Args) < 5 {
if !term.IsTerminal(int(os.Stdin.Fd())) { if !term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Println("usage: rip search <qobuz|tidal|deezer|soundcloud> <track|album|playlist|artist|label|video> <query...> [--limit N] [--force|--ignore-db] [--no-download]") fmt.Println("usage: rip search <qobuz|tidal|deezer|yandex|beatport|soundcloud> <track|album|playlist|artist|label|chart|video> <query...> [--limit N] [--force|--ignore-db] [--no-download]")
os.Exit(2) os.Exit(2)
} }
source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults)
@@ -376,6 +388,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)
+1 -1
View File
@@ -227,7 +227,7 @@ func TestParseGlobalArgsAllOfficialFlags(t *testing.T) {
if !opts.noDB || !opts.qualitySet || opts.quality != 3 || !opts.codecSet || opts.codec != "VORBIS" { if !opts.noDB || !opts.qualitySet || opts.quality != 3 || !opts.codecSet || opts.codec != "VORBIS" {
t.Fatalf("unexpected quality/codec/db opts: %+v", opts) t.Fatalf("unexpected quality/codec/db opts: %+v", opts)
} }
if !opts.noProgress || !opts.noSSLVerify || !opts.verbose { if !opts.noProgress || !opts.noSSLVerify || opts.verbose != 1 {
t.Fatalf("unexpected boolean opts: %+v", opts) t.Fatalf("unexpected boolean opts: %+v", opts)
} }
if opts.command != "search" { if opts.command != "search" {
+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 = []
+238 -7
View File
@@ -21,11 +21,14 @@ 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"
) )
type Main struct { type Main struct {
@@ -91,6 +94,10 @@ type videoDownloadableProvider interface {
GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error) GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error)
} }
type trackFallbackDownloader interface {
DownloadTrackFallback(ctx context.Context, trackID string, quality int, outputPath string) error
}
func New(cfg *config.Config) (*Main, error) { func New(cfg *config.Config) (*Main, error) {
var db store.Database var db store.Database
if cfg.Session.Database.DownloadsEnabled || cfg.Session.Database.FailedDownloadsEnabled { if cfg.Session.Database.DownloadsEnabled || cfg.Session.Database.FailedDownloadsEnabled {
@@ -104,24 +111,40 @@ 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),
} }
return &Main{ m := &Main{
Config: cfg, Config: cfg,
Providers: providers, Providers: providers,
Store: db, Store: db,
DL: download.NewWithOptions(cfg.Session.Downloads.VerifySSL, cfg.Session.CLI.ProgressBars), DL: download.NewWithOptions(cfg.Session.Downloads.VerifySSL, cfg.Session.CLI.ProgressBars, downloaderMaxConnsPerHost(cfg.Session.Downloads.MaxConnections)),
Tagger: tag.New(), Tagger: tag.New(),
Pending: []media.Pending{}, Pending: []media.Pending{},
Media: []media.Media{}, Media: []media.Media{},
}, nil }
verbose.SetSink(func(msg string) { m.DL.Logf("%s", msg) })
return m, nil
}
// downloaderMaxConnsPerHost picks the per-host idle connection cap for the
// shared download client. We floor at 16 so artwork/manifest fetches and
// concurrent track downloads to the same CDN host can reuse keep-alive
// sockets even when the user configured a tiny max_connections.
func downloaderMaxConnsPerHost(maxConnections int) int {
if maxConnections > 16 {
return maxConnections
}
return 16
} }
func (m *Main) Close() error { func (m *Main) Close() error {
verbose.SetSink(nil)
m.DL.Close() m.DL.Close()
artwork.CleanupTempDirs() artwork.CleanupTempDirs()
for _, p := range m.Providers { for _, p := range m.Providers {
@@ -182,7 +205,12 @@ func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error
case "artist": case "artist":
return m.ripCollection(ctx, p, source, "Artist", id, meta) return m.ripCollection(ctx, p, source, "Artist", id, meta)
case "label": case "label":
if source == "beatport" {
return m.ripTrackCollection(ctx, p, source, "Label", id, meta, false)
}
return m.ripCollection(ctx, p, source, "Label", id, meta) return m.ripCollection(ctx, p, source, "Label", id, meta)
case "chart":
return m.ripTrackCollection(ctx, p, source, "Chart", id, meta, true)
case "video": case "video":
return m.ripVideo(ctx, p, source, id, meta) return m.ripVideo(ctx, p, source, id, meta)
default: default:
@@ -306,6 +334,98 @@ func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kin
return nil return nil
} }
func (m *Main) ripTrackCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any, playlistLike bool) error {
if err := m.requireSourceDownloadAuth(source); err != nil {
return err
}
name := titleFromMetadata(meta, id)
if n := jsonutil.StringFromAny(meta["name"]); n != "" {
name = n
}
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, jsonutil.TitleCase(source))
}
folder := filepath.Join(base, naming.CleanName(name, naming.Config{RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, TruncateTo: m.Config.Session.Filepaths.TruncateTo}))
tracksMap, ok := meta["tracks"].(map[string]any)
if !ok {
return fmt.Errorf("%s missing tracks data", strings.ToLower(kind))
}
rawItems := make([]any, 0)
switch items := tracksMap["items"].(type) {
case []any:
rawItems = items
case []map[string]any:
for _, item := range items {
rawItems = append(rawItems, item)
}
default:
return fmt.Errorf("%s tracks missing items", strings.ToLower(kind))
}
ids := make([]string, 0, len(rawItems))
for _, raw := range rawItems {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
if id := jsonutil.StringFromAny(itm["id"]); id != "" {
ids = append(ids, id)
}
}
m.logf("%s: %s (%d tracks)\n", kind, name, len(ids))
failures := 0
runOne := func(i int, trackID string) {
opts := ripTrackOptions{albumFolder: folder, index: i, total: len(ids)}
if playlistLike {
opts.forPlaylist = true
opts.playlistName = name
opts.playlistPos = i
}
if err := m.ripTrack(ctx, p, source, trackID, "", opts); err != nil {
failures++
m.logf("track failed: id=%s reason=%v\n", trackID, err)
}
}
if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 {
for i, trackID := range ids {
runOne(i+1, trackID)
}
} else {
maxWorkers := m.Config.Session.Downloads.MaxConnections
if maxWorkers <= 0 {
maxWorkers = 6
}
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
var mu sync.Mutex
for i, trackID := range ids {
wg.Add(1)
sem <- struct{}{}
go func(pos int, tid string) {
defer wg.Done()
defer func() { <-sem }()
opts := ripTrackOptions{albumFolder: folder, index: pos, total: len(ids)}
if playlistLike {
opts.forPlaylist = true
opts.playlistName = name
opts.playlistPos = pos
}
if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil {
mu.Lock()
failures++
m.logf("track failed: id=%s reason=%v\n", tid, err)
mu.Unlock()
}
}(i+1, trackID)
}
wg.Wait()
}
if failures > 0 {
m.logf("%s done with %d failed track(s)\n", kind, failures)
}
return nil
}
func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID string, meta map[string]any) error { func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID string, meta map[string]any) error {
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, videoID) alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, videoID)
if err == nil && alreadyDownloaded && !m.IgnoreDB { if err == nil && alreadyDownloaded && !m.IgnoreDB {
@@ -869,16 +989,29 @@ 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 {
m.logf("retry: %s (%v)\n", filepath.Base(outPath), err) m.logf("retry: %s (%v)\n", filepath.Base(outPath), err)
if err = downloadOnce(); err != nil { if err = downloadOnce(); err != nil {
if fallbackProvider, ok := p.(trackFallbackDownloader); ok {
m.logf("fallback: %s via provider backup flow\n", filepath.Base(outPath))
if fbErr := fallbackProvider.DownloadTrackFallback(ctx, id, m.qualityForSource(source), outPath); fbErr == nil {
goto downloaded
} else {
m.logf("fallback failed: %s (%v)\n", filepath.Base(outPath), fbErr)
}
}
_ = m.Store.MarkFailed(ctx, source, "track", id) _ = m.Store.MarkFailed(ctx, source, "track", id)
return fmt.Errorf("id=%s title=%q download: %w", id, title, err) return fmt.Errorf("id=%s title=%q download: %w", id, title, err)
} }
} }
downloaded:
embedCoverPath := opts.albumEmbedCover embedCoverPath := opts.albumEmbedCover
if opts.forPlaylist { if opts.forPlaylist {
parent := opts.albumFolder parent := opts.albumFolder
@@ -890,8 +1023,11 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
embedCoverPath = res.EmbedPath embedCoverPath = res.EmbedPath
} }
} }
} else if opts.albumFolder == "" { } else if embedCoverPath == "" {
parent := filepath.Dir(outPath) parent := opts.albumFolder
if parent == "" {
parent = filepath.Dir(outPath)
}
if res, prepErr := artwork.Prepare(ctx, m.DL, parent, trackMetaAlbum(meta), m.Config.Session.Artwork, false); prepErr == nil { if res, prepErr := artwork.Prepare(ctx, m.DL, parent, trackMetaAlbum(meta), m.Config.Session.Artwork, false); prepErr == nil {
if res.EmbedPath != "" { if res.EmbedPath != "" {
embedCoverPath = res.EmbedPath embedCoverPath = res.EmbedPath
@@ -935,6 +1071,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:
@@ -965,6 +1105,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"
} }
@@ -1216,6 +1358,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
@@ -1260,11 +1403,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
@@ -1274,6 +1418,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"])
@@ -1321,13 +1470,16 @@ 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,
OmitDiscTags: opts.forPlaylist,
TrackNumber: trackNumber, TrackNumber: trackNumber,
DiscNumber: discNumber, DiscNumber: discNumber,
TrackTotal: trackTotal, TrackTotal: trackTotal,
DiscTotal: discTotal, DiscTotal: discTotal,
Date: date, Date: date,
Genre: genre, Genre: genre,
InitialKey: initialKey,
Comment: comment, Comment: comment,
Description: description, Description: description,
Lyrics: lyrics, Lyrics: lyrics,
@@ -1344,6 +1496,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
+95 -4
View File
@@ -309,7 +309,7 @@ func TestTrackRipFailsWhenTaggerReportsMissingFFmpeg(t *testing.T) {
"qobuz": &fakeProvider{url: ts.URL}, "qobuz": &fakeProvider{url: ts.URL},
}, },
Store: sqlite, Store: sqlite,
DL: download.NewWithOptions(true, false), DL: download.NewWithOptions(true, false, 0),
Tagger: failingTagger{err: fmt.Errorf("ffmpeg not found: %w", exec.ErrNotFound)}, Tagger: failingTagger{err: fmt.Errorf("ffmpeg not found: %w", exec.ErrNotFound)},
} }
@@ -490,6 +490,46 @@ func TestBuildTagMetadataUsesAlbumArtistOverride(t *testing.T) {
} }
} }
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()
@@ -537,7 +577,7 @@ func TestPlaylistRipPipeline(t *testing.T) {
"qobuz": &fakePlaylistProvider{url: ts.URL}, "qobuz": &fakePlaylistProvider{url: ts.URL},
}, },
Store: sqlite, Store: sqlite,
DL: download.NewWithOptions(true, false), DL: download.NewWithOptions(true, false, 0),
Tagger: noopTagger{}, Tagger: noopTagger{},
} }
@@ -588,7 +628,7 @@ func TestPlaylistRipUsesSourceSubdirectory(t *testing.T) {
"qobuz": &fakePlaylistProvider{url: ts.URL}, "qobuz": &fakePlaylistProvider{url: ts.URL},
}, },
Store: sqlite, Store: sqlite,
DL: download.NewWithOptions(true, false), DL: download.NewWithOptions(true, false, 0),
Tagger: noopTagger{}, Tagger: noopTagger{},
} }
@@ -773,7 +813,7 @@ func TestRipAlbumUsesResolvedAudioProfileForFolderName(t *testing.T) {
"qobuz": fake, "qobuz": fake,
}, },
Store: sqlite, Store: sqlite,
DL: download.NewWithOptions(true, false), DL: download.NewWithOptions(true, false, 0),
Tagger: noopTagger{}, Tagger: noopTagger{},
} }
@@ -824,6 +864,57 @@ 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 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),
+183 -7
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,16 @@ type Metadata struct {
Title string Title string
Album string Album string
Artist string Artist string
Artists []string
AlbumArtist string AlbumArtist string
OmitDiscTags bool
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
TrackTotal int TrackTotal int
DiscTotal int DiscTotal int
Date string Date string
Genre string Genre string
InitialKey string
Comment string Comment string
Description string Description string
Lyrics string Lyrics string
@@ -47,9 +51,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 +73,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 +109,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 +166,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,11 +182,7 @@ func toTags(meta Metadata) map[string]string {
"source_artist_id": meta.SourceArtistID, "source_artist_id": meta.SourceArtistID,
} }
if meta.TrackNumber > 0 { if meta.TrackNumber > 0 {
if meta.TrackTotal > 0 { tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
tags["track"] = fmt.Sprintf("%02d/%02d", meta.TrackNumber, meta.TrackTotal)
} else {
tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
}
} }
if meta.TrackTotal > 0 { if meta.TrackTotal > 0 {
tags["tracktotal"] = strconv.Itoa(meta.TrackTotal) tags["tracktotal"] = strconv.Itoa(meta.TrackTotal)
@@ -168,6 +200,150 @@ func toTags(meta Metadata) map[string]string {
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{
+150 -6
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"
@@ -20,6 +22,7 @@ import (
"golang.org/x/term" "golang.org/x/term"
"streamrip-go/internal/netutil" "streamrip-go/internal/netutil"
"streamrip-go/internal/verbose"
"golang.org/x/crypto/blowfish" "golang.org/x/crypto/blowfish"
) )
@@ -31,18 +34,23 @@ type Downloader struct {
barStarted atomic.Int32 barStarted atomic.Int32
} }
// downloadBufferSize sizes HTTP-to-disk copies. 1 MiB cuts read/write syscalls
// ~32x vs Go's default 32 KiB io.Copy buffer, which matters for multi-MB FLAC
// streams off CDNs that can sustain high per-connection throughput.
const downloadBufferSize = 1 << 20
func New() *Downloader { func New() *Downloader {
return NewWithOptions(true, true) return NewWithOptions(true, true, 0)
} }
func NewWithVerifySSL(verifySSL bool) *Downloader { func NewWithVerifySSL(verifySSL bool) *Downloader {
return NewWithOptions(verifySSL, true) return NewWithOptions(verifySSL, true, 0)
} }
func NewWithOptions(verifySSL bool, showProgress bool) *Downloader { func NewWithOptions(verifySSL bool, showProgress bool, maxConnsPerHost int) *Downloader {
forceProgress := strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "1") || strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "true") forceProgress := strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "1") || strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "true")
interactive := showProgress && (forceProgress || (term.IsTerminal(int(os.Stderr.Fd())) && strings.ToLower(os.Getenv("TERM")) != "dumb")) interactive := showProgress && (forceProgress || (term.IsTerminal(int(os.Stderr.Fd())) && strings.ToLower(os.Getenv("TERM")) != "dumb"))
d := &Downloader{http: netutil.NewHTTPClient(0, verifySSL), showProgress: interactive} d := &Downloader{http: netutil.NewHTTPClient(0, verifySSL, maxConnsPerHost), showProgress: interactive}
if interactive { if interactive {
d.progress = mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr)) d.progress = mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr))
} }
@@ -62,6 +70,7 @@ func (d *Downloader) FileVideo(ctx context.Context, sourceURL, outputPath string
} }
func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputPath, trackID string) error { func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputPath, trackID string) error {
logDownloadStart(sourceURL, outputPath)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err return err
} }
@@ -124,6 +133,11 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
) )
defer bar.SetTotal(-1, true) defer bar.SetTotal(-1, true)
} }
defer func() {
if !success && bar != nil {
bar.Abort(true)
}
}()
} }
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID)) block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
@@ -170,7 +184,122 @@ 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)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err return err
} }
@@ -244,7 +373,12 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all
) )
defer bar.SetTotal(-1, true) defer bar.SetTotal(-1, true)
} }
buf := make([]byte, 256*1024) defer func() {
if !success && bar != nil {
bar.Abort(true)
}
}()
buf := make([]byte, downloadBufferSize)
totalWritten := int64(0) totalWritten := int64(0)
for { for {
n, readErr := reader.Read(buf) n, readErr := reader.Read(buf)
@@ -269,7 +403,7 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all
return err return err
} }
} else { } else {
written, copyErr := io.Copy(out, reader) written, copyErr := io.CopyBuffer(out, reader, make([]byte, downloadBufferSize))
if copyErr != nil { if copyErr != nil {
return copyErr return copyErr
} }
@@ -304,6 +438,16 @@ func (d *Downloader) Logf(format string, args ...any) {
fmt.Print(msg) fmt.Print(msg)
} }
// logDownloadStart emits the source URL and destination filename when the
// user passed -v or higher. The transport-level logger covers the same
// requests at -vv, but this line gives a friendlier per-track summary.
func logDownloadStart(sourceURL, outputPath string) {
if !verbose.Enabled(verbose.V) {
return
}
verbose.Printf(verbose.V, "download %s -> %s\n", sourceURL, filepath.Base(outputPath))
}
func shortenName(name string, max int) string { func shortenName(name string, max int) string {
if max <= 0 { if max <= 0 {
return name return name
+58 -8
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"
@@ -18,7 +20,7 @@ import (
) )
func TestDownloaderHasNoClientTimeout(t *testing.T) { func TestDownloaderHasNoClientTimeout(t *testing.T) {
d := NewWithOptions(true, false) d := NewWithOptions(true, false, 0)
if d.http.Timeout != 0 { if d.http.Timeout != 0 {
t.Fatalf("http timeout = %v, want 0 (no global timeout)", d.http.Timeout) t.Fatalf("http timeout = %v, want 0 (no global timeout)", d.http.Timeout)
} }
@@ -95,7 +97,7 @@ func TestFileDeezerEncrypted(t *testing.T) {
})) }))
defer ts.Close() defer ts.Close()
d := NewWithOptions(true, false) d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.flac") out := filepath.Join(t.TempDir(), "x", "a.flac")
if err = d.FileDeezerEncrypted(context.Background(), ts.URL, out, trackID); err != nil { if err = d.FileDeezerEncrypted(context.Background(), ts.URL, out, trackID); err != nil {
t.Fatalf("FileDeezerEncrypted() error = %v", err) t.Fatalf("FileDeezerEncrypted() error = %v", err)
@@ -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")
@@ -117,7 +158,7 @@ func TestDownloaderFileTruncatedResponseRemovesPartialFile(t *testing.T) {
})) }))
defer ts.Close() defer ts.Close()
d := NewWithOptions(true, false) d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.bin") out := filepath.Join(t.TempDir(), "x", "a.bin")
err := d.File(context.Background(), ts.URL, out) err := d.File(context.Background(), ts.URL, out)
if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) { if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
@@ -135,7 +176,7 @@ func TestFileDeezerEncryptedTruncatedResponseRemovesPartialFile(t *testing.T) {
})) }))
defer ts.Close() defer ts.Close()
d := NewWithOptions(true, false) d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.flac") out := filepath.Join(t.TempDir(), "x", "a.flac")
err := d.FileDeezerEncrypted(context.Background(), ts.URL, out, "3135556") err := d.FileDeezerEncrypted(context.Background(), ts.URL, out, "3135556")
if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) { if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
@@ -152,7 +193,7 @@ func TestFileDeezerEncryptedBadStatus(t *testing.T) {
})) }))
defer ts.Close() defer ts.Close()
d := NewWithOptions(true, false) d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.flac") out := filepath.Join(t.TempDir(), "x", "a.flac")
err := d.FileDeezerEncrypted(context.Background(), ts.URL, out, "3135556") err := d.FileDeezerEncrypted(context.Background(), ts.URL, out, "3135556")
if err == nil || !strings.Contains(err.Error(), "status=403") { if err == nil || !strings.Contains(err.Error(), "status=403") {
@@ -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")
@@ -171,7 +221,7 @@ func TestDownloaderFileContextCancellationRemovesPartialFile(t *testing.T) {
})) }))
defer ts.Close() defer ts.Close()
d := NewWithOptions(true, false) d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "cancel.bin") out := filepath.Join(t.TempDir(), "x", "cancel.bin")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
defer cancel() defer cancel()
@@ -185,7 +235,7 @@ func TestDownloaderFileContextCancellationRemovesPartialFile(t *testing.T) {
} }
func TestStreamManifestWithFFmpegMissing(t *testing.T) { func TestStreamManifestWithFFmpegMissing(t *testing.T) {
d := NewWithOptions(true, false) d := NewWithOptions(true, false, 0)
t.Setenv("PATH", "") t.Setenv("PATH", "")
err := d.streamManifestWithFFmpeg(context.Background(), "https://example.com/live.m3u8", filepath.Join(t.TempDir(), "out.m4a"), false) err := d.streamManifestWithFFmpeg(context.Background(), "https://example.com/live.m3u8", filepath.Join(t.TempDir(), "out.m4a"), false)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "ffmpeg not found") { if err == nil || !strings.Contains(strings.ToLower(err.Error()), "ffmpeg not found") {
@@ -197,7 +247,7 @@ func TestStreamManifestWithFFmpegFailureRemovesPartialFile(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil { if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed") t.Skip("ffmpeg not installed")
} }
d := NewWithOptions(true, false) d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "out.m4a") out := filepath.Join(t.TempDir(), "out.m4a")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
+87 -2
View File
@@ -3,18 +3,103 @@ package netutil
import ( import (
"crypto/tls" "crypto/tls"
"net/http" "net/http"
"net/url"
"time" "time"
"streamrip-go/internal/verbose"
) )
func NewHTTPClient(timeout time.Duration, verifySSL bool) *http.Client { const defaultMaxConnsPerHost = 16
// NewHTTPClient builds an *http.Client whose transport is tuned for the
// concurrent download workloads this app issues against single CDN hosts.
//
// maxConnsPerHost caps idle keep-alive sockets per host; pass <= 0 to use a
// sensible default. The downloader and provider clients should pass the
// configured concurrency so keep-alive sockets aren't evicted between workers.
func NewHTTPClient(timeout time.Duration, verifySSL bool, maxConnsPerHost int) *http.Client {
if maxConnsPerHost <= 0 {
maxConnsPerHost = defaultMaxConnsPerHost
}
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
if transport.TLSClientConfig == nil { if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{} transport.TLSClientConfig = &tls.Config{}
} }
transport.TLSClientConfig.InsecureSkipVerify = !verifySSL transport.TLSClientConfig.InsecureSkipVerify = !verifySSL
transport.MaxIdleConnsPerHost = maxConnsPerHost
if maxIdle := maxConnsPerHost * 4; maxIdle > transport.MaxIdleConns {
transport.MaxIdleConns = maxIdle
}
if transport.MaxIdleConns < 100 {
transport.MaxIdleConns = 100
}
transport.MaxConnsPerHost = 0
transport.IdleConnTimeout = 90 * time.Second
transport.WriteBufferSize = 64 * 1024
transport.ReadBufferSize = 64 * 1024
transport.ForceAttemptHTTP2 = true
return &http.Client{ return &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: transport, Transport: &loggingTransport{base: transport},
} }
} }
// loggingTransport emits one verbose line per HTTP request when verbose
// level >= VV. The check is per-call so toggling the level at runtime
// affects subsequent requests without rebuilding clients.
type loggingTransport struct {
base http.RoundTripper
}
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !verbose.Enabled(verbose.VV) {
return t.base.RoundTrip(req)
}
start := time.Now()
resp, err := t.base.RoundTrip(req)
elapsed := time.Since(start).Round(time.Millisecond)
target := redactURL(req.URL)
if err != nil {
verbose.Printf(verbose.VV, "http %s %s -> error %v (%s)\n", req.Method, target, err, elapsed)
return resp, err
}
verbose.Printf(verbose.VV, "http %s %s -> %d (%s)\n", req.Method, target, resp.StatusCode, elapsed)
return resp, err
}
// redactURL hides values for query parameters that commonly carry
// credentials so -vv output is safe to paste in an issue.
func redactURL(u *url.URL) string {
if u == nil {
return ""
}
if u.RawQuery == "" {
return u.String()
}
q := u.Query()
redacted := false
for k := range q {
if isSensitiveParam(k) {
q.Set(k, "REDACTED")
redacted = true
}
}
if !redacted {
return u.String()
}
cp := *u
cp.RawQuery = q.Encode()
return cp.String()
}
func isSensitiveParam(name string) bool {
switch name {
case "user_auth_token", "api_token", "access_token", "refresh_token",
"request_sig", "signature", "password", "secret", "token", "code", "auth", "key":
return true
}
return false
}
+825
View File
@@ -0,0 +1,825 @@
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"]),
"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)
}
}
+104 -28
View File
@@ -62,7 +62,7 @@ type Client struct {
} }
func New(cfg *config.Config) *Client { func New(cfg *config.Config) *Client {
httpClient := netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL) httpClient := netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections)
if jar, err := cookiejar.New(nil); err == nil { if jar, err := cookiejar.New(nil); err == nil {
httpClient.Jar = jar httpClient.Jar = jar
} }
@@ -176,6 +176,9 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
if err != nil { if err != nil {
return nil, err return nil, err
} }
if tracks, pageErr := c.getCollectionPageItems(ctx, "/album/"+strings.TrimSpace(item)+"/tracks"); pageErr == nil {
resp["tracks"] = map[string]any{"data": tracks}
}
items := make([]any, 0) items := make([]any, 0)
if tracks, ok := resp["tracks"].(map[string]any); ok { if tracks, ok := resp["tracks"].(map[string]any); ok {
if data, ok := tracks["data"].([]any); ok { if data, ok := tracks["data"].([]any); ok {
@@ -197,6 +200,9 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
if err != nil { if err != nil {
return nil, err return nil, err
} }
if tracks, pageErr := c.getCollectionPageItems(ctx, "/playlist/"+strings.TrimSpace(item)+"/tracks"); pageErr == nil {
resp["tracks"] = map[string]any{"data": tracks}
}
items := make([]any, 0) items := make([]any, 0)
if tracks, ok := resp["tracks"].(map[string]any); ok { if tracks, ok := resp["tracks"].(map[string]any); ok {
if data, ok := tracks["data"].([]any); ok { if data, ok := tracks["data"].([]any); ok {
@@ -269,6 +275,35 @@ func (c *Client) getArtistAlbums(ctx context.Context, artistID string) (map[stri
return map[string]any{"data": all, "total": total}, nil return map[string]any{"data": all, "total": total}, nil
} }
func (c *Client) getCollectionPageItems(ctx context.Context, path string) ([]any, error) {
const pageSize = 100
index := 0
total := -1
all := make([]any, 0)
for {
params := url.Values{}
params.Set("limit", strconv.Itoa(pageSize))
params.Set("index", strconv.Itoa(index))
resp, err := c.apiGet(ctx, path, params)
if err != nil {
return nil, err
}
data, _ := resp["data"].([]any)
all = append(all, data...)
if total < 0 {
total = jsonutil.IntFromAny(resp["total"])
}
if len(data) < pageSize {
break
}
index += len(data)
if total > 0 && index >= total {
break
}
}
return all, nil
}
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) { func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
if strings.TrimSpace(c.license) == "" { if strings.TrimSpace(c.license) == "" {
if err := c.ensureLaunchSession(ctx); err != nil { if err := c.ensureLaunchSession(ctx); err != nil {
@@ -282,7 +317,7 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if err != nil { if err != nil {
return nil, err return nil, err
} }
trackToken, err := c.getTrackToken(ctx, item) trackToken, mediaTrackID, err := c.getTrackToken(ctx, item)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -294,7 +329,13 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if ext == "" { if ext == "" {
ext = "mp3" ext = "mp3"
} }
trackID := strings.TrimSpace(jsonutil.StringFromAny(meta["id"])) trackID := strings.TrimSpace(media.TrackID)
if trackID == "" {
trackID = strings.TrimSpace(mediaTrackID)
}
if trackID == "" {
trackID = strings.TrimSpace(jsonutil.StringFromAny(meta["id"]))
}
if trackID == "" { if trackID == "" {
trackID = strings.TrimSpace(item) trackID = strings.TrimSpace(item)
} }
@@ -549,32 +590,33 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
return nil return nil
} }
func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, error) { func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, string, error) {
if token, err := c.getTrackTokenFromPipe(ctx, trackID); err == nil && strings.TrimSpace(token) != "" { if token, mediaID, err := c.getTrackTokenFromPipe(ctx, trackID); err == nil && strings.TrimSpace(token) != "" {
return token, nil return token, mediaID, nil
} else if errors.Is(err, errDeezerJWTExpired) { } else if errors.Is(err, errDeezerJWTExpired) {
c.refreshJWTFromAvailableState(ctx) c.refreshJWTFromAvailableState(ctx)
if token, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(token) != "" { if token, mediaID, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(token) != "" {
return token, nil return token, mediaID, nil
} }
} }
if err := c.ensureJWT(ctx, "deezer jwt unavailable for track media token"); err == nil { if err := c.ensureJWT(ctx, "deezer jwt unavailable for track media token"); err == nil {
if token, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(token) != "" { if token, mediaID, retryErr := c.getTrackTokenFromPipe(ctx, trackID); retryErr == nil && strings.TrimSpace(token) != "" {
return token, nil return token, mediaID, nil
} }
} }
resp, err := c.apiGet(ctx, "/track/"+url.PathEscape(strings.TrimSpace(trackID)), nil) resp, err := c.apiGet(ctx, "/track/"+url.PathEscape(strings.TrimSpace(trackID)), nil)
if err != nil { if err != nil {
return "", err return "", "", err
} }
token := strings.TrimSpace(jsonutil.StringFromAny(resp["track_token"])) token := strings.TrimSpace(jsonutil.StringFromAny(resp["track_token"]))
if token == "" { if token == "" {
return "", errors.New("deezer track metadata missing track_token") return "", "", errors.New("deezer track metadata missing track_token")
} }
return token, nil mediaID := strings.TrimSpace(jsonutil.StringFromAny(resp["id"]))
return token, mediaID, nil
} }
func (c *Client) getTrackTokenFromPipe(ctx context.Context, trackID string) (string, error) { func (c *Client) getTrackTokenFromPipe(ctx context.Context, trackID string) (string, string, error) {
query := `query KmpMpTrackMedia($trackId: String!) { track(trackId: $trackId) { media { __typename ...TrackMediaFields } } } fragment TrackMediaFields on TrackMedia { id version token { payload expiresAt version } estimatedSizes { flac: FLAC mp3_320: MP3_320 mp3_128: MP3_128 mp3_misc: MP3_MISC opus_std: OPUS_STD opus_high: OPUS_HIGH sbc_256: SBC_256 aac_96: AAC_96 aac_64: AAC_64 ac4_ims: AC4_IMS dd_joc: DD_JOC mp4_ra1: MP4_RA1 mp4_ra2: MP4_RA2 mp4_ra3: MP4_RA3 } gain rights { sub { available } ads { available } } }` query := `query KmpMpTrackMedia($trackId: String!) { track(trackId: $trackId) { media { __typename ...TrackMediaFields } } } fragment TrackMediaFields on TrackMedia { id version token { payload expiresAt version } estimatedSizes { flac: FLAC mp3_320: MP3_320 mp3_128: MP3_128 mp3_misc: MP3_MISC opus_std: OPUS_STD opus_high: OPUS_HIGH sbc_256: SBC_256 aac_96: AAC_96 aac_64: AAC_64 ac4_ims: AC4_IMS dd_joc: DD_JOC mp4_ra1: MP4_RA1 mp4_ra2: MP4_RA2 mp4_ra3: MP4_RA3 } gain rights { sub { available } ads { available } } }`
body := map[string]any{ body := map[string]any{
"operationName": "KmpMpTrackMedia", "operationName": "KmpMpTrackMedia",
@@ -589,13 +631,15 @@ func (c *Client) getTrackTokenFromPipe(ctx context.Context, trackID string) (str
} }
out, err := c.pipeGraphQL(ctx, body, "deezer track media query") out, err := c.pipeGraphQL(ctx, body, "deezer track media query")
if err != nil { if err != nil {
return "", err return "", "", err
} }
payload := strings.TrimSpace(jsonutil.StringFromAny(jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "media"), "token")["payload"])) media := jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "media")
payload := strings.TrimSpace(jsonutil.StringFromAny(jsonutil.NestedMap(media, "token")["payload"]))
if payload == "" { if payload == "" {
return "", errors.New("deezer track media response missing token payload") return "", "", errors.New("deezer track media response missing token payload")
} }
return payload, nil mediaID := strings.TrimSpace(jsonutil.StringFromAny(media["id"]))
return payload, mediaID, nil
} }
type lyricsResult struct { type lyricsResult struct {
@@ -1145,9 +1189,10 @@ func randomDeezerUA() string {
} }
type mediaResult struct { type mediaResult struct {
URL string URL string
Format string Format string
Cipher string Cipher string
TrackID string
} }
type deezerMediaError struct { type deezerMediaError struct {
@@ -1270,19 +1315,50 @@ func (c *Client) getMediaURLWithRequest(ctx context.Context, trackToken string,
return nil, &deezerMediaError{Code: e.Code, Message: e.Message} return nil, &deezerMediaError{Code: e.Code, Message: e.Message}
} }
for _, want := range requestedFormats { for _, want := range requestedFormats {
for _, m := range parsed.Data[0].Media { for _, preferredCipher := range []string{"NONE", "BF_CBC_STRIPE"} {
if !strings.EqualFold(strings.TrimSpace(m.Format), want) { for _, m := range parsed.Data[0].Media {
continue if !strings.EqualFold(strings.TrimSpace(m.Format), want) {
continue
}
if !strings.EqualFold(strings.TrimSpace(m.Cipher.Type), preferredCipher) {
continue
}
if len(m.Sources) == 0 || strings.TrimSpace(m.Sources[0].URL) == "" {
continue
}
sourceURL := strings.TrimSpace(m.Sources[0].URL)
return &mediaResult{URL: sourceURL, Format: m.Format, Cipher: m.Cipher.Type, TrackID: extractTrackIDFromMediaURL(sourceURL)}, nil
} }
if len(m.Sources) == 0 || strings.TrimSpace(m.Sources[0].URL) == "" {
continue
}
return &mediaResult{URL: m.Sources[0].URL, Format: m.Format, Cipher: m.Cipher.Type}, nil
} }
} }
return nil, errors.New("deezer media response contains no sources") return nil, errors.New("deezer media response contains no sources")
} }
func extractTrackIDFromMediaURL(rawURL string) string {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
parts := strings.Split(strings.TrimSpace(strings.Trim(u.Path, "/")), "/")
for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i])
if p == "" {
continue
}
digitsOnly := true
for _, r := range p {
if r < '0' || r > '9' {
digitsOnly = false
break
}
}
if digitsOnly {
return p
}
}
return ""
}
func buildFormatPriority(quality int, allowFallback bool) []string { func buildFormatPriority(quality int, allowFallback bool) []string {
want := "FLAC" want := "FLAC"
if quality <= 0 { if quality <= 0 {
+168 -1
View File
@@ -99,6 +99,118 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
} }
} }
func TestGetMetadataAlbumPaginatesTracks(t *testing.T) {
callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/album/46514392":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 46514392, "title": "Clouseau30", "tracks": map[string]any{"data": []any{}}})
case "/album/46514392/tracks":
callCount++
index := r.URL.Query().Get("index")
limit := r.URL.Query().Get("limit")
if limit != "100" {
w.WriteHeader(http.StatusBadRequest)
return
}
switch index {
case "0":
items := make([]any, 0, 100)
for i := 0; i < 100; i++ {
items = append(items, map[string]any{"id": i + 1, "title": "T"})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 105})
case "100":
items := make([]any, 0, 5)
for i := 0; i < 5; i++ {
items = append(items, map[string]any{"id": 101 + i, "title": "T"})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 105})
default:
w.WriteHeader(http.StatusBadRequest)
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
origBase := baseURL
baseURL = ts.URL
defer func() { baseURL = origBase }()
meta, err := c.GetMetadata(context.Background(), "46514392", "album")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
tracksObj, _ := meta["tracks"].(map[string]any)
items, _ := tracksObj["items"].([]any)
if len(items) != 105 {
t.Fatalf("tracks len = %d, want 105", len(items))
}
if callCount != 2 {
t.Fatalf("track page call count = %d, want 2", callCount)
}
}
func TestGetMetadataPlaylistPaginatesTracks(t *testing.T) {
callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/playlist/123":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 123, "title": "Mix", "tracks": map[string]any{"data": []any{}}})
case "/playlist/123/tracks":
callCount++
index := r.URL.Query().Get("index")
limit := r.URL.Query().Get("limit")
if limit != "100" {
w.WriteHeader(http.StatusBadRequest)
return
}
switch index {
case "0":
items := make([]any, 0, 100)
for i := 0; i < 100; i++ {
items = append(items, map[string]any{"id": i + 1, "title": "T"})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 101})
case "100":
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 101, "title": "T"}}, "total": 101})
default:
w.WriteHeader(http.StatusBadRequest)
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
origBase := baseURL
baseURL = ts.URL
defer func() { baseURL = origBase }()
meta, err := c.GetMetadata(context.Background(), "123", "playlist")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
tracksObj, _ := meta["tracks"].(map[string]any)
items, _ := tracksObj["items"].([]any)
if len(items) != 101 {
t.Fatalf("tracks len = %d, want 101", len(items))
}
if callCount != 2 {
t.Fatalf("track page call count = %d, want 2", callCount)
}
}
func TestGetDownloadableNativeCipher(t *testing.T) { func TestGetDownloadableNativeCipher(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) {
switch r.URL.Path { switch r.URL.Path {
@@ -161,6 +273,58 @@ func TestGetDownloadableNativeCipher(t *testing.T) {
} }
} }
func TestGetDownloadablePrefersNoneCipherWhenAvailable(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/track/42":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "X", "track_token": "tt"})
case "/media":
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"errors": []any{}, "media": []any{
map[string]any{"cipher": map[string]any{"type": "BF_CBC_STRIPE"}, "format": "FLAC", "sources": []any{map[string]any{"url": "https://cdn.example/bf"}}},
map[string]any{"cipher": map[string]any{"type": "NONE"}, "format": "FLAC", "sources": []any{map[string]any{"url": "https://cdn.example/plain"}}},
}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.arl = "arl"
c.license = "license"
c.jwt = "jwt"
origBase := baseURL
origMedia := mediaURL
origPipe := pipeURL
baseURL = ts.URL
mediaURL = ts.URL + "/media"
pipeURL = ts.URL + "/pipe"
defer func() {
baseURL = origBase
mediaURL = origMedia
pipeURL = origPipe
}()
d, err := c.GetDownloadable(context.Background(), "42", 2)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if d.Cipher != "NONE" || d.URL != "https://cdn.example/plain" {
t.Fatalf("expected NONE cipher source, got %+v", d)
}
}
func TestExtractTrackIDFromMediaURL(t *testing.T) {
url := "https://f-cdnt-stream.dzcdn.net/media/1/9/6/4/8/2552667002/64821d6a2007e90768fa0300b508fcf4.flac?hdnea=x"
if got := extractTrackIDFromMediaURL(url); got != "2552667002" {
t.Fatalf("extractTrackIDFromMediaURL() = %q, want 2552667002", got)
}
}
func TestLoginPrefersARLFlowOverRefreshShortcut(t *testing.T) { func TestLoginPrefersARLFlowOverRefreshShortcut(t *testing.T) {
mobileToken := testMobileToken(t) mobileToken := testMobileToken(t)
refreshCalled := false refreshCalled := false
@@ -298,13 +462,16 @@ func TestGetTrackTokenPrefersPipeToken(t *testing.T) {
pipeURL = origPipe pipeURL = origPipe
}() }()
token, err := c.getTrackToken(context.Background(), "42") token, mediaID, err := c.getTrackToken(context.Background(), "42")
if err != nil { if err != nil {
t.Fatalf("getTrackToken() error = %v", err) t.Fatalf("getTrackToken() error = %v", err)
} }
if token != "pipe-track-token" { if token != "pipe-track-token" {
t.Fatalf("token = %q, want pipe-track-token", token) t.Fatalf("token = %q, want pipe-track-token", token)
} }
if mediaID != "" {
t.Fatalf("mediaID = %q, want empty when pipe media id missing", mediaID)
}
} }
func TestGetDownloadableUsesPipeTrackToken(t *testing.T) { func TestGetDownloadableUsesPipeTrackToken(t *testing.T) {
+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
} }
+617 -1
View File
@@ -1,20 +1,28 @@
package qobuz package qobuz
import ( import (
"bytes"
"context" "context"
"crypto/aes"
"crypto/cipher"
"crypto/md5" "crypto/md5"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"hash"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"streamrip-go/internal/config" "streamrip-go/internal/config"
@@ -22,10 +30,28 @@ import (
"streamrip-go/internal/netutil" "streamrip-go/internal/netutil"
"streamrip-go/internal/provider" "streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit" "streamrip-go/internal/ratelimit"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
"golang.org/x/crypto/hkdf"
) )
const baseURL = "https://www.qobuz.com/api.json/0.2" const baseURL = "https://www.qobuz.com/api.json/0.2"
const (
mobileAppID = "312369995"
mobileAppSecret = "e79f8b9be485692b0e5f9dd895826368"
mobileUserAgent = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717"
mobileAppVersion = "9.7.0.3"
mobileSessionProf = "qbz-1"
mobileSegmentTries = 3
)
var qobuzUUIDBytes = []byte{
0x3b, 0x42, 0x12, 0x92, 0x56, 0xf3, 0x5f, 0x75,
0x92, 0x36, 0x63, 0xb6, 0x9a, 0x1f, 0x52, 0xb2,
}
var ( var (
errMissingCredentials = errors.New("missing qobuz credentials") errMissingCredentials = errors.New("missing qobuz credentials")
errNotLoggedIn = errors.New("qobuz client not logged in") errNotLoggedIn = errors.New("qobuz client not logged in")
@@ -40,12 +66,29 @@ type Client struct {
loggedIn bool loggedIn bool
secret string secret string
uat string uat string
mobileMu sync.Mutex
mobileAccessToken string
mobileSessionID string
mobileSessionInfo string
mobileKEK []byte
}
type mobileFileURL struct {
URL string `json:"url"`
URLTemplate string `json:"url_template"`
NSegments int `json:"n_segments"`
FormatID int `json:"format_id"`
MimeType string `json:"mime_type"`
Sampling float64 `json:"sampling_rate"`
BitDepth int `json:"bits_depth"`
Key string `json:"key"`
} }
func New(cfg *config.Config) *Client { func New(cfg *config.Config) *Client {
return &Client{ return &Client{
cfg: cfg, cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL, baseURL: baseURL,
fetchCfg: nil, fetchCfg: nil,
@@ -399,6 +442,122 @@ func (c *Client) Close() error {
return nil return nil
} }
func (c *Client) DownloadTrackFallback(ctx context.Context, trackID string, quality int, outputPath string) error {
q := &c.cfg.Session.Qobuz
if strings.TrimSpace(q.EmailOrUserID) == "" || strings.TrimSpace(q.PasswordOrToken) == "" || q.UseAuthToken {
return errors.New("qobuz mobile fallback requires email/password credentials")
}
if quality < 1 || quality > 4 {
quality = q.Quality
}
formatID := qualityMap(quality)
if err := c.ensureMobileSession(ctx); err != nil {
return err
}
fileURL, err := c.mobileGetFileURL(ctx, trackID, formatID)
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
success := false
defer func() {
_ = out.Close()
if !success {
_ = os.Remove(outputPath)
}
}()
progress := mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr))
defer progress.Wait()
desc := shortenName(filepath.Base(outputPath), 54)
bar := 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 func() {
if !success {
bar.Abort(true)
}
bar.SetTotal(-1, true)
}()
if strings.TrimSpace(fileURL.URL) != "" && fileURL.NSegments == 0 {
err = c.mobileCopyURLToWriter(ctx, strings.TrimSpace(fileURL.URL), out, bar)
if err != nil {
return err
}
success = true
return nil
}
if strings.TrimSpace(fileURL.URLTemplate) == "" {
return errors.New("qobuz mobile fallback: no download URL available")
}
var trackKey []byte
if strings.TrimSpace(fileURL.Key) != "" {
trackKey, err = c.mobileDeriveTrackKey(fileURL.Key)
if err != nil {
return fmt.Errorf("derive mobile track key: %w", err)
}
}
nSegs := fileURL.NSegments
if nSegs == 0 {
nSegs = 1
}
initURL := strings.Replace(fileURL.URLTemplate, "$SEGMENT$", "0", 1)
initData, err := c.mobileDownloadSegment(ctx, initURL)
if err != nil {
return fmt.Errorf("download init segment: %w", err)
}
if hdr := extractFLACHeader(initData); hdr != nil {
if _, err = out.Write(hdr); err != nil {
return err
}
bar.IncrBy(len(hdr))
} else {
if _, err = out.Write(initData); err != nil {
return err
}
bar.IncrBy(len(initData))
}
for seg := 1; seg <= nSegs; seg++ {
segURL := strings.Replace(fileURL.URLTemplate, "$SEGMENT$", strconv.Itoa(seg), 1)
data, dlErr := c.mobileDownloadSegment(ctx, segURL)
if dlErr != nil {
return fmt.Errorf("download segment %d: %w", seg, dlErr)
}
frames := extractFrames(data, trackKey)
if _, err = out.Write(frames); err != nil {
return err
}
bar.IncrBy(len(frames))
}
if err = out.Sync(); err != nil {
return err
}
success = true
return nil
}
func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string]any, error) { func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string]any, error) {
pageLimit := 500 pageLimit := 500
params := url.Values{} params := url.Values{}
@@ -820,3 +979,460 @@ func sortedKeys(m map[string][]string) []string {
sort.Strings(keys) sort.Strings(keys)
return keys return keys
} }
func (c *Client) ensureMobileSession(ctx context.Context) error {
c.mobileMu.Lock()
defer c.mobileMu.Unlock()
if c.mobileAccessToken != "" && c.mobileSessionID != "" && c.mobileSessionInfo != "" {
return nil
}
q := &c.cfg.Session.Qobuz
if err := c.mobileLogin(ctx, q.EmailOrUserID, q.PasswordOrToken); err != nil {
return err
}
if err := c.mobileStartSession(ctx); err != nil {
c.mobileAccessToken = ""
return err
}
return nil
}
func (c *Client) mobileLogin(ctx context.Context, username, password string) error {
ts := time.Now().Unix()
params := url.Values{}
params.Set("app_id", mobileAppID)
params.Set("username", username)
params.Set("password", password)
params.Set("request_ts", strconv.FormatInt(ts, 10))
params.Set("request_sig", mobileSignRequest("oauth2/login", []kv{{"password", password}, {"username", username}}, ts))
reqURL := baseURL + "/oauth2/login?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return err
}
c.setMobileHeaders(req)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("mobile login request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("mobile login failed: status=%d body=%s", resp.StatusCode, string(body))
}
var parsed struct {
OAuth2 struct {
AccessToken string `json:"access_token"`
} `json:"oauth2"`
}
if err = json.Unmarshal(body, &parsed); err != nil {
return fmt.Errorf("mobile login parse failed: %w", err)
}
if strings.TrimSpace(parsed.OAuth2.AccessToken) == "" {
return errors.New("mobile login returned empty token")
}
c.mobileAccessToken = strings.TrimSpace(parsed.OAuth2.AccessToken)
return nil
}
func (c *Client) mobileStartSession(ctx context.Context) error {
ts := time.Now().Unix()
params := url.Values{}
params.Set("app_id", mobileAppID)
params.Set("request_ts", strconv.FormatInt(ts, 10))
params.Set("request_sig", mobileSignRequest("session/start", []kv{{"profile", mobileSessionProf}}, ts))
reqURL := baseURL + "/session/start?" + params.Encode()
form := url.Values{}
form.Set("profile", mobileSessionProf)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c.setMobileHeaders(req)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("mobile session request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("mobile session start failed: status=%d body=%s", resp.StatusCode, string(body))
}
var parsed struct {
SessionID string `json:"session_id"`
Infos string `json:"infos"`
}
if err = json.Unmarshal(body, &parsed); err != nil {
return fmt.Errorf("mobile session parse failed: %w", err)
}
if strings.TrimSpace(parsed.SessionID) == "" || strings.TrimSpace(parsed.Infos) == "" {
return errors.New("mobile session start returned incomplete session data")
}
c.mobileSessionID = strings.TrimSpace(parsed.SessionID)
c.mobileSessionInfo = strings.TrimSpace(parsed.Infos)
c.mobileKEK = nil
return nil
}
func (c *Client) mobileGetFileURL(ctx context.Context, trackID string, formatID int) (*mobileFileURL, error) {
ts := time.Now().Unix()
params := url.Values{}
params.Set("app_id", mobileAppID)
params.Set("track_id", trackID)
params.Set("format_id", strconv.Itoa(formatID))
params.Set("intent", "stream")
params.Set("request_ts", strconv.FormatInt(ts, 10))
params.Set("request_sig", mobileSignRequest("file/url", []kv{{"format_id", strconv.Itoa(formatID)}, {"intent", "stream"}, {"track_id", trackID}}, ts))
reqURL := baseURL + "/file/url?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
c.setMobileHeaders(req)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("mobile file url failed: status=%d body=%s", resp.StatusCode, string(body))
}
var parsed mobileFileURL
if err = json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("mobile file url parse failed: %w", err)
}
return &parsed, nil
}
func (c *Client) mobileCopyURLToWriter(ctx context.Context, sourceURL string, out io.Writer, bar *mpb.Bar) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return err
}
c.setMobileHeaders(req)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("mobile fallback download failed: status=%d", resp.StatusCode)
}
written, err := io.Copy(out, &countingReader{r: resp.Body, onRead: func(n int) {
if bar != nil && n > 0 {
bar.IncrBy(n)
}
}})
if err != nil {
return err
}
if resp.ContentLength > 0 && written != resp.ContentLength {
return io.ErrUnexpectedEOF
}
return nil
}
type countingReader struct {
r io.Reader
onRead func(int)
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
if n > 0 && c.onRead != nil {
c.onRead(n)
}
return n, err
}
func shortenName(name string, max int) string {
if max <= 0 {
return name
}
r := []rune(name)
if len(r) <= max {
return name
}
if max <= 3 {
return string(r[:max])
}
return string(r[:max-3]) + "..."
}
func (c *Client) mobileDownloadSegment(ctx context.Context, sourceURL string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < mobileSegmentTries; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return nil, err
}
c.setMobileHeaders(req)
resp, err := c.http.Do(req)
if err != nil {
lastErr = err
time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond)
continue
}
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("status=%d", resp.StatusCode)
_ = resp.Body.Close()
time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond)
continue
}
data, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
lastErr = readErr
time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond)
continue
}
return data, nil
}
if lastErr == nil {
lastErr = errors.New("unknown segment error")
}
return nil, fmt.Errorf("mobile segment download failed after retries: %w", lastErr)
}
func (c *Client) setMobileHeaders(req *http.Request) {
req.Header.Set("User-Agent", mobileUserAgent)
req.Header.Set("X-App-Id", mobileAppID)
req.Header.Set("X-App-Version", mobileAppVersion)
req.Header.Set("X-Device-Platform", "android")
req.Header.Set("X-Device-Model", "Nexus 6P")
req.Header.Set("X-Device-Os-Version", "9")
if c.mobileAccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.mobileAccessToken)
}
if c.mobileSessionID != "" {
req.Header.Set("X-Session-Id", c.mobileSessionID)
}
}
func mobileSignRequest(endpoint string, params []kv, ts int64) string {
method := strings.ReplaceAll(endpoint, "/", "")
sortKVs(params)
var sb strings.Builder
sb.WriteString(method)
for _, p := range params {
sb.WriteString(p.Key)
sb.WriteString(p.Value)
}
sb.WriteString(strconv.FormatInt(ts, 10))
sb.WriteString(mobileAppSecret)
h := md5.Sum([]byte(sb.String()))
return hex.EncodeToString(h[:])
}
type kv struct {
Key string
Value string
}
func sortKVs(s []kv) {
for i := 0; i < len(s); i++ {
for j := i + 1; j < len(s); j++ {
if s[j].Key < s[i].Key {
s[i], s[j] = s[j], s[i]
}
}
}
}
func (c *Client) mobileDeriveTrackKey(encryptedKey string) ([]byte, error) {
if len(c.mobileKEK) == 16 {
return unwrapQobuzTrackKey(encryptedKey, c.mobileKEK)
}
parts := strings.SplitN(c.mobileSessionInfo, ".", 2)
if len(parts) != 2 {
return nil, errors.New("invalid mobile session infos format")
}
salt, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("decode mobile salt: %w", err)
}
info, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("decode mobile info: %w", err)
}
reader := hkdf.New(func() hash.Hash { return sha256.New() }, hexDecodeOrNil(mobileAppSecret), salt, info)
kek := make([]byte, 16)
if _, err = io.ReadFull(reader, kek); err != nil {
return nil, fmt.Errorf("mobile hkdf derive failed: %w", err)
}
c.mobileKEK = kek
return unwrapQobuzTrackKey(encryptedKey, kek)
}
func hexDecodeOrNil(s string) []byte {
b, _ := hex.DecodeString(s)
return b
}
func unwrapQobuzTrackKey(encryptedKey string, kek []byte) ([]byte, error) {
parts := strings.SplitN(encryptedKey, ".", 3)
if len(parts) != 3 || parts[0] != mobileSessionProf {
return nil, errors.New("invalid qobuz track key format")
}
encKey, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("decode encrypted key failed: %w", err)
}
iv, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("decode key iv failed: %w", err)
}
block, err := aes.NewCipher(kek)
if err != nil {
return nil, err
}
decrypted := make([]byte, len(encKey))
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(decrypted, encKey)
if len(decrypted) < 16 {
return nil, errors.New("decrypted key too short")
}
return decrypted[:16], nil
}
func extractFLACHeader(data []byte) []byte {
blocks := findDFLABlocks(data)
if blocks == nil {
return nil
}
out := make([]byte, 4+len(blocks))
copy(out, "fLaC")
copy(out[4:], blocks)
return out
}
func findDFLABlocks(data []byte) []byte {
pos := 0
for pos+8 <= len(data) {
size := int(uint32(data[pos])<<24 | uint32(data[pos+1])<<16 | uint32(data[pos+2])<<8 | uint32(data[pos+3]))
if size < 8 || pos+size > len(data) {
break
}
t := data[pos+4 : pos+8]
if string(t) == "dfLa" {
body := data[pos+8 : pos+size]
if len(body) > 4 {
return body[4:]
}
}
var inner []byte
switch string(t) {
case "moov", "trak", "mdia", "minf", "stbl":
inner = data[pos+8 : pos+size]
case "stsd":
if pos+16 <= pos+size {
inner = data[pos+16 : pos+size]
}
case "fLaC":
if pos+36 <= pos+size {
inner = data[pos+36 : pos+size]
}
}
if inner != nil {
if result := findDFLABlocks(inner); result != nil {
return result
}
}
pos += size
}
return nil
}
func extractFrames(data []byte, key []byte) []byte {
var frames []byte
pos := 0
for pos+8 <= len(data) {
boxSize := int(uint32(data[pos])<<24 | uint32(data[pos+1])<<16 | uint32(data[pos+2])<<8 | uint32(data[pos+3]))
if boxSize < 8 || pos+boxSize > len(data) {
break
}
if string(data[pos+4:pos+8]) == "uuid" && boxSize >= 36 {
if pos+24 > len(data) {
pos += boxSize
continue
}
if bytes.Equal(data[pos+8:pos+24], qobuzUUIDBytes) {
f := parseUUIDBox(data, pos, boxSize, key)
frames = append(frames, f...)
}
}
pos += boxSize
}
return frames
}
func parseUUIDBox(data []byte, boxStart, boxSize int, key []byte) []byte {
bodyOff := boxStart + 24
if bodyOff+12 > len(data) {
return nil
}
rawOffset := readU32BE(data, bodyOff+4)
numSamples := int(readU24BE(data, bodyOff+9))
if numSamples == 0 || numSamples > 10000 {
return nil
}
tableOff := bodyOff + 12
sampleDataOff := boxStart + int(rawOffset)
var frames []byte
offset := sampleDataOff
for i := 0; i < numSamples; i++ {
et := tableOff + i*16
if et+16 > len(data) || offset >= len(data) {
break
}
size := readU32BE(data, et)
encFlag := data[et+6] != 0 || data[et+7] != 0
end := offset + int(size)
if end > len(data) {
break
}
if encFlag && len(key) == 16 {
iv := make([]byte, 16)
copy(iv[:8], data[et+8:et+16])
block, err := aes.NewCipher(key)
if err != nil {
return frames
}
stream := cipher.NewCTR(block, iv)
decrypted := make([]byte, end-offset)
stream.XORKeyStream(decrypted, data[offset:end])
frames = append(frames, decrypted...)
} else {
frames = append(frames, data[offset:end]...)
}
offset = end
}
return frames
}
func readU32BE(data []byte, off int) uint32 {
if off+4 > len(data) {
return 0
}
return uint32(data[off])<<24 | uint32(data[off+1])<<16 | uint32(data[off+2])<<8 | uint32(data[off+3])
}
func readU24BE(data []byte, off int) uint32 {
if off+3 > len(data) {
return 0
}
return uint32(data[off])<<16 | uint32(data[off+1])<<8 | uint32(data[off+2])
}
+194 -78
View File
@@ -22,11 +22,13 @@ import (
) )
const ( const (
baseURL = "https://api.tidalhifi.com/v1" baseURL = "https://api.tidalhifi.com/v1"
openAPIV2 = "https://openapi.tidal.com/v2" lyricsAPIv1 = "https://api.tidal.com/v1"
authURL = "https://auth.tidal.com/v1/oauth2" openAPIV2 = "https://openapi.tidal.com/v2"
clientID = "fX2JxdmntZWK0ixT" authURL = "https://auth.tidal.com/v1/oauth2"
clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=" clientID = "fX2JxdmntZWK0ixT"
clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="
tidalRequestAttempts = 3
) )
var qualityMap = map[int]string{ var qualityMap = map[int]string{
@@ -50,21 +52,23 @@ var atmosAudioQualities = []string{"HI_RES_LOSSLESS", "HI_RES", "LOSSLESS", "HIG
var ErrMissingTidalToken = errors.New("missing tidal access_token") var ErrMissingTidalToken = errors.New("missing tidal access_token")
type Client struct { type Client struct {
cfg *config.Config cfg *config.Config
http *http.Client http *http.Client
limiter *ratelimit.Limiter limiter *ratelimit.Limiter
baseURL string baseURL string
openAPI string lyricsAPI string
loggedIn bool openAPI string
loggedIn bool
} }
func New(cfg *config.Config) *Client { func New(cfg *config.Config) *Client {
return &Client{ return &Client{
cfg: cfg, cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL, baseURL: baseURL,
openAPI: openAPIV2, lyricsAPI: lyricsAPIv1,
openAPI: openAPIV2,
} }
} }
@@ -206,11 +210,52 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
if album, ok := resp["album"].(map[string]any); ok { if album, ok := resp["album"].(map[string]any); ok {
enrichTidalImage(album) enrichTidalImage(album)
} }
// Lyrics live on a separate endpoint, so fetching them costs an extra
// rate-limited roundtrip per track. Users who don't embed lyrics can
// opt out via metadata.exclude = ["lyrics"].
if !c.lyricsExcluded() {
if lyrics, lrc := c.fetchTrackLyrics(ctx, item); lyrics != "" || lrc != "" {
if lyrics != "" {
resp["lyrics"] = lyrics
}
if lrc != "" {
resp["lyrics_synced"] = lrc
}
}
}
} }
return resp, nil return resp, nil
} }
func (c *Client) lyricsExcluded() bool {
for _, k := range c.cfg.Session.Metadata.Exclude {
if strings.EqualFold(strings.TrimSpace(k), "lyrics") {
return true
}
}
return false
}
func (c *Client) fetchTrackLyrics(ctx context.Context, trackID string) (string, string) {
params := url.Values{}
params.Set("deviceType", "PHONE")
params.Set("locale", "en_US")
params.Set("platform", "ANDROID")
resp, status, err := c.apiRequest(ctx, "tracks/"+url.PathEscape(strings.TrimSpace(trackID))+"/lyrics", params, c.lyricsAPI)
if err != nil {
return "", ""
}
if status != http.StatusOK {
return "", ""
}
lyrics := strings.TrimSpace(stringify(resp["lyrics"]))
lrc := strings.TrimSpace(stringify(resp["subtitles"]))
return lyrics, lrc
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) { func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.loggedIn { if !c.loggedIn {
return nil, errors.New("tidal client not logged in") return nil, errors.New("tidal client not logged in")
@@ -247,10 +292,11 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in
} }
if c.cfg.Session.Tidal.PreferAtmos { if c.cfg.Session.Tidal.PreferAtmos {
if c.trackSupportsAtmos(ctx, trackID) { // No tracks/{id} pre-check: getAtmosDownloadable already validates
if d, _ := c.getAtmosDownloadable(ctx, trackID); d != nil { // each candidate response via playbackLooksAtmos and falls back
return d, nil // through the format-specific trackManifests paths.
} if d, _ := c.getAtmosDownloadable(ctx, trackID); d != nil {
return d, nil
} }
} }
@@ -265,6 +311,10 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in
} }
if status == http.StatusOK { if status == http.StatusOK {
if d := downloadableFromPlaybackManifest(resp); d != nil { if d := downloadableFromPlaybackManifest(resp); d != nil {
// Tidal's playbackinfo sometimes returns an m4a (HIGH/AAC)
// stream even when LOSSLESS+ was requested. There is no
// lossless m4a tier, so retry via the openAPI v2 manifest
// before settling for the downgrade.
if quality >= 2 && d.Extension == "m4a" { if quality >= 2 && d.Extension == "m4a" {
if strict, strictErr := c.getDownloadableFromTrackManifest(ctx, trackID, quality); strictErr == nil && strict != nil { if strict, strictErr := c.getDownloadableFromTrackManifest(ctx, trackID, quality); strictErr == nil && strict != nil {
return strict, nil return strict, nil
@@ -794,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 {
if err := c.limiter.Wait(ctx); err != nil { return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
return nil, 0, err }
}
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 {
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{}
} }
@@ -814,65 +964,31 @@ func (c *Client) apiRequest(ctx context.Context, path string, params url.Values,
reqURL += "?" + params.Encode() reqURL += "?" + params.Encode()
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) return c.doJSONWithRetry(ctx, func() (*http.Request, error) {
if err != nil { req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
return nil, 0, err if err != nil {
} return nil, err
req.Header.Set("Authorization", "Bearer "+c.cfg.Session.Tidal.AccessToken)
req.Header.Set("User-Agent", "streamrip-go/0.1")
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
} }
} req.Header.Set("Authorization", "Bearer "+c.cfg.Session.Tidal.AccessToken)
req.Header.Set("User-Agent", "streamrip-go/0.1")
return parsed, resp.StatusCode, nil return req, 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()))
} if err != nil {
return nil, err
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode()))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "streamrip-go/0.1")
if basicAuth {
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec))
req.Header.Set("Authorization", "Basic "+auth)
}
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
} }
} req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return out, resp.StatusCode, nil req.Header.Set("User-Agent", "streamrip-go/0.1")
if basicAuth {
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec))
req.Header.Set("Authorization", "Basic "+auth)
}
return req, nil
})
} }
func stringify(v any) string { func stringify(v any) string {
+154
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"
@@ -162,6 +163,159 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
} }
} }
func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/tracks/42":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "Song", "album": map[string]any{"id": 10, "title": "Album"}})
case "/v1/tracks/42/lyrics":
q := r.URL.Query()
if q.Get("deviceType") != "PHONE" || q.Get("locale") != "en_US" || q.Get("platform") != "ANDROID" || q.Get("countryCode") != "MY" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"error": "bad query"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"lyrics": "plain lyrics line",
"subtitles": "[00:00.00]plain lyrics line",
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "MY"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.baseURL = ts.URL + "/v1"
c.lyricsAPI = ts.URL + "/v1"
meta, err := c.GetMetadata(context.Background(), "42", "track")
if err != nil {
t.Fatalf("GetMetadata() err = %v", err)
}
if got := stringify(meta["lyrics"]); got != "plain lyrics line" {
t.Fatalf("lyrics = %q, want plain lyrics line", got)
}
if got := stringify(meta["lyrics_synced"]); got != "[00:00.00]plain lyrics line" {
t.Fatalf("lyrics_synced = %q, want synced lrc", got)
}
}
func TestGetMetadataTrackIgnoresLyricsEndpointFailure(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/tracks/42":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "Song"})
case "/v1/tracks/42/lyrics":
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{"error": "not found"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.baseURL = ts.URL + "/v1"
c.lyricsAPI = ts.URL + "/v1"
meta, err := c.GetMetadata(context.Background(), "42", "track")
if err != nil {
t.Fatalf("GetMetadata() err = %v", err)
}
if _, ok := meta["lyrics"]; ok {
t.Fatalf("did not expect lyrics when endpoint fails")
}
if _, ok := meta["lyrics_synced"]; ok {
t.Fatalf("did not expect lyrics_synced when endpoint fails")
}
}
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",
+68
View File
@@ -0,0 +1,68 @@
// Package verbose provides a process-wide verbosity level and a pluggable
// log sink so verbose output integrates with the downloader's progress bars.
//
// Level meaning:
//
// 0 (Off) - no extra output
// 1 (V) - log per-track CDN URLs from the downloader
// 2 (VV) - additionally log every outbound HTTP request via the
// netutil-wrapped transport (covers all provider API calls
// and downloads)
package verbose
import (
"fmt"
"os"
"sync/atomic"
)
const (
Off byte = 0
V byte = 1
VV byte = 2
)
var (
level atomic.Uint32
sink atomic.Pointer[func(string)]
)
// SetLevel clamps and stores the verbosity level. Pass 0 to disable.
func SetLevel(l int) {
if l < 0 {
l = 0
}
if l > int(VV) {
l = int(VV)
}
level.Store(uint32(l))
}
func Level() byte { return byte(level.Load()) }
func Enabled(l byte) bool { return Level() >= l }
// SetSink installs a writer for verbose output. Use this to route logs
// through the downloader so they don't tear progress bars. Pass nil to
// fall back to stderr.
func SetSink(fn func(string)) {
if fn == nil {
sink.Store(nil)
return
}
sink.Store(&fn)
}
// Printf emits a line at the given level if verbosity is enabled. The
// caller is responsible for including a trailing newline.
func Printf(l byte, format string, args ...any) {
if !Enabled(l) {
return
}
msg := fmt.Sprintf(format, args...)
if p := sink.Load(); p != nil {
(*p)(msg)
return
}
_, _ = fmt.Fprint(os.Stderr, msg)
}