Add uguu.se artwork upload for private Navidrome instances (#4)
This commit is contained in:
10
README.md
10
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
|
||||
|
||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/discord-rich-presence-plugin/master/.github/screenshot.png">
|
||||
|
||||
@@ -50,12 +51,13 @@ 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 |
|
||||
| **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 |
|
||||
|
||||
@@ -97,8 +102,9 @@ 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)) |
|
||||
| **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 |
|
||||
|
||||
|
||||
|
||||
110
coverart.go
Normal file
110
coverart.go
Normal 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
109
coverart_test.go
Normal 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
2
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
17
main.go
17
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 {
|
||||
|
||||
@@ -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", uguuEnabledKey).Return("", false)
|
||||
|
||||
// Connect mocks (isConnected check via heartbeat)
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"http": {
|
||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
||||
"requiredHosts": ["discord.com"]
|
||||
"requiredHosts": ["discord.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,11 @@
|
||||
"maxLength": 20,
|
||||
"pattern": "^[0-9]+$"
|
||||
},
|
||||
"uguuenabled": {
|
||||
"type": "boolean",
|
||||
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
|
||||
"default": false
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"title": "User Tokens",
|
||||
@@ -73,6 +81,10 @@
|
||||
"type": "Control",
|
||||
"scope": "#/properties/clientid"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/uguuenabled"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/users",
|
||||
|
||||
13
plugin_suite_test.go
Normal file
13
plugin_suite_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user