From 89c778d62f60355370e9001c9989b104a168d13b Mon Sep 17 00:00:00 2001 From: WoahAI <115117306+Woahai321@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:12:59 +0000 Subject: [PATCH 01/18] validated changes --- README.md | 54 +++++++---- coverart_test.go | 3 + main.go | 207 +++++++++++++++++++++++++++++++++++++++++-- main_test.go | 13 ++- manifest.json | 16 +++- plugin_suite_test.go | 3 + rpc.go | 32 +++++-- rpc_test.go | 3 + url_builder_test.go | 167 ++++++++++++++++++++++++++++++++++ 9 files changed, 461 insertions(+), 37 deletions(-) create mode 100644 url_builder_test.go diff --git a/README.md b/README.md index b190fe5..b8342c5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ Based on the [Navicord](https://github.com/logixism/navicord) project. ## Features - Shows currently playing track with title, artist, and album art +- Clickable track title and artist name link to Spotify (direct track link via [ListenBrainz](https://listenbrainz.org), falls back to Spotify search) +- Clickable album art links to the Spotify track page +- Navidrome logo overlay on album art (optional, enabled by default) - Customizable activity name: "Navidrome" is default, but can be configured to display track title, artist, or album - Displays playback progress with start/end timestamps - Automatic presence clearing when track finishes @@ -120,6 +123,11 @@ Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Ric - **What it does**: Automatically uploads album artwork to uguu.se (temporary hosting) so Discord can display it - **When to disable**: Your Navidrome is publicly accessible and you've set `ND_BASEURL` +#### Show Navidrome Logo Overlay +- **Default**: Enabled +- **What it does**: Displays the Navidrome logo as a small overlay in the corner of the album artwork +- **When to disable**: If you prefer a clean album art display without the logo badge + #### Users Add each Navidrome user who wants Discord Rich Presence. For each user, provide: - **Username**: The Navidrome login username (case-sensitive) @@ -139,14 +147,14 @@ The plugin implements three Navidrome capabilities: ### Host Services -| Service | Usage | -|-----------------|---------------------------------------------------------------------| -| **HTTP** | Discord API calls (gateway discovery, external assets registration) | -| **WebSocket** | Persistent connection to Discord gateway | -| **Cache** | Sequence numbers, processed image URLs | -| **Scheduler** | Recurring heartbeats, one-time presence clearing | -| **Artwork** | Track artwork public URL resolution | -| **SubsonicAPI** | Fetches track artwork data for image hosting upload | +| Service | Usage | +|-----------------|--------------------------------------------------------------------------------| +| **HTTP** | Discord API calls (gateway discovery, external assets registration), ListenBrainz Spotify resolution | +| **WebSocket** | Persistent connection to Discord gateway | +| **Cache** | Sequence numbers, processed image URLs, resolved Spotify URLs | +| **Scheduler** | Recurring heartbeats, one-time presence clearing | +| **Artwork** | Track artwork public URL resolution | +| **SubsonicAPI** | Fetches track artwork data for image hosting upload | ### Flow @@ -176,15 +184,31 @@ Discord requires images to be registered via their external assets API. The plug **For non-public Navidrome instances**: If your server isn't publicly accessible (e.g., behind a VPN or firewall), enable the "Upload to uguu.se" option. This uploads artwork to a temporary file host so Discord can display it. +### Spotify Linking + +The plugin enriches the Discord presence with clickable Spotify links so others can easily find what you're listening to: + +- **Track title** → links to the Spotify track (or a Spotify search as fallback) +- **Artist name** → links to a Spotify search for the artist +- **Album art** → links to the Spotify track page + +Track URLs are resolved via the [ListenBrainz Labs API](https://labs.api.listenbrainz.org): +1. If the track has a MusicBrainz Recording ID (MBID), that is used for an exact lookup +2. Otherwise, artist name, track title, and album are used for a metadata-based lookup +3. If neither resolves, a Spotify search URL is used as a fallback + +Resolved URLs are cached (30 days for direct track links, 4 hours for search fallbacks). + ### Files -| File | Description | -|--------------------------------|------------------------------------------------------------------------| -| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations | -| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management | -| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting | -| [manifest.json](manifest.json) | Plugin metadata and permission declarations | -| [Makefile](Makefile) | Build automation | +| File | Description | +|------------------------------------|--------------------------------------------------------------------------------| +| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution | +| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management | +| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting | +| [manifest.json](manifest.json) | Plugin metadata and permission declarations | +| [navidrome.webp](navidrome.webp) | Navidrome logo used as the album art overlay badge | +| [Makefile](Makefile) | Build automation | ## Building diff --git a/coverart_test.go b/coverart_test.go index e3131e8..069fa4c 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + package main import ( diff --git a/main.go b/main.go index be5a81e..01cd06b 100644 --- a/main.go +++ b/main.go @@ -11,8 +11,11 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "net/url" "strings" "time" @@ -25,11 +28,16 @@ import ( // Configuration keys const ( - clientIDKey = "clientid" - usersKey = "users" - activityNameKey = "activityname" + 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" + // Activity name display options const ( activityNameDefault = "Default" @@ -57,6 +65,174 @@ 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) @@ -157,13 +333,25 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { activityName = input.Track.Artist } + // Navidrome logo overlay: shown by default; disabled only when explicitly set to "false" + navLogoOption, _ := pdk.GetConfig(navLogoOverlayKey) + smallImage, smallText := "", "" + if navLogoOption != "false" { + smallImage = navidromeLogoURL + smallText = "Navidrome" + } + // Send activity update + statusDisplayType := 2 if err := rpc.sendActivity(clientID, input.Username, userToken, activity{ - Application: clientID, - Name: activityName, - Type: 2, // Listening - Details: input.Track.Title, - State: input.Track.Artist, + Application: clientID, + Name: activityName, + Type: 2, // Listening + Details: input.Track.Title, + DetailsURL: spotifySearch(input.Track.Title), + State: input.Track.Artist, + StateURL: spotifySearch(input.Track.Artist), + StatusDisplayType: &statusDisplayType, Timestamps: activityTimestamps{ Start: startTime, End: endTime, @@ -171,6 +359,9 @@ 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), + SmallImage: smallImage, + SmallText: smallText, }, }); err != nil { return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err) diff --git a/main_test.go b/main_test.go index cd9aa90..a109a87 100644 --- a/main_test.go +++ b/main_test.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + package main import ( @@ -121,6 +124,7 @@ var _ = Describe("discordPlugin", func() { 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("", false) + pdk.PDKMock.On("GetConfig", navLogoOverlayKey).Return("", false) // Connect mocks (isConnected check via heartbeat) host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) @@ -174,10 +178,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", 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")) diff --git a/manifest.json b/manifest.json index 42cfdd4..9aead00 100644 --- a/manifest.json +++ b/manifest.json @@ -10,10 +10,12 @@ "reason": "To process scrobbles on behalf of users" }, "http": { - "reason": "To communicate with Discord API for gateway discovery and image uploads", + "reason": "To communicate with Discord API, image uploads, Spotify track resolution via ListenBrainz, and fetch the Navidrome logo overlay", "requiredHosts": [ "discord.com", - "uguu.se" + "uguu.se", + "labs.api.listenbrainz.org", + "raw.githubusercontent.com" ] }, "websocket": { @@ -64,6 +66,12 @@ "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", "default": false }, + "navlogooverlay": { + "type": "boolean", + "title": "Show Navidrome logo overlay on album art", + "description": "Displays the Navidrome logo as a small overlay in the corner of the album artwork in Discord", + "default": true + }, "users": { "type": "array", "title": "User Tokens", @@ -115,6 +123,10 @@ "type": "Control", "scope": "#/properties/uguuenabled" }, + { + "type": "Control", + "scope": "#/properties/navlogooverlay" + }, { "type": "Control", "scope": "#/properties/users", diff --git a/plugin_suite_test.go b/plugin_suite_test.go index ad01992..3194af2 100644 --- a/plugin_suite_test.go +++ b/plugin_suite_test.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + package main import ( diff --git a/rpc.go b/rpc.go index 229bc0f..405490a 100644 --- a/rpc.go +++ b/rpc.go @@ -63,15 +63,18 @@ func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error { return nil } -// activity represents a Discord activity. +// activity represents a Discord activity sent via Gateway opcode 3. type activity struct { - Name string `json:"name"` - Type int `json:"type"` - Details string `json:"details"` - State string `json:"state"` - Application string `json:"application_id"` - Timestamps activityTimestamps `json:"timestamps"` - Assets activityAssets `json:"assets"` + Name string `json:"name"` + Type int `json:"type"` + Details string `json:"details"` + DetailsURL string `json:"details_url,omitempty"` + State string `json:"state"` + StateURL string `json:"state_url,omitempty"` + Application string `json:"application_id"` + StatusDisplayType *int `json:"status_display_type,omitempty"` + Timestamps activityTimestamps `json:"timestamps"` + Assets activityAssets `json:"assets"` } type activityTimestamps struct { @@ -82,6 +85,9 @@ type activityTimestamps struct { type activityAssets struct { LargeImage string `json:"large_image"` LargeText string `json:"large_text"` + LargeURL string `json:"large_url,omitempty"` + SmallImage string `json:"small_image,omitempty"` + SmallText string `json:"small_text,omitempty"` } // presencePayload represents a Discord presence update. @@ -198,6 +204,16 @@ func (r *discordRPC) sendActivity(clientID, username, token string, data activit data.Assets.LargeImage = processedImage } + if data.Assets.SmallImage != "" { + processedSmall, err := r.processImage(data.Assets.SmallImage, clientID, token, false) + if err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process small image for user %s: %v", username, err)) + data.Assets.SmallImage = "" + } else { + data.Assets.SmallImage = processedSmall + } + } + presence := presencePayload{ Activities: []activity{data}, Status: "dnd", diff --git a/rpc_test.go b/rpc_test.go index b85c27e..7d5cc0f 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + package main import ( diff --git a/url_builder_test.go b/url_builder_test.go new file mode 100644 index 0000000..95360fb --- /dev/null +++ b/url_builder_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +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) + } + } +} + +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) + } + } +} + +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) + } + }) + } +} + +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) + } + + // 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) + } +} + +func jsonQuote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + -- 2.52.0 From 62df36b8709cf220b17c2893034854f00049cb53 Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 20:41:35 -0500 Subject: [PATCH 02/18] refactor: clean up integration test files and improve Spotify URL resolution logic --- coverart_test.go | 3 - main.go | 191 ++----------------------- main_test.go | 43 +++--- manifest.json | 5 +- plugin_suite_test.go | 3 - rpc.go | 17 +-- rpc_test.go | 3 - spotify.go | 182 +++++++++++++++++++++++ url_builder_test.go => spotify_test.go | 1 - 9 files changed, 217 insertions(+), 231 deletions(-) create mode 100644 spotify.go rename url_builder_test.go => spotify_test.go (99%) 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) } - -- 2.52.0 From 480a8a18d77474af1a142bcb9a7a78746887438f Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 21:03:34 -0500 Subject: [PATCH 03/18] feat: add Spotify link-through option and remove option to disable ND logo overlay --- main.go | 27 +++++++++++++-------------- main_test.go | 14 +++++++------- manifest.json | 10 +++++----- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/main.go b/main.go index 572e0a7..c522e88 100644 --- a/main.go +++ b/main.go @@ -25,10 +25,10 @@ import ( // Configuration keys const ( - clientIDKey = "clientid" - usersKey = "users" - activityNameKey = "activityname" - navLogoOverlayKey = "navlogooverlay" + clientIDKey = "clientid" + usersKey = "users" + activityNameKey = "activityname" + spotifyLinksKey = "spotifylinks" ) // navidromeLogoURL is the small overlay image shown in the bottom-right of the album art. @@ -162,16 +162,15 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { activityName = input.Track.Artist } - // Navidrome logo overlay: shown by default; disabled only when explicitly set to "false" - navLogoOption, _ := pdk.GetConfig(navLogoOverlayKey) - smallImage, smallText := "", "" - if navLogoOption != "false" { - smallImage = navidromeLogoURL - smallText = "Navidrome" + // Resolve Spotify URLs if enabled + var spotifyURL, artistSearchURL string + spotifyLinksOption, _ := pdk.GetConfig(spotifyLinksKey) + if spotifyLinksOption == "true" { + spotifyURL = resolveSpotifyURL(input.Track) + artistSearchURL = spotifySearch(input.Track.Artist) } // Send activity update - spotifyURL := resolveSpotifyURL(input.Track) if err := rpc.sendActivity(clientID, input.Username, userToken, activity{ Application: clientID, Name: activityName, @@ -179,7 +178,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { Details: input.Track.Title, DetailsURL: spotifyURL, State: input.Track.Artist, - StateURL: spotifySearch(input.Track.Artist), + StateURL: artistSearchURL, StatusDisplayType: 2, Timestamps: activityTimestamps{ Start: startTime, @@ -189,8 +188,8 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { LargeImage: getImageURL(input.Username, input.Track.ID), LargeText: input.Track.Album, LargeURL: spotifyURL, - SmallImage: smallImage, - SmallText: smallText, + SmallImage: navidromeLogoURL, + SmallText: "Navidrome", }, }); err != nil { return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err) diff --git a/main_test.go b/main_test.go index aa7ee4e..ff98959 100644 --- a/main_test.go +++ b/main_test.go @@ -121,7 +121,7 @@ var _ = Describe("discordPlugin", func() { 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("", false) - pdk.PDKMock.On("GetConfig", navLogoOverlayKey).Return("", false) + pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false) // Connect mocks (isConnected check via heartbeat) host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) @@ -142,15 +142,15 @@ var _ = Describe("discordPlugin", func() { // Cancel existing clear schedule (may or may not exist) host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) - // Cache mocks (Spotify URL resolution + Discord image processing) + // Cache mocks (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 POST requests (ListenBrainz + Discord external assets API) + // Mock HTTP POST requests (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":[]}]`))) + pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{}`))) // Schedule clear activity callback host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) @@ -175,7 +175,7 @@ var _ = Describe("discordPlugin", func() { 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", spotifyLinksKey).Return("", false) // Connect mocks host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) @@ -195,13 +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) - // Cache mocks (Spotify URL resolution + Discord image processing) + // Cache mocks (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) 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":[]}]`))) + pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{}`))) 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 f180528..ee95130 100644 --- a/manifest.json +++ b/manifest.json @@ -65,11 +65,11 @@ "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", "default": false }, - "navlogooverlay": { + "spotifylinks": { "type": "boolean", - "title": "Show Navidrome logo overlay on album art", - "description": "Displays the Navidrome logo as a small overlay in the corner of the album artwork in Discord", - "default": true + "title": "Enable Spotify link-through", + "description": "When enabled, clicking the track title or album art in Discord opens the corresponding Spotify page", + "default": false }, "users": { "type": "array", @@ -124,7 +124,7 @@ }, { "type": "Control", - "scope": "#/properties/navlogooverlay" + "scope": "#/properties/spotifylinks" }, { "type": "Control", -- 2.52.0 From 902239759ae34baedfcc45212e73660b60ed3aaf Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 21:35:40 -0500 Subject: [PATCH 04/18] refactor: simplify processImage by removing recursion and add conditional SmallImage Remove the recursive fallback pattern from processImage (5 duplicated branches) and replace with a straight-line flow that returns errors to the caller. Move fallback orchestration to sendActivity, which now tries track artwork first, falls back to the Navidrome logo, and only shows the SmallImage overlay when LargeImage is actual track art. --- rpc.go | 66 ++++++++-------- rpc_test.go | 222 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 242 insertions(+), 46 deletions(-) diff --git a/rpc.go b/rpc.go index a75c5e7..3305039 100644 --- a/rpc.go +++ b/rpc.go @@ -24,6 +24,12 @@ const ( const heartbeatInterval = 41 // Heartbeat interval in seconds +// Image cache TTL constants +const ( + imageCacheTTL int64 = 4 * 60 * 60 // 4 hours for track artwork + defaultImageCacheTTL int64 = 48 * 60 * 60 // 48 hours for default Navidrome logo +) + // Scheduler callback payloads for routing const ( payloadHeartbeat = "heartbeat" @@ -112,13 +118,11 @@ type identifyProperties struct { // Image Processing // ============================================================================ -// processImage processes an image URL for Discord, with fallback to default image. -func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) { +// processImage processes an image URL for Discord. Returns the processed image +// string (mp:prefixed) or an error. No fallback logic — the caller handles retries. +func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (string, error) { if imageURL == "" { - if isDefaultImage { - return "", fmt.Errorf("default image URL is empty") - } - return r.processImage(navidromeLogoURL, clientID, token, true) + return "", fmt.Errorf("image URL is empty") } if strings.HasPrefix(imageURL, "mp:") { @@ -142,43 +146,25 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma resp := req.Send() if resp.Status() >= 400 { - if isDefaultImage { - return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status()) - } - return r.processImage(navidromeLogoURL, clientID, token, true) + return "", fmt.Errorf("failed to process image: HTTP %d", resp.Status()) } var data []map[string]string if err := json.Unmarshal(resp.Body(), &data); err != nil { - if isDefaultImage { - return "", fmt.Errorf("failed to unmarshal default image response: %w", err) - } - return r.processImage(navidromeLogoURL, clientID, token, true) + return "", fmt.Errorf("failed to unmarshal image response: %w", err) } if len(data) == 0 { - if isDefaultImage { - return "", fmt.Errorf("no data returned for default image") - } - return r.processImage(navidromeLogoURL, clientID, token, true) + return "", fmt.Errorf("no data returned for image") } image := data[0]["external_asset_path"] if image == "" { - if isDefaultImage { - return "", fmt.Errorf("empty external_asset_path for default image") - } - return r.processImage(navidromeLogoURL, clientID, token, true) + return "", fmt.Errorf("empty external_asset_path for image") } processedImage := fmt.Sprintf("mp:%s", image) - // Cache the processed image URL - var ttl int64 = 4 * 60 * 60 // 4 hours for regular images - if isDefaultImage { - ttl = 48 * 60 * 60 // 48 hours for default image - } - _ = host.CacheSetString(cacheKey, processedImage, ttl) pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl)) @@ -193,19 +179,33 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma func (r *discordRPC) sendActivity(clientID, username, token string, data activity) error { pdk.Log(pdk.LogInfo, fmt.Sprintf("Sending activity for user %s: %s - %s", username, data.Details, data.State)) - processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false) + // Try track artwork first, fall back to Navidrome logo + usingDefaultImage := false + processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, imageCacheTTL) if err != nil { - pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process image for user %s, continuing without image: %v", username, err)) - data.Assets.LargeImage = "" + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process track image for user %s: %v, falling back to default", username, err)) + processedImage, err = r.processImage(navidromeLogoURL, clientID, token, defaultImageCacheTTL) + if err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process default image for user %s: %v, continuing without image", username, err)) + data.Assets.LargeImage = "" + } else { + data.Assets.LargeImage = processedImage + usingDefaultImage = true + } } else { data.Assets.LargeImage = processedImage } - if data.Assets.SmallImage != "" { - processedSmall, err := r.processImage(data.Assets.SmallImage, clientID, token, false) + // Only show SmallImage (Navidrome logo overlay) when LargeImage is actual track artwork + if usingDefaultImage || data.Assets.LargeImage == "" { + data.Assets.SmallImage = "" + data.Assets.SmallText = "" + } else if data.Assets.SmallImage != "" { + processedSmall, err := r.processImage(data.Assets.SmallImage, clientID, token, defaultImageCacheTTL) if err != nil { pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process small image for user %s: %v", username, err)) data.Assets.SmallImage = "" + data.Assets.SmallText = "" } else { data.Assets.SmallImage = processedSmall } diff --git a/rpc_test.go b/rpc_test.go index b85c27e..258e9c9 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -232,26 +232,115 @@ var _ = Describe("discordRPC", func() { }) }) + Describe("processImage", func() { + BeforeEach(func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + }) + + It("returns error for empty URL", func() { + _, err := r.processImage("", "client123", "token123", imageCacheTTL) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("image URL is empty")) + }) + + It("returns mp: prefixed URL as-is", func() { + result, err := r.processImage("mp:external/abc123", "client123", "token123", imageCacheTTL) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal("mp:external/abc123")) + }) + + It("returns cached value on cache hit", func() { + host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { + return strings.HasPrefix(key, "discord.image.") + })).Return("mp:cached/image", true, nil) + + result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal("mp:cached/image")) + }) + + It("processes image via Discord API and caches result", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.MatchedBy(func(val string) bool { + return val == "mp:external/new-asset" + }), int64(imageCacheTTL)).Return(nil) + + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + 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) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal("mp:external/new-asset")) + }) + + It("returns error on HTTP failure", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))) + + _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("HTTP 500")) + }) + + It("returns error on unmarshal failure", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + 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) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to unmarshal")) + }) + + It("returns error on empty response array", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[]`))) + + _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no data returned")) + }) + + It("returns error on empty external_asset_path", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + 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) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("empty external_asset_path")) + }) + }) + Describe("sendActivity", func() { BeforeEach(func() { pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { - return strings.HasPrefix(key, "discord.image.") - })).Return("", false, nil) - host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) - - // Mock HTTP request for Discord external assets API (image processing) - // When processImage is called, it makes an HTTP request - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) }) - It("sends activity update to Discord", func() { + It("sends activity with track artwork and SmallImage overlay", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + 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 { return strings.Contains(msg, `"op":3`) && - strings.Contains(msg, `"name":"Test Song"`) && - strings.Contains(msg, `"state":"Test Artist"`) + strings.Contains(msg, `"large_image":"mp:external/art"`) && + strings.Contains(msg, `"small_image":"mp:external/art"`) && + strings.Contains(msg, `"small_text":"Navidrome"`) })).Return(nil) err := r.sendActivity("client123", "testuser", "token123", activity{ @@ -260,6 +349,113 @@ var _ = Describe("discordRPC", func() { Type: 2, State: "Test Artist", Details: "Test Album", + Assets: activityAssets{ + LargeImage: "https://example.com/art.jpg", + LargeText: "Test Album", + SmallImage: navidromeLogoURL, + SmallText: "Navidrome", + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("falls back to default image and clears SmallImage", func() { + // Track art fails (HTTP error), default image succeeds + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + trackReq := &pdk.HTTPRequest{} + defaultReq := &pdk.HTTPRequest{} + + // First call (track art) returns 500, second call (default) succeeds + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(trackReq).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("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 { + return strings.Contains(msg, `"op":3`) && + strings.Contains(msg, `"large_image":"mp:external/logo"`) && + !strings.Contains(msg, `"small_image":"mp:`) && + !strings.Contains(msg, `"small_text":"Navidrome"`) + })).Return(nil) + + err := r.sendActivity("client123", "testuser", "token123", activity{ + Application: "client123", + Name: "Test Song", + Type: 2, + State: "Test Artist", + Details: "Test Album", + Assets: activityAssets{ + LargeImage: "https://example.com/art.jpg", + LargeText: "Test Album", + SmallImage: navidromeLogoURL, + SmallText: "Navidrome", + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("clears all images when both track art and default fail", func() { + host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + 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 { + return strings.Contains(msg, `"op":3`) && + strings.Contains(msg, `"large_image":""`) && + !strings.Contains(msg, `"small_image":"mp:`) + })).Return(nil) + + err := r.sendActivity("client123", "testuser", "token123", activity{ + Application: "client123", + Name: "Test Song", + Type: 2, + State: "Test Artist", + Details: "Test Album", + Assets: activityAssets{ + LargeImage: "https://example.com/art.jpg", + LargeText: "Test Album", + SmallImage: navidromeLogoURL, + SmallText: "Navidrome", + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("handles SmallImage processing failure gracefully", func() { + // LargeImage from cache (succeeds), SmallImage API fails + host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { + return strings.HasPrefix(key, "discord.image.") + })).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{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))) + + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"large_image":"mp:cached/large"`) && + !strings.Contains(msg, `"small_image":"mp:`) + })).Return(nil) + + err := r.sendActivity("client123", "testuser", "token123", activity{ + Application: "client123", + Name: "Test Song", + Type: 2, + State: "Test Artist", + Details: "Test Album", + Assets: activityAssets{ + LargeImage: "https://example.com/art.jpg", + LargeText: "Test Album", + SmallImage: navidromeLogoURL, + SmallText: "Navidrome", + }, }) Expect(err).ToNot(HaveOccurred()) }) -- 2.52.0 From 49caff0cb70f85c4c5f24fe561ca6716522712ff Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 21:57:02 -0500 Subject: [PATCH 05/18] feat: update status display type based on activity name configuration --- main.go | 5 ++++- main_test.go | 13 +++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index c522e88..3e0f69d 100644 --- a/main.go +++ b/main.go @@ -152,6 +152,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { // Resolve the activity name based on configuration activityName := "Navidrome" + statusDisplayType := 0 activityNameOption, _ := pdk.GetConfig(activityNameKey) switch activityNameOption { case activityNameTrack: @@ -160,6 +161,8 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { activityName = input.Track.Album case activityNameArtist: activityName = input.Track.Artist + default: + statusDisplayType = 2 } // Resolve Spotify URLs if enabled @@ -179,7 +182,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { DetailsURL: spotifyURL, State: input.Track.Artist, StateURL: artistSearchURL, - StatusDisplayType: 2, + StatusDisplayType: statusDisplayType, Timestamps: activityTimestamps{ Start: startTime, End: endTime, diff --git a/main_test.go b/main_test.go index ff98959..caa3e5e 100644 --- a/main_test.go +++ b/main_test.go @@ -170,7 +170,7 @@ var _ = Describe("discordPlugin", func() { }) DescribeTable("activity name configuration", - func(configValue string, configExists bool, expectedName string) { + func(configValue string, configExists bool, expectedName string, expectedDisplayType int) { 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) @@ -217,12 +217,13 @@ var _ = Describe("discordPlugin", func() { }) Expect(err).ToNot(HaveOccurred()) Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"name":"%s"`, expectedName))) + Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"status_display_type":%d`, expectedDisplayType))) }, - Entry("defaults to Navidrome when not configured", "", false, "Navidrome"), - Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome"), - Entry("uses track title when configured", "Track", true, "Test Song"), - Entry("uses track album when configured", "Album", true, "Test Album"), - Entry("uses track artist when configured", "Artist", true, "Test Artist"), + Entry("defaults to Navidrome when not configured", "", false, "Navidrome", 2), + Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome", 2), + Entry("uses track title when configured", "Track", true, "Test Song", 0), + Entry("uses track album when configured", "Album", true, "Test Album", 0), + Entry("uses track artist when configured", "Artist", true, "Test Artist", 0), ) }) -- 2.52.0 From 04a31978ce89ee39b6d65ed5d09ff438de8f63e4 Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 22:00:41 -0500 Subject: [PATCH 06/18] test: refactor Spotify tests to use Ginkgo and Gomega for consistency --- spotify_test.go | 364 ++++++++++++++++++++++++++++-------------------- 1 file changed, 210 insertions(+), 154 deletions(-) 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")) + }) + }) +}) -- 2.52.0 From d10ee8588d89a62997c9063641b29c3ed3e467fb Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 22:28:13 -0500 Subject: [PATCH 07/18] fix: address code review issues for Spotify and Discord RPC - Use MD5 hashing for image and Spotify cache keys instead of raw hex encoding (rpc.go) and SHA-256 (spotify.go) - Validate Spotify track IDs with base-62 regex before using in URLs - Fix buildSpotifySearchURL parameter order to match (artist, title) usage - Tighten test mock matchers with shared helpers for cache keys and external-assets URLs, replacing broad mock.Anything usage - Update test Spotify IDs to use valid base-62 identifiers --- main_test.go | 12 ++++----- plugin_suite_test.go | 9 +++++++ rpc.go | 5 +++- rpc_test.go | 50 ++++++++++++++++------------------ spotify.go | 18 +++++++++---- spotify_test.go | 64 +++++++++++++++++++++++++++----------------- 6 files changed, 95 insertions(+), 63 deletions(-) diff --git a/main_test.go b/main_test.go index caa3e5e..a1b6ed0 100644 --- a/main_test.go +++ b/main_test.go @@ -143,13 +143,13 @@ var _ = Describe("discordPlugin", func() { host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) // Cache mocks (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.CacheMock.On("GetString", discordImageKey).Return("", false, 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) // Mock HTTP POST requests (Discord external assets API) 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(`{}`))) // Schedule clear activity callback @@ -196,11 +196,11 @@ var _ = Describe("discordPlugin", func() { host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) // Cache mocks (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.CacheMock.On("GetString", discordImageKey).Return("", false, 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) 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(`{}`))) host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) diff --git a/plugin_suite_test.go b/plugin_suite_test.go index ad01992..8f69496 100644 --- a/plugin_suite_test.go +++ b/plugin_suite_test.go @@ -1,13 +1,22 @@ package main import ( + "strings" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" ) func TestDiscordPlugin(t *testing.T) { RegisterFailHandler(Fail) 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.") }) +) diff --git a/rpc.go b/rpc.go index 3305039..2a18690 100644 --- a/rpc.go +++ b/rpc.go @@ -6,6 +6,8 @@ package main import ( + "crypto/md5" + "encoding/hex" "encoding/json" "fmt" "strings" @@ -130,7 +132,8 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) ( } // 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) if err == nil && exists { pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL)) diff --git a/rpc_test.go b/rpc_test.go index 258e9c9..fbee7a3 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -260,13 +260,13 @@ var _ = Describe("discordRPC", func() { }) It("processes image via Discord API and caches result", func() { - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) - host.CacheMock.On("SetString", mock.Anything, mock.MatchedBy(func(val string) bool { + host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) + host.CacheMock.On("SetString", discordImageKey, mock.MatchedBy(func(val string) bool { return val == "mp:external/new-asset" }), int64(imageCacheTTL)).Return(nil) 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"}]`))) 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() { - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) 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`))) _, 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() { - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) 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"}`))) _, 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() { - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) 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(`[]`))) _, 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() { - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) 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":""}]`))) _, 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() { - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) - host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) + host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) 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"}]`))) 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() { // Track art fails (HTTP error), default image succeeds - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) - host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) + host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) trackReq := &pdk.HTTPRequest{} defaultReq := &pdk.HTTPRequest{} // 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("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() 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() { - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) + host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) 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"}`))) 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() { // LargeImage from cache (succeeds), SmallImage API fails - host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { - return strings.HasPrefix(key, "discord.image.") - })).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() + host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/large", true, nil).Once() + host.CacheMock.On("GetString", discordImageKey).Return("", false, nil).Once() 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`))) host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { diff --git a/spotify.go b/spotify.go index cba0a4b..8f618bd 100644 --- a/spotify.go +++ b/spotify.go @@ -1,11 +1,12 @@ package main import ( - "crypto/sha256" + "crypto/md5" "encoding/hex" "encoding/json" "fmt" "net/url" + "regexp" "strings" "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. // 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}, " ")) if query == "" { 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. 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]) } @@ -101,7 +102,7 @@ func parseSpotifyID(body []byte) string { } for _, r := range results { for _, id := range r.SpotifyTrackIDs { - if id != "" { + if isValidSpotifyID(id) { return id } } @@ -109,6 +110,13 @@ func parseSpotifyID(body []byte) string { 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, // falling back to a search URL. Results are cached. func resolveSpotifyURL(track scrobbler.TrackInfo) string { @@ -150,7 +158,7 @@ func resolveSpotifyURL(track scrobbler.TrackInfo) string { } // 3. Fallback to search URL - searchURL := buildSpotifySearchURL(track.Title, track.Artist) + searchURL := buildSpotifySearchURL(track.Artist, track.Title) _ = 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 diff --git a/spotify_test.go b/spotify_test.go index 95fb6de..d825dc3 100644 --- a/spotify_test.go +++ b/spotify_test.go @@ -33,17 +33,17 @@ var _ = Describe("Spotify", func() { Describe("buildSpotifySearchURL", func() { DescribeTable("constructs Spotify search URL", - func(title, artist, expectedSubstring string) { - url := buildSpotifySearchURL(title, artist) + func(artist, title, expectedSubstring string) { + url := buildSpotifySearchURL(artist, title) 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("artist and title", "Rick Astley", "Never Gonna Give You Up", "Rick%20Astley"), + Entry("another track", "Radiohead", "Karma Police", "Radiohead"), + Entry("empty artist", "", "Only Title", "Only%20Title"), + Entry("empty title", "Solo Artist", "", "Solo%20Artist"), Entry("both empty", "", "", ""), ) }) @@ -93,7 +93,23 @@ var _ = Describe("Spotify", func() { Entry("invalid JSON", `not json`, ""), 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() { - 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{ Title: "Karma Police", @@ -139,14 +155,14 @@ var _ = Describe("Spotify", func() { }) 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) + host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil) + host.CacheMock.On("SetString", spotifyURLKey, 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"]}]`))) + []byte(`[{"spotify_track_ids":["63OQupATfueTdZMWIV7nzz"]}]`))) url := resolveSpotifyURL(scrobbler.TrackInfo{ Title: "Karma Police", @@ -154,13 +170,13 @@ var _ = Describe("Spotify", func() { 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) + Expect(url).To(Equal("https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz")) + host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, "https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz", 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) + host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil) + host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil) // MBID request fails mbidReq := &pdk.HTTPRequest{} @@ -171,7 +187,7 @@ var _ = Describe("Spotify", func() { 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"]}]`))) + []byte(`[{"spotify_track_ids":["4wlLbLeDWbA6TzwZFp1UaK"]}]`))) url := resolveSpotifyURL(scrobbler.TrackInfo{ Title: "Karma Police", @@ -179,12 +195,12 @@ var _ = Describe("Spotify", func() { Album: "OK Computer", 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() { - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) - host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil) + host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil) // No MBID, metadata request fails metaReq := &pdk.HTTPRequest{} @@ -198,17 +214,17 @@ var _ = Describe("Spotify", func() { }) Expect(url).To(HavePrefix("https://open.spotify.com/search/")) 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() { - host.CacheMock.On("GetString", mock.Anything).Return("", false, nil) - host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil) + host.CacheMock.On("SetString", spotifyURLKey, 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"]}]`))) + []byte(`[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`))) url := resolveSpotifyURL(scrobbler.TrackInfo{ Title: "Some Song", @@ -216,7 +232,7 @@ var _ = Describe("Spotify", func() { Album: "Some Album", 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")) }) }) }) -- 2.52.0 From 8a1d37efeb7c325c31ca85f99054806a18ca2d89 Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 22:31:59 -0500 Subject: [PATCH 08/18] docs: update README to clarify Navidrome logo overlay and add Spotify link-through feature --- README.md | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b8342c5..1f8d9c8 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Based on the [Navicord](https://github.com/logixism/navicord) project. - Shows currently playing track with title, artist, and album art - Clickable track title and artist name link to Spotify (direct track link via [ListenBrainz](https://listenbrainz.org), falls back to Spotify search) - Clickable album art links to the Spotify track page -- Navidrome logo overlay on album art (optional, enabled by default) +- Navidrome logo overlay on album art when track artwork is available - Customizable activity name: "Navidrome" is default, but can be configured to display track title, artist, or album - Displays playback progress with start/end timestamps - Automatic presence clearing when track finishes @@ -51,6 +51,7 @@ We don't provide instructions for obtaining the token as it may violate Discord' - **Activity Name Display**: Choose what to show as the activity name (Default, Track, Album, Artist) - "Default" is recommended to help spread awareness of your favorite music server 😉, but feel free to choose the option that best suits your preferences - **Upload to uguu.se**: Enable this if your Navidrome isn't publicly accessible (see Album Art section below) + - **Enable Spotify link-through**: Enable this to make track title and album art clickable links to Spotify - **Users**: Add your Navidrome username and Discord token from Step 3 ### Step 5: Enable Discord Activity Sharing @@ -123,10 +124,10 @@ Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Ric - **What it does**: Automatically uploads album artwork to uguu.se (temporary hosting) so Discord can display it - **When to disable**: Your Navidrome is publicly accessible and you've set `ND_BASEURL` -#### Show Navidrome Logo Overlay -- **Default**: Enabled -- **What it does**: Displays the Navidrome logo as a small overlay in the corner of the album artwork -- **When to disable**: If you prefer a clean album art display without the logo badge +#### Enable Spotify Link-through +- **Default**: Disabled +- **What it does**: When enabled, clicking the track title or album art in Discord opens the corresponding Spotify page +- **How it works**: Track URLs are resolved via [ListenBrainz Labs](https://labs.api.listenbrainz.org) for direct Spotify links, falling back to Spotify search when no match is found #### Users Add each Navidrome user who wants Discord Rich Presence. For each user, provide: @@ -147,14 +148,14 @@ The plugin implements three Navidrome capabilities: ### Host Services -| Service | Usage | -|-----------------|--------------------------------------------------------------------------------| +| Service | Usage | +|-----------------|------------------------------------------------------------------------------------------------------| | **HTTP** | Discord API calls (gateway discovery, external assets registration), ListenBrainz Spotify resolution | -| **WebSocket** | Persistent connection to Discord gateway | -| **Cache** | Sequence numbers, processed image URLs, resolved Spotify URLs | -| **Scheduler** | Recurring heartbeats, one-time presence clearing | -| **Artwork** | Track artwork public URL resolution | -| **SubsonicAPI** | Fetches track artwork data for image hosting upload | +| **WebSocket** | Persistent connection to Discord gateway | +| **Cache** | Sequence numbers, processed image URLs, resolved Spotify URLs | +| **Scheduler** | Recurring heartbeats, one-time presence clearing | +| **Artwork** | Track artwork public URL resolution | +| **SubsonicAPI** | Fetches track artwork data for image hosting upload | ### Flow @@ -201,14 +202,14 @@ Resolved URLs are cached (30 days for direct track links, 4 hours for search fal ### Files -| File | Description | -|------------------------------------|--------------------------------------------------------------------------------| -| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution | -| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management | -| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting | -| [manifest.json](manifest.json) | Plugin metadata and permission declarations | -| [navidrome.webp](navidrome.webp) | Navidrome logo used as the album art overlay badge | -| [Makefile](Makefile) | Build automation | +| File | Description | +|----------------------------------|-------------------------------------------------------------------------------------| +| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution | +| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management | +| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting | +| [manifest.json](manifest.json) | Plugin metadata and permission declarations | +| [navidrome.webp](navidrome.webp) | Navidrome logo used as the album art overlay badge | +| [Makefile](Makefile) | Build automation | ## Building -- 2.52.0 From 019fff137d27d4c197f7275dfaa383cf89df3a8e Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 23:13:30 -0500 Subject: [PATCH 09/18] refactor: update status display logic and improve Spotify URL handling --- README.md | 1 - main.go | 9 +++++---- rpc.go | 12 ++++++++---- spotify.go | 30 +++++++++++++----------------- spotify_test.go | 19 +++++++------------ 5 files changed, 33 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 1f8d9c8..738f2de 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,6 @@ Resolved URLs are cached (30 days for direct track links, 4 hours for search fal | [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management | | [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting | | [manifest.json](manifest.json) | Plugin metadata and permission declarations | -| [navidrome.webp](navidrome.webp) | Navidrome logo used as the album art overlay badge | | [Makefile](Makefile) | Build automation | ## Building diff --git a/main.go b/main.go index 3e0f69d..9aebd0b 100644 --- a/main.go +++ b/main.go @@ -152,17 +152,18 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { // Resolve the activity name based on configuration activityName := "Navidrome" - statusDisplayType := 0 + statusDisplayType := statusDisplayListening activityNameOption, _ := pdk.GetConfig(activityNameKey) switch activityNameOption { case activityNameTrack: activityName = input.Track.Title + statusDisplayType = statusDisplayDefault case activityNameAlbum: activityName = input.Track.Album + statusDisplayType = statusDisplayDefault case activityNameArtist: activityName = input.Track.Artist - default: - statusDisplayType = 2 + statusDisplayType = statusDisplayDefault } // Resolve Spotify URLs if enabled @@ -170,7 +171,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { spotifyLinksOption, _ := pdk.GetConfig(spotifyLinksKey) if spotifyLinksOption == "true" { spotifyURL = resolveSpotifyURL(input.Track) - artistSearchURL = spotifySearch(input.Track.Artist) + artistSearchURL = spotifySearchURL(input.Track.Artist) } // Send activity update diff --git a/rpc.go b/rpc.go index 2a18690..d710c57 100644 --- a/rpc.go +++ b/rpc.go @@ -6,8 +6,6 @@ package main import ( - "crypto/md5" - "encoding/hex" "encoding/json" "fmt" "strings" @@ -24,6 +22,13 @@ const ( presenceOpCode = 3 // Presence update operation code ) +// Discord status_display_type values control how the activity name is shown. +// Type 0 renders the name as-is; type 2 renders the name with a "Listening to" prefix. +const ( + statusDisplayDefault = 0 // Show activity name as-is (e.g. track title) + statusDisplayListening = 2 // Show "Listening to " (used for "Navidrome") +) + const heartbeatInterval = 41 // Heartbeat interval in seconds // Image cache TTL constants @@ -132,8 +137,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) ( } // Check cache first - h := md5.Sum([]byte(imageURL)) - cacheKey := "discord.image." + hex.EncodeToString(h[:8]) + cacheKey := "discord.image." + hashKey(imageURL) cachedValue, exists, err := host.CacheGetString(cacheKey) if err == nil && exists { pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL)) diff --git a/spotify.go b/spotify.go index 8f618bd..20cec67 100644 --- a/spotify.go +++ b/spotify.go @@ -14,6 +14,12 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" ) +// hashKey returns a hex-encoded MD5 hash of s, for use as a cache key suffix. +func hashKey(s string) string { + h := md5.Sum([]byte(s)) + return hex.EncodeToString(h[:]) +} + const ( spotifyCacheTTLHit int64 = 30 * 24 * 60 * 60 // 30 days for resolved track IDs spotifyCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses (retry later) @@ -25,29 +31,19 @@ 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(artist, title string) string { - query := strings.TrimSpace(strings.Join([]string{artist, title}, " ")) +// spotifySearchURL builds a Spotify search URL from one or more terms. +// Empty terms are ignored. Returns "" if all terms are empty. +func spotifySearchURL(terms ...string) string { + query := strings.TrimSpace(strings.Join(terms, " ")) 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) + return "https://open.spotify.com/search/" + url.PathEscape(query) } // spotifyCacheKey returns a deterministic cache key for a track's Spotify URL. func spotifyCacheKey(artist, title, album string) string { - 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." + hashKey(strings.ToLower(artist)+"\x00"+strings.ToLower(title)+"\x00"+strings.ToLower(album)) } // trySpotifyFromMBID calls the ListenBrainz spotify-id-from-mbid endpoint. @@ -158,7 +154,7 @@ func resolveSpotifyURL(track scrobbler.TrackInfo) string { } // 3. Fallback to search URL - searchURL := buildSpotifySearchURL(track.Artist, track.Title) + searchURL := spotifySearchURL(track.Artist, track.Title) _ = 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 diff --git a/spotify_test.go b/spotify_test.go index d825dc3..99719ec 100644 --- a/spotify_test.go +++ b/spotify_test.go @@ -31,20 +31,15 @@ var _ = Describe("Spotify", func() { ) }) - Describe("buildSpotifySearchURL", func() { + Describe("spotifySearchURL", func() { DescribeTable("constructs Spotify search URL", - func(artist, title, expectedSubstring string) { - url := buildSpotifySearchURL(artist, title) - Expect(url).To(HavePrefix("https://open.spotify.com/search/")) - if expectedSubstring != "" { - Expect(url).To(ContainSubstring(expectedSubstring)) - } + func(expectedURL string, terms ...string) { + Expect(spotifySearchURL(terms...)).To(Equal(expectedURL)) }, - Entry("artist and title", "Rick Astley", "Never Gonna Give You Up", "Rick%20Astley"), - Entry("another track", "Radiohead", "Karma Police", "Radiohead"), - Entry("empty artist", "", "Only Title", "Only%20Title"), - Entry("empty title", "Solo Artist", "", "Solo%20Artist"), - Entry("both empty", "", "", ""), + Entry("artist and title", "https://open.spotify.com/search/Rick%20Astley%20Never%20Gonna%20Give%20You%20Up", "Rick Astley", "Never Gonna Give You Up"), + Entry("single term", "https://open.spotify.com/search/Radiohead", "Radiohead"), + Entry("empty terms", "", "", ""), + Entry("one empty term", "https://open.spotify.com/search/Solo%20Artist", "Solo Artist", ""), ) }) -- 2.52.0 From a23e3f1e4de758a6c748871f696ef5d2700c3f80 Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 23:23:53 -0500 Subject: [PATCH 10/18] refactor: simplify primary artist resolution and remove unused parsing function --- spotify.go | 29 ++--------------------------- spotify_test.go | 35 +++++++++++------------------------ 2 files changed, 13 insertions(+), 51 deletions(-) diff --git a/spotify.go b/spotify.go index 20cec67..ef9b862 100644 --- a/spotify.go +++ b/spotify.go @@ -116,8 +116,8 @@ func isValidSpotifyID(id string) bool { // 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 { + var primary string + if len(track.Artists) > 0 { primary = track.Artists[0].Name } @@ -159,28 +159,3 @@ func resolveSpotifyURL(track scrobbler.TrackInfo) string { 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/spotify_test.go b/spotify_test.go index 99719ec..73e9dc3 100644 --- a/spotify_test.go +++ b/spotify_test.go @@ -14,23 +14,6 @@ import ( ) 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", "", "", ""), - ) - }) - Describe("spotifySearchURL", func() { DescribeTable("constructs Spotify search URL", func(expectedURL string, terms ...string) { @@ -142,9 +125,10 @@ var _ = Describe("Spotify", func() { host.CacheMock.On("GetString", spotifyURLKey).Return("https://open.spotify.com/track/cached123", true, nil) url := resolveSpotifyURL(scrobbler.TrackInfo{ - Title: "Karma Police", - Artist: "Radiohead", - Album: "OK Computer", + Title: "Karma Police", + Artist: "Radiohead", + Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}}, + Album: "OK Computer", }) Expect(url).To(Equal("https://open.spotify.com/track/cached123")) }) @@ -162,6 +146,7 @@ var _ = Describe("Spotify", func() { url := resolveSpotifyURL(scrobbler.TrackInfo{ Title: "Karma Police", Artist: "Radiohead", + Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}}, Album: "OK Computer", MBZRecordingID: "mbid-123", }) @@ -187,6 +172,7 @@ var _ = Describe("Spotify", func() { url := resolveSpotifyURL(scrobbler.TrackInfo{ Title: "Karma Police", Artist: "Radiohead", + Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}}, Album: "OK Computer", MBZRecordingID: "mbid-123", }) @@ -203,16 +189,17 @@ var _ = Describe("Spotify", func() { pdk.PDKMock.On("Send", metaReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))) url := resolveSpotifyURL(scrobbler.TrackInfo{ - Title: "Karma Police", - Artist: "Radiohead", - Album: "OK Computer", + Title: "Karma Police", + Artist: "Radiohead", + Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}}, + Album: "OK Computer", }) Expect(url).To(HavePrefix("https://open.spotify.com/search/")) Expect(url).To(ContainSubstring("Radiohead")) host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, mock.Anything, spotifyCacheTTLMiss) }) - It("uses Artists fallback when primary artist parse is empty", func() { + It("uses Artists[0] for primary artist", func() { host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil) host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil) -- 2.52.0 From df3a42620376a45cf783946f94f08b6108599a38 Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 23 Feb 2026 23:41:56 -0500 Subject: [PATCH 11/18] feat: add a link to Navidrome's website on the overlay logo --- logo.webp | Bin 0 -> 18872 bytes main.go | 11 ++++++++--- rpc.go | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 logo.webp diff --git a/logo.webp b/logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..36236080bc2751f3ab26e8e548913a94417eb772 GIT binary patch literal 18872 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 Date: Tue, 24 Feb 2026 13:46:56 -0500 Subject: [PATCH 12/18] refactor: update HTTP request handling to use host.HTTPSend for improved error management --- coverart.go | 20 +++++++++++------- coverart_test.go | 15 ++++++------- go.mod | 2 +- go.sum | 4 ++-- main_test.go | 22 +++++++++---------- plugin_suite_test.go | 3 ++- rpc.go | 38 ++++++++++++++++++++------------- rpc_test.go | 50 +++++++++++++------------------------------- spotify.go | 47 ++++++++++++++++++++++++----------------- spotify_test.go | 35 +++++++++++++++---------------- 10 files changed, 119 insertions(+), 117 deletions(-) diff --git a/coverart.go b/coverart.go index 46592a0..81b8ceb 100644 --- a/coverart.go +++ b/coverart.go @@ -84,17 +84,21 @@ func uploadToUguu(imageData []byte, contentType string) (string, error) { body = append(body, imageData...) body = append(body, []byte(fmt.Sprintf("\r\n--%s--\r\n", boundary))...) - req := pdk.NewHTTPRequest(pdk.MethodPost, "https://uguu.se/upload") - req.SetHeader("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary)) - req.SetBody(body) - - resp := req.Send() - if resp.Status() >= 400 { - return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.Status()) + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "POST", + URL: "https://uguu.se/upload", + Headers: map[string]string{"Content-Type": fmt.Sprintf("multipart/form-data; boundary=%s", boundary)}, + Body: body, + }) + if err != nil { + return "", fmt.Errorf("uguu.se upload failed: %w", err) + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.StatusCode) } var result uguuResponse - if err := json.Unmarshal(resp.Body(), &result); err != nil { + if err := json.Unmarshal(resp.Body, &result); err != nil { return "", fmt.Errorf("failed to parse uguu.se response: %w", err) } diff --git a/coverart_test.go b/coverart_test.go index e3131e8..8d9eeeb 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -20,6 +20,8 @@ var _ = Describe("getImageURL", func() { host.ArtworkMock.Calls = nil host.SubsonicAPIMock.ExpectedCalls = nil host.SubsonicAPIMock.Calls = nil + host.HTTPMock.ExpectedCalls = nil + host.HTTPMock.Calls = nil pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() }) @@ -71,10 +73,9 @@ var _ = Describe("getImageURL", func() { Return("image/jpeg", imageData, nil) // Mock uguu.se HTTP upload - uguuReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq) - pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(200, nil, - []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`))) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://uguu.se/upload" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)}, nil) // Mock cache set host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)).Return(nil) @@ -98,9 +99,9 @@ var _ = Describe("getImageURL", func() { host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300"). Return("image/jpeg", []byte("fake-image-data"), nil) - uguuReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq) - pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`{"success":false}`))) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://uguu.se/upload" + })).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`{"success":false}`)}, nil) url := getImageURL("testuser", "track1") Expect(url).To(BeEmpty()) diff --git a/go.mod b/go.mod index dbaa9df..1bd00d1 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module discord-rich-presence go 1.25 require ( - github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11 + github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224182233-40719d928320 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 702fa22..1fcf1d8 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11 h1:VE4bqzkS6apWDtco9hAGdThFttjbYoLR0DEILAGDyyc= -github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224182233-40719d928320 h1:TVn0Jv9Xd4aoyTbBoSMAv38Mfh8lWX/kMP2au2KX1cQ= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224182233-40719d928320/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= diff --git a/main_test.go b/main_test.go index a1b6ed0..ce399fb 100644 --- a/main_test.go +++ b/main_test.go @@ -33,6 +33,8 @@ var _ = Describe("discordPlugin", func() { host.ArtworkMock.Calls = nil host.SubsonicAPIMock.ExpectedCalls = nil host.SubsonicAPIMock.Calls = nil + host.HTTPMock.ExpectedCalls = nil + host.HTTPMock.Calls = nil }) Describe("getConfig", func() { @@ -128,9 +130,9 @@ var _ = Describe("discordPlugin", func() { // Mock HTTP GET request for gateway discovery gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) - gatewayReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once() - pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once() + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) // Mock WebSocket connection host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { @@ -148,9 +150,7 @@ var _ = Describe("discordPlugin", func() { host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) // Mock HTTP POST requests (Discord external assets API) - postReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(postReq) - pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{}`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil) // Schedule clear activity callback host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) @@ -180,9 +180,9 @@ var _ = Describe("discordPlugin", func() { // Connect mocks host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) - gatewayReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once() - pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once() + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { return strings.Contains(url, "gateway.discord.gg") }), mock.Anything, "testuser").Return("testuser", nil) @@ -199,9 +199,7 @@ var _ = Describe("discordPlugin", func() { host.CacheMock.On("GetString", discordImageKey).Return("", false, 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) - postReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(postReq) - pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{}`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil) host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ diff --git a/plugin_suite_test.go b/plugin_suite_test.go index 8f69496..9e183c7 100644 --- a/plugin_suite_test.go +++ b/plugin_suite_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/navidrome/navidrome/plugins/pdk/go/host" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -17,6 +18,6 @@ func TestDiscordPlugin(t *testing.T) { // 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") }) + externalAssetsReq = mock.MatchedBy(func(req host.HTTPRequest) bool { return strings.Contains(req.URL, "external-assets") }) spotifyURLKey = mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, "spotify.url.") }) ) diff --git a/rpc.go b/rpc.go index d3a2682..0b15bb3 100644 --- a/rpc.go +++ b/rpc.go @@ -147,18 +147,22 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) ( // Process via Discord API body := fmt.Sprintf(`{"urls":[%q]}`, imageURL) - req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID)) - req.SetHeader("Authorization", token) - req.SetHeader("Content-Type", "application/json") - req.SetBody([]byte(body)) - - resp := req.Send() - if resp.Status() >= 400 { - return "", fmt.Errorf("failed to process image: HTTP %d", resp.Status()) + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "POST", + URL: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID), + Headers: map[string]string{"Authorization": token, "Content-Type": "application/json"}, + Body: []byte(body), + }) + if err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for image processing: %v", err)) + return "", fmt.Errorf("failed to process image: %w", err) + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("failed to process image: HTTP %d", resp.StatusCode) } var data []map[string]string - if err := json.Unmarshal(resp.Body(), &data); err != nil { + if err := json.Unmarshal(resp.Body, &data); err != nil { return "", fmt.Errorf("failed to unmarshal image response: %w", err) } @@ -257,14 +261,20 @@ func (r *discordRPC) sendMessage(username string, opCode int, payload any) error // getDiscordGateway retrieves the Discord gateway URL. func (r *discordRPC) getDiscordGateway() (string, error) { - req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway") - resp := req.Send() - if resp.Status() != 200 { - return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.Status()) + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "GET", + URL: "https://discord.com/api/gateway", + }) + if err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for Discord gateway: %v", err)) + return "", fmt.Errorf("failed to get Discord gateway: %w", err) + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.StatusCode) } var result map[string]string - if err := json.Unmarshal(resp.Body(), &result); err != nil { + if err := json.Unmarshal(resp.Body, &result); err != nil { return "", fmt.Errorf("failed to parse Discord gateway response: %w", err) } return result["url"], nil diff --git a/rpc_test.go b/rpc_test.go index fbee7a3..be5dca5 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -25,6 +25,8 @@ var _ = Describe("discordRPC", func() { host.WebSocketMock.Calls = nil host.SchedulerMock.ExpectedCalls = nil host.SchedulerMock.Calls = nil + host.HTTPMock.ExpectedCalls = nil + host.HTTPMock.Calls = nil }) Describe("sendMessage", func() { @@ -81,9 +83,9 @@ var _ = Describe("discordRPC", func() { // Mock HTTP GET request for gateway discovery gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) // Mock WebSocket connection host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { @@ -265,9 +267,7 @@ var _ = Describe("discordRPC", func() { return val == "mp:external/new-asset" }), int64(imageCacheTTL)).Return(nil) - httpReq := &pdk.HTTPRequest{} - 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"}]`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/new-asset"}]`)}, nil) result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) Expect(err).ToNot(HaveOccurred()) @@ -277,9 +277,7 @@ var _ = Describe("discordRPC", func() { It("returns error on HTTP failure", func() { host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil) _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) Expect(err).To(HaveOccurred()) @@ -289,9 +287,7 @@ var _ = Describe("discordRPC", func() { It("returns error on unmarshal failure", func() { host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"not":"an-array"}`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"not":"an-array"}`)}, nil) _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) Expect(err).To(HaveOccurred()) @@ -301,9 +297,7 @@ var _ = Describe("discordRPC", func() { It("returns error on empty response array", func() { host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[]`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[]`)}, nil) _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) Expect(err).To(HaveOccurred()) @@ -313,9 +307,7 @@ var _ = Describe("discordRPC", func() { It("returns error on empty external_asset_path", func() { host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":""}]`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":""}]`)}, nil) _, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL) Expect(err).To(HaveOccurred()) @@ -332,9 +324,7 @@ var _ = Describe("discordRPC", func() { host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) - httpReq := &pdk.HTTPRequest{} - 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"}]`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/art"}]`)}, nil) host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, `"op":3`) && @@ -364,15 +354,9 @@ var _ = Describe("discordRPC", func() { host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) - trackReq := &pdk.HTTPRequest{} - defaultReq := &pdk.HTTPRequest{} - // First call (track art) returns 500, second call (default) succeeds - 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("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(defaultReq).Once() - pdk.PDKMock.On("Send", defaultReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"external_asset_path":"external/logo"}]`))).Once() + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil).Once() + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/logo"}]`)}, nil).Once() host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, `"op":3`) && @@ -400,9 +384,7 @@ var _ = Describe("discordRPC", func() { It("clears all images when both track art and default fail", func() { host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"not":"array"}`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"not":"array"}`)}, nil) host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, `"op":3`) && @@ -431,9 +413,7 @@ var _ = Describe("discordRPC", func() { host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/large", true, nil).Once() host.CacheMock.On("GetString", discordImageKey).Return("", false, nil).Once() - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, externalAssetsURL).Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`error`))) + host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil) host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, `"large_image":"mp:cached/large"`) && diff --git a/spotify.go b/spotify.go index ef9b862..11ef8a8 100644 --- a/spotify.go +++ b/spotify.go @@ -49,19 +49,23 @@ func spotifyCacheKey(artist, title, album string) string { // 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()))) + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "POST", + URL: "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(body), + }) + if err != nil { + pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz MBID lookup request failed: %v", err)) return "" } - id := parseSpotifyID(resp.Body()) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup failed: HTTP %d, body=%s", resp.StatusCode, 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()))) + 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 } @@ -69,20 +73,25 @@ func trySpotifyFromMBID(mbid string) string { // 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()))) + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "POST", + URL: "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(payload), + }) + if err != nil { + pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata lookup request failed: %v", err)) return "" } - pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", status, string(resp.Body()))) - id := parseSpotifyID(resp.Body()) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata lookup failed: HTTP %d, body=%s", resp.StatusCode, string(resp.Body))) + return "" + } + pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", resp.StatusCode, 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)) } diff --git a/spotify_test.go b/spotify_test.go index 73e9dc3..0926dd8 100644 --- a/spotify_test.go +++ b/spotify_test.go @@ -118,6 +118,8 @@ var _ = Describe("Spotify", func() { pdk.ResetMock() host.CacheMock.ExpectedCalls = nil host.CacheMock.Calls = nil + host.HTTPMock.ExpectedCalls = nil + host.HTTPMock.Calls = nil pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() }) @@ -138,10 +140,9 @@ var _ = Describe("Spotify", func() { host.CacheMock.On("SetString", spotifyURLKey, 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":["63OQupATfueTdZMWIV7nzz"]}]`))) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["63OQupATfueTdZMWIV7nzz"]}]`)}, nil) url := resolveSpotifyURL(scrobbler.TrackInfo{ Title: "Karma Police", @@ -159,15 +160,14 @@ var _ = Describe("Spotify", func() { host.CacheMock.On("SetString", spotifyURLKey, 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(`[]`))) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json" + })).Return(&host.HTTPResponse{StatusCode: 404, Body: []byte(`[]`)}, nil) // 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":["4wlLbLeDWbA6TzwZFp1UaK"]}]`))) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["4wlLbLeDWbA6TzwZFp1UaK"]}]`)}, nil) url := resolveSpotifyURL(scrobbler.TrackInfo{ Title: "Karma Police", @@ -184,9 +184,9 @@ var _ = Describe("Spotify", func() { host.CacheMock.On("SetString", spotifyURLKey, 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`))) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json" + })).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil) url := resolveSpotifyURL(scrobbler.TrackInfo{ Title: "Karma Police", @@ -203,10 +203,9 @@ var _ = Describe("Spotify", func() { host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil) host.CacheMock.On("SetString", spotifyURLKey, 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":["4tIGK5G9hNDA50ZdGioZRG"]}]`))) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`)}, nil) url := resolveSpotifyURL(scrobbler.TrackInfo{ Title: "Some Song", -- 2.52.0 From f76b95636a2c60e52fafbbcae7d3631ca1503a67 Mon Sep 17 00:00:00 2001 From: deluan Date: Tue, 24 Feb 2026 14:10:26 -0500 Subject: [PATCH 13/18] refactor: replace MD5 hash with FNV-1a and remove regex dependency to reduce ndp size --- spotify.go | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/spotify.go b/spotify.go index 11ef8a8..0bc135a 100644 --- a/spotify.go +++ b/spotify.go @@ -1,12 +1,9 @@ package main import ( - "crypto/md5" - "encoding/hex" "encoding/json" "fmt" "net/url" - "regexp" "strings" "github.com/navidrome/navidrome/plugins/pdk/go/host" @@ -14,10 +11,16 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" ) -// hashKey returns a hex-encoded MD5 hash of s, for use as a cache key suffix. +// hashKey returns a hex-encoded FNV-1a hash of s, for use as a cache key suffix. func hashKey(s string) string { - h := md5.Sum([]byte(s)) - return hex.EncodeToString(h[:]) + const offset64 uint64 = 14695981039346656037 + const prime64 uint64 = 1099511628211 + h := offset64 + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= prime64 + } + return fmt.Sprintf("%016x", h) } const ( @@ -115,11 +118,18 @@ func parseSpotifyID(body []byte) string { return "" } -// isValidSpotifyID checks that a Spotify track ID contains only base-62 characters. -var spotifyIDRegex = regexp.MustCompile(`^[0-9A-Za-z]+$`) - +// isValidSpotifyID checks that a Spotify track ID is non-empty and contains only base-62 characters. func isValidSpotifyID(id string) bool { - return spotifyIDRegex.MatchString(id) + if len(id) == 0 { + return false + } + for i := 0; i < len(id); i++ { + c := id[i] + if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) { + return false + } + } + return true } // resolveSpotifyURL resolves a direct Spotify track URL via ListenBrainz Labs, -- 2.52.0 From 4e4b25dafd47e2674872346a3b68962eadc5dd33 Mon Sep 17 00:00:00 2001 From: deluan Date: Tue, 24 Feb 2026 14:33:25 -0500 Subject: [PATCH 14/18] chore: update Navidrome plugin dependency to latest version --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1bd00d1..c8301e0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module discord-rich-presence go 1.25 require ( - github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224182233-40719d928320 + github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224192836-652c27690be6 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 1fcf1d8..68fab87 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224182233-40719d928320 h1:TVn0Jv9Xd4aoyTbBoSMAv38Mfh8lWX/kMP2au2KX1cQ= -github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224182233-40719d928320/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224192836-652c27690be6 h1:nALRtN92GA309if/sS7KG/p3L613ye4CiyQf1kEXmG8= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224192836-652c27690be6/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -- 2.52.0 From 34acde621003abc49166684f0d3f6e4f32dbe281 Mon Sep 17 00:00:00 2001 From: deluan Date: Tue, 24 Feb 2026 14:55:23 -0500 Subject: [PATCH 15/18] chore: update screenshot --- .github/screenshot.png | Bin 52139 -> 20591 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/screenshot.png b/.github/screenshot.png index 4f18b731cb56702f5d890470fcacb9f096a12202..afccccd3efcb3db0be7ed3f19afc49c52ef7ba44 100644 GIT binary patch delta 20182 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 M>FVdQ&MBb@04oyQh5!Hn -- 2.52.0 From 8b3ed88bcd927b7bd45947015d395ac5549a1374 Mon Sep 17 00:00:00 2001 From: deluan Date: Tue, 24 Feb 2026 17:35:47 -0500 Subject: [PATCH 16/18] docs: add links to discord docs --- main.go | 8 +++--- rpc.go | 83 ++++++++++++++++++++++++++++++++------------------------- 2 files changed, 50 insertions(+), 41 deletions(-) diff --git a/main.go b/main.go index ce39cd3..52871f8 100644 --- a/main.go +++ b/main.go @@ -156,18 +156,18 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { // Resolve the activity name based on configuration activityName := "Navidrome" - statusDisplayType := statusDisplayListening + statusDisplayType := statusDisplayDetails activityNameOption, _ := pdk.GetConfig(activityNameKey) switch activityNameOption { case activityNameTrack: activityName = input.Track.Title - statusDisplayType = statusDisplayDefault + statusDisplayType = statusDisplayName case activityNameAlbum: activityName = input.Track.Album - statusDisplayType = statusDisplayDefault + statusDisplayType = statusDisplayName case activityNameArtist: activityName = input.Track.Artist - statusDisplayType = statusDisplayDefault + statusDisplayType = statusDisplayName } // Resolve Spotify URLs if enabled diff --git a/rpc.go b/rpc.go index 0b15bb3..7b2c573 100644 --- a/rpc.go +++ b/rpc.go @@ -3,6 +3,11 @@ // This file handles all Discord gateway communication including WebSocket connections, // presence updates, and heartbeat management. The discordRPC struct implements WebSocket // callback interfaces and encapsulates all Discord communication logic. +// +// References: +// - Gateway Events (official): https://docs.discord.com/developers/events/gateway-events +// - Activity object (community): https://discord-api-types.dev/api/next/discord-api-types-v10/interface/GatewayActivity +// - Presence resources (community): https://docs.discord.food/resources/presence package main import ( @@ -15,22 +20,6 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/websocket" ) -// Discord WebSocket Gateway constants -const ( - heartbeatOpCode = 1 // Heartbeat operation code - gateOpCode = 2 // Identify operation code - presenceOpCode = 3 // Presence update operation code -) - -// Discord status_display_type values control how the activity name is shown. -// Type 0 renders the name as-is; type 2 renders the name with a "Listening to" prefix. -const ( - statusDisplayDefault = 0 // Show activity name as-is (e.g. track title) - statusDisplayListening = 2 // Show "Listening to " (used for "Navidrome") -) - -const heartbeatInterval = 41 // Heartbeat interval in seconds - // Image cache TTL constants const ( imageCacheTTL int64 = 4 * 60 * 60 // 4 hours for track artwork @@ -47,31 +36,24 @@ const ( type discordRPC struct{} // ============================================================================ -// WebSocket Callback Implementation +// Discord types and constants // ============================================================================ -// OnTextMessage handles incoming WebSocket text messages. -func (r *discordRPC) OnTextMessage(input websocket.OnTextMessageRequest) error { - return r.handleWebSocketMessage(input.ConnectionID, input.Message) -} +// Discord WebSocket Gateway constants +const ( + heartbeatOpCode = 1 // Heartbeat operation code + gateOpCode = 2 // Identify operation code + presenceOpCode = 3 // Presence update operation code +) -// OnBinaryMessage handles incoming WebSocket binary messages. -func (r *discordRPC) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionID)) - return nil -} +// Discord status_display_type values control how the activity is shown in the member list. +const ( + statusDisplayName = 0 // Show activity name in member list + statusDisplayState = 1 // Show state field in member list + statusDisplayDetails = 2 // Show details field in member list +) -// OnError handles WebSocket errors. -func (r *discordRPC) OnError(input websocket.OnErrorRequest) error { - pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionID, input.Error)) - return nil -} - -// OnClose handles WebSocket connection closure. -func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error { - pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionID, input.Code, input.Reason)) - return nil -} +const heartbeatInterval = 41 // Heartbeat interval in seconds // activity represents a Discord activity sent via Gateway opcode 3. type activity struct { @@ -122,6 +104,33 @@ type identifyProperties struct { Device string `json:"device"` } +// ============================================================================ +// WebSocket Callback Implementation +// ============================================================================ + +// OnTextMessage handles incoming WebSocket text messages. +func (r *discordRPC) OnTextMessage(input websocket.OnTextMessageRequest) error { + return r.handleWebSocketMessage(input.ConnectionID, input.Message) +} + +// OnBinaryMessage handles incoming WebSocket binary messages. +func (r *discordRPC) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { + pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionID)) + return nil +} + +// OnError handles WebSocket errors. +func (r *discordRPC) OnError(input websocket.OnErrorRequest) error { + pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionID, input.Error)) + return nil +} + +// OnClose handles WebSocket connection closure. +func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error { + pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionID, input.Code, input.Reason)) + return nil +} + // ============================================================================ // Image Processing // ============================================================================ -- 2.52.0 From ac4382ed5e6503e89f5a170f1843f1c67f6fc73c Mon Sep 17 00:00:00 2001 From: deluan Date: Tue, 24 Feb 2026 17:53:51 -0500 Subject: [PATCH 17/18] build: update target from wasi to wasip1 for TinyGo compilation --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c6cccc5..bfc51d1 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ test: build: ifdef TINYGO - tinygo build -opt=2 -scheduler=none -no-debug -o $(WASM_FILE) -target wasi -buildmode=c-shared . + tinygo build -opt=2 -scheduler=none -no-debug -o $(WASM_FILE) -target wasip1 -buildmode=c-shared . else GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $(WASM_FILE) . endif -- 2.52.0 From 56b449b23ae2cc11ffa681932c25764baf42900e Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 27 Feb 2026 19:01:50 -0500 Subject: [PATCH 18/18] chore: update dependencies to latest versions --- go.mod | 18 ++++++++++-------- go.sum | 41 +++++++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index c8301e0..758bebd 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module discord-rich-presence -go 1.25 +go 1.25.0 require ( - github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224192836-652c27690be6 + github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260228000019-bd8032b3274a github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/stretchr/testify v1.11.1 @@ -15,19 +15,21 @@ require ( github.com/extism/go-pdk v1.1.4-0.20260122165646-35abd9e2ba55 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect github.com/maruel/natural v1.3.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 68fab87..e34c1cd 100644 --- a/go.sum +++ b/go.sum @@ -14,24 +14,29 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224192836-652c27690be6 h1:nALRtN92GA309if/sS7KG/p3L613ye4CiyQf1kEXmG8= -github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260224192836-652c27690be6/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260227223558-c8df2f6b8b7b h1:ztDQtaxgZv2HWu6QqqZre1SAOraUjkghWGi612tJzhg= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260227223558-c8df2f6b8b7b/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260228000019-bd8032b3274a h1:yR7eqMqdoyZMhdGrFD/0PRoaxyDBZfSXfgHLv6B1vSg= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260228000019-bd8032b3274a/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= @@ -40,8 +45,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -54,22 +59,22 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -- 2.52.0