Add optional image hosting for non-public Navidrome instances
When Navidrome is behind a private network (e.g. Tailscale), Discord cannot fetch artwork URLs. This adds optional integration with imgbb (24h expiry, requires API key) and uguu.se (3h expiry, no key needed) to upload cover art to a public host. Closes #1 # Conflicts: # go.mod # go.sum
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("getImageURL", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.ResetMock()
|
||||
host.CacheMock.ExpectedCalls = nil
|
||||
host.CacheMock.Calls = nil
|
||||
host.ArtworkMock.ExpectedCalls = nil
|
||||
host.ArtworkMock.Calls = nil
|
||||
host.SubsonicAPIMock.ExpectedCalls = nil
|
||||
host.SubsonicAPIMock.Calls = nil
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
Describe("no image host configured", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("GetConfig", imageHostKey).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")
|
||||
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")
|
||||
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")
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("imgbb image host", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("GetConfig", imageHostKey).Return("imgbb", true)
|
||||
})
|
||||
|
||||
It("returns cached URL when available", func() {
|
||||
pdk.PDKMock.On("GetConfig", imgbbApiKeyKey).Return("test-api-key", true)
|
||||
host.CacheMock.On("GetString", "imgbb.artwork.track1").Return("https://i.ibb.co/cached.jpg", true, nil)
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
Expect(url).To(Equal("https://i.ibb.co/cached.jpg"))
|
||||
})
|
||||
|
||||
It("uploads artwork and caches the result", func() {
|
||||
pdk.PDKMock.On("GetConfig", imgbbApiKeyKey).Return("test-api-key", true)
|
||||
host.CacheMock.On("GetString", "imgbb.artwork.track1").Return("", false, nil)
|
||||
|
||||
// Mock SubsonicAPICallRaw
|
||||
imageData := []byte("fake-image-data")
|
||||
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
|
||||
Return("image/jpeg", imageData, nil)
|
||||
|
||||
// Mock imgbb HTTP upload
|
||||
imgbbReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://api.imgbb.com/1/upload").Return(imgbbReq)
|
||||
pdk.PDKMock.On("Send", imgbbReq).Return(pdk.NewStubHTTPResponse(200, nil,
|
||||
[]byte(`{"success":true,"data":{"display_url":"https://i.ibb.co/uploaded.jpg"}}`)))
|
||||
|
||||
// Mock cache set
|
||||
host.CacheMock.On("SetString", "imgbb.artwork.track1", "https://i.ibb.co/uploaded.jpg", int64(82800)).Return(nil)
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
Expect(url).To(Equal("https://i.ibb.co/uploaded.jpg"))
|
||||
host.CacheMock.AssertCalled(GinkgoT(), "SetString", "imgbb.artwork.track1", "https://i.ibb.co/uploaded.jpg", int64(82800))
|
||||
})
|
||||
|
||||
It("returns empty when no API key configured", func() {
|
||||
pdk.PDKMock.On("GetConfig", imgbbApiKeyKey).Return("", false)
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty when artwork data fetch fails", func() {
|
||||
pdk.PDKMock.On("GetConfig", imgbbApiKeyKey).Return("test-api-key", true)
|
||||
host.CacheMock.On("GetString", "imgbb.artwork.track1").Return("", false, nil)
|
||||
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
|
||||
Return("", []byte(nil), errors.New("fetch failed"))
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty when imgbb upload fails", func() {
|
||||
pdk.PDKMock.On("GetConfig", imgbbApiKeyKey).Return("test-api-key", true)
|
||||
host.CacheMock.On("GetString", "imgbb.artwork.track1").Return("", false, nil)
|
||||
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
|
||||
Return("image/jpeg", []byte("fake-image-data"), nil)
|
||||
|
||||
imgbbReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://api.imgbb.com/1/upload").Return(imgbbReq)
|
||||
pdk.PDKMock.On("Send", imgbbReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`{"success":false}`)))
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("uguu image host", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("GetConfig", imageHostKey).Return("uguu", true)
|
||||
})
|
||||
|
||||
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")
|
||||
Expect(url).To(Equal("https://a.uguu.se/cached.jpg"))
|
||||
})
|
||||
|
||||
It("uploads artwork and caches the result", func() {
|
||||
host.CacheMock.On("GetString", "uguu.artwork.track1").Return("", false, nil)
|
||||
|
||||
// Mock SubsonicAPICallRaw
|
||||
imageData := []byte("fake-image-data")
|
||||
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
|
||||
Return("image/jpeg", imageData, nil)
|
||||
|
||||
// Mock uguu.se HTTP upload
|
||||
uguuReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq)
|
||||
pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(200, nil,
|
||||
[]byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)))
|
||||
|
||||
// Mock cache set
|
||||
host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)).Return(nil)
|
||||
|
||||
url := getImageURL("testuser", "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))
|
||||
})
|
||||
|
||||
It("returns empty when artwork data fetch fails", func() {
|
||||
host.CacheMock.On("GetString", "uguu.artwork.track1").Return("", false, nil)
|
||||
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
|
||||
Return("", []byte(nil), errors.New("fetch failed"))
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty when uguu.se upload fails", func() {
|
||||
host.CacheMock.On("GetString", "uguu.artwork.track1").Return("", false, nil)
|
||||
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
|
||||
Return("image/jpeg", []byte("fake-image-data"), nil)
|
||||
|
||||
uguuReq := &pdk.HTTPRequest{}
|
||||
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")
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user