diff --git a/spotify_test.go b/spotify_test.go index ae40e5e..95fb6de 100644 --- a/spotify_test.go +++ b/spotify_test.go @@ -2,165 +2,221 @@ package main import ( "encoding/json" - "strings" - "testing" + "fmt" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" + "github.com/stretchr/testify/mock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestParsePrimaryArtist(t *testing.T) { - tests := []struct { - input string - wantPrimary string - wantFeat string - }{ - {"Radiohead", "Radiohead", ""}, - {"Wretch 32 Feat. Badness & Ghetts", "Wretch 32", "Feat. Badness & Ghetts"}, - {"Artist Ft. Guest", "Artist", "Ft. Guest"}, - {"Artist Featuring Someone", "Artist", "Featuring Someone"}, - {"PinkPantheress & Ice Spice", "PinkPantheress", ""}, - {"Artist A / Artist B", "Artist A", ""}, - {"", "", ""}, - } - for _, tt := range tests { - gotPrimary, gotFeat := parsePrimaryArtist(tt.input) - if gotPrimary != tt.wantPrimary { - t.Errorf("parsePrimaryArtist(%q) primary = %q, want %q", tt.input, gotPrimary, tt.wantPrimary) - } - if gotFeat != tt.wantFeat { - t.Errorf("parsePrimaryArtist(%q) feat = %q, want %q", tt.input, gotFeat, tt.wantFeat) - } - } -} +var _ = Describe("Spotify", func() { + Describe("parsePrimaryArtist", func() { + DescribeTable("extracts primary artist and feat suffix", + func(input, expectedPrimary, expectedFeat string) { + primary, feat := parsePrimaryArtist(input) + Expect(primary).To(Equal(expectedPrimary)) + Expect(feat).To(Equal(expectedFeat)) + }, + Entry("simple artist", "Radiohead", "Radiohead", ""), + Entry("Feat. separator", "Wretch 32 Feat. Badness & Ghetts", "Wretch 32", "Feat. Badness & Ghetts"), + Entry("Ft. separator", "Artist Ft. Guest", "Artist", "Ft. Guest"), + Entry("Featuring separator", "Artist Featuring Someone", "Artist", "Featuring Someone"), + Entry("& co-artist", "PinkPantheress & Ice Spice", "PinkPantheress", ""), + Entry("/ co-artist", "Artist A / Artist B", "Artist A", ""), + Entry("empty string", "", "", ""), + ) + }) -func TestBuildSpotifySearchURL(t *testing.T) { - tests := []struct { - title, artist string - wantPrefix string - wantContains string - }{ - {"Never Gonna Give You Up", "Rick Astley", "https://open.spotify.com/search/", "Rick%20Astley"}, - {"Karma Police", "Radiohead", "https://open.spotify.com/search/", "Radiohead"}, - {"", "Solo Artist", "https://open.spotify.com/search/", "Solo%20Artist"}, - {"Only Title", "", "https://open.spotify.com/search/", "Only%20Title"}, - {"", "", "https://open.spotify.com/search/", ""}, - } - for _, tt := range tests { - got := buildSpotifySearchURL(tt.title, tt.artist) - if !strings.HasPrefix(got, tt.wantPrefix) { - t.Errorf("buildSpotifySearchURL(%q, %q) = %q, want prefix %q", tt.title, tt.artist, got, tt.wantPrefix) - } - if tt.wantContains != "" && !strings.Contains(got, tt.wantContains) { - t.Errorf("buildSpotifySearchURL(%q, %q) = %q, want to contain %q", tt.title, tt.artist, got, tt.wantContains) - } - } -} + Describe("buildSpotifySearchURL", func() { + DescribeTable("constructs Spotify search URL", + func(title, artist, expectedSubstring string) { + url := buildSpotifySearchURL(title, artist) + Expect(url).To(HavePrefix("https://open.spotify.com/search/")) + if expectedSubstring != "" { + Expect(url).To(ContainSubstring(expectedSubstring)) + } + }, + Entry("artist and title", "Never Gonna Give You Up", "Rick Astley", "Rick%20Astley"), + Entry("another track", "Karma Police", "Radiohead", "Radiohead"), + Entry("empty title", "", "Solo Artist", "Solo%20Artist"), + Entry("empty artist", "Only Title", "", "Only%20Title"), + Entry("both empty", "", "", ""), + ) + }) -func TestSpotifyCacheKey(t *testing.T) { - key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer") - key2 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer") - key3 := spotifyCacheKey("Radiohead", "Karma Police", "The Bends") - - if key1 != key2 { - t.Error("identical inputs should produce identical cache keys") - } - if key1 == key3 { - t.Error("different albums should produce different cache keys") - } - if !strings.HasPrefix(key1, "spotify.url.") { - t.Errorf("cache key %q should start with 'spotify.url.'", key1) - } - - // Case-insensitive: "Radiohead" == "radiohead" - keyUpper := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer") - keyLower := spotifyCacheKey("radiohead", "karma police", "ok computer") - if keyUpper != keyLower { - t.Error("cache key should be case-insensitive") - } -} - -func TestParseSpotifyID(t *testing.T) { - tests := []struct { - name string - body string - want string - }{ - { - name: "valid single result", - body: `[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`, - want: "4tIGK5G9hNDA50ZdGioZRG", - }, - { - name: "multiple IDs picks first", - body: `[{"artist_name":"Lil Baby & Drake","track_name":"Yes Indeed","spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ","4wlLbLeDWbA6TzwZFp1UaK"]}]`, - want: "6vN77lE9LK6HP2DewaN6HZ", - }, - { - name: "valid result with extra fields", - body: `[{"artist_name":"Radiohead","track_name":"Karma Police","spotify_track_ids":["63OQupATfueTdZMWIV7nzz"],"release_name":"OK Computer"}]`, - want: "63OQupATfueTdZMWIV7nzz", - }, - { - name: "empty spotify_track_ids array", - body: `[{"spotify_track_ids":[]}]`, - want: "", - }, - { - name: "no spotify_track_ids field", - body: `[{"artist_name":"Unknown"}]`, - want: "", - }, - { - name: "empty array", - body: `[]`, - want: "", - }, - { - name: "invalid JSON", - body: `not json`, - want: "", - }, - { - name: "null spotify_track_ids with next result valid", - body: `[{"spotify_track_ids":[]},{"spotify_track_ids":["abc123"]}]`, - want: "abc123", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := parseSpotifyID([]byte(tt.body)) - if got != tt.want { - t.Errorf("parseSpotifyID(%s) = %q, want %q", tt.body, got, tt.want) - } + Describe("spotifyCacheKey", func() { + It("produces identical keys for identical inputs", func() { + key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer") + key2 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer") + Expect(key1).To(Equal(key2)) }) - } -} -func TestListenBrainzRequestPayloads(t *testing.T) { - // Verify MBID request body is valid JSON - mbid := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - mbidBody := []byte(`[{"recording_mbid":"` + mbid + `"}]`) - var mbidParsed []map[string]string - if err := json.Unmarshal(mbidBody, &mbidParsed); err != nil { - t.Fatalf("MBID request body is not valid JSON: %v", err) - } - if mbidParsed[0]["recording_mbid"] != mbid { - t.Errorf("MBID body recording_mbid = %q, want %q", mbidParsed[0]["recording_mbid"], mbid) - } + It("produces different keys for different albums", func() { + key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer") + key2 := spotifyCacheKey("Radiohead", "Karma Police", "The Bends") + Expect(key1).ToNot(Equal(key2)) + }) - // Verify metadata request body handles special characters via %q formatting - artist := `Guns N' Roses` - title := `Sweet Child O' Mine` - album := `Appetite for Destruction` - metaBody := []byte(`[{"artist_name":` + jsonQuote(artist) + `,"track_name":` + jsonQuote(title) + `,"release_name":` + jsonQuote(album) + `}]`) - var metaParsed []map[string]string - if err := json.Unmarshal(metaBody, &metaParsed); err != nil { - t.Fatalf("Metadata request body is not valid JSON: %v", err) - } - if metaParsed[0]["artist_name"] != artist { - t.Errorf("artist_name = %q, want %q", metaParsed[0]["artist_name"], artist) - } -} + It("uses the correct prefix", func() { + key := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer") + Expect(key).To(HavePrefix("spotify.url.")) + }) -func jsonQuote(s string) string { - b, _ := json.Marshal(s) - return string(b) -} + It("is case-insensitive", func() { + keyUpper := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer") + keyLower := spotifyCacheKey("radiohead", "karma police", "ok computer") + Expect(keyUpper).To(Equal(keyLower)) + }) + }) + + Describe("parseSpotifyID", func() { + DescribeTable("extracts first Spotify track ID from ListenBrainz response", + func(body, expectedID string) { + Expect(parseSpotifyID([]byte(body))).To(Equal(expectedID)) + }, + Entry("valid single result", + `[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`, "4tIGK5G9hNDA50ZdGioZRG"), + Entry("multiple IDs picks first", + `[{"artist_name":"Lil Baby & Drake","track_name":"Yes Indeed","spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ","4wlLbLeDWbA6TzwZFp1UaK"]}]`, "6vN77lE9LK6HP2DewaN6HZ"), + Entry("valid result with extra fields", + `[{"artist_name":"Radiohead","track_name":"Karma Police","spotify_track_ids":["63OQupATfueTdZMWIV7nzz"],"release_name":"OK Computer"}]`, "63OQupATfueTdZMWIV7nzz"), + Entry("empty spotify_track_ids array", + `[{"spotify_track_ids":[]}]`, ""), + Entry("no spotify_track_ids field", + `[{"artist_name":"Unknown"}]`, ""), + Entry("empty array", + `[]`, ""), + Entry("invalid JSON", + `not json`, ""), + Entry("null first result falls through to second", + `[{"spotify_track_ids":[]},{"spotify_track_ids":["abc123"]}]`, "abc123"), + ) + }) + + Describe("ListenBrainz request payloads", func() { + It("builds valid JSON for MBID requests", func() { + mbid := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + body := []byte(fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid)) + var parsed []map[string]string + Expect(json.Unmarshal(body, &parsed)).To(Succeed()) + Expect(parsed[0]["recording_mbid"]).To(Equal(mbid)) + }) + + It("builds valid JSON for metadata requests with special characters", func() { + artist := `Guns N' Roses` + title := `Sweet Child O' Mine` + album := `Appetite for Destruction` + payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album) + var parsed []map[string]string + Expect(json.Unmarshal([]byte(payload), &parsed)).To(Succeed()) + Expect(parsed[0]["artist_name"]).To(Equal(artist)) + Expect(parsed[0]["track_name"]).To(Equal(title)) + Expect(parsed[0]["release_name"]).To(Equal(album)) + }) + }) + + Describe("resolveSpotifyURL", func() { + BeforeEach(func() { + pdk.ResetMock() + host.CacheMock.ExpectedCalls = nil + host.CacheMock.Calls = nil + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + }) + + It("returns cached URL on cache hit", func() { + host.CacheMock.On("GetString", mock.Anything).Return("https://open.spotify.com/track/cached123", true, nil) + + url := resolveSpotifyURL(scrobbler.TrackInfo{ + Title: "Karma Police", + Artist: "Radiohead", + Album: "OK Computer", + }) + Expect(url).To(Equal("https://open.spotify.com/track/cached123")) + }) + + It("resolves via MBID when available", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // Mock the MBID HTTP request + mbidReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json").Return(mbidReq) + pdk.PDKMock.On("Send", mbidReq).Return(pdk.NewStubHTTPResponse(200, nil, + []byte(`[{"spotify_track_ids":["track123"]}]`))) + + url := resolveSpotifyURL(scrobbler.TrackInfo{ + Title: "Karma Police", + Artist: "Radiohead", + Album: "OK Computer", + MBZRecordingID: "mbid-123", + }) + Expect(url).To(Equal("https://open.spotify.com/track/track123")) + host.CacheMock.AssertCalled(GinkgoT(), "SetString", mock.Anything, "https://open.spotify.com/track/track123", spotifyCacheTTLHit) + }) + + It("falls back to metadata lookup when MBID fails", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // MBID request fails + mbidReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json").Return(mbidReq) + pdk.PDKMock.On("Send", mbidReq).Return(pdk.NewStubHTTPResponse(404, nil, []byte(`[]`))) + + // Metadata request succeeds + metaReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json").Return(metaReq) + pdk.PDKMock.On("Send", metaReq).Return(pdk.NewStubHTTPResponse(200, nil, + []byte(`[{"spotify_track_ids":["meta456"]}]`))) + + url := resolveSpotifyURL(scrobbler.TrackInfo{ + Title: "Karma Police", + Artist: "Radiohead", + Album: "OK Computer", + MBZRecordingID: "mbid-123", + }) + Expect(url).To(Equal("https://open.spotify.com/track/meta456")) + }) + + It("falls back to search URL when both lookups fail", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // No MBID, metadata request fails + metaReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json").Return(metaReq) + pdk.PDKMock.On("Send", metaReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))) + + url := resolveSpotifyURL(scrobbler.TrackInfo{ + Title: "Karma Police", + Artist: "Radiohead", + Album: "OK Computer", + }) + Expect(url).To(HavePrefix("https://open.spotify.com/search/")) + Expect(url).To(ContainSubstring("Radiohead")) + host.CacheMock.AssertCalled(GinkgoT(), "SetString", mock.Anything, mock.Anything, spotifyCacheTTLMiss) + }) + + It("uses Artists fallback when primary artist parse is empty", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + metaReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json").Return(metaReq) + pdk.PDKMock.On("Send", metaReq).Return(pdk.NewStubHTTPResponse(200, nil, + []byte(`[{"spotify_track_ids":["fromArtists789"]}]`))) + + url := resolveSpotifyURL(scrobbler.TrackInfo{ + Title: "Some Song", + Artist: "", + Album: "Some Album", + Artists: []scrobbler.ArtistRef{{Name: "Fallback Artist"}}, + }) + Expect(url).To(Equal("https://open.spotify.com/track/fromArtists789")) + }) + }) +})