Use Cover Art Archive for albums with MusicBrainz IDs #12
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
+83
-8
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
|
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"
|
||||
|
||||
Reference in New Issue
Block a user
Caching an empty URL string prevents the fallback logic in
getImageURLfrom working on subsequent requests for the same track. The cache should only be set if a valid URL is obtained.Not relevant