Refactor image hosting to remove imgbb support and enable uguu.se as the primary image host
This commit is contained in:
+14
-85
@@ -1,29 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configuration keys for image hosting
|
// Configuration key for uguu.se image hosting
|
||||||
const (
|
const uguuEnabledKey = "uguuenabled"
|
||||||
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
|
// uguu.se API response
|
||||||
type uguuResponse struct {
|
type uguuResponse struct {
|
||||||
@@ -33,18 +20,13 @@ type uguuResponse struct {
|
|||||||
} `json:"files"`
|
} `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 {
|
func getImageURL(username, trackID string) string {
|
||||||
imageHost, _ := pdk.GetConfig(imageHostKey)
|
uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey)
|
||||||
|
if uguuEnabled == "true" {
|
||||||
switch imageHost {
|
|
||||||
case "imgbb":
|
|
||||||
return getImageViaImgbb(username, trackID)
|
|
||||||
case "uguu":
|
|
||||||
return getImageViaUguu(username, trackID)
|
return getImageViaUguu(username, trackID)
|
||||||
default:
|
|
||||||
return getImageDirect(trackID)
|
|
||||||
}
|
}
|
||||||
|
return getImageDirect(trackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getImageDirect returns the artwork URL directly from Navidrome (current behavior).
|
// getImageDirect returns the artwork URL directly from Navidrome (current behavior).
|
||||||
@@ -62,16 +44,13 @@ func getImageDirect(trackID string) string {
|
|||||||
return artworkURL
|
return artworkURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadFunc uploads image data to an external host and returns the public URL.
|
// getImageViaUguu fetches artwork and uploads it to uguu.se.
|
||||||
type uploadFunc func(contentType string, data []byte) (string, error)
|
func getImageViaUguu(username, trackID string) string {
|
||||||
|
|
||||||
// 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
|
// Check cache first
|
||||||
cacheKey := fmt.Sprintf("%s.artwork.%s", provider, trackID)
|
cacheKey := fmt.Sprintf("uguu.artwork.%s", trackID)
|
||||||
cachedURL, exists, err := host.CacheGetString(cacheKey)
|
cachedURL, exists, err := host.CacheGetString(cacheKey)
|
||||||
if err == nil && exists {
|
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
|
return cachedURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,67 +61,17 @@ func getImageViaHost(provider, username, trackID string, cacheTTL int64, upload
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload to external host
|
// Upload to uguu.se
|
||||||
url, err := upload(contentType, data)
|
url, err := uploadToUguu(data, contentType)
|
||||||
if err != nil {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = host.CacheSetString(cacheKey, url, cacheTTL)
|
_ = host.CacheSetString(cacheKey, url, 9000)
|
||||||
return url
|
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.
|
// uploadToUguu uploads image data to uguu.se and returns the file URL.
|
||||||
func uploadToUguu(imageData []byte, contentType string) (string, error) {
|
func uploadToUguu(imageData []byte, contentType string) (string, error) {
|
||||||
// Build multipart/form-data body manually (TinyGo-compatible)
|
// 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()
|
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("no image host configured", func() {
|
Describe("uguu disabled (default)", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
pdk.PDKMock.On("GetConfig", imageHostKey).Return("", false)
|
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns artwork URL directly", func() {
|
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() {
|
BeforeEach(func() {
|
||||||
pdk.PDKMock.On("GetConfig", imageHostKey).Return("imgbb", true)
|
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", 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() {
|
It("returns cached URL when available", func() {
|
||||||
|
|||||||
+1
-1
@@ -118,7 +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)
|
pdk.PDKMock.On("GetConfig", uguuEnabledKey).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"))
|
||||||
|
|||||||
+6
-24
@@ -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", "api.imgbb.com", "uguu.se"]
|
"requiredHosts": ["discord.com", "uguu.se"]
|
||||||
},
|
},
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"reason": "To maintain real-time connection with Discord gateway",
|
"reason": "To maintain real-time connection with Discord gateway",
|
||||||
@@ -42,16 +42,10 @@
|
|||||||
"maxLength": 20,
|
"maxLength": 20,
|
||||||
"pattern": "^[0-9]+$"
|
"pattern": "^[0-9]+$"
|
||||||
},
|
},
|
||||||
"imagehost": {
|
"uguuenabled": {
|
||||||
"type": "string",
|
"type": "boolean",
|
||||||
"title": "Image Hosting Service",
|
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
|
||||||
"description": "Upload artwork to a public image host so Discord can display it. Required when Navidrome is not publicly accessible.",
|
"default": false
|
||||||
"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",
|
||||||
@@ -89,19 +83,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Control",
|
"type": "Control",
|
||||||
"scope": "#/properties/imagehost"
|
"scope": "#/properties/uguuenabled"
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Control",
|
|
||||||
"scope": "#/properties/imgbbapikey",
|
|
||||||
"options": { "format": "password" },
|
|
||||||
"rule": {
|
|
||||||
"effect": "SHOW",
|
|
||||||
"condition": {
|
|
||||||
"scope": "#/properties/imagehost",
|
|
||||||
"schema": { "const": "imgbb" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Control",
|
"type": "Control",
|
||||||
|
|||||||
Reference in New Issue
Block a user