Spotify Link-Through & Navidrome Logo Overlay #15

Merged
Woahai321 merged 19 commits from main into main 2026-03-04 10:04:03 -07:00
6 changed files with 95 additions and 63 deletions
Showing only changes of commit d10ee8588d - Show all commits
+6 -6
View File
@@ -143,13 +143,13 @@ var _ = Describe("discordPlugin", func() {
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
// Cache mocks (Discord image processing) // Cache mocks (Discord image processing)
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
// Mock HTTP POST requests (Discord external assets API) // Mock HTTP POST requests (Discord external assets API)
postReq := &pdk.HTTPRequest{} postReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(postReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(postReq)
pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{}`))) pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{}`)))
// Schedule clear activity callback // Schedule clear activity callback
@@ -196,11 +196,11 @@ var _ = Describe("discordPlugin", func() {
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
// Cache mocks (Discord image processing) // Cache mocks (Discord image processing)
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
postReq := &pdk.HTTPRequest{} postReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(postReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(postReq)
pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{}`))) pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{}`)))
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
+9
View File
@@ -1,13 +1,22 @@
package main package main
import ( import (
"strings"
"testing" "testing"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
) )
func TestDiscordPlugin(t *testing.T) { func TestDiscordPlugin(t *testing.T) {
RegisterFailHandler(Fail) RegisterFailHandler(Fail)
RunSpecs(t, "Discord Plugin Main Suite") RunSpecs(t, "Discord Plugin Main Suite")
} }
// Shared matchers for tighter mock expectations across all test files.
var (
discordImageKey = mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, "discord.image.") })
externalAssetsURL = mock.MatchedBy(func(url string) bool { return strings.Contains(url, "external-assets") })
spotifyURLKey = mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, "spotify.url.") })
)
+4 -1
View File
@@ -6,6 +6,8 @@
package main package main
import ( import (
"crypto/md5"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
@@ -130,7 +132,8 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (
} }
// Check cache first // Check cache first
cacheKey := fmt.Sprintf("discord.image.%x", imageURL) h := md5.Sum([]byte(imageURL))
cacheKey := "discord.image." + hex.EncodeToString(h[:8])
cachedValue, exists, err := host.CacheGetString(cacheKey) cachedValue, exists, err := host.CacheGetString(cacheKey)
if err == nil && exists { if err == nil && exists {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL)) pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL))
+23 -27
View File
@@ -260,13 +260,13 @@ var _ = Describe("discordRPC", func() {
}) })
It("processes image via Discord API and caches result", func() { It("processes image via Discord API and caches result", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.MatchedBy(func(val string) bool { host.CacheMock.On("SetString", discordImageKey, mock.MatchedBy(func(val string) bool {
return val == "mp:external/new-asset" return val == "mp:external/new-asset"
}), int64(imageCacheTTL)).Return(nil) }), int64(imageCacheTTL)).Return(nil)
httpReq := &pdk.HTTPRequest{} httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":"external/new-asset"}]`))) pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":"external/new-asset"}]`)))
result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
@@ -275,10 +275,10 @@ var _ = Describe("discordRPC", func() {
}) })
It("returns error on HTTP failure", func() { It("returns error on HTTP failure", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
httpReq := &pdk.HTTPRequest{} httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))) pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`)))
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
@@ -287,10 +287,10 @@ var _ = Describe("discordRPC", func() {
}) })
It("returns error on unmarshal failure", func() { It("returns error on unmarshal failure", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
httpReq := &pdk.HTTPRequest{} httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"not":"an-array"}`))) pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"not":"an-array"}`)))
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
@@ -299,10 +299,10 @@ var _ = Describe("discordRPC", func() {
}) })
It("returns error on empty response array", func() { It("returns error on empty response array", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
httpReq := &pdk.HTTPRequest{} httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[]`))) pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[]`)))
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
@@ -311,10 +311,10 @@ var _ = Describe("discordRPC", func() {
}) })
It("returns error on empty external_asset_path", func() { It("returns error on empty external_asset_path", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
httpReq := &pdk.HTTPRequest{} httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":""}]`))) pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":""}]`)))
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
@@ -329,11 +329,11 @@ var _ = Describe("discordRPC", func() {
}) })
It("sends activity with track artwork and SmallImage overlay", func() { It("sends activity with track artwork and SmallImage overlay", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
httpReq := &pdk.HTTPRequest{} httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":"external/art"}]`))) pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":"external/art"}]`)))
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
@@ -361,17 +361,17 @@ var _ = Describe("discordRPC", func() {
It("falls back to default image and clears SmallImage", func() { It("falls back to default image and clears SmallImage", func() {
// Track art fails (HTTP error), default image succeeds // Track art fails (HTTP error), default image succeeds
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
trackReq := &pdk.HTTPRequest{} trackReq := &pdk.HTTPRequest{}
defaultReq := &pdk.HTTPRequest{} defaultReq := &pdk.HTTPRequest{}
// First call (track art) returns 500, second call (default) succeeds // First call (track art) returns 500, second call (default) succeeds
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(trackReq).Once() pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(trackReq).Once()
pdk.PDKMock.On("Send", trackReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))).Once() pdk.PDKMock.On("Send", trackReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))).Once()
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(defaultReq).Once() pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(defaultReq).Once()
pdk.PDKMock.On("Send", defaultReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":"external/logo"}]`))).Once() pdk.PDKMock.On("Send", defaultReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":"external/logo"}]`))).Once()
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
@@ -398,10 +398,10 @@ var _ = Describe("discordRPC", func() {
}) })
It("clears all images when both track art and default fail", func() { It("clears all images when both track art and default fail", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
httpReq := &pdk.HTTPRequest{} httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"not":"array"}`))) pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"not":"array"}`)))
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
@@ -428,15 +428,11 @@ var _ = Describe("discordRPC", func() {
It("handles SmallImage processing failure gracefully", func() { It("handles SmallImage processing failure gracefully", func() {
// LargeImage from cache (succeeds), SmallImage API fails // LargeImage from cache (succeeds), SmallImage API fails
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/large", true, nil).Once()
return strings.HasPrefix(key, "discord.image.") host.CacheMock.On("GetString", discordImageKey).Return("", false, nil).Once()
})).Return("mp:cached/large", true, nil).Once()
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
return strings.HasPrefix(key, "discord.image.")
})).Return("", false, nil).Once()
httpReq := &pdk.HTTPRequest{} httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))) pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`)))
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
+13 -5
View File
@@ -1,11 +1,12 @@
package main package main
import ( import (
"crypto/sha256" "crypto/md5"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"regexp"
"strings" "strings"
"github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/host"
@@ -26,7 +27,7 @@ type listenBrainzResult struct {
// buildSpotifySearchURL constructs a Spotify search URL using artist and title. // buildSpotifySearchURL constructs a Spotify search URL using artist and title.
// Used as the ultimate fallback when ListenBrainz resolution fails. // Used as the ultimate fallback when ListenBrainz resolution fails.
func buildSpotifySearchURL(title, artist string) string { func buildSpotifySearchURL(artist, title string) string {
query := strings.TrimSpace(strings.Join([]string{artist, title}, " ")) query := strings.TrimSpace(strings.Join([]string{artist, title}, " "))
if query == "" { if query == "" {
return "https://open.spotify.com/search/" return "https://open.spotify.com/search/"
@@ -45,7 +46,7 @@ func spotifySearch(term string) string {
// spotifyCacheKey returns a deterministic cache key for a track's Spotify URL. // spotifyCacheKey returns a deterministic cache key for a track's Spotify URL.
func spotifyCacheKey(artist, title, album string) string { func spotifyCacheKey(artist, title, album string) string {
h := sha256.Sum256([]byte(strings.ToLower(artist) + "\x00" + strings.ToLower(title) + "\x00" + strings.ToLower(album))) h := md5.Sum([]byte(strings.ToLower(artist) + "\x00" + strings.ToLower(title) + "\x00" + strings.ToLower(album)))
return "spotify.url." + hex.EncodeToString(h[:8]) return "spotify.url." + hex.EncodeToString(h[:8])
} }
@@ -101,7 +102,7 @@ func parseSpotifyID(body []byte) string {
} }
for _, r := range results { for _, r := range results {
for _, id := range r.SpotifyTrackIDs { for _, id := range r.SpotifyTrackIDs {
if id != "" { if isValidSpotifyID(id) {
return id return id
} }
} }
@@ -109,6 +110,13 @@ func parseSpotifyID(body []byte) string {
return "" return ""
} }
// isValidSpotifyID checks that a Spotify track ID contains only base-62 characters.
var spotifyIDRegex = regexp.MustCompile(`^[0-9A-Za-z]+$`)
func isValidSpotifyID(id string) bool {
return spotifyIDRegex.MatchString(id)
}
// resolveSpotifyURL resolves a direct Spotify track URL via ListenBrainz Labs, // resolveSpotifyURL resolves a direct Spotify track URL via ListenBrainz Labs,
// falling back to a search URL. Results are cached. // falling back to a search URL. Results are cached.
func resolveSpotifyURL(track scrobbler.TrackInfo) string { func resolveSpotifyURL(track scrobbler.TrackInfo) string {
@@ -150,7 +158,7 @@ func resolveSpotifyURL(track scrobbler.TrackInfo) string {
} }
// 3. Fallback to search URL // 3. Fallback to search URL
searchURL := buildSpotifySearchURL(track.Title, track.Artist) searchURL := buildSpotifySearchURL(track.Artist, track.Title)
_ = host.CacheSetString(cacheKey, searchURL, spotifyCacheTTLMiss) _ = host.CacheSetString(cacheKey, searchURL, spotifyCacheTTLMiss)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Spotify resolution missed, falling back to search URL for %q - %q: %s", primary, track.Title, searchURL)) pdk.Log(pdk.LogInfo, fmt.Sprintf("Spotify resolution missed, falling back to search URL for %q - %q: %s", primary, track.Title, searchURL))
return searchURL return searchURL
+40 -24
View File
@@ -33,17 +33,17 @@ var _ = Describe("Spotify", func() {
Describe("buildSpotifySearchURL", func() { Describe("buildSpotifySearchURL", func() {
DescribeTable("constructs Spotify search URL", DescribeTable("constructs Spotify search URL",
func(title, artist, expectedSubstring string) { func(artist, title, expectedSubstring string) {
url := buildSpotifySearchURL(title, artist) url := buildSpotifySearchURL(artist, title)
Expect(url).To(HavePrefix("https://open.spotify.com/search/")) Expect(url).To(HavePrefix("https://open.spotify.com/search/"))
if expectedSubstring != "" { if expectedSubstring != "" {
Expect(url).To(ContainSubstring(expectedSubstring)) Expect(url).To(ContainSubstring(expectedSubstring))
} }
}, },
Entry("artist and title", "Never Gonna Give You Up", "Rick Astley", "Rick%20Astley"), Entry("artist and title", "Rick Astley", "Never Gonna Give You Up", "Rick%20Astley"),
Entry("another track", "Karma Police", "Radiohead", "Radiohead"), Entry("another track", "Radiohead", "Karma Police", "Radiohead"),
Entry("empty title", "", "Solo Artist", "Solo%20Artist"), Entry("empty artist", "", "Only Title", "Only%20Title"),
Entry("empty artist", "Only Title", "", "Only%20Title"), Entry("empty title", "Solo Artist", "", "Solo%20Artist"),
Entry("both empty", "", "", ""), Entry("both empty", "", "", ""),
) )
}) })
@@ -93,7 +93,23 @@ var _ = Describe("Spotify", func() {
Entry("invalid JSON", Entry("invalid JSON",
`not json`, ""), `not json`, ""),
Entry("null first result falls through to second", Entry("null first result falls through to second",
`[{"spotify_track_ids":[]},{"spotify_track_ids":["abc123"]}]`, "abc123"), `[{"spotify_track_ids":[]},{"spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ"]}]`, "6vN77lE9LK6HP2DewaN6HZ"),
Entry("skips invalid ID with special characters",
`[{"spotify_track_ids":["abc!@#$%^&*()_+=-12345"]}]`, ""),
)
})
Describe("isValidSpotifyID", func() {
DescribeTable("validates Spotify track IDs",
func(id string, expected bool) {
Expect(isValidSpotifyID(id)).To(Equal(expected))
},
Entry("valid 22-char ID", "6vN77lE9LK6HP2DewaN6HZ", true),
Entry("another valid ID", "4tIGK5G9hNDA50ZdGioZRG", true),
Entry("short valid ID", "abc123", true),
Entry("special characters", "6vN77lE9!K6HP2DewaN6HZ", false),
Entry("spaces", "6vN77 E9LK6HP2DewaN6HZ", false),
Entry("empty string", "", false),
) )
}) })
@@ -128,7 +144,7 @@ var _ = Describe("Spotify", func() {
}) })
It("returns cached URL on cache hit", func() { It("returns cached URL on cache hit", func() {
host.CacheMock.On("GetString", mock.Anything).Return("https://open.spotify.com/track/cached123", true, nil) host.CacheMock.On("GetString", spotifyURLKey).Return("https://open.spotify.com/track/cached123", true, nil)
url := resolveSpotifyURL(scrobbler.TrackInfo{ url := resolveSpotifyURL(scrobbler.TrackInfo{
Title: "Karma Police", Title: "Karma Police",
@@ -139,14 +155,14 @@ var _ = Describe("Spotify", func() {
}) })
It("resolves via MBID when available", func() { It("resolves via MBID when available", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
// Mock the MBID HTTP request // Mock the MBID HTTP request
mbidReq := &pdk.HTTPRequest{} mbidReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json").Return(mbidReq) 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, pdk.PDKMock.On("Send", mbidReq).Return(pdk.NewStubHTTPResponse(200, nil,
[]byte(`[{"spotify_track_ids":["track123"]}]`))) []byte(`[{"spotify_track_ids":["63OQupATfueTdZMWIV7nzz"]}]`)))
url := resolveSpotifyURL(scrobbler.TrackInfo{ url := resolveSpotifyURL(scrobbler.TrackInfo{
Title: "Karma Police", Title: "Karma Police",
@@ -154,13 +170,13 @@ var _ = Describe("Spotify", func() {
Album: "OK Computer", Album: "OK Computer",
MBZRecordingID: "mbid-123", MBZRecordingID: "mbid-123",
}) })
Expect(url).To(Equal("https://open.spotify.com/track/track123")) Expect(url).To(Equal("https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz"))
host.CacheMock.AssertCalled(GinkgoT(), "SetString", mock.Anything, "https://open.spotify.com/track/track123", spotifyCacheTTLHit) host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, "https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz", spotifyCacheTTLHit)
}) })
It("falls back to metadata lookup when MBID fails", func() { It("falls back to metadata lookup when MBID fails", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
// MBID request fails // MBID request fails
mbidReq := &pdk.HTTPRequest{} mbidReq := &pdk.HTTPRequest{}
@@ -171,7 +187,7 @@ var _ = Describe("Spotify", func() {
metaReq := &pdk.HTTPRequest{} metaReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json").Return(metaReq) 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, pdk.PDKMock.On("Send", metaReq).Return(pdk.NewStubHTTPResponse(200, nil,
[]byte(`[{"spotify_track_ids":["meta456"]}]`))) []byte(`[{"spotify_track_ids":["4wlLbLeDWbA6TzwZFp1UaK"]}]`)))
url := resolveSpotifyURL(scrobbler.TrackInfo{ url := resolveSpotifyURL(scrobbler.TrackInfo{
Title: "Karma Police", Title: "Karma Police",
@@ -179,12 +195,12 @@ var _ = Describe("Spotify", func() {
Album: "OK Computer", Album: "OK Computer",
MBZRecordingID: "mbid-123", MBZRecordingID: "mbid-123",
}) })
Expect(url).To(Equal("https://open.spotify.com/track/meta456")) Expect(url).To(Equal("https://open.spotify.com/track/4wlLbLeDWbA6TzwZFp1UaK"))
}) })
It("falls back to search URL when both lookups fail", func() { It("falls back to search URL when both lookups fail", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
// No MBID, metadata request fails // No MBID, metadata request fails
metaReq := &pdk.HTTPRequest{} metaReq := &pdk.HTTPRequest{}
@@ -198,17 +214,17 @@ var _ = Describe("Spotify", func() {
}) })
Expect(url).To(HavePrefix("https://open.spotify.com/search/")) Expect(url).To(HavePrefix("https://open.spotify.com/search/"))
Expect(url).To(ContainSubstring("Radiohead")) Expect(url).To(ContainSubstring("Radiohead"))
host.CacheMock.AssertCalled(GinkgoT(), "SetString", mock.Anything, mock.Anything, spotifyCacheTTLMiss) host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, mock.Anything, spotifyCacheTTLMiss)
}) })
It("uses Artists fallback when primary artist parse is empty", func() { It("uses Artists fallback when primary artist parse is empty", func() {
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
metaReq := &pdk.HTTPRequest{} metaReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json").Return(metaReq) 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, pdk.PDKMock.On("Send", metaReq).Return(pdk.NewStubHTTPResponse(200, nil,
[]byte(`[{"spotify_track_ids":["fromArtists789"]}]`))) []byte(`[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`)))
url := resolveSpotifyURL(scrobbler.TrackInfo{ url := resolveSpotifyURL(scrobbler.TrackInfo{
Title: "Some Song", Title: "Some Song",
@@ -216,7 +232,7 @@ var _ = Describe("Spotify", func() {
Album: "Some Album", Album: "Some Album",
Artists: []scrobbler.ArtistRef{{Name: "Fallback Artist"}}, Artists: []scrobbler.ArtistRef{{Name: "Fallback Artist"}},
}) })
Expect(url).To(Equal("https://open.spotify.com/track/fromArtists789")) Expect(url).To(Equal("https://open.spotify.com/track/4tIGK5G9hNDA50ZdGioZRG"))
}) })
}) })
}) })