From 875c29b2d16d35ae46b706d524ffdb36817cef6a Mon Sep 17 00:00:00 2001 From: deluan Date: Wed, 4 Feb 2026 15:34:51 -0500 Subject: [PATCH 1/5] 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 --- coverart.go | 180 +++++++++++++++++++++++++++++++++++++++++++ coverart_test.go | 177 ++++++++++++++++++++++++++++++++++++++++++ main.go | 17 +--- main_test.go | 9 +-- manifest.json | 32 +++++++- plugin_suite_test.go | 13 ++++ 6 files changed, 405 insertions(+), 23 deletions(-) create mode 100644 coverart.go create mode 100644 coverart_test.go create mode 100644 plugin_suite_test.go diff --git a/coverart.go b/coverart.go new file mode 100644 index 0000000..77c9904 --- /dev/null +++ b/coverart.go @@ -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 +} diff --git a/coverart_test.go b/coverart_test.go new file mode 100644 index 0000000..4d0dfcb --- /dev/null +++ b/coverart_test.go @@ -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()) + }) + }) +}) diff --git a/main.go b/main.go index abd628a..4813ace 100644 --- a/main.go +++ b/main.go @@ -91,21 +91,6 @@ func getConfig() (clientID string, users map[string]string, err error) { 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 // ============================================================================ @@ -163,7 +148,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { End: endTime, }, Assets: activityAssets{ - LargeImage: getImageURL(input.Track.ID), + LargeImage: getImageURL(input.Username, input.Track.ID), LargeText: input.Track.Album, }, }); err != nil { diff --git a/main_test.go b/main_test.go index 6c90f04..3033efb 100644 --- a/main_test.go +++ b/main_test.go @@ -3,7 +3,6 @@ package main import ( "errors" "strings" - "testing" "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" @@ -15,11 +14,6 @@ import ( . "github.com/onsi/gomega" ) -func TestDiscordPlugin(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Discord Plugin Main Suite") -} - var _ = Describe("discordPlugin", func() { var plugin discordPlugin @@ -36,6 +30,8 @@ var _ = Describe("discordPlugin", func() { host.SchedulerMock.Calls = nil host.ArtworkMock.ExpectedCalls = nil host.ArtworkMock.Calls = nil + host.SubsonicAPIMock.ExpectedCalls = nil + host.SubsonicAPIMock.Calls = nil }) Describe("getConfig", func() { @@ -122,6 +118,7 @@ var _ = Describe("discordPlugin", func() { It("successfully sends now playing update", 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", imageHostKey).Return("", false) // Connect mocks (isConnected check via heartbeat) host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) diff --git a/manifest.json b/manifest.json index 0822931..4cd9229 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,7 @@ }, "http": { "reason": "To communicate with Discord API for gateway discovery and image uploads", - "requiredHosts": ["discord.com"] + "requiredHosts": ["discord.com", "api.imgbb.com", "uguu.se"] }, "websocket": { "reason": "To maintain real-time connection with Discord gateway", @@ -25,6 +25,9 @@ }, "artwork": { "reason": "To get track artwork URLs for rich presence display" + }, + "subsonicapi": { + "reason": "To fetch track artwork data for image hosting upload" } }, "config": { @@ -39,6 +42,17 @@ "maxLength": 20, "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": { "type": "array", "title": "User Tokens", @@ -73,6 +87,22 @@ "type": "Control", "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", "scope": "#/properties/users", diff --git a/plugin_suite_test.go b/plugin_suite_test.go new file mode 100644 index 0000000..ad01992 --- /dev/null +++ b/plugin_suite_test.go @@ -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") +} -- 2.52.0 From a6386d0fc916559b76b6bceacfcc64c9a3f55862 Mon Sep 17 00:00:00 2001 From: deluan Date: Wed, 4 Feb 2026 15:54:46 -0500 Subject: [PATCH 2/5] Fix URL encoding in image upload request and update manifest for optional image hosting --- coverart.go | 3 ++- manifest.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/coverart.go b/coverart.go index 77c9904..94fd46b 100644 --- a/coverart.go +++ b/coverart.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/url" "strings" "github.com/navidrome/navidrome/plugins/pdk/go/host" @@ -115,7 +116,7 @@ func getImageViaUguu(username, trackID string) string { // 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) + body := fmt.Sprintf("key=%s&image=%s&expiration=86400", url.QueryEscape(apiKey), url.QueryEscape(encoded)) req := pdk.NewHTTPRequest(pdk.MethodPost, "https://api.imgbb.com/1/upload") req.SetHeader("Content-Type", "application/x-www-form-urlencoded") diff --git a/manifest.json b/manifest.json index 4cd9229..8328bf8 100644 --- a/manifest.json +++ b/manifest.json @@ -46,7 +46,7 @@ "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"] + "enum": ["", "imgbb", "uguu"] }, "imgbbapikey": { "type": "string", -- 2.52.0 From 0f14624678121fb8c44150ecf0aa72e8c3bc6865 Mon Sep 17 00:00:00 2001 From: deluan Date: Wed, 4 Feb 2026 16:07:39 -0500 Subject: [PATCH 3/5] Refactor image hosting to remove imgbb support and enable uguu.se as the primary image host --- coverart.go | 99 +++++++----------------------------------------- coverart_test.go | 76 ++----------------------------------- main_test.go | 2 +- manifest.json | 30 +++------------ 4 files changed, 25 insertions(+), 182 deletions(-) diff --git a/coverart.go b/coverart.go index 94fd46b..46592a0 100644 --- a/coverart.go +++ b/coverart.go @@ -1,29 +1,16 @@ package main import ( - "encoding/base64" "encoding/json" "fmt" - "net/url" "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"` -} +// Configuration key for uguu.se image hosting +const uguuEnabledKey = "uguuenabled" // uguu.se API response type uguuResponse struct { @@ -33,18 +20,13 @@ type uguuResponse struct { } `json:"files"` } -// getImageURL retrieves the track artwork URL, optionally uploading to a public image host. +// getImageURL retrieves the track artwork URL, optionally uploading to uguu.se. func getImageURL(username, trackID string) string { - imageHost, _ := pdk.GetConfig(imageHostKey) - - switch imageHost { - case "imgbb": - return getImageViaImgbb(username, trackID) - case "uguu": + uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey) + if uguuEnabled == "true" { return getImageViaUguu(username, trackID) - default: - return getImageDirect(trackID) } + return getImageDirect(trackID) } // getImageDirect returns the artwork URL directly from Navidrome (current behavior). @@ -62,16 +44,13 @@ func getImageDirect(trackID string) string { 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 { +// getImageViaUguu fetches artwork and uploads it to uguu.se. +func getImageViaUguu(username, trackID string) string { // Check cache first - cacheKey := fmt.Sprintf("%s.artwork.%s", provider, trackID) + 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 %s artwork: %s", provider, trackID)) + pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for uguu artwork: %s", trackID)) return cachedURL } @@ -82,67 +61,17 @@ func getImageViaHost(provider, username, trackID string, cacheTTL int64, upload return "" } - // Upload to external host - url, err := upload(contentType, data) + // Upload to uguu.se + url, err := uploadToUguu(data, contentType) if err != nil { - pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to upload to %s: %v", provider, err)) + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to upload to uguu.se: %v", err)) return "" } - _ = host.CacheSetString(cacheKey, url, cacheTTL) + _ = host.CacheSetString(cacheKey, url, 9000) 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", url.QueryEscape(apiKey), url.QueryEscape(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) diff --git a/coverart_test.go b/coverart_test.go index 4d0dfcb..e3131e8 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -23,9 +23,9 @@ var _ = Describe("getImageURL", func() { pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() }) - Describe("no image host configured", func() { + Describe("uguu disabled (default)", func() { BeforeEach(func() { - pdk.PDKMock.On("GetConfig", imageHostKey).Return("", false) + pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) }) It("returns artwork URL directly", func() { @@ -50,77 +50,9 @@ var _ = Describe("getImageURL", func() { }) }) - Describe("imgbb image host", func() { + Describe("uguu enabled", 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) + pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true) }) It("returns cached URL when available", func() { diff --git a/main_test.go b/main_test.go index 3033efb..dfe944b 100644 --- a/main_test.go +++ b/main_test.go @@ -118,7 +118,7 @@ var _ = Describe("discordPlugin", func() { It("successfully sends now playing update", 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", imageHostKey).Return("", false) + pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) // Connect mocks (isConnected check via heartbeat) host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) diff --git a/manifest.json b/manifest.json index 8328bf8..cdf26ed 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,7 @@ }, "http": { "reason": "To communicate with Discord API for gateway discovery and image uploads", - "requiredHosts": ["discord.com", "api.imgbb.com", "uguu.se"] + "requiredHosts": ["discord.com", "uguu.se"] }, "websocket": { "reason": "To maintain real-time connection with Discord gateway", @@ -42,16 +42,10 @@ "maxLength": 20, "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/" + "uguuenabled": { + "type": "boolean", + "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", + "default": false }, "users": { "type": "array", @@ -89,19 +83,7 @@ }, { "type": "Control", - "scope": "#/properties/imagehost" - }, - { - "type": "Control", - "scope": "#/properties/imgbbapikey", - "options": { "format": "password" }, - "rule": { - "effect": "SHOW", - "condition": { - "scope": "#/properties/imagehost", - "schema": { "const": "imgbb" } - } - } + "scope": "#/properties/uguuenabled" }, { "type": "Control", -- 2.52.0 From b157d1970c874be9aed8f78b0c7c1c5bd9eed576 Mon Sep 17 00:00:00 2001 From: deluan Date: Wed, 4 Feb 2026 19:39:33 -0500 Subject: [PATCH 4/5] Update README.md to document optional image hosting via uguu.se for non-public Navidrome instances --- README.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f7e6113..c59cc6f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Based on the [Navicord](https://github.com/logixism/navicord) project. - Displays playback progress with start/end timestamps - Automatic presence clearing when track finishes - Multi-user support with individual Discord tokens +- Optional image hosting via [uguu.se](https://uguu.se) for non-public Navidrome instances @@ -49,13 +50,14 @@ The plugin implements three Navidrome capabilities: ### Host Services -| Service | Usage | -|---------------|---------------------------------------------------------------------| -| **HTTP** | Discord API calls (gateway discovery, external assets registration) | -| **WebSocket** | Persistent connection to Discord gateway | -| **Cache** | Sequence numbers, processed image URLs | -| **Scheduler** | Recurring heartbeats, one-time presence clearing | -| **Artwork** | Track artwork public URL resolution | +| Service | Usage | +|-----------------|---------------------------------------------------------------------| +| **HTTP** | Discord API calls (gateway discovery, external assets registration) | +| **WebSocket** | Persistent connection to Discord gateway | +| **Cache** | Sequence numbers, processed image URLs | +| **Scheduler** | Recurring heartbeats, one-time presence clearing | +| **Artwork** | Track artwork public URL resolution | +| **SubsonicAPI** | Fetches track artwork data for image hosting upload | ### Flow @@ -83,12 +85,15 @@ Discord requires images to be registered via their external assets API. The plug 3. Caches the result (4 hours for track art, 48 hours for default image) 4. Falls back to a default image if artwork is unavailable +**For non-public Navidrome instances**: If your server isn't publicly accessible (e.g., behind a VPN or firewall), enable the "Upload to uguu.se" option. This uploads artwork to a temporary file host so Discord can display it. + ### Files | File | Description | |--------------------------------|------------------------------------------------------------------------| | [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations | | [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management | +| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting | | [manifest.json](manifest.json) | Plugin metadata and permission declarations | | [Makefile](Makefile) | Build automation | @@ -96,10 +101,11 @@ Discord requires images to be registered via their external assets API. The plug Configure via the Navidrome UI under **Settings > Plugins > Discord Rich Presence**: -| Field | Description | -|---------------|-----------------------------------------------------------------------------------------------------------------| -| **Client ID** | Your Discord Application ID (create at [Discord Developer Portal](https://discord.com/developers/applications)) | -| **Users** | Array of username/token pairs mapping Navidrome users to Discord tokens | +| Field | Description | +|-----------------------|-----------------------------------------------------------------------------------------------------------------| +| **Client ID** | Your Discord Application ID (create at [Discord Developer Portal](https://discord.com/developers/applications)) | +| **Upload to uguu.se** | Enable if your Navidrome instance isn't publicly accessible (uploads artwork to temporary file host) | +| **Users** | Array of username/token pairs mapping Navidrome users to Discord tokens | ## Building -- 2.52.0 From 9a040c33dffebc2cf52207dfea2c4467c8be086f Mon Sep 17 00:00:00 2001 From: deluan Date: Sat, 7 Feb 2026 14:21:32 -0500 Subject: [PATCH 5/5] Update dependencies in go.mod and go.sum for navidrome/plugins/pdk --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b6265ce..dbaa9df 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module discord-rich-presence go 1.25 require ( - github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207003554-6fb4cd277ef7 + github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index ab8bd5e..702fa22 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,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/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207003554-6fb4cd277ef7 h1:vA0PhcvJuaUGAtbNchGCmHNQ4NJ5z9whE+xxd6Jw9Z8= -github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207003554-6fb4cd277ef7/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11 h1:VE4bqzkS6apWDtco9hAGdThFttjbYoLR0DEILAGDyyc= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4= 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/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -- 2.52.0