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:
+180
@@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configuration keys for image hosting
|
||||||
|
const (
|
||||||
|
imageHostKey = "imagehost"
|
||||||
|
imgbbApiKeyKey = "imgbbapikey"
|
||||||
|
)
|
||||||
|
|
||||||
|
// imgbb API response
|
||||||
|
type imgbbResponse struct {
|
||||||
|
Data struct {
|
||||||
|
DisplayURL string `json:"display_url"`
|
||||||
|
} `json:"data"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 a public image host.
|
||||||
|
func getImageURL(username, trackID string) string {
|
||||||
|
imageHost, _ := pdk.GetConfig(imageHostKey)
|
||||||
|
|
||||||
|
switch imageHost {
|
||||||
|
case "imgbb":
|
||||||
|
return getImageViaImgbb(username, trackID)
|
||||||
|
case "uguu":
|
||||||
|
return getImageViaUguu(username, trackID)
|
||||||
|
default:
|
||||||
|
return getImageDirect(trackID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getImageDirect returns the artwork URL directly from Navidrome (current behavior).
|
||||||
|
func getImageDirect(trackID string) string {
|
||||||
|
artworkURL, err := host.ArtworkGetTrackUrl(trackID, 300)
|
||||||
|
if err != nil {
|
||||||
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %v", err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't use localhost URLs
|
||||||
|
if strings.HasPrefix(artworkURL, "http://localhost") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return artworkURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadFunc uploads image data to an external host and returns the public URL.
|
||||||
|
type uploadFunc func(contentType string, data []byte) (string, error)
|
||||||
|
|
||||||
|
// getImageViaHost fetches artwork and uploads it using the provided upload function.
|
||||||
|
func getImageViaHost(provider, username, trackID string, cacheTTL int64, upload uploadFunc) string {
|
||||||
|
// Check cache first
|
||||||
|
cacheKey := fmt.Sprintf("%s.artwork.%s", provider, trackID)
|
||||||
|
cachedURL, exists, err := host.CacheGetString(cacheKey)
|
||||||
|
if err == nil && exists {
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for %s artwork: %s", provider, trackID))
|
||||||
|
return cachedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch artwork data from Navidrome
|
||||||
|
contentType, data, err := host.SubsonicAPICallRaw(fmt.Sprintf("/getCoverArt?u=%s&id=%s&size=300", username, trackID))
|
||||||
|
if err != nil {
|
||||||
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to fetch artwork data: %v", err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to external host
|
||||||
|
url, err := upload(contentType, data)
|
||||||
|
if err != nil {
|
||||||
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to upload to %s: %v", provider, err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = host.CacheSetString(cacheKey, url, cacheTTL)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// getImageViaImgbb fetches artwork and uploads it to imgbb.
|
||||||
|
func getImageViaImgbb(username, trackID string) string {
|
||||||
|
apiKey, ok := pdk.GetConfig(imgbbApiKeyKey)
|
||||||
|
if !ok || apiKey == "" {
|
||||||
|
pdk.Log(pdk.LogWarn, "imgbb image host selected but no API key configured")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return getImageViaHost("imgbb", username, trackID, 82800, func(_ string, data []byte) (string, error) {
|
||||||
|
return uploadToImgbb(apiKey, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getImageViaUguu fetches artwork and uploads it to uguu.se.
|
||||||
|
func getImageViaUguu(username, trackID string) string {
|
||||||
|
return getImageViaHost("uguu", username, trackID, 9000, func(contentType string, data []byte) (string, error) {
|
||||||
|
return uploadToUguu(data, contentType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadToImgbb uploads image data to imgbb and returns the display URL.
|
||||||
|
func uploadToImgbb(apiKey string, imageData []byte) (string, error) {
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(imageData)
|
||||||
|
body := fmt.Sprintf("key=%s&image=%s&expiration=86400", apiKey, encoded)
|
||||||
|
|
||||||
|
req := pdk.NewHTTPRequest(pdk.MethodPost, "https://api.imgbb.com/1/upload")
|
||||||
|
req.SetHeader("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBody([]byte(body))
|
||||||
|
|
||||||
|
resp := req.Send()
|
||||||
|
if resp.Status() >= 400 {
|
||||||
|
return "", fmt.Errorf("imgbb upload failed: HTTP %d", resp.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
var result imgbbResponse
|
||||||
|
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse imgbb response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Success {
|
||||||
|
return "", fmt.Errorf("imgbb upload was not successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Data.DisplayURL == "" {
|
||||||
|
return "", fmt.Errorf("imgbb returned empty display URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Data.DisplayURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadToUguu uploads image data to uguu.se and returns the file URL.
|
||||||
|
func uploadToUguu(imageData []byte, contentType string) (string, error) {
|
||||||
|
// Build multipart/form-data body manually (TinyGo-compatible)
|
||||||
|
boundary := "----NavidromeCoverArt"
|
||||||
|
var body []byte
|
||||||
|
body = append(body, []byte(fmt.Sprintf("--%s\r\n", boundary))...)
|
||||||
|
body = append(body, []byte(fmt.Sprintf("Content-Disposition: form-data; name=\"files[]\"; filename=\"cover.jpg\"\r\n"))...)
|
||||||
|
body = append(body, []byte(fmt.Sprintf("Content-Type: %s\r\n", contentType))...)
|
||||||
|
body = append(body, []byte("\r\n")...)
|
||||||
|
body = append(body, imageData...)
|
||||||
|
body = append(body, []byte(fmt.Sprintf("\r\n--%s--\r\n", boundary))...)
|
||||||
|
|
||||||
|
req := pdk.NewHTTPRequest(pdk.MethodPost, "https://uguu.se/upload")
|
||||||
|
req.SetHeader("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
|
||||||
|
req.SetBody(body)
|
||||||
|
|
||||||
|
resp := req.Send()
|
||||||
|
if resp.Status() >= 400 {
|
||||||
|
return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
var result uguuResponse
|
||||||
|
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse uguu.se response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Success || len(result.Files) == 0 {
|
||||||
|
return "", fmt.Errorf("uguu.se upload was not successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Files[0].URL == "" {
|
||||||
|
return "", fmt.Errorf("uguu.se returned empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Files[0].URL, nil
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -91,21 +91,6 @@ func getConfig() (clientID string, users map[string]string, err error) {
|
|||||||
return clientID, users, nil
|
return clientID, users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getImageURL retrieves the track artwork URL.
|
|
||||||
func getImageURL(trackID string) string {
|
|
||||||
artworkURL, err := host.ArtworkGetTrackUrl(trackID, 300)
|
|
||||||
if err != nil {
|
|
||||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %v", err))
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't use localhost URLs
|
|
||||||
if strings.HasPrefix(artworkURL, "http://localhost") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return artworkURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Scrobbler Implementation
|
// Scrobbler Implementation
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -163,7 +148,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
|||||||
End: endTime,
|
End: endTime,
|
||||||
},
|
},
|
||||||
Assets: activityAssets{
|
Assets: activityAssets{
|
||||||
LargeImage: getImageURL(input.Track.ID),
|
LargeImage: getImageURL(input.Username, input.Track.ID),
|
||||||
LargeText: input.Track.Album,
|
LargeText: input.Track.Album,
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
+3
-6
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
|
||||||
|
|
||||||
"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"
|
||||||
@@ -15,11 +14,6 @@ import (
|
|||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDiscordPlugin(t *testing.T) {
|
|
||||||
RegisterFailHandler(Fail)
|
|
||||||
RunSpecs(t, "Discord Plugin Main Suite")
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = Describe("discordPlugin", func() {
|
var _ = Describe("discordPlugin", func() {
|
||||||
var plugin discordPlugin
|
var plugin discordPlugin
|
||||||
|
|
||||||
@@ -36,6 +30,8 @@ var _ = Describe("discordPlugin", func() {
|
|||||||
host.SchedulerMock.Calls = nil
|
host.SchedulerMock.Calls = nil
|
||||||
host.ArtworkMock.ExpectedCalls = nil
|
host.ArtworkMock.ExpectedCalls = nil
|
||||||
host.ArtworkMock.Calls = nil
|
host.ArtworkMock.Calls = nil
|
||||||
|
host.SubsonicAPIMock.ExpectedCalls = nil
|
||||||
|
host.SubsonicAPIMock.Calls = nil
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("getConfig", func() {
|
Describe("getConfig", func() {
|
||||||
@@ -122,6 +118,7 @@ var _ = Describe("discordPlugin", func() {
|
|||||||
It("successfully sends now playing update", func() {
|
It("successfully sends now playing update", 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", imageHostKey).Return("", false)
|
||||||
|
|
||||||
// Connect mocks (isConnected check via heartbeat)
|
// Connect mocks (isConnected check via heartbeat)
|
||||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||||
|
|||||||
+31
-1
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"http": {
|
"http": {
|
||||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
||||||
"requiredHosts": ["discord.com"]
|
"requiredHosts": ["discord.com", "api.imgbb.com", "uguu.se"]
|
||||||
},
|
},
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"reason": "To maintain real-time connection with Discord gateway",
|
"reason": "To maintain real-time connection with Discord gateway",
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
},
|
},
|
||||||
"artwork": {
|
"artwork": {
|
||||||
"reason": "To get track artwork URLs for rich presence display"
|
"reason": "To get track artwork URLs for rich presence display"
|
||||||
|
},
|
||||||
|
"subsonicapi": {
|
||||||
|
"reason": "To fetch track artwork data for image hosting upload"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@@ -39,6 +42,17 @@
|
|||||||
"maxLength": 20,
|
"maxLength": 20,
|
||||||
"pattern": "^[0-9]+$"
|
"pattern": "^[0-9]+$"
|
||||||
},
|
},
|
||||||
|
"imagehost": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Image Hosting Service",
|
||||||
|
"description": "Upload artwork to a public image host so Discord can display it. Required when Navidrome is not publicly accessible.",
|
||||||
|
"enum": ["imgbb", "uguu"]
|
||||||
|
},
|
||||||
|
"imgbbapikey": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "imgbb API Key",
|
||||||
|
"description": "API key from imgbb.com. Get one at https://api.imgbb.com/"
|
||||||
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "User Tokens",
|
"title": "User Tokens",
|
||||||
@@ -73,6 +87,22 @@
|
|||||||
"type": "Control",
|
"type": "Control",
|
||||||
"scope": "#/properties/clientid"
|
"scope": "#/properties/clientid"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "Control",
|
||||||
|
"scope": "#/properties/imagehost"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Control",
|
||||||
|
"scope": "#/properties/imgbbapikey",
|
||||||
|
"options": { "format": "password" },
|
||||||
|
"rule": {
|
||||||
|
"effect": "SHOW",
|
||||||
|
"condition": {
|
||||||
|
"scope": "#/properties/imagehost",
|
||||||
|
"schema": { "const": "imgbb" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "Control",
|
"type": "Control",
|
||||||
"scope": "#/properties/users",
|
"scope": "#/properties/users",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiscordPlugin(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Discord Plugin Main Suite")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user