diff --git a/coverart_test.go b/coverart_test.go index 069fa4c..e3131e8 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -1,6 +1,3 @@ -//go:build integration -// +build integration - package main import ( diff --git a/main.go b/main.go index 01cd06b..572e0a7 100644 --- a/main.go +++ b/main.go @@ -11,11 +11,8 @@ package main import ( - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" - "net/url" "strings" "time" @@ -28,15 +25,15 @@ import ( // Configuration keys const ( - clientIDKey = "clientid" - usersKey = "users" - activityNameKey = "activityname" - navLogoOverlayKey = "navlogooverlay" + clientIDKey = "clientid" + usersKey = "users" + activityNameKey = "activityname" + navLogoOverlayKey = "navlogooverlay" ) // navidromeLogoURL is the small overlay image shown in the bottom-right of the album art. -// The file is stored in the plugin repository so Discord can fetch it as an external asset. -const navidromeLogoURL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/navidrome.webp" +// The file is stored in Navidrome's website repository so Discord can fetch it as an external asset. +const navidromeLogoURL = "https://raw.githubusercontent.com/navidrome/website/refs/heads/master/assets/icons/logo.webp" // Activity name display options const ( @@ -65,174 +62,6 @@ func init() { websocket.Register(rpc) } -// buildSpotifySearchURL constructs a Spotify search URL using artist and title. -// Used as the ultimate fallback when ListenBrainz resolution fails. -func buildSpotifySearchURL(title, artist string) string { - query := strings.TrimSpace(strings.Join([]string{artist, title}, " ")) - if query == "" { - return "https://open.spotify.com/search/" - } - return fmt.Sprintf("https://open.spotify.com/search/%s", url.PathEscape(query)) -} - -// spotifySearch builds a Spotify search URL for a single search term. -func spotifySearch(term string) string { - term = strings.TrimSpace(term) - if term == "" { - return "" - } - return "https://open.spotify.com/search/" + url.PathEscape(term) -} - -const ( - spotifyCacheTTLHit int64 = 30 * 24 * 60 * 60 // 30 days for resolved track IDs - spotifyCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses (retry later) -) - -// spotifyCacheKey returns a deterministic cache key for a track's Spotify URL. -func spotifyCacheKey(artist, title, album string) string { - h := sha256.Sum256([]byte(strings.ToLower(artist) + "\x00" + strings.ToLower(title) + "\x00" + strings.ToLower(album))) - return "spotify.url." + hex.EncodeToString(h[:8]) -} - -// listenBrainzResult captures the relevant field from ListenBrainz Labs JSON responses. -// The API returns spotify_track_ids as an array of strings. -type listenBrainzResult struct { - SpotifyTrackIDs []string `json:"spotify_track_ids"` -} - -// trySpotifyFromMBID calls the ListenBrainz spotify-id-from-mbid endpoint. -func trySpotifyFromMBID(mbid string) string { - body := fmt.Sprintf(`[{"recording_mbid":"%s"}]`, mbid) - req := pdk.NewHTTPRequest(pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json") - req.SetHeader("Content-Type", "application/json") - req.SetBody([]byte(body)) - - resp := req.Send() - status := resp.Status() - if status < 200 || status >= 300 { - pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz MBID lookup failed: HTTP %d, body=%s", status, string(resp.Body()))) - return "" - } - id := parseSpotifyID(resp.Body()) - if id == "" { - pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz MBID lookup returned no spotify_track_id for mbid=%s, body=%s", mbid, string(resp.Body()))) - } - return id -} - -// trySpotifyFromMetadata calls the ListenBrainz spotify-id-from-metadata endpoint. -func trySpotifyFromMetadata(artist, title, album string) string { - payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album) - req := pdk.NewHTTPRequest(pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json") - req.SetHeader("Content-Type", "application/json") - req.SetBody([]byte(payload)) - - pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata request: %s", payload)) - - resp := req.Send() - status := resp.Status() - if status < 200 || status >= 300 { - pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata lookup failed: HTTP %d, body=%s", status, string(resp.Body()))) - return "" - } - pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", status, string(resp.Body()))) - id := parseSpotifyID(resp.Body()) - if id == "" { - pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata returned no spotify_track_id for %q - %q", artist, title)) - } - return id -} - -// parseSpotifyID extracts the first spotify track ID from a ListenBrainz Labs JSON response. -// The response is an array of objects with spotify_track_ids arrays; we take the first non-empty ID. -func parseSpotifyID(body []byte) string { - var results []listenBrainzResult - if err := json.Unmarshal(body, &results); err != nil { - return "" - } - for _, r := range results { - for _, id := range r.SpotifyTrackIDs { - if id != "" { - return id - } - } - } - return "" -} - -// resolveSpotifyURL resolves a direct Spotify track URL via ListenBrainz Labs, -// falling back to a search URL. Results are cached. -func resolveSpotifyURL(track scrobbler.TrackInfo) string { - primary, _ := parsePrimaryArtist(track.Artist) - if primary == "" && len(track.Artists) > 0 { - primary = track.Artists[0].Name - } - - cacheKey := spotifyCacheKey(primary, track.Title, track.Album) - - if cached, exists, err := host.CacheGetString(cacheKey); err == nil && exists { - pdk.Log(pdk.LogInfo, fmt.Sprintf("Spotify URL cache hit for %q - %q → %s", primary, track.Title, cached)) - return cached - } - - pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolving Spotify URL for: artist=%q title=%q album=%q mbid=%q", primary, track.Title, track.Album, track.MBZRecordingID)) - - // 1. Try MBID lookup (most accurate) - if track.MBZRecordingID != "" { - if trackID := trySpotifyFromMBID(track.MBZRecordingID); trackID != "" { - directURL := "https://open.spotify.com/track/" + trackID - _ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit) - pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via MBID for %q: %s", track.Title, directURL)) - return directURL - } - pdk.Log(pdk.LogInfo, "MBID lookup did not return a Spotify ID, trying metadata…") - } else { - pdk.Log(pdk.LogInfo, "No MBZRecordingID available, skipping MBID lookup") - } - - // 2. Try metadata lookup - if primary != "" && track.Title != "" { - if trackID := trySpotifyFromMetadata(primary, track.Title, track.Album); trackID != "" { - directURL := "https://open.spotify.com/track/" + trackID - _ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit) - pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via metadata for %q - %q: %s", primary, track.Title, directURL)) - return directURL - } - } - - // 3. Fallback to search URL - searchURL := buildSpotifySearchURL(track.Title, track.Artist) - _ = 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)) - return searchURL -} - -// parsePrimaryArtist returns the primary artist (before "Feat." / "Ft." / "Featuring") -// and the optional feat suffix. For artist resolution, only the primary artist is used; -// co-artists identified by "Feat.", "Ft.", "Featuring", "&", or "/" are stripped. -func parsePrimaryArtist(artist string) (primary, featSuffix string) { - artist = strings.TrimSpace(artist) - if artist == "" { - return "", "" - } - lower := strings.ToLower(artist) - for _, sep := range []string{" feat. ", " ft. ", " featuring "} { - if i := strings.Index(lower, sep); i >= 0 { - primary = strings.TrimSpace(artist[:i]) - featSuffix = strings.TrimSpace(artist[i:]) - return primary, featSuffix - } - } - // Split on co-artist separators; take only the first artist. - for _, sep := range []string{" & ", " / "} { - if i := strings.Index(artist, sep); i >= 0 { - return strings.TrimSpace(artist[:i]), "" - } - } - return artist, "" -} - // getConfig loads the plugin configuration. func getConfig() (clientID string, users map[string]string, err error) { clientID, ok := pdk.GetConfig(clientIDKey) @@ -342,16 +171,16 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { } // Send activity update - statusDisplayType := 2 + spotifyURL := resolveSpotifyURL(input.Track) if err := rpc.sendActivity(clientID, input.Username, userToken, activity{ Application: clientID, Name: activityName, Type: 2, // Listening Details: input.Track.Title, - DetailsURL: spotifySearch(input.Track.Title), + DetailsURL: spotifyURL, State: input.Track.Artist, StateURL: spotifySearch(input.Track.Artist), - StatusDisplayType: &statusDisplayType, + StatusDisplayType: 2, Timestamps: activityTimestamps{ Start: startTime, End: endTime, @@ -359,7 +188,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { Assets: activityAssets{ LargeImage: getImageURL(input.Username, input.Track.ID), LargeText: input.Track.Album, - LargeURL: resolveSpotifyURL(input.Track), + LargeURL: spotifyURL, SmallImage: smallImage, SmallText: smallText, }, diff --git a/main_test.go b/main_test.go index a109a87..aa7ee4e 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,3 @@ -//go:build integration -// +build integration - package main import ( @@ -145,19 +142,15 @@ var _ = Describe("discordPlugin", func() { // Cancel existing clear schedule (may or may not exist) host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) - // Image mocks - cache miss, will make HTTP request to Discord - host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { - return strings.HasPrefix(key, "discord.image.") - })).Return("", false, nil) + // Cache mocks (Spotify URL resolution + Discord image processing) + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) - // Mock HTTP request for Discord external assets API - assetsReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool { - return strings.Contains(url, "external-assets") - })).Return(assetsReq) - pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) + // Mock HTTP POST requests (ListenBrainz + Discord external assets API) + postReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(postReq) + pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"spotify_track_ids":[]}]`))) // Schedule clear activity callback host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) @@ -178,11 +171,11 @@ var _ = Describe("discordPlugin", func() { DescribeTable("activity name configuration", func(configValue string, configExists bool, expectedName string) { - pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) - pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true) - pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) - pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists) - pdk.PDKMock.On("GetConfig", navLogoOverlayKey).Return("", false) + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true) + pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) + pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists) + pdk.PDKMock.On("GetConfig", navLogoOverlayKey).Return("", false) // Connect mocks host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) @@ -202,17 +195,13 @@ var _ = Describe("discordPlugin", func() { host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil) host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) - // Image mocks - host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { - return strings.HasPrefix(key, "discord.image.") - })).Return("", false, nil) + // Cache mocks (Spotify URL resolution + Discord image processing) + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) - assetsReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool { - return strings.Contains(url, "external-assets") - })).Return(assetsReq) - pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) + postReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(postReq) + pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"spotify_track_ids":[]}]`))) host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ diff --git a/manifest.json b/manifest.json index 9aead00..f180528 100644 --- a/manifest.json +++ b/manifest.json @@ -10,12 +10,11 @@ "reason": "To process scrobbles on behalf of users" }, "http": { - "reason": "To communicate with Discord API, image uploads, Spotify track resolution via ListenBrainz, and fetch the Navidrome logo overlay", + "reason": "To communicate with Discord API, image uploads, and ListenBrainz for track resolution", "requiredHosts": [ "discord.com", "uguu.se", - "labs.api.listenbrainz.org", - "raw.githubusercontent.com" + "labs.api.listenbrainz.org" ] }, "websocket": { diff --git a/plugin_suite_test.go b/plugin_suite_test.go index 3194af2..ad01992 100644 --- a/plugin_suite_test.go +++ b/plugin_suite_test.go @@ -1,6 +1,3 @@ -//go:build integration -// +build integration - package main import ( diff --git a/rpc.go b/rpc.go index 405490a..a75c5e7 100644 --- a/rpc.go +++ b/rpc.go @@ -22,10 +22,7 @@ const ( presenceOpCode = 3 // Presence update operation code ) -const ( - heartbeatInterval = 41 // Heartbeat interval in seconds - defaultImage = "https://i.imgur.com/hb3XPzA.png" -) +const heartbeatInterval = 41 // Heartbeat interval in seconds // Scheduler callback payloads for routing const ( @@ -72,7 +69,7 @@ type activity struct { State string `json:"state"` StateURL string `json:"state_url,omitempty"` Application string `json:"application_id"` - StatusDisplayType *int `json:"status_display_type,omitempty"` + StatusDisplayType int `json:"status_display_type"` Timestamps activityTimestamps `json:"timestamps"` Assets activityAssets `json:"assets"` } @@ -121,7 +118,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma if isDefaultImage { return "", fmt.Errorf("default image URL is empty") } - return r.processImage(defaultImage, clientID, token, true) + return r.processImage(navidromeLogoURL, clientID, token, true) } if strings.HasPrefix(imageURL, "mp:") { @@ -148,7 +145,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma if isDefaultImage { return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status()) } - return r.processImage(defaultImage, clientID, token, true) + return r.processImage(navidromeLogoURL, clientID, token, true) } var data []map[string]string @@ -156,14 +153,14 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma if isDefaultImage { return "", fmt.Errorf("failed to unmarshal default image response: %w", err) } - return r.processImage(defaultImage, clientID, token, true) + return r.processImage(navidromeLogoURL, clientID, token, true) } if len(data) == 0 { if isDefaultImage { return "", fmt.Errorf("no data returned for default image") } - return r.processImage(defaultImage, clientID, token, true) + return r.processImage(navidromeLogoURL, clientID, token, true) } image := data[0]["external_asset_path"] @@ -171,7 +168,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma if isDefaultImage { return "", fmt.Errorf("empty external_asset_path for default image") } - return r.processImage(defaultImage, clientID, token, true) + return r.processImage(navidromeLogoURL, clientID, token, true) } processedImage := fmt.Sprintf("mp:%s", image) diff --git a/rpc_test.go b/rpc_test.go index 7d5cc0f..b85c27e 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -1,6 +1,3 @@ -//go:build integration -// +build integration - package main import ( diff --git a/spotify.go b/spotify.go new file mode 100644 index 0000000..cba0a4b --- /dev/null +++ b/spotify.go @@ -0,0 +1,182 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" +) + +const ( + spotifyCacheTTLHit int64 = 30 * 24 * 60 * 60 // 30 days for resolved track IDs + spotifyCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses (retry later) +) + +// listenBrainzResult captures the relevant field from ListenBrainz Labs JSON responses. +// The API returns spotify_track_ids as an array of strings. +type listenBrainzResult struct { + SpotifyTrackIDs []string `json:"spotify_track_ids"` +} + +// buildSpotifySearchURL constructs a Spotify search URL using artist and title. +// Used as the ultimate fallback when ListenBrainz resolution fails. +func buildSpotifySearchURL(title, artist string) string { + query := strings.TrimSpace(strings.Join([]string{artist, title}, " ")) + if query == "" { + return "https://open.spotify.com/search/" + } + return fmt.Sprintf("https://open.spotify.com/search/%s", url.PathEscape(query)) +} + +// spotifySearch builds a Spotify search URL for a single search term. +func spotifySearch(term string) string { + term = strings.TrimSpace(term) + if term == "" { + return "" + } + return "https://open.spotify.com/search/" + url.PathEscape(term) +} + +// spotifyCacheKey returns a deterministic cache key for a track's Spotify URL. +func spotifyCacheKey(artist, title, album string) string { + h := sha256.Sum256([]byte(strings.ToLower(artist) + "\x00" + strings.ToLower(title) + "\x00" + strings.ToLower(album))) + return "spotify.url." + hex.EncodeToString(h[:8]) +} + +// trySpotifyFromMBID calls the ListenBrainz spotify-id-from-mbid endpoint. +func trySpotifyFromMBID(mbid string) string { + body := fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid) + req := pdk.NewHTTPRequest(pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json") + req.SetHeader("Content-Type", "application/json") + req.SetBody([]byte(body)) + + resp := req.Send() + status := resp.Status() + if status < 200 || status >= 300 { + pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup failed: HTTP %d, body=%s", status, string(resp.Body()))) + return "" + } + id := parseSpotifyID(resp.Body()) + if id == "" { + pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup returned no spotify_track_id for mbid=%s, body=%s", mbid, string(resp.Body()))) + } + return id +} + +// trySpotifyFromMetadata calls the ListenBrainz spotify-id-from-metadata endpoint. +func trySpotifyFromMetadata(artist, title, album string) string { + payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album) + req := pdk.NewHTTPRequest(pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json") + req.SetHeader("Content-Type", "application/json") + req.SetBody([]byte(payload)) + + pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata request: %s", payload)) + + resp := req.Send() + status := resp.Status() + if status < 200 || status >= 300 { + pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata lookup failed: HTTP %d, body=%s", status, string(resp.Body()))) + return "" + } + pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", status, string(resp.Body()))) + id := parseSpotifyID(resp.Body()) + if id == "" { + pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata returned no spotify_track_id for %q - %q", artist, title)) + } + return id +} + +// parseSpotifyID extracts the first spotify track ID from a ListenBrainz Labs JSON response. +// The response is an array of objects with spotify_track_ids arrays; we take the first non-empty ID. +func parseSpotifyID(body []byte) string { + var results []listenBrainzResult + if err := json.Unmarshal(body, &results); err != nil { + return "" + } + for _, r := range results { + for _, id := range r.SpotifyTrackIDs { + if id != "" { + return id + } + } + } + return "" +} + +// resolveSpotifyURL resolves a direct Spotify track URL via ListenBrainz Labs, +// falling back to a search URL. Results are cached. +func resolveSpotifyURL(track scrobbler.TrackInfo) string { + primary, _ := parsePrimaryArtist(track.Artist) + if primary == "" && len(track.Artists) > 0 { + primary = track.Artists[0].Name + } + + cacheKey := spotifyCacheKey(primary, track.Title, track.Album) + + if cached, exists, err := host.CacheGetString(cacheKey); err == nil && exists { + pdk.Log(pdk.LogDebug, fmt.Sprintf("Spotify URL cache hit for %q - %q → %s", primary, track.Title, cached)) + return cached + } + + pdk.Log(pdk.LogDebug, fmt.Sprintf("Resolving Spotify URL for: artist=%q title=%q album=%q mbid=%q", primary, track.Title, track.Album, track.MBZRecordingID)) + + // 1. Try MBID lookup (most accurate) + if track.MBZRecordingID != "" { + if trackID := trySpotifyFromMBID(track.MBZRecordingID); trackID != "" { + directURL := "https://open.spotify.com/track/" + trackID + _ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit) + pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via MBID for %q: %s", track.Title, directURL)) + return directURL + } + pdk.Log(pdk.LogDebug, "MBID lookup did not return a Spotify ID, trying metadata…") + } else { + pdk.Log(pdk.LogDebug, "No MBZRecordingID available, skipping MBID lookup") + } + + // 2. Try metadata lookup + if primary != "" && track.Title != "" { + if trackID := trySpotifyFromMetadata(primary, track.Title, track.Album); trackID != "" { + directURL := "https://open.spotify.com/track/" + trackID + _ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit) + pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via metadata for %q - %q: %s", primary, track.Title, directURL)) + return directURL + } + } + + // 3. Fallback to search URL + searchURL := buildSpotifySearchURL(track.Title, track.Artist) + _ = 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)) + return searchURL +} + +// parsePrimaryArtist returns the primary artist (before "Feat." / "Ft." / "Featuring") +// and the optional feat suffix. For artist resolution, only the primary artist is used; +// co-artists identified by "Feat.", "Ft.", "Featuring", "&", or "/" are stripped. +func parsePrimaryArtist(artist string) (primary, featSuffix string) { + artist = strings.TrimSpace(artist) + if artist == "" { + return "", "" + } + lower := strings.ToLower(artist) + for _, sep := range []string{" feat. ", " ft. ", " featuring "} { + if i := strings.Index(lower, sep); i >= 0 { + primary = strings.TrimSpace(artist[:i]) + featSuffix = strings.TrimSpace(artist[i:]) + return primary, featSuffix + } + } + // Split on co-artist separators; take only the first artist. + for _, sep := range []string{" & ", " / "} { + if i := strings.Index(artist, sep); i >= 0 { + return strings.TrimSpace(artist[:i]), "" + } + } + return artist, "" +} diff --git a/url_builder_test.go b/spotify_test.go similarity index 99% rename from url_builder_test.go rename to spotify_test.go index 95360fb..ae40e5e 100644 --- a/url_builder_test.go +++ b/spotify_test.go @@ -164,4 +164,3 @@ func jsonQuote(s string) string { b, _ := json.Marshal(s) return string(b) } -