refactor: delete dead code, dedupe helpers, add tests for untested packages

Dead code
  Removed, each verified to have no non-test caller: promptSearchSelection
  (77 lines, superseded by promptSearchSelectionMenu), AddPlaylistByTrackIDs,
  download.NewWithVerifySSL, qobuz.Client.GetTrackMetadata,
  tidal.Client.trackSupportsAtmos (test-only, and the comment above
  GetDownloadable already documented its removal from the live path) and its
  now-orphaned test, yandex.canonicalTrackRequestID (a no-op returning its
  trimmed input; the call site uses strings.TrimSpace directly) and the unused
  defaultEstimatedKbps constant.

Config keys that did nothing
  qobuz.download_booklets and misc.check_for_updates were declared, defaulted
  and documented in config.toml.example but read by no code. The whole
  [youtube] block went too: there is no YouTube provider. go-toml ignores
  unknown keys, verified, so existing user configs still load.

Duplicated helpers
  tidal.stringify and yandex.firstNonEmpty were byte-identical copies of
  jsonutil.StringFromAny and jsonutil.FirstNonEmpty. displayArtistNames
  existed three times, byte-identical, in internal/app, provider/yandex and
  provider/beatport; it now lives in jsonutil. The CLI's normalizeCodec
  delegates to config.NormalizeCodec so the flag and the config file are
  checked by the same rules.

Tests for packages that had none
  internal/jsonutil (0% -> 100%) and internal/netutil (0% -> 78%), the latter
  covering redactURL, which is a security control, and the zero-timeout
  contract that internal/download and the qobuz body copy depend on.
  internal/config rose to 82% with coverage of the new Validate(), the
  0600 permissions, CloneConfigData not sharing slices, and a concurrent
  UpdateTokens test that exercises the mutex under -race.

Test suite is ~85x faster
  Provider tests built clients with the default 60 requests/minute, so the
  rate limiter slept 1s per HTTP call: deezer took 31s, tidal 11s, qobuz 8s.
  Each provider test package now builds its config through a testConfigData
  helper that disables throttling. Whole suite: ~60s -> 0.7s, and 2.8s
  under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JorenandClaude Opus 5 committed 2026-10-03 17:05:55 +02:00
1 parent 201aa5daa7
commit b3b6a2eb1d
19 files changed
+701 -392

No files matched your search

+4 -17
View File
@@ -546,7 +546,7 @@ func (c *Client) normalizeRelease(raw map[string]any, tracks []any) map[string]a
func (c *Client) normalizeTrack(raw map[string]any) map[string]any {
release := mapAny(raw["release"])
trackArtistNames := artistNames(raw["artists"])
artistName := displayArtistNames(trackArtistNames)
artistName := jsonutil.DisplayArtistNames(trackArtistNames)
artistID := firstArtistID(raw["artists"])
albumArtist := strings.TrimSpace(jsonutil.StringFromAny(release["album_artist_name"]))
if albumArtist == "" {
@@ -592,7 +592,7 @@ func (c *Client) normalizeTrackListItem(raw map[string]any) 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)},
"artist": map[string]any{"name": jsonutil.DisplayArtistNames(names)},
"album": map[string]any{"id": jsonutil.NestedString(raw, "release", "id"), "title": jsonutil.NestedString(raw, "release", "name")},
"track_number": jsonutil.IntFromAny(raw["number"]),
}
@@ -712,32 +712,19 @@ func artistNames(v any) []string {
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))
return jsonutil.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 jsonutil.DisplayArtistNames(artistNames(common))
}
return c.releaseArtistName(releaseArtists)
}
+15 -4
View File
@@ -9,12 +9,13 @@ import (
"time"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
)
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 := &config.Config{Session: testConfigData(), File: testConfigData()}
cfg.Session.Beatport.Username = "user"
cfg.Session.Beatport.Password = "pass"
cfg.Session.Beatport.AccessToken = "token"
@@ -199,7 +200,7 @@ func TestAlbumMetadataUsesArtistPresentOnEveryTrack(t *testing.T) {
}
func TestReleaseArtistCollapseCanBeDisabled(t *testing.T) {
cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()}
cfg := &config.Config{Session: testConfigData(), File: testConfigData()}
cfg.Session.Beatport.VariousArtistsThreshold = -1
c := New(cfg)
got := c.releaseArtistName([]any{
@@ -214,13 +215,13 @@ func TestReleaseArtistCollapseCanBeDisabled(t *testing.T) {
}
func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) {
got := displayArtistNames(artistNames([]any{
got := jsonutil.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)
t.Fatalf("DisplayArtistNames() = %q", got)
}
}
@@ -265,3 +266,13 @@ func TestArtistMetadataUsesReleases(t *testing.T) {
t.Fatalf("unexpected album: %+v", album)
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
+29 -19
View File
@@ -24,7 +24,7 @@ func TestSearchTrack(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -74,7 +74,7 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -135,7 +135,7 @@ func TestGetMetadataAlbumPaginatesTracks(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -189,7 +189,7 @@ func TestGetMetadataPlaylistPaginatesTracks(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -241,7 +241,7 @@ func TestGetDownloadableNativeCipher(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -289,7 +289,7 @@ func TestGetDownloadablePrefersNoneCipherWhenAvailable(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -354,7 +354,7 @@ func TestLoginPrefersARLFlowOverRefreshShortcut(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Deezer.ARL = "arl"
cfgData.Deezer.RefreshToken = "refresh-token"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -386,7 +386,7 @@ func TestLoginPrefersARLFlowOverRefreshShortcut(t *testing.T) {
}
func TestGetDownloadableRequiresARL(t *testing.T) {
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Deezer.ARL = ""
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -409,7 +409,7 @@ func TestGetDownloadableDRMError(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -448,7 +448,7 @@ func TestGetTrackTokenPrefersPipeToken(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.jwt = "jwt-token"
@@ -499,7 +499,7 @@ func TestGetDownloadableUsesPipeTrackToken(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -541,7 +541,7 @@ func TestGetMetadataAddsLyricsFromPipe(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.jwt = "jwt"
@@ -594,7 +594,7 @@ func TestLoginWithCredentials(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Deezer.Email = "tidal1@alpin.sbs"
cfgData.Deezer.Password = "tidal1@alpin.sbs"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -662,7 +662,7 @@ func TestMobileAuthIncludesAppContextParams(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
origGateway := gatewayURL
@@ -703,7 +703,7 @@ func TestMobileUserAutologIncludesRefreshToken(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.sid = "sid123"
c.userID = "42"
@@ -748,7 +748,7 @@ func TestLoginWithRefreshToken(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Deezer.RefreshToken = "refresh-token"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -782,7 +782,7 @@ func TestRefreshJWTHTTPError(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.refresh = "refresh-token"
@@ -802,7 +802,7 @@ func TestRefreshLicenseFromPipeGraphQLError(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.jwt = "jwt-token"
@@ -848,7 +848,7 @@ func TestRefreshLicenseFromPipeUsesMobileGraphQLShape(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.jwt = "jwt-token"
@@ -863,3 +863,13 @@ func TestRefreshLicenseFromPipeUsesMobileGraphQLShape(t *testing.T) {
t.Fatalf("license = %q, want license-token", c.license)
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
-8
View File
@@ -269,14 +269,6 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
return resp, nil
}
func (c *Client) GetTrackMetadata(ctx context.Context, id string) (*TrackMetadata, error) {
raw, err := c.GetMetadata(ctx, id, "track")
if err != nil {
return nil, err
}
return ParseTrackMetadata(raw)
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.LoggedIn() {
return nil, errNotLoggedIn
+14 -4
View File
@@ -208,7 +208,7 @@ func TestGetArtistPagination(t *testing.T) {
func newTestClient(t *testing.T) *Client {
t.Helper()
d := config.DefaultConfigData()
d := testConfigData()
d.Qobuz.AppID = "12345"
cfg := &config.Config{File: d, Session: d}
return New(cfg)
@@ -248,7 +248,7 @@ func TestLoginRefreshesAppCredentialsWhenSecretInvalid(t *testing.T) {
}))
defer ts.Close()
d := config.DefaultConfigData()
d := testConfigData()
d.Qobuz.EmailOrUserID = "user@example.com"
d.Qobuz.PasswordOrToken = "hash"
d.Qobuz.AppID = "old-app"
@@ -302,7 +302,7 @@ func TestLoginRetriesAfterRefreshingAppCredentials(t *testing.T) {
}))
defer ts.Close()
d := config.DefaultConfigData()
d := testConfigData()
d.Qobuz.EmailOrUserID = "user@example.com"
d.Qobuz.PasswordOrToken = "hash"
d.Qobuz.AppID = "old-app"
@@ -396,7 +396,7 @@ func qobuzSecretSig(requestTS, secret string) string {
}
func TestRefreshAppCredentialsRejectsEmptyData(t *testing.T) {
d := config.DefaultConfigData()
d := testConfigData()
c := New(&config.Config{File: d, Session: d})
c.fetchCfg = func(context.Context) (string, []string, error) {
return "", []string{" "}, nil
@@ -406,3 +406,13 @@ func TestRefreshAppCredentialsRejectsEmptyData(t *testing.T) {
t.Fatalf("expected error for empty refreshed app credentials")
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
+16 -6
View File
@@ -14,7 +14,7 @@ import (
)
func TestGetTrackMetadataAndDownloadable(t *testing.T) {
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
@@ -49,7 +49,7 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) {
}
func TestGetPlaylistMetadata(t *testing.T) {
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
@@ -81,7 +81,7 @@ func TestGetPlaylistMetadata(t *testing.T) {
}
func TestSearchTrack(t *testing.T) {
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
@@ -122,7 +122,7 @@ func TestSearchPlaylist(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.http = ts.Client()
@@ -167,7 +167,7 @@ func TestSearchPlaylistAcceptsDotsInPath(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.http = ts.Client()
@@ -203,7 +203,7 @@ func TestSearchPlaylistAcceptsDotsInPath(t *testing.T) {
}
func TestLoginShowsYtDlpHint(t *testing.T) {
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.bin = "definitely-not-a-real-yt-dlp-bin"
err := c.Login(context.Background())
@@ -254,3 +254,13 @@ func TestCanonicalSoundcloudURLAcceptsSubdomain(t *testing.T) {
t.Fatalf("canonical url = %q, want %q", got, "https://soundcloud.com/a/b")
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
+26 -65
View File
@@ -120,10 +120,10 @@ func (c *Client) Login(ctx context.Context) error {
return fmt.Errorf("tidal login failed: status=%d body=%v", status, resp)
}
if v := stringify(resp["countryCode"]); v != "" {
if v := jsonutil.StringFromAny(resp["countryCode"]); v != "" {
c.cfg.Session.Tidal.CountryCode = v
}
if v := stringify(resp["userId"]); v != "" {
if v := jsonutil.StringFromAny(resp["userId"]); v != "" {
c.cfg.Session.Tidal.UserID = v
}
@@ -159,12 +159,12 @@ func (c *Client) refreshAccessToken(ctx context.Context) error {
return fmt.Errorf("tidal token refresh failed: status=%d body=%v", status, resp)
}
newToken := stringify(resp["access_token"])
newToken := jsonutil.StringFromAny(resp["access_token"])
if newToken == "" {
return errors.New("tidal token refresh missing access_token")
}
newRefresh := stringify(resp["refresh_token"])
newRefresh := jsonutil.StringFromAny(resp["refresh_token"])
expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
if expiresIn <= 0 {
expiresIn = 7 * 24 * 3600
@@ -263,8 +263,8 @@ func (c *Client) fetchTrackLyrics(ctx context.Context, trackID string) (string,
return "", ""
}
lyrics := strings.TrimSpace(stringify(resp["lyrics"]))
lrc := strings.TrimSpace(stringify(resp["subtitles"]))
lyrics := strings.TrimSpace(jsonutil.StringFromAny(resp["lyrics"]))
lrc := strings.TrimSpace(jsonutil.StringFromAny(resp["subtitles"]))
return lyrics, lrc
}
@@ -339,30 +339,6 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in
return c.getDownloadableFromTrackManifest(ctx, trackID, quality)
}
func (c *Client) trackSupportsAtmos(ctx context.Context, trackID string) bool {
resp, status, err := c.apiRequest(ctx, "tracks/"+trackID, url.Values{}, c.baseURL)
if err != nil || status != http.StatusOK {
return false
}
if modes, ok := resp["audioModes"].([]any); ok {
for _, mode := range modes {
if strings.Contains(strings.ToUpper(stringify(mode)), "ATMOS") {
return true
}
}
}
if mm, ok := resp["mediaMetadata"].(map[string]any); ok {
if tags, ok := mm["tags"].([]any); ok {
for _, tag := range tags {
if strings.Contains(strings.ToUpper(stringify(tag)), "ATMOS") {
return true
}
}
}
}
return false
}
func (c *Client) getAtmosDownloadable(ctx context.Context, trackID string) (*provider.Downloadable, error) {
var lastErr error
for _, aq := range atmosAudioQualities {
@@ -407,18 +383,18 @@ func (c *Client) getAtmosDownloadable(ctx context.Context, trackID string) (*pro
}
func playbackLooksAtmos(resp map[string]any) bool {
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(resp["audioMode"])), "ATMOS") {
return true
}
if modes, ok := resp["audioModes"].([]any); ok {
for _, raw := range modes {
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(raw)), "ATMOS") {
return true
}
}
}
manifestB64 := stringify(resp["manifest"])
manifestB64 := jsonutil.StringFromAny(resp["manifest"])
if manifestB64 == "" {
return false
}
@@ -430,17 +406,17 @@ func playbackLooksAtmos(resp map[string]any) bool {
if err = json.Unmarshal(b, &manifest); err != nil {
return false
}
if strings.Contains(strings.ToUpper(stringify(manifest["audioMode"])), "ATMOS") {
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(manifest["audioMode"])), "ATMOS") {
return true
}
if modes, ok := manifest["audioModes"].([]any); ok {
for _, raw := range modes {
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(raw)), "ATMOS") {
return true
}
}
}
codec := strings.ToLower(stringify(manifest["codecs"]))
codec := strings.ToLower(jsonutil.StringFromAny(manifest["codecs"]))
return strings.Contains(codec, "ec-3") || strings.Contains(codec, "eac3") || strings.Contains(codec, "joc") || strings.Contains(codec, "atmos")
}
@@ -524,7 +500,7 @@ func (c *Client) fetchArtistAlbums(ctx context.Context, artistID string) ([]map[
if wrapped, ok := itm["item"].(map[string]any); ok {
itm = wrapped
}
id := stringify(itm["id"])
id := jsonutil.StringFromAny(itm["id"])
if id == "" {
continue
}
@@ -576,14 +552,14 @@ func (c *Client) getDownloadableFromTrackManifestForFormats(ctx context.Context,
if !ok {
return nil, errors.New("tidal trackManifests missing attributes")
}
uri := stringify(attrs["uri"])
uri := jsonutil.StringFromAny(attrs["uri"])
if uri == "" {
return nil, errors.New("tidal trackManifests missing uri")
}
attrFormats, _ := attrs["formats"].([]any)
ext := "m4a"
for _, f := range attrFormats {
fv := strings.ToUpper(stringify(f))
fv := strings.ToUpper(jsonutil.StringFromAny(f))
if strings.Contains(fv, "FLAC") {
ext = "flac"
break
@@ -630,7 +606,7 @@ func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*pro
return nil, fmt.Errorf("tidal video playbackinfo failed: status=%d", status)
}
manifestB64 := stringify(resp["manifest"])
manifestB64 := jsonutil.StringFromAny(resp["manifest"])
if manifestB64 == "" {
return nil, errors.New("tidal video manifest missing")
}
@@ -646,7 +622,7 @@ func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*pro
if !ok || len(urls) == 0 {
return nil, errors.New("tidal video manifest urls missing")
}
masterURL := stringify(urls[0])
masterURL := jsonutil.StringFromAny(urls[0])
if masterURL == "" {
return nil, errors.New("tidal video master url missing")
}
@@ -677,7 +653,7 @@ func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*pro
}
func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadable {
manifestB64 := stringify(resp["manifest"])
manifestB64 := jsonutil.StringFromAny(resp["manifest"])
if manifestB64 == "" {
return nil
}
@@ -693,11 +669,11 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
if !ok || len(urls) == 0 {
return nil
}
streamURL := stringify(urls[0])
streamURL := jsonutil.StringFromAny(urls[0])
if streamURL == "" {
return nil
}
codec := strings.ToLower(stringify(manifest["codecs"]))
codec := strings.ToLower(jsonutil.StringFromAny(manifest["codecs"]))
ext := "m4a"
if strings.Contains(codec, "flac") {
ext = "flac"
@@ -708,14 +684,14 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
if profile.Container == "" {
profile = tidalAudioProfileFromExtension(ext)
}
audioQuality := strings.ToUpper(strings.TrimSpace(stringify(resp["audioQuality"])))
audioQuality := strings.ToUpper(strings.TrimSpace(jsonutil.StringFromAny(resp["audioQuality"])))
if audioQuality == "" {
audioQuality = strings.ToUpper(strings.TrimSpace(stringify(manifest["audioQuality"])))
audioQuality = strings.ToUpper(strings.TrimSpace(jsonutil.StringFromAny(manifest["audioQuality"])))
}
if audioQuality != "" {
profile = applyTidalAudioQuality(profile, audioQuality)
}
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(resp["audioMode"])), "ATMOS") {
profile = tidalAtmosAudioProfile()
}
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal", Audio: profile}
@@ -724,7 +700,7 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
func tidalAudioProfileFromFormats(formats []any) provider.AudioProfile {
best := provider.AudioProfile{}
for _, raw := range formats {
f := strings.ToUpper(strings.TrimSpace(stringify(raw)))
f := strings.ToUpper(strings.TrimSpace(jsonutil.StringFromAny(raw)))
switch {
case strings.Contains(f, "EAC3") || strings.Contains(f, "JOC") || strings.Contains(f, "ATMOS"):
return tidalAtmosAudioProfile()
@@ -1003,28 +979,13 @@ func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values,
})
}
func stringify(v any) string {
switch t := v.(type) {
case string:
return t
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return ""
}
}
func enrichTidalImage(meta map[string]any) {
if _, ok := meta["image"].(map[string]any); ok {
return
}
cover := stringify(meta["cover"])
cover := jsonutil.StringFromAny(meta["cover"])
if cover == "" {
cover = stringify(meta["squareImage"])
cover = jsonutil.StringFromAny(meta["squareImage"])
}
if cover == "" {
return
+27 -38
View File
@@ -12,10 +12,11 @@ import (
"testing"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
)
func TestLoginMissingToken(t *testing.T) {
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Tidal.AccessToken = ""
c := New(&config.Config{File: cfgData, Session: cfgData})
err := c.Login(context.Background())
@@ -37,7 +38,7 @@ func TestSearch(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -73,7 +74,7 @@ func TestGetVideoDownloadable(t *testing.T) {
}))
defer server.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -143,7 +144,7 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -185,7 +186,7 @@ func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "MY"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -197,10 +198,10 @@ func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) {
if err != nil {
t.Fatalf("GetMetadata() err = %v", err)
}
if got := stringify(meta["lyrics"]); got != "plain lyrics line" {
if got := jsonutil.StringFromAny(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" {
if got := jsonutil.StringFromAny(meta["lyrics_synced"]); got != "[00:00.00]plain lyrics line" {
t.Fatalf("lyrics_synced = %q, want synced lrc", got)
}
}
@@ -219,7 +220,7 @@ func TestGetMetadataTrackIgnoresLyricsEndpointFailure(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -257,7 +258,7 @@ func TestAPIRequestRetriesTooManyRequests(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Downloads.RequestsPerMinute = 0
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
@@ -274,8 +275,8 @@ func TestAPIRequestRetriesTooManyRequests(t *testing.T) {
if calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}
if stringify(resp["title"]) != "Song" {
t.Fatalf("title = %q, want Song", stringify(resp["title"]))
if jsonutil.StringFromAny(resp["title"]) != "Song" {
t.Fatalf("title = %q, want Song", jsonutil.StringFromAny(resp["title"]))
}
}
@@ -297,7 +298,7 @@ func TestAPIPostRetriesTooManyRequests(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Downloads.RequestsPerMinute = 0
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -311,8 +312,8 @@ func TestAPIPostRetriesTooManyRequests(t *testing.T) {
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"]))
if jsonutil.StringFromAny(resp["access_token"]) != "fresh-token" {
t.Fatalf("access_token = %q, want fresh-token", jsonutil.StringFromAny(resp["access_token"]))
}
}
@@ -342,7 +343,7 @@ func TestGetDownloadablePrefersAtmosWhenEnabled(t *testing.T) {
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
cfgData.Tidal.PreferAtmos = true
@@ -377,28 +378,6 @@ func TestPlaybackLooksAtmosFromManifest(t *testing.T) {
}
}
func TestTrackSupportsAtmosFromTags(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/tracks/42" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "audioModes": []any{"STEREO"}, "mediaMetadata": map[string]any{"tags": []any{"LOSSLESS", "DOLBY_ATMOS"}}})
}))
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"
if !c.trackSupportsAtmos(context.Background(), "42") {
t.Fatalf("expected atmos support from mediaMetadata tags")
}
}
func TestFormatsForQuality(t *testing.T) {
tests := []struct {
name string
@@ -448,7 +427,7 @@ func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T)
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData := testConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -473,3 +452,13 @@ func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T)
t.Fatalf("unexpected audio profile: %+v", d.Audio)
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
+11 -42
View File
@@ -36,7 +36,6 @@ const (
requestAttempts = 3
desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5"
legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA"
defaultEstimatedKbps = 50000
)
var ErrMissingYandexToken = errors.New("missing yandex access_token")
@@ -180,7 +179,7 @@ func (c *Client) Close() error {
}
func (c *Client) getTrackMetadata(ctx context.Context, item string) (map[string]any, error) {
trackRef := canonicalTrackRequestID(item)
trackRef := strings.TrimSpace(item)
form := url.Values{}
form.Set("trackIds", trackRef)
form.Set("withMixData", "true")
@@ -577,7 +576,7 @@ func normalizeTrack(raw map[string]any, fallbackID string, artistSeparator strin
trackID := canonicalTrackRefFromRaw(raw, fallbackID)
albumRaw := firstAlbum(raw)
artistNames := artistNames(raw)
artistName := displayArtistNames(artistNames)
artistName := jsonutil.DisplayArtistNames(artistNames)
albumArtist := joinAlbumArtists(albumRaw, artistSeparator)
if albumArtist == "" {
albumArtist = artistName
@@ -596,7 +595,7 @@ func normalizeTrack(raw map[string]any, fallbackID string, artistSeparator strin
"tracks_count": trackTotal,
"media_number": discNumber,
"numberOfVolumes": discTotal,
"release_date": firstNonEmpty(jsonutil.StringFromAny(raw["releaseDate"]), jsonutil.StringFromAny(albumRaw["releaseDate"])),
"release_date": jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["releaseDate"]), jsonutil.StringFromAny(albumRaw["releaseDate"])),
"parental_warning": isExplicit(raw),
"explicit": isExplicit(raw),
"source_track_id": jsonutil.StringFromAny(raw["realId"]),
@@ -684,9 +683,9 @@ func normalizePlaylist(raw map[string]any) map[string]any {
}
}
owner, _ := raw["owner"].(map[string]any)
ownerName := firstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
ownerName := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
return map[string]any{
"id": firstNonEmpty(jsonutil.StringFromAny(raw["playlistUuid"]), jsonutil.StringFromAny(raw["kind"])),
"id": jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["playlistUuid"]), jsonutil.StringFromAny(raw["kind"])),
"name": jsonutil.StringFromAny(raw["title"]),
"title": jsonutil.StringFromAny(raw["title"]),
"description": jsonutil.StringFromAny(raw["description"]),
@@ -738,8 +737,8 @@ func normalizeArtistSearchItem(raw map[string]any) map[string]any {
func normalizePlaylistSearchItem(raw map[string]any) map[string]any {
owner, _ := raw["owner"].(map[string]any)
ownerID := firstNonEmpty(jsonutil.StringFromAny(owner["uid"]), jsonutil.StringFromAny(raw["uid"]))
ownerName := firstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
ownerID := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(owner["uid"]), jsonutil.StringFromAny(raw["uid"]))
ownerName := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
return map[string]any{
"id": ownerID + ":" + jsonutil.StringFromAny(raw["kind"]),
"title": jsonutil.StringFromAny(raw["title"]),
@@ -783,7 +782,7 @@ func firstArtistID(raw map[string]any) string {
}
func joinArtists(raw map[string]any, artistSeparator string) string {
return displayArtistNames(artistNames(raw))
return jsonutil.DisplayArtistNames(artistNames(raw))
}
func artistNames(raw map[string]any) []string {
@@ -805,7 +804,7 @@ func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string {
if len(albumRaw) == 0 {
return ""
}
return displayArtistNames(albumArtistNames(albumRaw))
return jsonutil.DisplayArtistNames(albumArtistNames(albumRaw))
}
func albumArtistNames(albumRaw map[string]any) []string {
@@ -823,19 +822,6 @@ func albumArtistNames(albumRaw map[string]any) []string {
return parts
}
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) artistSeparator() string {
if c.cfg.Session.Metadata.ArtistSeparator == "" {
return "; "
@@ -852,19 +838,11 @@ func trackNumbers(albumRaw map[string]any) (int, int) {
return trackNumber, firstPositiveInt(jsonutil.IntFromAny(position["volume"]), 1)
}
func canonicalTrackRequestID(item string) string {
item = strings.TrimSpace(item)
if item == "" {
return item
}
return item
}
func canonicalTrackRefFromRaw(raw map[string]any, fallback string) string {
if strings.Contains(strings.TrimSpace(fallback), ":") {
return strings.TrimSpace(fallback)
}
trackID := firstNonEmpty(jsonutil.StringFromAny(raw["realId"]), jsonutil.StringFromAny(raw["id"]))
trackID := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["realId"]), jsonutil.StringFromAny(raw["id"]))
albumID := jsonutil.StringFromAny(firstAlbum(raw)["id"])
if trackID == "" {
trackID = strings.TrimSpace(fallback)
@@ -931,7 +909,7 @@ func imageMapFromTrack(raw map[string]any, albumRaw map[string]any) map[string]a
}
func imageMapFromURI(raw map[string]any) map[string]any {
uri := firstNonEmpty(jsonutil.StringFromAny(raw["ogImage"]), jsonutil.StringFromAny(raw["coverUri"]))
uri := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["ogImage"]), jsonutil.StringFromAny(raw["coverUri"]))
if uri == "" {
if cover, ok := raw["cover"].(map[string]any); ok {
uri = jsonutil.StringFromAny(cover["uri"])
@@ -1049,12 +1027,3 @@ func firstPositiveInt(vals ...int) int {
}
return 0
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+12 -9
View File
@@ -60,7 +60,7 @@ func TestGetDownloadableUsesModernGetFileInfo(t *testing.T) {
}))
defer ts.Close()
d := config.DefaultConfigData()
d := testConfigData()
d.Downloads.RequestsPerMinute = 0
d.Yandex.AccessToken = "token"
c := New(&config.Config{File: d, Session: d})
@@ -132,7 +132,7 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
}))
defer ts.Close()
d := config.DefaultConfigData()
d := testConfigData()
d.Downloads.RequestsPerMinute = 0
d.Yandex.AccessToken = "token"
c := New(&config.Config{File: d, Session: d})
@@ -167,13 +167,6 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
}
}
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",
@@ -189,3 +182,13 @@ func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {
t.Fatalf("legacyDirectURL() = %q, want %q", url, want)
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}