Use Cover Art Archive for albums with MusicBrainz IDs #12

Closed
sproutsberry wants to merge 11 commits from cover-art-archive into main
5 changed files with 86 additions and 11 deletions
Showing only changes of commit 10e42be952 - Show all commits
+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
+2 -2
View File
11
@@ -151,7 +151,7 @@ func getThumbnailForMBZAlbumID(mbzAlbumID string) (string, error) {
return "", nil
}
func getImageViaCAA(username, mbzAlbumID string) string {
func getImageViaCAA(mbzAlbumID string) string {
cacheKey := fmt.Sprintf("caa.artwork.%s", mbzAlbumID)
cachedURL, exists, err := host.CacheGetString(cacheKey)
if err == nil && exists {
@@ -179,7 +179,7 @@ const caaEnabledKey = "caaenabled"
func getImageURL(username string, track scrobbler.TrackInfo) string {
caaEnabled, _ := pdk.GetConfig(caaEnabledKey)
if caaEnabled == "true" && track.MBZAlbumID != "" {
if url := getImageViaCAA(username, track.MBZAlbumID); url != "" {
if url := getImageViaCAA(track.MBZAlbumID); url != "" {
return url
}
}
+68 -8
View File
@@ -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"
@@ -23,29 +24,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 +55,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 +82,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 +92,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 +105,65 @@ 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))
})
})
})
+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