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) +} +