Add uguu.se artwork upload for private Navidrome instances #4
+14
-85
@@ -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)
|
||||
|
||||
+4
-72
@@ -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() {
|
||||
|
||||
+1
-1
@@ -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"))
|
||||
|
||||
+6
-24
@@ -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,
|
||||
|
The imagehost field uses an enum with only two values ("imgbb", "uguu"), but does not include an empty string option. This means users cannot explicitly unset this field once it's been set. Consider either making this field optional (not in the enum) or adding an empty string/null option to allow users to disable image hosting after enabling it. The imagehost field uses an enum with only two values ("imgbb", "uguu"), but does not include an empty string option. This means users cannot explicitly unset this field once it's been set. Consider either making this field optional (not in the enum) or adding an empty string/null option to allow users to disable image hosting after enabling it.
```suggestion
"enum": ["", "imgbb", "uguu"]
```
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user
The API key is directly interpolated into a URL-encoded form body without proper escaping. If the API key contains special characters like '&' or '=', this could break the request format. Consider using url.Values or proper URL encoding to safely construct the form body.
When an empty string is provided for imagehost (e.g., user clears the field), the switch statement will fall through to the default case and use getImageDirect. This is acceptable behavior, but it might be clearer to explicitly handle the empty string case for better code readability and maintainability.