From 84d0c4e310068cf57d98c6c7dbae3b8d058da9c1 Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 20 Mar 2026 18:38:37 -0400 Subject: [PATCH 1/9] build: update Navidrome PDK for NoFollowRedirects support --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7434ec9..ad204e2 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module discord-rich-presence go 1.25.0 require ( - github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4 + github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260320221607-03844a9a369a 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 3aa11a6..c9cf8be 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,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-20260303204839-f03ca44a8ec4 h1:LgSTogYiu31eQF8BMh3fDuIcZ82chzIZDi/U/HZYYbA= -github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260320221607-03844a9a369a h1:EHllNfhSpL6F3EqM4M0GDHQZb7DyClw0y7afddd8XPg= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260320221607-03844a9a369a/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= -- 2.52.0 From 0b728493c608a3e78387fe0f27aea57a5ed22b3c Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 20 Mar 2026 18:41:29 -0400 Subject: [PATCH 2/9] feat: add headCoverArt helper for CAA HEAD requests --- coverart.go | 24 ++++++++++++++++++++++ coverart_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/coverart.go b/coverart.go index 81b8ceb..a94a888 100644 --- a/coverart.go +++ b/coverart.go @@ -12,6 +12,30 @@ import ( // Configuration key for uguu.se image hosting const uguuEnabledKey = "uguuenabled" +const caaEnabledKey = "caaenabled" + +// headCoverArt sends a HEAD request to the given CAA URL without following redirects. +// Returns the Location header value on 307 (image exists), or "" otherwise. +func headCoverArt(url string) string { + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "HEAD", + URL: url, + NoFollowRedirects: true, + }) + if err != nil { + pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD request failed for %s: %v", url, err)) + return "" + } + if resp.StatusCode != 307 { + return "" + } + location := resp.Headers["Location"] + if location == "" { + pdk.Log(pdk.LogWarn, fmt.Sprintf("CAA returned 307 but no Location header for %s", url)) + } + return location +} + // uguu.se API response type uguuResponse struct { Success bool `json:"success"` diff --git a/coverart_test.go b/coverart_test.go index 8d9eeeb..c808984 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -11,6 +11,59 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("headCoverArt", func() { + BeforeEach(func() { + pdk.ResetMock() + host.HTTPMock.ExpectedCalls = nil + host.HTTPMock.Calls = nil + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + }) + + It("returns Location header on 307 response", func() { + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "HEAD" && + req.URL == "https://coverartarchive.org/release/test-mbid/front-500" && + req.NoFollowRedirects == true + })).Return(&host.HTTPResponse{ + StatusCode: 307, + Headers: map[string]string{"Location": "https://archive.org/download/mbid-test/thumb500.jpg"}, + }, nil) + + result := headCoverArt("https://coverartarchive.org/release/test-mbid/front-500") + Expect(result).To(Equal("https://archive.org/download/mbid-test/thumb500.jpg")) + }) + + It("returns empty string on 404 response", func() { + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "HEAD" && req.NoFollowRedirects == true + })).Return(&host.HTTPResponse{StatusCode: 404}, nil) + + result := headCoverArt("https://coverartarchive.org/release/no-art/front-500") + Expect(result).To(BeEmpty()) + }) + + It("returns empty string on HTTP error", func() { + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "HEAD" + })).Return((*host.HTTPResponse)(nil), errors.New("connection refused")) + + result := headCoverArt("https://coverartarchive.org/release/err/front-500") + Expect(result).To(BeEmpty()) + }) + + It("returns empty string when Location header is missing on 307", func() { + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "HEAD" + })).Return(&host.HTTPResponse{ + StatusCode: 307, + Headers: map[string]string{}, + }, nil) + + result := headCoverArt("https://coverartarchive.org/release/no-location/front-500") + Expect(result).To(BeEmpty()) + }) +}) + var _ = Describe("getImageURL", func() { BeforeEach(func() { pdk.ResetMock() -- 2.52.0 From 7cc9208d877cf777143afc196a853a06f6435526 Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 20 Mar 2026 18:45:30 -0400 Subject: [PATCH 3/9] feat: add getImageViaCoverArt with release/release-group fallback --- coverart.go | 51 ++++++++++++++++++++++++++ coverart_test.go | 95 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/coverart.go b/coverart.go index a94a888..8ac5446 100644 --- a/coverart.go +++ b/coverart.go @@ -36,6 +36,57 @@ func headCoverArt(url string) string { return location } +const ( + caaCacheTTLHit int64 = 24 * 60 * 60 // 24 hours for resolved artwork + caaCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses +) + +// getImageViaCoverArt checks the Cover Art Archive for album artwork. +// Tries the release first, then falls back to the release group. +// Returns the archive.org image URL on success, "" on failure. +func getImageViaCoverArt(mbzAlbumID, mbzReleaseGroupID string) string { + if mbzAlbumID == "" && mbzReleaseGroupID == "" { + return "" + } + + // Determine cache key: use album ID when available, otherwise release group ID + cacheKey := "caa.artwork." + mbzAlbumID + if mbzAlbumID == "" { + cacheKey = "caa.artwork.rg." + mbzReleaseGroupID + } + + // Check cache + cachedURL, exists, err := host.CacheGetString(cacheKey) + if err == nil && exists { + pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA cache hit for %s", cacheKey)) + return cachedURL + } + + // Try release first + var imageURL string + if mbzAlbumID != "" { + imageURL = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release/%s/front-500", mbzAlbumID)) + } + + // Fall back to release group + if imageURL == "" && mbzReleaseGroupID != "" { + imageURL = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release-group/%s/front-500", mbzReleaseGroupID)) + } + + // Cache the result (hit or miss) + ttl := caaCacheTTLHit + if imageURL == "" { + ttl = caaCacheTTLMiss + } + _ = host.CacheSetString(cacheKey, imageURL, ttl) + + if imageURL != "" { + pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA resolved artwork for %s: %s", cacheKey, imageURL)) + } + + return imageURL +} + // uguu.se API response type uguuResponse struct { Success bool `json:"success"` diff --git a/coverart_test.go b/coverart_test.go index c808984..3f8a7c9 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -161,3 +161,98 @@ var _ = Describe("getImageURL", func() { }) }) }) + +var _ = Describe("getImageViaCoverArt", func() { + BeforeEach(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() + }) + + It("returns cached URL on cache hit", func() { + host.CacheMock.On("GetString", "caa.artwork.album-123").Return("https://archive.org/cached.jpg", true, nil) + + result := getImageViaCoverArt("album-123", "rg-456") + Expect(result).To(Equal("https://archive.org/cached.jpg")) + host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything) + }) + + It("returns empty on cache hit with empty string (known miss)", func() { + host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", true, nil) + + result := getImageViaCoverArt("album-123", "rg-456") + Expect(result).To(BeEmpty()) + host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything) + }) + + It("returns release URL on 307 and caches it", func() { + host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release/album-123/front-500" + })).Return(&host.HTTPResponse{ + StatusCode: 307, + Headers: map[string]string{"Location": "https://archive.org/release-art.jpg"}, + }, nil) + host.CacheMock.On("SetString", "caa.artwork.album-123", "https://archive.org/release-art.jpg", int64(86400)).Return(nil) + + result := getImageViaCoverArt("album-123", "rg-456") + Expect(result).To(Equal("https://archive.org/release-art.jpg")) + host.CacheMock.AssertCalled(GinkgoT(), "SetString", "caa.artwork.album-123", "https://archive.org/release-art.jpg", int64(86400)) + }) + + It("falls back to release-group when release returns 404", func() { + host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release/album-123/front-500" + })).Return(&host.HTTPResponse{StatusCode: 404}, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500" + })).Return(&host.HTTPResponse{ + StatusCode: 307, + Headers: map[string]string{"Location": "https://archive.org/rg-art.jpg"}, + }, nil) + host.CacheMock.On("SetString", "caa.artwork.album-123", "https://archive.org/rg-art.jpg", int64(86400)).Return(nil) + + result := getImageViaCoverArt("album-123", "rg-456") + Expect(result).To(Equal("https://archive.org/rg-art.jpg")) + }) + + It("caches empty string when both release and release-group fail", func() { + host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release/album-123/front-500" + })).Return(&host.HTTPResponse{StatusCode: 404}, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500" + })).Return(&host.HTTPResponse{StatusCode: 404}, nil) + host.CacheMock.On("SetString", "caa.artwork.album-123", "", int64(14400)).Return(nil) + + result := getImageViaCoverArt("album-123", "rg-456") + Expect(result).To(BeEmpty()) + host.CacheMock.AssertCalled(GinkgoT(), "SetString", "caa.artwork.album-123", "", int64(14400)) + }) + + It("tries only release-group when MBZAlbumID is empty", func() { + host.CacheMock.On("GetString", "caa.artwork.rg.rg-456").Return("", false, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500" + })).Return(&host.HTTPResponse{ + StatusCode: 307, + Headers: map[string]string{"Location": "https://archive.org/rg-art.jpg"}, + }, nil) + host.CacheMock.On("SetString", "caa.artwork.rg.rg-456", "https://archive.org/rg-art.jpg", int64(86400)).Return(nil) + + result := getImageViaCoverArt("", "rg-456") + Expect(result).To(Equal("https://archive.org/rg-art.jpg")) + }) + + It("returns empty when both IDs are empty", func() { + result := getImageViaCoverArt("", "") + Expect(result).To(BeEmpty()) + host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything) + host.CacheMock.AssertNotCalled(GinkgoT(), "GetString", mock.Anything) + }) +}) -- 2.52.0 From 0f7ede580e8c6cf3934a7b01b3ccbbb6b118612f Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 20 Mar 2026 18:50:49 -0400 Subject: [PATCH 4/9] feat: integrate CAA into getImageURL priority chain --- coverart.go | 18 ++++++++--- coverart_test.go | 83 ++++++++++++++++++++++++++++++++++++++++++++---- main.go | 2 +- main_test.go | 2 ++ 4 files changed, 93 insertions(+), 12 deletions(-) diff --git a/coverart.go b/coverart.go index 8ac5446..660feb7 100644 --- a/coverart.go +++ b/coverart.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" ) // Configuration key for uguu.se image hosting @@ -95,13 +96,22 @@ type uguuResponse struct { } `json:"files"` } -// getImageURL retrieves the track artwork URL, optionally uploading to uguu.se. -func getImageURL(username, trackID string) string { +// getImageURL retrieves the track artwork URL, checking CAA first if enabled, +// then uguu.se, then direct Navidrome URL. +func getImageURL(username string, track scrobbler.TrackInfo) string { + caaEnabled, _ := pdk.GetConfig(caaEnabledKey) + if caaEnabled == "true" { + if url := getImageViaCoverArt(track.MBZAlbumID, track.MBZReleaseGroupID); url != "" { + return url + } + } + uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey) if uguuEnabled == "true" { - return getImageViaUguu(username, trackID) + return getImageViaUguu(username, track.ID) } - return getImageDirect(trackID) + + return getImageDirect(track.ID) } // getImageDirect returns the artwork URL directly from Navidrome (current behavior). diff --git a/coverart_test.go b/coverart_test.go index 3f8a7c9..c9d4ace 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -5,6 +5,7 @@ import ( "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" @@ -80,40 +81,42 @@ var _ = Describe("getImageURL", func() { Describe("uguu disabled (default)", func() { BeforeEach(func() { + pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) }) It("returns artwork URL directly", func() { host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) - url := getImageURL("testuser", "track1") + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"}) Expect(url).To(Equal("https://example.com/art.jpg")) }) It("returns empty for localhost URL", func() { host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("http://localhost:4533/art.jpg", nil) - url := getImageURL("testuser", "track1") + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"}) Expect(url).To(BeEmpty()) }) It("returns empty when artwork fetch fails", func() { host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("", errors.New("not found")) - url := getImageURL("testuser", "track1") + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"}) Expect(url).To(BeEmpty()) }) }) Describe("uguu enabled", func() { BeforeEach(func() { + pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true) }) It("returns cached URL when available", func() { host.CacheMock.On("GetString", "uguu.artwork.track1").Return("https://a.uguu.se/cached.jpg", true, nil) - url := getImageURL("testuser", "track1") + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"}) Expect(url).To(Equal("https://a.uguu.se/cached.jpg")) }) @@ -133,7 +136,7 @@ var _ = Describe("getImageURL", func() { // Mock cache set host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)).Return(nil) - url := getImageURL("testuser", "track1") + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"}) Expect(url).To(Equal("https://a.uguu.se/uploaded.jpg")) host.CacheMock.AssertCalled(GinkgoT(), "SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)) }) @@ -143,7 +146,7 @@ var _ = Describe("getImageURL", func() { host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300"). Return("", []byte(nil), errors.New("fetch failed")) - url := getImageURL("testuser", "track1") + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"}) Expect(url).To(BeEmpty()) }) @@ -156,10 +159,76 @@ var _ = Describe("getImageURL", func() { return req.URL == "https://uguu.se/upload" })).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`{"success":false}`)}, nil) - url := getImageURL("testuser", "track1") + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"}) Expect(url).To(BeEmpty()) }) }) + + Describe("CAA enabled", func() { + BeforeEach(func() { + pdk.PDKMock.ExpectedCalls = nil + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("true", true) + pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) + }) + + It("returns CAA URL when release HEAD succeeds", func() { + host.CacheMock.On("GetString", "caa.artwork.album-id").Return("", false, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release/album-id/front-500" + })).Return(&host.HTTPResponse{ + StatusCode: 307, + Headers: map[string]string{"Location": "https://archive.org/art.jpg"}, + }, nil) + host.CacheMock.On("SetString", "caa.artwork.album-id", "https://archive.org/art.jpg", int64(86400)).Return(nil) + + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZAlbumID: "album-id", MBZReleaseGroupID: "rg-id"}) + Expect(url).To(Equal("https://archive.org/art.jpg")) + host.ArtworkMock.AssertNotCalled(GinkgoT(), "GetTrackUrl", mock.Anything, mock.Anything) + host.SubsonicAPIMock.AssertNotCalled(GinkgoT(), "CallRaw", mock.Anything) + }) + + It("falls through to direct when CAA misses and uguu is disabled", func() { + host.CacheMock.On("GetString", "caa.artwork.album-id").Return("", false, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release/album-id/front-500" + })).Return(&host.HTTPResponse{StatusCode: 404}, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release-group/rg-id/front-500" + })).Return(&host.HTTPResponse{StatusCode: 404}, nil) + host.CacheMock.On("SetString", "caa.artwork.album-id", "", int64(14400)).Return(nil) + host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) + + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZAlbumID: "album-id", MBZReleaseGroupID: "rg-id"}) + Expect(url).To(Equal("https://example.com/art.jpg")) + }) + + It("falls through to uguu when CAA misses and uguu is enabled", func() { + pdk.PDKMock.ExpectedCalls = nil + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("true", true) + pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true) + + host.CacheMock.On("GetString", "caa.artwork.rg.rg-id").Return("", false, nil) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release-group/rg-id/front-500" + })).Return(&host.HTTPResponse{StatusCode: 404}, nil) + host.CacheMock.On("SetString", "caa.artwork.rg.rg-id", "", int64(14400)).Return(nil) + + host.CacheMock.On("GetString", "uguu.artwork.track1").Return("https://a.uguu.se/cached.jpg", true, nil) + + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZReleaseGroupID: "rg-id"}) + Expect(url).To(Equal("https://a.uguu.se/cached.jpg")) + }) + + It("skips CAA when no MBZ IDs are present", func() { + host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) + + url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"}) + Expect(url).To(Equal("https://example.com/art.jpg")) + host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything) + }) + }) }) var _ = Describe("getImageViaCoverArt", func() { diff --git a/main.go b/main.go index 52871f8..5c4813b 100644 --- a/main.go +++ b/main.go @@ -193,7 +193,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { End: endTime, }, Assets: activityAssets{ - LargeImage: getImageURL(input.Username, input.Track.ID), + LargeImage: getImageURL(input.Username, input.Track), LargeText: input.Track.Album, LargeURL: spotifyURL, SmallImage: navidromeLogoURL, diff --git a/main_test.go b/main_test.go index ce399fb..0f7f2b2 100644 --- a/main_test.go +++ b/main_test.go @@ -122,6 +122,7 @@ var _ = Describe("discordPlugin", func() { 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", caaEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false) pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false) @@ -174,6 +175,7 @@ var _ = Describe("discordPlugin", func() { 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", caaEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists) pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false) -- 2.52.0 From 40be06fee522ba2baf8b57a5b6a93b8c08b8d64d Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 20 Mar 2026 18:53:27 -0400 Subject: [PATCH 5/9] feat: add CAA config option and coverartarchive.org permission --- manifest.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index ec9304a..82ecc0a 100644 --- a/manifest.json +++ b/manifest.json @@ -14,7 +14,8 @@ "requiredHosts": [ "discord.com", "uguu.se", - "labs.api.listenbrainz.org" + "labs.api.listenbrainz.org", + "coverartarchive.org" ] }, "websocket": { @@ -65,6 +66,12 @@ "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", "default": false }, + "caaenabled": { + "type": "boolean", + "title": "Use artwork from Cover Art Archive (for MusicBrainz-tagged music)", + "description": "When enabled, attempts to fetch album artwork from the Cover Art Archive using MusicBrainz IDs. Takes priority over other artwork methods.", + "default": false + }, "spotifylinks": { "type": "boolean", "title": "Enable Spotify link-through", @@ -118,6 +125,10 @@ "format": "radio" } }, + { + "type": "Control", + "scope": "#/properties/caaenabled" + }, { "type": "Control", "scope": "#/properties/uguuenabled" -- 2.52.0 From 6ed2c2ce4568519b39057e6bb1522f976faeb50d Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 20 Mar 2026 20:00:19 -0400 Subject: [PATCH 6/9] refactor: consolidate config keys and cache TTL constants - Move caaEnabledKey and uguuEnabledKey to main.go config const block - Extract uguu cache TTL magic number (9000) to named constant - Add 5s timeout to CAA HEAD requests --- coverart.go | 14 ++++++-------- coverart_test.go | 4 ++-- main.go | 2 ++ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/coverart.go b/coverart.go index 660feb7..a93f9de 100644 --- a/coverart.go +++ b/coverart.go @@ -10,11 +10,6 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" ) -// Configuration key for uguu.se image hosting -const uguuEnabledKey = "uguuenabled" - -const caaEnabledKey = "caaenabled" - // headCoverArt sends a HEAD request to the given CAA URL without following redirects. // Returns the Location header value on 307 (image exists), or "" otherwise. func headCoverArt(url string) string { @@ -22,6 +17,7 @@ func headCoverArt(url string) string { Method: "HEAD", URL: url, NoFollowRedirects: true, + TimeoutMs: 5000, // 5s timeout to avoid blocking NowPlaying }) if err != nil { pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD request failed for %s: %v", url, err)) @@ -37,9 +33,11 @@ func headCoverArt(url string) string { return location } +// Cache TTLs for cover art lookups const ( - caaCacheTTLHit int64 = 24 * 60 * 60 // 24 hours for resolved artwork - caaCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses + caaCacheTTLHit int64 = 24 * 60 * 60 // 24 hours for resolved CAA artwork + caaCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for CAA misses + uguuCacheTTL int64 = 9000 // ~2.5 hours for uguu.se uploads ) // getImageViaCoverArt checks the Cover Art Archive for album artwork. @@ -153,7 +151,7 @@ func getImageViaUguu(username, trackID string) string { return "" } - _ = host.CacheSetString(cacheKey, url, 9000) + _ = host.CacheSetString(cacheKey, url, uguuCacheTTL) return url } diff --git a/coverart_test.go b/coverart_test.go index c9d4ace..d095c91 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -134,11 +134,11 @@ var _ = Describe("getImageURL", func() { })).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) + host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", uguuCacheTTL).Return(nil) url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"}) Expect(url).To(Equal("https://a.uguu.se/uploaded.jpg")) - host.CacheMock.AssertCalled(GinkgoT(), "SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)) + host.CacheMock.AssertCalled(GinkgoT(), "SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", uguuCacheTTL) }) It("returns empty when artwork data fetch fails", func() { diff --git a/main.go b/main.go index 5c4813b..f149285 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,8 @@ const ( usersKey = "users" activityNameKey = "activityname" spotifyLinksKey = "spotifylinks" + caaEnabledKey = "caaenabled" + uguuEnabledKey = "uguuenabled" ) const ( -- 2.52.0 From 4f097294faad231aabdaa641ea69e225dd1f94d7 Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 20 Mar 2026 20:18:23 -0400 Subject: [PATCH 7/9] fix: update uguu cache TTL to 2.5 hours and reorder config options --- coverart.go | 2 +- manifest.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/coverart.go b/coverart.go index a93f9de..9df569d 100644 --- a/coverart.go +++ b/coverart.go @@ -37,7 +37,7 @@ func headCoverArt(url string) string { const ( caaCacheTTLHit int64 = 24 * 60 * 60 // 24 hours for resolved CAA artwork caaCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for CAA misses - uguuCacheTTL int64 = 9000 // ~2.5 hours for uguu.se uploads + uguuCacheTTL int64 = 150 * 60 // 2.5 hours for uguu.se uploads ) // getImageViaCoverArt checks the Cover Art Archive for album artwork. diff --git a/manifest.json b/manifest.json index 82ecc0a..a0a053f 100644 --- a/manifest.json +++ b/manifest.json @@ -61,17 +61,17 @@ ], "default": "Default" }, - "uguuenabled": { - "type": "boolean", - "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", - "default": false - }, "caaenabled": { "type": "boolean", "title": "Use artwork from Cover Art Archive (for MusicBrainz-tagged music)", "description": "When enabled, attempts to fetch album artwork from the Cover Art Archive using MusicBrainz IDs. Takes priority over other artwork methods.", "default": false }, + "uguuenabled": { + "type": "boolean", + "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", + "default": false + }, "spotifylinks": { "type": "boolean", "title": "Enable Spotify link-through", -- 2.52.0 From 357f922070311f2956c2e952ca23dc54c6ae53ca Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 20 Mar 2026 20:28:44 -0400 Subject: [PATCH 8/9] refactor: remove magic number --- coverart.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/coverart.go b/coverart.go index 9df569d..8aa17c1 100644 --- a/coverart.go +++ b/coverart.go @@ -10,6 +10,15 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" ) +// Cache TTLs for cover art lookups +const ( + caaCacheTTLHit int64 = 24 * 60 * 60 // 24 hours for resolved CAA artwork + caaCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for CAA misses + uguuCacheTTL int64 = 150 * 60 // 2.5 hours for uguu.se uploads + + caaTimeOut = 4000 // 4 seconds timeout for CAA HEAD requests to avoid blocking NowPlaying +) + // headCoverArt sends a HEAD request to the given CAA URL without following redirects. // Returns the Location header value on 307 (image exists), or "" otherwise. func headCoverArt(url string) string { @@ -17,7 +26,7 @@ func headCoverArt(url string) string { Method: "HEAD", URL: url, NoFollowRedirects: true, - TimeoutMs: 5000, // 5s timeout to avoid blocking NowPlaying + TimeoutMs: caaTimeOut, }) if err != nil { pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD request failed for %s: %v", url, err)) @@ -33,13 +42,6 @@ func headCoverArt(url string) string { return location } -// Cache TTLs for cover art lookups -const ( - caaCacheTTLHit int64 = 24 * 60 * 60 // 24 hours for resolved CAA artwork - caaCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for CAA misses - uguuCacheTTL int64 = 150 * 60 // 2.5 hours for uguu.se uploads -) - // getImageViaCoverArt checks the Cover Art Archive for album artwork. // Tries the release first, then falls back to the release group. // Returns the archive.org image URL on success, "" on failure. -- 2.52.0 From 76d4074ca1e2f4540c0601f98e01c4ca580e8d25 Mon Sep 17 00:00:00 2001 From: deluan Date: Fri, 20 Mar 2026 20:32:41 -0400 Subject: [PATCH 9/9] fix: distinguish transient failures from definitive misses in CAA cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit headCoverArt now returns (url, definitive) — transient failures (network errors, 5xx) are not cached, allowing immediate retries. Only definitive 404s are cached for caaCacheTTLMiss (4h). --- coverart.go | 32 ++++++++++++++++++++------------ coverart_test.go | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/coverart.go b/coverart.go index 8aa17c1..2f1bfe2 100644 --- a/coverart.go +++ b/coverart.go @@ -20,8 +20,10 @@ const ( ) // headCoverArt sends a HEAD request to the given CAA URL without following redirects. -// Returns the Location header value on 307 (image exists), or "" otherwise. -func headCoverArt(url string) string { +// Returns (location, true) on 307 with a Location header (image exists), +// ("", true) on 404 (definitive miss — safe to cache), +// ("", false) on network errors or unexpected responses (transient — do not cache). +func headCoverArt(url string) (string, bool) { resp, err := host.HTTPSend(host.HTTPRequest{ Method: "HEAD", URL: url, @@ -30,16 +32,20 @@ func headCoverArt(url string) string { }) if err != nil { pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD request failed for %s: %v", url, err)) - return "" + return "", false + } + if resp.StatusCode == 404 { + return "", true } if resp.StatusCode != 307 { - return "" + pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD unexpected status %d for %s", resp.StatusCode, url)) + return "", false } location := resp.Headers["Location"] if location == "" { pdk.Log(pdk.LogWarn, fmt.Sprintf("CAA returned 307 but no Location header for %s", url)) } - return location + return location, true } // getImageViaCoverArt checks the Cover Art Archive for album artwork. @@ -65,21 +71,23 @@ func getImageViaCoverArt(mbzAlbumID, mbzReleaseGroupID string) string { // Try release first var imageURL string + definitive := false if mbzAlbumID != "" { - imageURL = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release/%s/front-500", mbzAlbumID)) + imageURL, definitive = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release/%s/front-500", mbzAlbumID)) } // Fall back to release group if imageURL == "" && mbzReleaseGroupID != "" { - imageURL = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release-group/%s/front-500", mbzReleaseGroupID)) + imageURL, definitive = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release-group/%s/front-500", mbzReleaseGroupID)) } - // Cache the result (hit or miss) - ttl := caaCacheTTLHit - if imageURL == "" { - ttl = caaCacheTTLMiss + // Cache hits always; only cache misses if the response was definitive (404), + // not transient failures (network errors, 5xx) which should be retried sooner. + if imageURL != "" { + _ = host.CacheSetString(cacheKey, imageURL, caaCacheTTLHit) + } else if definitive { + _ = host.CacheSetString(cacheKey, "", caaCacheTTLMiss) } - _ = host.CacheSetString(cacheKey, imageURL, ttl) if imageURL != "" { pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA resolved artwork for %s: %s", cacheKey, imageURL)) diff --git a/coverart_test.go b/coverart_test.go index d095c91..c6be6bf 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -20,7 +20,7 @@ var _ = Describe("headCoverArt", func() { pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() }) - It("returns Location header on 307 response", func() { + It("returns Location header and definitive=true on 307 response", func() { host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { return req.Method == "HEAD" && req.URL == "https://coverartarchive.org/release/test-mbid/front-500" && @@ -30,29 +30,32 @@ var _ = Describe("headCoverArt", func() { Headers: map[string]string{"Location": "https://archive.org/download/mbid-test/thumb500.jpg"}, }, nil) - result := headCoverArt("https://coverartarchive.org/release/test-mbid/front-500") + result, definitive := headCoverArt("https://coverartarchive.org/release/test-mbid/front-500") Expect(result).To(Equal("https://archive.org/download/mbid-test/thumb500.jpg")) + Expect(definitive).To(BeTrue()) }) - It("returns empty string on 404 response", func() { + It("returns empty and definitive=true on 404 response", func() { host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { return req.Method == "HEAD" && req.NoFollowRedirects == true })).Return(&host.HTTPResponse{StatusCode: 404}, nil) - result := headCoverArt("https://coverartarchive.org/release/no-art/front-500") + result, definitive := headCoverArt("https://coverartarchive.org/release/no-art/front-500") Expect(result).To(BeEmpty()) + Expect(definitive).To(BeTrue()) }) - It("returns empty string on HTTP error", func() { + It("returns empty and definitive=false on HTTP error", func() { host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { return req.Method == "HEAD" })).Return((*host.HTTPResponse)(nil), errors.New("connection refused")) - result := headCoverArt("https://coverartarchive.org/release/err/front-500") + result, definitive := headCoverArt("https://coverartarchive.org/release/err/front-500") Expect(result).To(BeEmpty()) + Expect(definitive).To(BeFalse()) }) - It("returns empty string when Location header is missing on 307", func() { + It("returns empty and definitive=true when Location header is missing on 307", func() { host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { return req.Method == "HEAD" })).Return(&host.HTTPResponse{ @@ -60,8 +63,9 @@ var _ = Describe("headCoverArt", func() { Headers: map[string]string{}, }, nil) - result := headCoverArt("https://coverartarchive.org/release/no-location/front-500") + result, definitive := headCoverArt("https://coverartarchive.org/release/no-location/front-500") Expect(result).To(BeEmpty()) + Expect(definitive).To(BeTrue()) }) }) @@ -304,6 +308,22 @@ var _ = Describe("getImageViaCoverArt", func() { host.CacheMock.AssertCalled(GinkgoT(), "SetString", "caa.artwork.album-123", "", int64(14400)) }) + It("does not cache miss on transient failure", func() { + host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil) + // Both requests fail with network errors (transient) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release/album-123/front-500" + })).Return((*host.HTTPResponse)(nil), errors.New("connection refused")) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500" + })).Return((*host.HTTPResponse)(nil), errors.New("timeout")) + + result := getImageViaCoverArt("album-123", "rg-456") + Expect(result).To(BeEmpty()) + // Should NOT cache the miss since failures were transient + host.CacheMock.AssertNotCalled(GinkgoT(), "SetString", mock.Anything, mock.Anything, mock.Anything) + }) + It("tries only release-group when MBZAlbumID is empty", func() { host.CacheMock.On("GetString", "caa.artwork.rg.rg-456").Return("", false, nil) host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { -- 2.52.0