Use Cover Art Archive for albums with MusicBrainz IDs #12

Closed
sproutsberry wants to merge 11 commits from cover-art-archive into main
6 changed files with 224 additions and 30 deletions
+13
View File
@@ -88,6 +88,15 @@ For album artwork to display in Discord, Discord needs to be able to access the
**How it works**: Album art is automatically uploaded to uguu.se (temporary, anonymous hosting service) so Discord can access it. Files are deleted after 3 hours.
### Backup: Cover Art Archive
**Use this if**: You have your music tagged with MusicBrainz
**Setup**:
1. In plugin settings: **Enable** "Use artwork from Cover Art Archive"
2. No other configuration needed
**How it works**: Cover art is linked directly from MusicBrainz via Release ID using the [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API). Will fall back to other methods if no artwork is found.
### Troubleshooting Album Art
- **No album art showing**: Check Navidrome logs for errors
- **Using public instance**: Verify ND_BASEURL is correct and Navidrome was restarted
@@ -115,6 +124,10 @@ Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Ric
- **Album**: Shows the currently playing track's album name
- **Artist**: Shows the currently playing track's artist name
#### Use artwork from Cover Art Archive
- **When to enable**: Your Navidrome instance is NOT publicly accessible from the internet, or you don't feel comfortable with directly linking
- **What it does**: Attempts to find and link album artwork with MusicBrainz before using other methods
#### Upload to uguu.se
- **When to enable**: Your Navidrome instance is NOT publicly accessible from the internet
- **What it does**: Automatically uploads album artwork to uguu.se (temporary hosting) so Discord can display it
+114 -20
View File
@@ -4,30 +4,16 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"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
const uguuEnabledKey = "uguuenabled"
// uguu.se API response
type uguuResponse struct {
Success bool `json:"success"`
Files []struct {
URL string `json:"url"`
} `json:"files"`
}
// getImageURL retrieves the track artwork URL, optionally uploading to uguu.se.
func getImageURL(username, trackID string) string {
uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey)
if uguuEnabled == "true" {
return getImageViaUguu(username, trackID)
}
return getImageDirect(trackID)
}
// ============================================================================
// Direct
// ============================================================================
// getImageDirect returns the artwork URL directly from Navidrome (current behavior).
func getImageDirect(trackID string) string {
@@ -44,13 +30,25 @@ func getImageDirect(trackID string) string {
return artworkURL
}
// ============================================================================
// uguu.se
// ============================================================================
// uguu.se API response
type uguuResponse struct {
Success bool `json:"success"`
Files []struct {
URL string `json:"url"`
} `json:"files"`
}
// getImageViaUguu fetches artwork and uploads it to uguu.se.
func getImageViaUguu(username, trackID string) string {
// Check cache first
cacheKey := fmt.Sprintf("uguu.artwork.%s", trackID)
cachedURL, exists, err := host.CacheGetString(cacheKey)
if err == nil && exists {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for uguu artwork: %s", trackID))
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for uguu.se artwork: %s", trackID))
return cachedURL
}
gemini-code-assist[bot] commented 2026-02-08 16:36:51 -07:00 (Migrated from github.com)
Review

high

Caching an empty URL string prevents the fallback logic in getImageURL from working on subsequent requests for the same track. The cache should only be set if a valid URL is obtained.

    if url != "" {
        cacheKey := fmt.Sprintf(cacheKeyFormat, trackID)
        _ = host.CacheSetString(cacheKey, url, 9000)
    }

    return url

![high](https://www.gstatic.com/codereviewagent/high-priority.svg) Caching an empty URL string prevents the fallback logic in `getImageURL` from working on subsequent requests for the same track. The cache should only be set if a valid URL is obtained. ```go if url != "" { cacheKey := fmt.Sprintf(cacheKeyFormat, trackID) _ = host.CacheSetString(cacheKey, url, 9000) } return url ```
sproutsberry commented 2026-02-08 16:44:26 -07:00 (Migrated from github.com)
Review

Not relevant

Not relevant
9
@@ -108,3 +106,99 @@ func uploadToUguu(imageData []byte, contentType string) (string, error) {
return result.Files[0].URL, nil
}
// ============================================================================
// Cover Art Archive
// ============================================================================
const CAA_TIMEOUT = 5 * time.Second
// caaResponse only includes relevant parameters; see API for full response
// https://musicbrainz.org/doc/Cover_Art_Archive/API
type caaResponse struct {
Images []struct {
Front bool `json:"front"`
Back bool `json:"back"`
ImageURL string `json:"image"`
ThumbnailImageURLs struct {
Size250 string `json:"250"`
Size500 string `json:"500"`
Size1200 string `json:"1200"`
} `json:"thumbnails"`
} `json:"images"`
}
func getThumbnailForMBZAlbumID(mbzAlbumID string) (string, error) {
req := pdk.NewHTTPRequest(pdk.MethodGet, fmt.Sprintf("https://coverartarchive.org/release/%s", mbzAlbumID))
respChan := make(chan pdk.HTTPResponse, 1)
go func() { respChan <- req.Send() }()
var result caaResponse
select {
case resp := <-respChan:
if status := resp.Status(); status == 404 {
pdk.Log(pdk.LogDebug, fmt.Sprintf("No cover art for MusicBrainz Album ID: %s", mbzAlbumID))
return "", nil
} else if status >= 400 {
return "", fmt.Errorf("HTTP %d", resp.Status())
}
if err := json.Unmarshal(resp.Body(), &result); err != nil {
return "", fmt.Errorf("failed to parse: %w", err)
}
case <-time.After(CAA_TIMEOUT):
return "", fmt.Errorf("Timed out")
}
for _, image := range result.Images {
if image.Front {
return image.ThumbnailImageURLs.Size250, nil
}
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("No front cover art for MusicBrainz Album ID: %s (%d images)", mbzAlbumID, len(result.Images)))
return "", nil
}
func getImageViaCAA(mbzAlbumID string) string {
cacheKey := fmt.Sprintf("caa.artwork.%s", mbzAlbumID)
cachedURL, exists, err := host.CacheGetString(cacheKey)
if err == nil && exists {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for Cover Art Archive artwork: %s", mbzAlbumID))
return cachedURL
}
url, err := getThumbnailForMBZAlbumID(mbzAlbumID)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Cover Art Archive request failed for %s: %v", mbzAlbumID, err))
return ""
}
_ = host.CacheSetString(cacheKey, url, 86400)
return url
}
// ============================================================================
// Image URL Resolution
// ============================================================================
const uguuEnabledKey = "uguuenabled"
const caaEnabledKey = "caaenabled"
func getImageURL(username string, track scrobbler.TrackInfo) string {
caaEnabled, _ := pdk.GetConfig(caaEnabledKey)
if caaEnabled == "true" && track.MBZAlbumID != "" {
if url := getImageViaCAA(track.MBZAlbumID); url != "" {
return url
}
}
uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey)
if uguuEnabled == "true" {
return getImageViaUguu(username, track.ID)
}
return getImageDirect(track.ID)
}
+83 -8
View File
@@ -2,9 +2,11 @@ package main
import (
"errors"
"time"
"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"
@@ -23,29 +25,30 @@ var _ = Describe("getImageURL", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
Describe("uguu disabled (default)", func() {
Describe("direct", func() {
BeforeEach(func() {
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", caaEnabledKey).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())
})
})
@@ -53,12 +56,13 @@ var _ = Describe("getImageURL", func() {
Describe("uguu enabled", func() {
BeforeEach(func() {
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true)
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
})
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"))
})
@@ -79,7 +83,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))
})
@@ -89,7 +93,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())
})
@@ -102,8 +106,79 @@ var _ = Describe("getImageURL", func() {
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}`)))
url := getImageURL("testuser", "track1")
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
Expect(url).To(BeEmpty())
})
})
Describe("caa enabled", func() {
BeforeEach(func() {
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("true", true)
})
It("returns cached URL when available", func() {
host.CacheMock.On("GetString", "caa.artwork.test").Return("https://coverartarchive.org/release/test/0-250.jpg", true, nil)
url := getImageURL("testuser", scrobbler.TrackInfo{MBZAlbumID: "test"})
Expect(url).To(Equal("https://coverartarchive.org/release/test/0-250.jpg"))
})
It("fetches 250px thumbnail and caches the result", func() {
host.CacheMock.On("GetString", "caa.artwork.test").Return("", false, nil)
// Mock coverartarchive.org HTTP get
caaReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://coverartarchive.org/release/test").Return(caaReq)
pdk.PDKMock.On("Send", caaReq).Return(pdk.NewStubHTTPResponse(200, nil,
[]byte(`{"images":[{"front":true,"thumbnails":{"250":"https://coverartarchive.org/release/test/0-250.jpg"}}]}`)))
// Mock cache set
host.CacheMock.On("SetString", "caa.artwork.test", "https://coverartarchive.org/release/test/0-250.jpg", int64(86400)).Return(nil)
url := getImageURL("testuser", scrobbler.TrackInfo{MBZAlbumID: "test"})
Expect(url).To(Equal("https://coverartarchive.org/release/test/0-250.jpg"))
host.CacheMock.AssertCalled(GinkgoT(), "SetString", "caa.artwork.test", "https://coverartarchive.org/release/test/0-250.jpg", int64(86400))
})
It("returns artwork directly when no MBZAlbumID is provided", func() {
host.CacheMock.On("GetString", "caa.artwork.test").Return("", false, nil)
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"))
})
It("returns artwork directly when no suitable artwork is found", func() {
host.CacheMock.On("GetString", "caa.artwork.test").Return("", false, nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
// Mock coverartarchive.org HTTP get
caaReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://coverartarchive.org/release/test").Return(caaReq)
pdk.PDKMock.On("Send", caaReq).Return(pdk.NewStubHTTPResponse(200, nil,
[]byte(`{"images":[{"front":false,"thumbnails":{"250":"https://coverartarchive.org/release/test/0-250.jpg"}}]}`)))
// Mock cache set
host.CacheMock.On("SetString", "caa.artwork.test", "", int64(86400)).Return(nil)
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZAlbumID: "test"})
Expect(url).To(Equal("https://example.com/art.jpg"))
host.CacheMock.AssertCalled(GinkgoT(), "SetString", "caa.artwork.test", "", int64(86400))
})
It("returns artwork directly after 5 second timeout", func() {
host.CacheMock.On("GetString", "caa.artwork.test").Return("", false, nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
// Mock coverartarchive.org HTTP get
caaReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://coverartarchive.org/release/test").Return(caaReq)
pdk.PDKMock.On("Send", caaReq).WaitUntil(time.After(7 * time.Second)).Return(pdk.NewStubHTTPResponse(200, nil,
[]byte(`{"images":[{"front":false,"thumbnails":{"250":"https://coverartarchive.org/release/test/0-250.jpg"}}]}`)))
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZAlbumID: "test"})
Expect(url).To(Equal("https://example.com/art.jpg"))
})
})
})
+1 -1
View File
@@ -169,7 +169,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,
},
}); err != nil {
+2
View File
@@ -120,6 +120,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)
// Connect mocks (isConnected check via heartbeat)
@@ -177,6 +178,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)
// Connect mocks
+11 -1
View File
@@ -13,7 +13,8 @@
"reason": "To communicate with Discord API for gateway discovery and image uploads",
"requiredHosts": [
"discord.com",
"uguu.se"
"uguu.se",
"coverartarchive.org"
]
},
"websocket": {
@@ -64,6 +65,11 @@
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
deluan commented 2026-02-09 12:49:21 -07:00 (Migrated from github.com)
Review

The default should be false.

The default should be false.
"default": false
},
"caaenabled": {
"type": "boolean",
"title": "Use artwork from Cover Art Archive when available",
"default": false
},
"users": {
"type": "array",
"title": "User Tokens",
@@ -111,6 +117,10 @@
"format": "radio"
}
},
{
"type": "Control",
"scope": "#/properties/caaenabled"
},
{
"type": "Control",
"scope": "#/properties/uguuenabled"