diff --git a/README.md b/README.md index b190fe5..6a8e129 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/coverart.go b/coverart.go index 46592a0..dbc3f65 100644 --- a/coverart.go +++ b/coverart.go @@ -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 } @@ -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) +} diff --git a/coverart_test.go b/coverart_test.go index e3131e8..7ffd242 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -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")) + }) + }) }) diff --git a/main.go b/main.go index be5a81e..006225c 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/main_test.go b/main_test.go index cd9aa90..52b0b45 100644 --- a/main_test.go +++ b/main_test.go @@ -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 diff --git a/manifest.json b/manifest.json index 42cfdd4..045f7f2 100644 --- a/manifest.json +++ b/manifest.json @@ -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)", "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"