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") +}