Spotify Link-Through & Navidrome Logo Overlay #15

Merged
Woahai321 merged 19 commits from main into main 2026-03-04 10:04:03 -07:00
9 changed files with 461 additions and 37 deletions
Showing only changes of commit 89c778d62f - Show all commits
+39 -15
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
//go:build integration
// +build integration
package main
import (
+199 -8
View File
@@ -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)
gemini-code-assist[bot] commented 2026-02-22 16:37:26 -07:00 (Migrated from github.com)
Review

medium

For consistency and improved robustness, it's better to use the %q format verb to safely quote the mbid string, just as you've done in trySpotifyFromMetadata. While MBIDs are UUIDs and unlikely to contain special characters that need escaping in a JSON string, this change would prevent any potential JSON injection issues and make the code more resilient.

body := fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid)
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) For consistency and improved robustness, it's better to use the `%q` format verb to safely quote the `mbid` string, just as you've done in `trySpotifyFromMetadata`. While MBIDs are UUIDs and unlikely to contain special characters that need escaping in a JSON string, this change would prevent any potential JSON injection issues and make the code more resilient. ```suggestion body := fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid) ```
}
// 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)
1
+9 -4
View File
@@ -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"))
+14 -2
View File
@@ -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"
]
},
gemini-code-assist[bot] commented 2026-02-22 16:37:26 -07:00 (Migrated from github.com)
Review

high

The Navidrome logo overlay is fetched from cdn.jsdelivr.net, but this host is missing from the requiredHosts list. This will prevent the logo from being displayed due to security restrictions. Please add cdn.jsdelivr.net to the list.

Additionally, raw.githubusercontent.com is included but doesn't appear to be used by the plugin. If it's not needed, you could remove it to keep the required permissions minimal.

      "requiredHosts": [
        "discord.com",
        "uguu.se",
        "labs.api.listenbrainz.org",
        "cdn.jsdelivr.net"
      ]
![high](https://www.gstatic.com/codereviewagent/high-priority.svg) The Navidrome logo overlay is fetched from `cdn.jsdelivr.net`, but this host is missing from the `requiredHosts` list. This will prevent the logo from being displayed due to security restrictions. Please add `cdn.jsdelivr.net` to the list. Additionally, `raw.githubusercontent.com` is included but doesn't appear to be used by the plugin. If it's not needed, you could remove it to keep the required permissions minimal. ```suggestion "requiredHosts": [ "discord.com", "uguu.se", "labs.api.listenbrainz.org", "cdn.jsdelivr.net" ] ```
"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",
+3
View File
@@ -1,3 +1,6 @@
//go:build integration
// +build integration
package main
import (
+24 -8
View File
@@ -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",
+3
View File
@@ -1,3 +1,6 @@
//go:build integration
// +build integration
package main
import (
+167
View File
@@ -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)
}