feat: use Cover Art Archive for albums with MusicBrainz IDs (#27)

This commit was merged in pull request #27.
This commit is contained in:
Deluan Quintão
2026-03-20 20:34:11 -04:00
committed by GitHub
parent 9d9dce052a
commit 606a7f2389
7 changed files with 366 additions and 21 deletions
+100 -7
View File
@@ -7,10 +7,94 @@ import (
"github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
) )
// Configuration key for uguu.se image hosting // Cache TTLs for cover art lookups
const uguuEnabledKey = "uguuenabled" 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 (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,
NoFollowRedirects: true,
TimeoutMs: caaTimeOut,
})
if err != nil {
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD request failed for %s: %v", url, err))
return "", false
}
if resp.StatusCode == 404 {
return "", true
}
if resp.StatusCode != 307 {
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, true
}
// 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
definitive := false
if mbzAlbumID != "" {
imageURL, definitive = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release/%s/front-500", mbzAlbumID))
}
// Fall back to release group
if imageURL == "" && mbzReleaseGroupID != "" {
imageURL, definitive = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release-group/%s/front-500", mbzReleaseGroupID))
}
// 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)
}
if imageURL != "" {
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA resolved artwork for %s: %s", cacheKey, imageURL))
}
return imageURL
}
// uguu.se API response // uguu.se API response
type uguuResponse struct { type uguuResponse struct {
@@ -20,13 +104,22 @@ type uguuResponse struct {
} `json:"files"` } `json:"files"`
} }
// getImageURL retrieves the track artwork URL, optionally uploading to uguu.se. // getImageURL retrieves the track artwork URL, checking CAA first if enabled,
func getImageURL(username, trackID string) string { // 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) uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey)
if uguuEnabled == "true" { 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). // getImageDirect returns the artwork URL directly from Navidrome (current behavior).
@@ -68,7 +161,7 @@ func getImageViaUguu(username, trackID string) string {
return "" return ""
} }
_ = host.CacheSetString(cacheKey, url, 9000) _ = host.CacheSetString(cacheKey, url, uguuCacheTTL)
return url return url
} }
+246 -9
View File
@@ -5,12 +5,70 @@ import (
"github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "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 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" &&
req.NoFollowRedirects == true
})).Return(&host.HTTPResponse{
StatusCode: 307,
Headers: map[string]string{"Location": "https://archive.org/download/mbid-test/thumb500.jpg"},
}, nil)
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 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, definitive := headCoverArt("https://coverartarchive.org/release/no-art/front-500")
Expect(result).To(BeEmpty())
Expect(definitive).To(BeTrue())
})
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, definitive := headCoverArt("https://coverartarchive.org/release/err/front-500")
Expect(result).To(BeEmpty())
Expect(definitive).To(BeFalse())
})
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{
StatusCode: 307,
Headers: map[string]string{},
}, nil)
result, definitive := headCoverArt("https://coverartarchive.org/release/no-location/front-500")
Expect(result).To(BeEmpty())
Expect(definitive).To(BeTrue())
})
})
var _ = Describe("getImageURL", func() { var _ = Describe("getImageURL", func() {
BeforeEach(func() { BeforeEach(func() {
pdk.ResetMock() pdk.ResetMock()
@@ -27,40 +85,42 @@ var _ = Describe("getImageURL", func() {
Describe("uguu disabled (default)", func() { Describe("uguu disabled (default)", func() {
BeforeEach(func() { BeforeEach(func() {
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
}) })
It("returns artwork URL directly", func() { It("returns artwork URL directly", func() {
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) 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")) Expect(url).To(Equal("https://example.com/art.jpg"))
}) })
It("returns empty for localhost URL", func() { It("returns empty for localhost URL", func() {
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("http://localhost:4533/art.jpg", nil) 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()) Expect(url).To(BeEmpty())
}) })
It("returns empty when artwork fetch fails", func() { It("returns empty when artwork fetch fails", func() {
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("", errors.New("not found")) 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()) Expect(url).To(BeEmpty())
}) })
}) })
Describe("uguu enabled", func() { Describe("uguu enabled", func() {
BeforeEach(func() { BeforeEach(func() {
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true)
}) })
It("returns cached URL when available", func() { It("returns cached URL when available", func() {
host.CacheMock.On("GetString", "uguu.artwork.track1").Return("https://a.uguu.se/cached.jpg", true, nil) 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")) Expect(url).To(Equal("https://a.uguu.se/cached.jpg"))
}) })
@@ -78,11 +138,11 @@ var _ = Describe("getImageURL", func() {
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)}, nil) })).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)}, nil)
// Mock cache set // 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", "track1") url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
Expect(url).To(Equal("https://a.uguu.se/uploaded.jpg")) 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() { It("returns empty when artwork data fetch fails", func() {
@@ -90,7 +150,7 @@ var _ = Describe("getImageURL", func() {
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300"). host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
Return("", []byte(nil), errors.New("fetch failed")) Return("", []byte(nil), errors.New("fetch failed"))
url := getImageURL("testuser", "track1") url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
Expect(url).To(BeEmpty()) Expect(url).To(BeEmpty())
}) })
@@ -103,8 +163,185 @@ var _ = Describe("getImageURL", func() {
return req.URL == "https://uguu.se/upload" return req.URL == "https://uguu.se/upload"
})).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`{"success":false}`)}, nil) })).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()) 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() {
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("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 {
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)
})
}) })
+1 -1
View File
@@ -3,7 +3,7 @@ module discord-rich-presence
go 1.25.0 go 1.25.0
require ( 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/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1 github.com/onsi/gomega v1.39.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
+2 -2
View File
@@ -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/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 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= 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-20260320221607-03844a9a369a h1:EHllNfhSpL6F3EqM4M0GDHQZb7DyClw0y7afddd8XPg=
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/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= 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/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
+3 -1
View File
@@ -29,6 +29,8 @@ const (
usersKey = "users" usersKey = "users"
activityNameKey = "activityname" activityNameKey = "activityname"
spotifyLinksKey = "spotifylinks" spotifyLinksKey = "spotifylinks"
caaEnabledKey = "caaenabled"
uguuEnabledKey = "uguuenabled"
) )
const ( const (
@@ -193,7 +195,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
End: endTime, End: endTime,
}, },
Assets: activityAssets{ Assets: activityAssets{
LargeImage: getImageURL(input.Username, input.Track.ID), LargeImage: getImageURL(input.Username, input.Track),
LargeText: input.Track.Album, LargeText: input.Track.Album,
LargeURL: spotifyURL, LargeURL: spotifyURL,
SmallImage: navidromeLogoURL, SmallImage: navidromeLogoURL,
+2
View File
@@ -122,6 +122,7 @@ var _ = Describe("discordPlugin", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) 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", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) 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", activityNameKey).Return("", false)
pdk.PDKMock.On("GetConfig", spotifyLinksKey).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", clientIDKey).Return("test-client-id", true)
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true) pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) 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", activityNameKey).Return(configValue, configExists)
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false) pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
+12 -1
View File
@@ -14,7 +14,8 @@
"requiredHosts": [ "requiredHosts": [
"discord.com", "discord.com",
"uguu.se", "uguu.se",
"labs.api.listenbrainz.org" "labs.api.listenbrainz.org",
"coverartarchive.org"
] ]
}, },
"websocket": { "websocket": {
@@ -60,6 +61,12 @@
], ],
"default": "Default" "default": "Default"
}, },
"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": { "uguuenabled": {
"type": "boolean", "type": "boolean",
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
@@ -118,6 +125,10 @@
"format": "radio" "format": "radio"
} }
}, },
{
"type": "Control",
"scope": "#/properties/caaenabled"
},
{ {
"type": "Control", "type": "Control",
"scope": "#/properties/uguuenabled" "scope": "#/properties/uguuenabled"