From cdc693bd7f46ce8049569287de48bc167696174e Mon Sep 17 00:00:00 2001 From: deluan Date: Mon, 2 Feb 2026 18:44:59 -0500 Subject: [PATCH] Initial commit, copy from examples folder --- .gitignore | 2 + Makefile | 16 ++ README.md | 129 ++++++++++++++++ go.mod | 33 +++++ go.sum | 75 ++++++++++ main.go | 219 +++++++++++++++++++++++++++ main_test.go | 221 ++++++++++++++++++++++++++++ manifest.json | 98 +++++++++++++ rpc.go | 400 ++++++++++++++++++++++++++++++++++++++++++++++++++ rpc_test.go | 279 +++++++++++++++++++++++++++++++++++ 10 files changed, 1472 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 manifest.json create mode 100644 rpc.go create mode 100644 rpc_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5286d3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.wasm +*.ndp \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..587c85e --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: test build package clean + +PLUGIN_NAME := discord-rich-presence +WASM_FILE := plugin.wasm + +test: + go test -race ./... + +build: + tinygo build -target wasip1 -buildmode=c-shared -o $(WASM_FILE) -scheduler=none . + +package: build + zip $(PLUGIN_NAME).ndp $(WASM_FILE) manifest.json + +clean: + rm -f $(WASM_FILE) $(PLUGIN_NAME).ndp diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad49bd9 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Discord Rich Presence Plugin + +This plugin integrates Navidrome with Discord Rich Presence, displaying your currently playing track in your Discord status. It demonstrates how a Navidrome plugin can maintain real-time connections to external services while remaining completely stateless. Based on the [Navicord](https://github.com/logixism/navicord) project. + +**⚠️ WARNING: This plugin requires storing Discord user tokens, which may violate Discord's Terms of Service. Use at your own risk.** + +## Features + +- Shows currently playing track with title, artist, and album art +- Displays playback progress with start/end timestamps +- Automatic presence clearing when track finishes +- Multi-user support with individual Discord tokens + +## How It Works + +### Plugin Capabilities + +The plugin implements three Navidrome capabilities: + +| Capability | Purpose | +|-----------------------|------------------------------------------------------------------------------| +| **Scrobbler** | Receives `NowPlaying` events when users start playing tracks | +| **WebSocketCallback** | Handles incoming Discord gateway messages (heartbeat ACKs, sequence numbers) | +| **SchedulerCallback** | Processes scheduled events for heartbeats and presence clearing | + +### 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 | +| [manifest.json](manifest.json) | Plugin metadata and permission declarations | +| [Makefile](Makefile) | Build automation | + +### 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 URL resolution | + +### Flow + +1. **Track starts playing** - Navidrome calls `NowPlaying` +2. **Plugin connects** - If not already connected, establishes WebSocket to Discord gateway +3. **Authentication** - Sends identify payload with user's Discord token +4. **Presence update** - Sends activity with track info and processed artwork URL +5. **Heartbeat loop** - Recurring scheduler sends heartbeats every 41 seconds to keep connection alive +6. **Track ends** - One-time scheduler callback clears presence and disconnects + +### Stateless Design + +Navidrome plugins are stateless - each call creates a fresh instance. This plugin handles that by: + +- **WebSocket connections**: Managed by host, keyed by username +- **Sequence numbers**: Stored in cache for heartbeat messages +- **Configuration**: Reloaded on every method call +- **Artwork URLs**: Cached after processing through Discord's external assets API + +### Image Processing + +Discord requires images to be registered via their external assets API. The plugin: +1. Fetches track artwork URL from Navidrome +2. Registers it with Discord's API to get an `mp:` prefixed URL +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 + +## Configuration + +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)) | +| **Users** | Array of username/token pairs mapping Navidrome users to Discord tokens | + +Example JSON configuration: +```json +{ + "clientid": "123456789012345678", + "users": [ + {"username": "alice", "token": "discord-token-here"}, + {"username": "bob", "token": "another-discord-token"} + ] +} +``` + +## Building + +Although the plugin can be compiled to WebAssembly with standard Go, it is recommended to use +[TinyGo](https://tinygo.org/getting-started/install/) for smaller binary size. + + +```sh +# Run tests +make test + +# Build plugin.wasm +make build + +# Create distributable package +make package +``` + +The `make package` command creates `discord-rich-presence.ndp` containing the compiled WebAssembly module and manifest. + +Manual build: +```sh +tinygo build -target wasip1 -buildmode=c-shared -o plugin.wasm -scheduler=none . +zip discord-rich-presence.ndp plugin.wasm manifest.json +``` + +Using standard Go: +```sh +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm . +zip discord-rich-presence.ndp plugin.wasm manifest.json +``` + +## Installation + +1. Copy the `discord-rich-presence.ndp` file to your Navidrome plugins folder (default is `plugins/` under the Navidrome data directory). +2. Configure the plugin in **Settings > Plugins > Discord Rich Presence** +3. Enable the plugin + +There is no need to restart Navidrome; Check the logs for any errors during initialization. + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2475f8e --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module discord-rich-presence + +go 1.25 + +require ( + github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260202223454-15526b25e5c3 + github.com/onsi/ginkgo/v2 v2.28.1 + github.com/onsi/gomega v1.39.1 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect + github.com/maruel/natural v1.3.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e7f3265 --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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-20260202223454-15526b25e5c3 h1:Lnnc4Qf8/QcyeoW9zslgrGz/4IvoyZ8NCoI0WcDzEmY= +github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260202223454-15526b25e5c3/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo= +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= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..abd628a --- /dev/null +++ b/main.go @@ -0,0 +1,219 @@ +// Discord Rich Presence Plugin for Navidrome +// +// This plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can +// keep a real-time connection to an external service while remaining completely stateless. +// +// Capabilities: Scrobbler, SchedulerCallback, WebSocketCallback +// +// NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord +// token being stored in the Navidrome configuration file, which is not secure and may be +// against Discord's terms of service. Use it at your own risk. +package main + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" + "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" + "github.com/navidrome/navidrome/plugins/pdk/go/websocket" +) + +// Configuration keys +const ( + clientIDKey = "clientid" + usersKey = "users" +) + +// userToken represents a user-token mapping from the config +type userToken struct { + Username string `json:"username"` + Token string `json:"token"` +} + +// discordPlugin implements the scrobbler and scheduler interfaces. +type discordPlugin struct{} + +// rpc handles Discord gateway communication (via websockets). +var rpc = &discordRPC{} + +// init registers the plugin capabilities +func init() { + scrobbler.Register(&discordPlugin{}) + scheduler.Register(&discordPlugin{}) + websocket.Register(rpc) +} + +// getConfig loads the plugin configuration. +func getConfig() (clientID string, users map[string]string, err error) { + clientID, ok := pdk.GetConfig(clientIDKey) + if !ok || clientID == "" { + pdk.Log(pdk.LogWarn, "missing ClientID in configuration") + return "", nil, nil + } + + // Get the users array from config + usersJSON, ok := pdk.GetConfig(usersKey) + if !ok || usersJSON == "" { + pdk.Log(pdk.LogWarn, "no users configured") + return clientID, nil, nil + } + + // Parse the JSON array + var userTokens []userToken + if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil { + pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err)) + return clientID, nil, nil + } + + if len(userTokens) == 0 { + pdk.Log(pdk.LogWarn, "no users configured") + return clientID, nil, nil + } + + // Build the users map + users = make(map[string]string) + for _, ut := range userTokens { + if ut.Username != "" && ut.Token != "" { + users[ut.Username] = ut.Token + } + } + + if len(users) == 0 { + pdk.Log(pdk.LogWarn, "no valid users configured") + return clientID, nil, 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 +// ============================================================================ + +// IsAuthorized checks if a user is authorized for Discord Rich Presence. +func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (bool, error) { + _, users, err := getConfig() + if err != nil { + return false, fmt.Errorf("failed to check user authorization: %w", err) + } + + _, authorized := users[input.Username] + pdk.Log(pdk.LogInfo, fmt.Sprintf("IsAuthorized for user %s: %v", input.Username, authorized)) + return authorized, nil +} + +// NowPlaying sends a now playing notification to Discord. +func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { + pdk.Log(pdk.LogInfo, fmt.Sprintf("Setting presence for user %s, track: %s", input.Username, input.Track.Title)) + + // Load configuration + clientID, users, err := getConfig() + if err != nil { + return fmt.Errorf("%w: failed to get config: %v", scrobbler.ScrobblerErrorRetryLater, err) + } + + // Check authorization + userToken, authorized := users[input.Username] + if !authorized { + return fmt.Errorf("%w: user '%s' not authorized", scrobbler.ScrobblerErrorNotAuthorized, input.Username) + } + + // Connect to Discord + if err := rpc.connect(input.Username, userToken); err != nil { + return fmt.Errorf("%w: failed to connect to Discord: %v", scrobbler.ScrobblerErrorRetryLater, err) + } + + // Cancel any existing completion schedule + _ = host.SchedulerCancelSchedule(fmt.Sprintf("%s-clear", input.Username)) + + // Calculate timestamps + now := time.Now().Unix() + startTime := (now - int64(input.Position)) * 1000 + endTime := startTime + int64(input.Track.Duration)*1000 + + // Send activity update + if err := rpc.sendActivity(clientID, input.Username, userToken, activity{ + Application: clientID, + Name: "Navidrome", + Type: 2, // Listening + Details: input.Track.Title, + State: input.Track.Artist, + Timestamps: activityTimestamps{ + Start: startTime, + End: endTime, + }, + Assets: activityAssets{ + LargeImage: getImageURL(input.Track.ID), + LargeText: input.Track.Album, + }, + }); err != nil { + return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err) + } + + // Schedule a timer to clear the activity after the track completes + remainingSeconds := int32(input.Track.Duration) - input.Position + 5 + _, err = host.SchedulerScheduleOneTime(remainingSeconds, payloadClearActivity, fmt.Sprintf("%s-clear", input.Username)) + if err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to schedule completion timer: %v", err)) + } + + return nil +} + +// Scrobble handles scrobble requests (no-op for Discord). +func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) error { + // Discord Rich Presence doesn't need scrobble events + return nil +} + +// ============================================================================ +// Scheduler Callback Implementation +// ============================================================================ + +// OnCallback handles scheduler callbacks. +func (p *discordPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error { + pdk.Log(pdk.LogDebug, fmt.Sprintf("Scheduler callback: id=%s, payload=%s, recurring=%v", input.ScheduleID, input.Payload, input.IsRecurring)) + + // Route based on payload + switch input.Payload { + case payloadHeartbeat: + // Heartbeat callback - scheduleId is the username + if err := rpc.handleHeartbeatCallback(input.ScheduleID); err != nil { + return err + } + + case payloadClearActivity: + // Clear activity callback - scheduleId is "username-clear" + username := strings.TrimSuffix(input.ScheduleID, "-clear") + if err := rpc.handleClearActivityCallback(username); err != nil { + return err + } + + default: + pdk.Log(pdk.LogWarn, fmt.Sprintf("Unknown scheduler callback payload: %s", input.Payload)) + } + + return nil +} + +func main() {} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..6c90f04 --- /dev/null +++ b/main_test.go @@ -0,0 +1,221 @@ +package main + +import ( + "errors" + "strings" + "testing" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" + "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" + "github.com/stretchr/testify/mock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDiscordPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Discord Plugin Main Suite") +} + +var _ = Describe("discordPlugin", func() { + var plugin discordPlugin + + BeforeEach(func() { + plugin = discordPlugin{} + pdk.ResetMock() + host.CacheMock.ExpectedCalls = nil + host.CacheMock.Calls = nil + host.ConfigMock.ExpectedCalls = nil + host.ConfigMock.Calls = nil + host.WebSocketMock.ExpectedCalls = nil + host.WebSocketMock.Calls = nil + host.SchedulerMock.ExpectedCalls = nil + host.SchedulerMock.Calls = nil + host.ArtworkMock.ExpectedCalls = nil + host.ArtworkMock.Calls = nil + }) + + Describe("getConfig", func() { + It("returns config values when properly set", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"user1","token":"token1"},{"username":"user2","token":"token2"}]`, true) + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + + clientID, users, err := getConfig() + Expect(err).ToNot(HaveOccurred()) + Expect(clientID).To(Equal("test-client-id")) + Expect(users).To(HaveLen(2)) + Expect(users["user1"]).To(Equal("token1")) + Expect(users["user2"]).To(Equal("token2")) + }) + + It("returns empty client ID when not set", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("", false) + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + + clientID, users, err := getConfig() + Expect(err).ToNot(HaveOccurred()) + Expect(clientID).To(BeEmpty()) + Expect(users).To(BeNil()) + }) + + It("returns nil users when users not configured", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("", false) + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + + clientID, users, err := getConfig() + Expect(err).ToNot(HaveOccurred()) + Expect(clientID).To(Equal("test-client-id")) + Expect(users).To(BeNil()) + }) + }) + + Describe("IsAuthorized", func() { + BeforeEach(func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + }) + + It("returns true for authorized user", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"token123"}]`, true) + + authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{ + Username: "testuser", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(authorized).To(BeTrue()) + }) + + It("returns false for unauthorized user", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"otheruser","token":"token123"}]`, true) + + authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{ + Username: "testuser", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(authorized).To(BeFalse()) + }) + }) + + Describe("NowPlaying", func() { + BeforeEach(func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + }) + + It("returns not authorized error when user not in config", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"otheruser","token":"token"}]`, true) + + err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ + Username: "testuser", + Track: scrobbler.TrackInfo{Title: "Test Song"}, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, scrobbler.ScrobblerErrorNotAuthorized)).To(BeTrue()) + }) + + 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) + + // Connect mocks (isConnected check via heartbeat) + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) + + // Mock HTTP GET request for gateway discovery + gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) + gatewayReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once() + pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once() + + // Mock WebSocket connection + host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { + return strings.Contains(url, "gateway.discord.gg") + }), mock.Anything, "testuser").Return("testuser", nil) + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil) + + // Cancel existing clear schedule (may or may not exist) + host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) + + // Image mocks - cache miss, will make HTTP request to Discord + host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { + return strings.HasPrefix(key, "discord.image.") + })).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) + + // Mock HTTP request for Discord external assets API + assetsReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool { + return strings.Contains(url, "external-assets") + })).Return(assetsReq) + pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) + + // Schedule clear activity callback + host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) + + err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ + Username: "testuser", + Position: 10, + Track: scrobbler.TrackInfo{ + ID: "track1", + Title: "Test Song", + Artist: "Test Artist", + Album: "Test Album", + Duration: 180, + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Scrobble", func() { + It("does nothing (returns nil)", func() { + err := plugin.Scrobble(scrobbler.ScrobbleRequest{}) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("OnCallback", func() { + BeforeEach(func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + }) + + It("handles heartbeat callback", func() { + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil) + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + + err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{ + ScheduleID: "testuser", + Payload: payloadHeartbeat, + IsRecurring: true, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("handles clearActivity callback", func() { + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil) + + err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{ + ScheduleID: "testuser-clear", + Payload: payloadClearActivity, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("logs warning for unknown payload", func() { + err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{ + ScheduleID: "testuser", + Payload: "unknown", + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..8e53acb --- /dev/null +++ b/manifest.json @@ -0,0 +1,98 @@ +{ + "name": "Discord Rich Presence", + "author": "Navidrome Team", + "version": "0.1.0", + "description": "Discord Rich Presence integration for Navidrome", + "website": "https://github.com/navidrome/discord-rich-presence-plugin", + "permissions": { + "users": { + "reason": "To process scrobbles on behalf of users" + }, + "http": { + "reason": "To communicate with Discord API for gateway discovery and image uploads", + "requiredHosts": ["discord.com"] + }, + "websocket": { + "reason": "To maintain real-time connection with Discord gateway", + "requiredHosts": ["gateway.discord.gg"] + }, + "cache": { + "reason": "To store connection state and sequence numbers" + }, + "scheduler": { + "reason": "To schedule heartbeat messages and activity clearing" + }, + "artwork": { + "reason": "To get track artwork URLs for rich presence display" + } + }, + "config": { + "schema": { + "type": "object", + "properties": { + "clientid": { + "type": "string", + "title": "Discord Application Client ID", + "description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications", + "minLength": 17, + "maxLength": 20, + "pattern": "^[0-9]+$" + }, + "users": { + "type": "array", + "title": "User Tokens", + "description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "title": "Navidrome Username", + "description": "The Navidrome username to associate with this Discord token", + "minLength": 1 + }, + "token": { + "type": "string", + "title": "Discord Token", + "description": "The user's Discord token (keep this secret!)", + "minLength": 1 + } + }, + "required": ["username", "token"] + } + } + }, + "required": ["clientid", "users"] + }, + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/clientid" + }, + { + "type": "Control", + "scope": "#/properties/users", + "options": { + "elementLabelProp": "username", + "detail": { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/username" + }, + { + "type": "Control", + "scope": "#/properties/token" + } + ] + } + } + } + ] + } + } +} diff --git a/rpc.go b/rpc.go new file mode 100644 index 0000000..229bc0f --- /dev/null +++ b/rpc.go @@ -0,0 +1,400 @@ +// Discord Rich Presence Plugin - RPC Communication +// +// This file handles all Discord gateway communication including WebSocket connections, +// presence updates, and heartbeat management. The discordRPC struct implements WebSocket +// callback interfaces and encapsulates all Discord communication logic. +package main + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/websocket" +) + +// Discord WebSocket Gateway constants +const ( + heartbeatOpCode = 1 // Heartbeat operation code + gateOpCode = 2 // Identify operation code + presenceOpCode = 3 // Presence update operation code +) + +const ( + heartbeatInterval = 41 // Heartbeat interval in seconds + defaultImage = "https://i.imgur.com/hb3XPzA.png" +) + +// Scheduler callback payloads for routing +const ( + payloadHeartbeat = "heartbeat" + payloadClearActivity = "clear-activity" +) + +// discordRPC handles Discord gateway communication and implements WebSocket callbacks. +type discordRPC struct{} + +// ============================================================================ +// WebSocket Callback Implementation +// ============================================================================ + +// OnTextMessage handles incoming WebSocket text messages. +func (r *discordRPC) OnTextMessage(input websocket.OnTextMessageRequest) error { + return r.handleWebSocketMessage(input.ConnectionID, input.Message) +} + +// OnBinaryMessage handles incoming WebSocket binary messages. +func (r *discordRPC) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { + pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionID)) + return nil +} + +// OnError handles WebSocket errors. +func (r *discordRPC) OnError(input websocket.OnErrorRequest) error { + pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionID, input.Error)) + return nil +} + +// OnClose handles WebSocket connection closure. +func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error { + pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionID, input.Code, input.Reason)) + return nil +} + +// activity represents a Discord activity. +type activity struct { + Name string `json:"name"` + Type int `json:"type"` + Details string `json:"details"` + State string `json:"state"` + Application string `json:"application_id"` + Timestamps activityTimestamps `json:"timestamps"` + Assets activityAssets `json:"assets"` +} + +type activityTimestamps struct { + Start int64 `json:"start"` + End int64 `json:"end"` +} + +type activityAssets struct { + LargeImage string `json:"large_image"` + LargeText string `json:"large_text"` +} + +// presencePayload represents a Discord presence update. +type presencePayload struct { + Activities []activity `json:"activities"` + Since int64 `json:"since"` + Status string `json:"status"` + Afk bool `json:"afk"` +} + +// identifyPayload represents a Discord identify payload. +type identifyPayload struct { + Token string `json:"token"` + Intents int `json:"intents"` + Properties identifyProperties `json:"properties"` +} + +type identifyProperties struct { + OS string `json:"os"` + Browser string `json:"browser"` + Device string `json:"device"` +} + +// ============================================================================ +// Image Processing +// ============================================================================ + +// processImage processes an image URL for Discord, with fallback to default image. +func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) { + if imageURL == "" { + if isDefaultImage { + return "", fmt.Errorf("default image URL is empty") + } + return r.processImage(defaultImage, clientID, token, true) + } + + if strings.HasPrefix(imageURL, "mp:") { + return imageURL, nil + } + + // Check cache first + cacheKey := fmt.Sprintf("discord.image.%x", imageURL) + cachedValue, exists, err := host.CacheGetString(cacheKey) + if err == nil && exists { + pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL)) + return cachedValue, nil + } + + // Process via Discord API + body := fmt.Sprintf(`{"urls":[%q]}`, imageURL) + req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID)) + req.SetHeader("Authorization", token) + req.SetHeader("Content-Type", "application/json") + req.SetBody([]byte(body)) + + resp := req.Send() + if resp.Status() >= 400 { + if isDefaultImage { + return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status()) + } + return r.processImage(defaultImage, clientID, token, true) + } + + var data []map[string]string + if err := json.Unmarshal(resp.Body(), &data); err != nil { + if isDefaultImage { + return "", fmt.Errorf("failed to unmarshal default image response: %w", err) + } + return r.processImage(defaultImage, clientID, token, true) + } + + if len(data) == 0 { + if isDefaultImage { + return "", fmt.Errorf("no data returned for default image") + } + return r.processImage(defaultImage, clientID, token, true) + } + + image := data[0]["external_asset_path"] + if image == "" { + if isDefaultImage { + return "", fmt.Errorf("empty external_asset_path for default image") + } + return r.processImage(defaultImage, clientID, token, true) + } + + processedImage := fmt.Sprintf("mp:%s", image) + + // Cache the processed image URL + var ttl int64 = 4 * 60 * 60 // 4 hours for regular images + if isDefaultImage { + ttl = 48 * 60 * 60 // 48 hours for default image + } + + _ = host.CacheSetString(cacheKey, processedImage, ttl) + pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl)) + + return processedImage, nil +} + +// ============================================================================ +// Activity Management +// ============================================================================ + +// sendActivity sends an activity update to Discord. +func (r *discordRPC) sendActivity(clientID, username, token string, data activity) error { + pdk.Log(pdk.LogInfo, fmt.Sprintf("Sending activity for user %s: %s - %s", username, data.Details, data.State)) + + processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false) + if err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process image for user %s, continuing without image: %v", username, err)) + data.Assets.LargeImage = "" + } else { + data.Assets.LargeImage = processedImage + } + + presence := presencePayload{ + Activities: []activity{data}, + Status: "dnd", + Afk: false, + } + return r.sendMessage(username, presenceOpCode, presence) +} + +// clearActivity clears the Discord activity for a user. +func (r *discordRPC) clearActivity(username string) error { + pdk.Log(pdk.LogInfo, fmt.Sprintf("Clearing activity for user %s", username)) + return r.sendMessage(username, presenceOpCode, presencePayload{}) +} + +// ============================================================================ +// Low-level Communication +// ============================================================================ + +// sendMessage sends a message over the WebSocket connection. +func (r *discordRPC) sendMessage(username string, opCode int, payload any) error { + message := map[string]any{ + "op": opCode, + "d": payload, + } + b, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + err = host.WebSocketSendText(username, string(b)) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + return nil +} + +// getDiscordGateway retrieves the Discord gateway URL. +func (r *discordRPC) getDiscordGateway() (string, error) { + req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway") + resp := req.Send() + if resp.Status() != 200 { + return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.Status()) + } + + var result map[string]string + if err := json.Unmarshal(resp.Body(), &result); err != nil { + return "", fmt.Errorf("failed to parse Discord gateway response: %w", err) + } + return result["url"], nil +} + +// sendHeartbeat sends a heartbeat to Discord. +func (r *discordRPC) sendHeartbeat(username string) error { + seqNum, _, err := host.CacheGetInt(fmt.Sprintf("discord.seq.%s", username)) + if err != nil { + return fmt.Errorf("failed to get sequence number: %w", err) + } + + pdk.Log(pdk.LogDebug, fmt.Sprintf("Sending heartbeat for user %s: %d", username, seqNum)) + return r.sendMessage(username, heartbeatOpCode, seqNum) +} + +// cleanupFailedConnection cleans up a failed Discord connection. +func (r *discordRPC) cleanupFailedConnection(username string) { + pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaning up failed connection for user %s", username)) + + // Cancel the heartbeat schedule + if err := host.SchedulerCancelSchedule(username); err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to cancel heartbeat schedule for user %s: %v", username, err)) + } + + // Close the WebSocket connection + if err := host.WebSocketCloseConnection(username, 1000, "Connection lost"); err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to close WebSocket connection for user %s: %v", username, err)) + } + + // Clean up cache entries + _ = host.CacheRemove(fmt.Sprintf("discord.seq.%s", username)) + + pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaned up connection for user %s", username)) +} + +// isConnected checks if a user is connected to Discord by testing the heartbeat. +func (r *discordRPC) isConnected(username string) bool { + err := r.sendHeartbeat(username) + if err != nil { + pdk.Log(pdk.LogDebug, fmt.Sprintf("Heartbeat test failed for user %s: %v", username, err)) + return false + } + return true +} + +// connect establishes a connection to Discord for a user. +func (r *discordRPC) connect(username, token string) error { + if r.isConnected(username) { + pdk.Log(pdk.LogInfo, fmt.Sprintf("Reusing existing connection for user %s", username)) + return nil + } + pdk.Log(pdk.LogInfo, fmt.Sprintf("Creating new connection for user %s", username)) + + // Get Discord Gateway URL + gateway, err := r.getDiscordGateway() + if err != nil { + return fmt.Errorf("failed to get Discord gateway: %w", err) + } + pdk.Log(pdk.LogDebug, fmt.Sprintf("Using gateway: %s", gateway)) + + // Connect to Discord Gateway + _, err = host.WebSocketConnect(gateway, nil, username) + if err != nil { + return fmt.Errorf("failed to connect to WebSocket: %w", err) + } + + // Send identify payload + payload := identifyPayload{ + Token: token, + Intents: 0, + Properties: identifyProperties{ + OS: "Windows 10", + Browser: "Discord Client", + Device: "Discord Client", + }, + } + if err := r.sendMessage(username, gateOpCode, payload); err != nil { + return fmt.Errorf("failed to send identify payload: %w", err) + } + + // Schedule heartbeats for this user/connection + cronExpr := fmt.Sprintf("@every %ds", heartbeatInterval) + scheduleID, err := host.SchedulerScheduleRecurring(cronExpr, payloadHeartbeat, username) + if err != nil { + return fmt.Errorf("failed to schedule heartbeat: %w", err) + } + pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduled heartbeat for user %s with ID %s", username, scheduleID)) + + pdk.Log(pdk.LogInfo, fmt.Sprintf("Successfully authenticated user %s", username)) + return nil +} + +// disconnect closes the Discord connection for a user. +func (r *discordRPC) disconnect(username string) error { + if err := host.SchedulerCancelSchedule(username); err != nil { + return fmt.Errorf("failed to cancel schedule: %w", err) + } + + if err := host.WebSocketCloseConnection(username, 1000, "Navidrome disconnect"); err != nil { + return fmt.Errorf("failed to close WebSocket connection: %w", err) + } + return nil +} + +// handleWebSocketMessage processes incoming WebSocket messages from Discord. +func (r *discordRPC) handleWebSocketMessage(connectionID, message string) error { + if len(message) < 1024 { + pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s': %s", connectionID, message)) + } else { + pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s' (truncated): %s...", connectionID, message[:1021])) + } + + // Parse the message + var msg map[string]any + if err := json.Unmarshal([]byte(message), &msg); err != nil { + return fmt.Errorf("failed to parse WebSocket message: %w", err) + } + + // Store sequence number if present + if v := msg["s"]; v != nil { + seq := int64(v.(float64)) + pdk.Log(pdk.LogTrace, fmt.Sprintf("Received sequence number for connection '%s': %d", connectionID, seq)) + if err := host.CacheSetInt(fmt.Sprintf("discord.seq.%s", connectionID), seq, int64(heartbeatInterval*2)); err != nil { + return fmt.Errorf("failed to store sequence number for user %s: %w", connectionID, err) + } + } + return nil +} + +// handleHeartbeatCallback processes heartbeat scheduler callbacks. +func (r *discordRPC) handleHeartbeatCallback(username string) error { + if err := r.sendHeartbeat(username); err != nil { + // On first heartbeat failure, immediately clean up the connection + pdk.Log(pdk.LogWarn, fmt.Sprintf("Heartbeat failed for user %s, cleaning up connection: %v", username, err)) + r.cleanupFailedConnection(username) + return fmt.Errorf("heartbeat failed, connection cleaned up: %w", err) + } + return nil +} + +// handleClearActivityCallback processes clear activity scheduler callbacks. +func (r *discordRPC) handleClearActivityCallback(username string) error { + pdk.Log(pdk.LogInfo, fmt.Sprintf("Removing presence for user %s", username)) + if err := r.clearActivity(username); err != nil { + return fmt.Errorf("failed to clear activity: %w", err) + } + + pdk.Log(pdk.LogInfo, fmt.Sprintf("Disconnecting user %s", username)) + if err := r.disconnect(username); err != nil { + return fmt.Errorf("failed to disconnect from Discord: %w", err) + } + return nil +} diff --git a/rpc_test.go b/rpc_test.go new file mode 100644 index 0000000..b85c27e --- /dev/null +++ b/rpc_test.go @@ -0,0 +1,279 @@ +package main + +import ( + "errors" + "strings" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/websocket" + "github.com/stretchr/testify/mock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("discordRPC", func() { + var r *discordRPC + + BeforeEach(func() { + r = &discordRPC{} + pdk.ResetMock() + host.CacheMock.ExpectedCalls = nil + host.CacheMock.Calls = nil + host.WebSocketMock.ExpectedCalls = nil + host.WebSocketMock.Calls = nil + host.SchedulerMock.ExpectedCalls = nil + host.SchedulerMock.Calls = nil + }) + + Describe("sendMessage", func() { + It("sends JSON message over WebSocket", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":3`) + })).Return(nil) + + err := r.sendMessage("testuser", presenceOpCode, map[string]string{"status": "online"}) + Expect(err).ToNot(HaveOccurred()) + host.WebSocketMock.AssertExpectations(GinkgoT()) + }) + + It("returns error when WebSocket send fails", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.WebSocketMock.On("SendText", mock.Anything, mock.Anything). + Return(errors.New("connection closed")) + + err := r.sendMessage("testuser", presenceOpCode, map[string]string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("connection closed")) + }) + }) + + Describe("sendHeartbeat", func() { + It("retrieves sequence number from cache and sends heartbeat", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(123), true, nil) + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":1`) && strings.Contains(msg, "123") + })).Return(nil) + + err := r.sendHeartbeat("testuser") + Expect(err).ToNot(HaveOccurred()) + host.CacheMock.AssertExpectations(GinkgoT()) + host.WebSocketMock.AssertExpectations(GinkgoT()) + }) + + It("returns error when cache get fails", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache error")) + + err := r.sendHeartbeat("testuser") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cache error")) + }) + }) + + Describe("connect", func() { + It("establishes WebSocket connection and sends identify payload", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) + + // Mock HTTP GET request for gateway discovery + gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq) + pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)) + + // Mock WebSocket connection + host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { + return strings.Contains(url, "gateway.discord.gg") + }), mock.Anything, "testuser").Return("testuser", nil) + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":2`) && strings.Contains(msg, "test-token") + })).Return(nil) + host.SchedulerMock.On("ScheduleRecurring", "@every 41s", payloadHeartbeat, "testuser"). + Return("testuser", nil) + + err := r.connect("testuser", "test-token") + Expect(err).ToNot(HaveOccurred()) + }) + + It("reuses existing connection if connected", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil) + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + + err := r.connect("testuser", "test-token") + Expect(err).ToNot(HaveOccurred()) + host.WebSocketMock.AssertNotCalled(GinkgoT(), "Connect", mock.Anything, mock.Anything, mock.Anything) + }) + }) + + Describe("disconnect", func() { + It("cancels schedule and closes WebSocket connection", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil) + + err := r.disconnect("testuser") + Expect(err).ToNot(HaveOccurred()) + host.SchedulerMock.AssertExpectations(GinkgoT()) + host.WebSocketMock.AssertExpectations(GinkgoT()) + }) + }) + + Describe("cleanupFailedConnection", func() { + It("cancels schedule, closes WebSocket, and clears cache", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil) + host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil) + + r.cleanupFailedConnection("testuser") + + host.SchedulerMock.AssertExpectations(GinkgoT()) + host.WebSocketMock.AssertExpectations(GinkgoT()) + }) + }) + + Describe("handleHeartbeatCallback", func() { + It("sends heartbeat successfully", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil) + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + + err := r.handleHeartbeatCallback("testuser") + Expect(err).ToNot(HaveOccurred()) + }) + + It("cleans up connection on heartbeat failure", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache miss")) + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil) + host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil) + + err := r.handleHeartbeatCallback("testuser") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("connection cleaned up")) + }) + }) + + Describe("handleClearActivityCallback", func() { + It("clears activity and disconnects", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`) + })).Return(nil) + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil) + + err := r.handleClearActivityCallback("testuser") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("WebSocket callbacks", func() { + Describe("OnTextMessage", func() { + It("handles valid JSON message", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("SetInt", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + err := r.OnTextMessage(websocket.OnTextMessageRequest{ + ConnectionID: "testuser", + Message: `{"s":42}`, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error for invalid JSON", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + err := r.OnTextMessage(websocket.OnTextMessageRequest{ + ConnectionID: "testuser", + Message: `not json`, + }) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("OnBinaryMessage", func() { + It("handles binary message without error", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{ + ConnectionID: "testuser", + Data: "AQID", // base64 encoded [0x01, 0x02, 0x03] + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("OnError", func() { + It("handles error without returning error", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + err := r.OnError(websocket.OnErrorRequest{ + ConnectionID: "testuser", + Error: "test error", + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("OnClose", func() { + It("handles close without returning error", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + err := r.OnClose(websocket.OnCloseRequest{ + ConnectionID: "testuser", + Code: 1000, + Reason: "normal close", + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Describe("sendActivity", func() { + BeforeEach(func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { + return strings.HasPrefix(key, "discord.image.") + })).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // Mock HTTP request for Discord external assets API (image processing) + // When processImage is called, it makes an HTTP request + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) + }) + + It("sends activity update to Discord", func() { + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":3`) && + strings.Contains(msg, `"name":"Test Song"`) && + strings.Contains(msg, `"state":"Test Artist"`) + })).Return(nil) + + err := r.sendActivity("client123", "testuser", "token123", activity{ + Application: "client123", + Name: "Test Song", + Type: 2, + State: "Test Artist", + Details: "Test Album", + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("clearActivity", func() { + It("sends presence update with nil activities", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`) + })).Return(nil) + + err := r.clearActivity("testuser") + Expect(err).ToNot(HaveOccurred()) + }) + }) +})