Add uguu.se artwork upload for private Navidrome instances (#4)

This commit is contained in:
Deluan Quintão
2026-02-07 14:27:32 -05:00
committed by GitHub
parent e84a89809e
commit 758759cda0
9 changed files with 269 additions and 37 deletions

View File

@@ -17,6 +17,7 @@ Based on the [Navicord](https://github.com/logixism/navicord) project.
- Displays playback progress with start/end timestamps - Displays playback progress with start/end timestamps
- Automatic presence clearing when track finishes - Automatic presence clearing when track finishes
- Multi-user support with individual Discord tokens - Multi-user support with individual Discord tokens
- Optional image hosting via [uguu.se](https://uguu.se) for non-public Navidrome instances
<img height="550" src="https://raw.githubusercontent.com/navidrome/discord-rich-presence-plugin/master/.github/screenshot.png"> <img height="550" src="https://raw.githubusercontent.com/navidrome/discord-rich-presence-plugin/master/.github/screenshot.png">
@@ -49,13 +50,14 @@ The plugin implements three Navidrome capabilities:
### Host Services ### Host Services
| Service | Usage | | Service | Usage |
|---------------|---------------------------------------------------------------------| |-----------------|---------------------------------------------------------------------|
| **HTTP** | Discord API calls (gateway discovery, external assets registration) | | **HTTP** | Discord API calls (gateway discovery, external assets registration) |
| **WebSocket** | Persistent connection to Discord gateway | | **WebSocket** | Persistent connection to Discord gateway |
| **Cache** | Sequence numbers, processed image URLs | | **Cache** | Sequence numbers, processed image URLs |
| **Scheduler** | Recurring heartbeats, one-time presence clearing | | **Scheduler** | Recurring heartbeats, one-time presence clearing |
| **Artwork** | Track artwork public URL resolution | | **Artwork** | Track artwork public URL resolution |
| **SubsonicAPI** | Fetches track artwork data for image hosting upload |
### Flow ### 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) 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 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 ### Files
| File | Description | | File | Description |
|--------------------------------|------------------------------------------------------------------------| |--------------------------------|------------------------------------------------------------------------|
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations | | [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations |
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management | | [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 | | [manifest.json](manifest.json) | Plugin metadata and permission declarations |
| [Makefile](Makefile) | Build automation | | [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**: Configure via the Navidrome UI under **Settings > Plugins > Discord Rich Presence**:
| Field | Description | | Field | Description |
|---------------|-----------------------------------------------------------------------------------------------------------------| |-----------------------|-----------------------------------------------------------------------------------------------------------------|
| **Client ID** | Your Discord Application ID (create at [Discord Developer Portal](https://discord.com/developers/applications)) | | **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 | | **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 ## Building

110
coverart.go Normal file
View File

@@ -0,0 +1,110 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// Configuration key for uguu.se image hosting
const uguuEnabledKey = "uguuenabled"
// 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 uguu.se.
func getImageURL(username, trackID string) string {
uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey)
if uguuEnabled == "true" {
return getImageViaUguu(username, trackID)
}
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
}
// getImageViaUguu fetches artwork and uploads it to uguu.se.
func getImageViaUguu(username, trackID string) string {
// Check cache first
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 uguu artwork: %s", 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 uguu.se
url, err := uploadToUguu(data, contentType)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to upload to uguu.se: %v", err))
return ""
}
_ = host.CacheSetString(cacheKey, url, 9000)
return url
}
// 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
}

109
coverart_test.go Normal file
View File

@@ -0,0 +1,109 @@
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("uguu disabled (default)", func() {
BeforeEach(func() {
pdk.PDKMock.On("GetConfig", uguuEnabledKey).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("uguu enabled", func() {
BeforeEach(func() {
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", 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())
})
})
})

2
go.mod
View File

@@ -3,7 +3,7 @@ module discord-rich-presence
go 1.25 go 1.25
require ( 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/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1 github.com/onsi/gomega v1.39.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1

4
go.sum
View File

@@ -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/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 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= 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-20260207182358-29f98b889b11 h1:VE4bqzkS6apWDtco9hAGdThFttjbYoLR0DEILAGDyyc=
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/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= 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/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=

17
main.go
View File

@@ -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 {

View File

@@ -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", 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"))

View File

@@ -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", "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,11 @@
"maxLength": 20, "maxLength": 20,
"pattern": "^[0-9]+$" "pattern": "^[0-9]+$"
}, },
"uguuenabled": {
"type": "boolean",
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
"default": false
},
"users": { "users": {
"type": "array", "type": "array",
"title": "User Tokens", "title": "User Tokens",
@@ -73,6 +81,10 @@
"type": "Control", "type": "Control",
"scope": "#/properties/clientid" "scope": "#/properties/clientid"
}, },
{
"type": "Control",
"scope": "#/properties/uguuenabled"
},
{ {
"type": "Control", "type": "Control",
"scope": "#/properties/users", "scope": "#/properties/users",

13
plugin_suite_test.go Normal file
View File

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