From b9fceac12cca692388c602de808c756b25eba1a0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 26 Dec 2025 11:26:52 -0500 Subject: [PATCH] feat: add Discord Rich Presence example plugin for Navidrome integration Signed-off-by: Deluan --- plugins/examples/README.md | 10 +- .../examples/discord-rich-presence/README.md | 120 ++++++ plugins/examples/discord-rich-presence/go.mod | 5 + plugins/examples/discord-rich-presence/go.sum | 2 + .../examples/discord-rich-presence/main.go | 290 +++++++++++++ .../discord-rich-presence/nd_host_artwork.go | 170 ++++++++ .../discord-rich-presence/nd_host_cache.go | 375 ++++++++++++++++ .../nd_host_scheduler.go | 133 ++++++ .../nd_host_websocket.go | 177 ++++++++ .../examples/discord-rich-presence/pdk.gen.go | 399 ++++++++++++++++++ plugins/examples/discord-rich-presence/rpc.go | 363 ++++++++++++++++ plugins/host/go/nd_host_websocket.go | 30 +- 12 files changed, 2060 insertions(+), 14 deletions(-) create mode 100644 plugins/examples/discord-rich-presence/README.md create mode 100644 plugins/examples/discord-rich-presence/go.mod create mode 100644 plugins/examples/discord-rich-presence/go.sum create mode 100644 plugins/examples/discord-rich-presence/main.go create mode 100644 plugins/examples/discord-rich-presence/nd_host_artwork.go create mode 100644 plugins/examples/discord-rich-presence/nd_host_cache.go create mode 100644 plugins/examples/discord-rich-presence/nd_host_scheduler.go create mode 100644 plugins/examples/discord-rich-presence/nd_host_websocket.go create mode 100644 plugins/examples/discord-rich-presence/pdk.gen.go create mode 100644 plugins/examples/discord-rich-presence/rpc.go diff --git a/plugins/examples/README.md b/plugins/examples/README.md index 887b37c4e..99609e29c 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -32,10 +32,12 @@ make clean ## Available Examples -| Plugin | Description | -|-------------------------|---------------------------------------------------------------| -| [minimal](minimal/) | A minimal example showing the basic plugin structure | -| [wikimedia](wikimedia/) | Fetches artist metadata from Wikidata, DBpedia, and Wikipedia | +| Plugin | Description | +|-------------------------------------------------|-------------------------------------------------------------------------| +| [minimal](minimal/) | A minimal example showing the basic plugin structure | +| [wikimedia](wikimedia/) | Fetches artist metadata from Wikidata, DBpedia, and Wikipedia | +| [crypto-ticker](crypto-ticker/) | Real-time cryptocurrency prices from Coinbase using WebSocket | +| [discord-rich-presence](discord-rich-presence/) | Discord Rich Presence integration using Scrobbler, WebSocket, Scheduler | ## Testing with Extism CLI diff --git a/plugins/examples/discord-rich-presence/README.md b/plugins/examples/discord-rich-presence/README.md new file mode 100644 index 000000000..c316676f2 --- /dev/null +++ b/plugins/examples/discord-rich-presence/README.md @@ -0,0 +1,120 @@ +# Discord Rich Presence Plugin + +This example 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. This plugin is based on the [Navicord](https://github.com/logixism/navicord) project, which provides similar functionality. + +**⚠️ WARNING: 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.** + +## Overview + +The plugin exposes three capabilities: + +- **Scrobbler** – receives `NowPlaying` notifications from Navidrome +- **WebSocketCallback** – handles Discord gateway messages +- **SchedulerCallback** – used to clear presence and send periodic heartbeats + +It relies on several host services declared in the manifest: + +- `http` – queries Discord API endpoints +- `websocket` – maintains gateway connections +- `scheduler` – schedules heartbeats and presence cleanup +- `cache` – stores sequence numbers for heartbeats +- `artwork` – resolves track artwork URLs + +## Architecture + +Each call from Navidrome creates a new plugin instance. The plugin registers capabilities by exporting the required functions: + +```go +// Scrobbler capability +//export nd_scrobbler_is_authorized +//export nd_scrobbler_now_playing +//export nd_scrobbler_scrobble + +// WebSocket callback capability +//export nd_websocket_on_text_message +//export nd_websocket_on_binary_message +//export nd_websocket_on_error +//export nd_websocket_on_close + +// Scheduler callback capability +//export nd_scheduler_callback +``` + +When `NowPlaying` is invoked the plugin: + +1. Loads `clientid` and user tokens from the configuration (because plugins are stateless). +2. Connects to Discord using `WebSocketService` if no connection exists. +3. Sends the activity payload with track details and artwork. +4. Schedules a one-time callback to clear the presence after the track finishes. + +Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in `CacheService` to remain available across plugin instances. + +The scheduler callback uses the `payload` field to route to the appropriate handler: +- `"heartbeat"` – sends a heartbeat to Discord (recurring) +- `"clear-activity"` – clears the presence and disconnects (one-time) + +## Stateless Operation + +Navidrome plugins are completely stateless – each method call instantiates a new plugin instance and discards it afterwards. + +To work within this model the plugin stores no in-memory state. Connections are keyed by username inside the host services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every method call. + +## Configuration + +Add the following to `navidrome.toml` and adjust for your tokens: + +```toml +[PluginConfig.discord-rich-presence] +ClientID = "123456789012345678" +Users = "alice:token123,bob:token456" +``` + +- `ClientID` is your Discord application ID +- `Users` is a comma-separated list of `username:token` pairs used for authorization + +## Building + +From the `plugins/examples/` directory: + +```sh +make discord-rich-presence.wasm +``` + +Or manually: + +```sh +cd discord-rich-presence +tinygo build -target wasip1 -buildmode=c-shared -o ../discord-rich-presence.wasm . +``` + +## Installation + +Place the resulting `discord-rich-presence.wasm` in your Navidrome plugins folder and enable plugins in your configuration: + +```toml +[Plugins] +Enabled = true +Folder = "/path/to/plugins" +``` + +## Files + +| File | Description | +|------|-------------| +| `main.go` | Plugin entry point, manifest, scrobbler implementation | +| `rpc.go` | Discord gateway communication and RPC logic | +| `pdk.gen.go` | Generated types from XTP schemas (combined) | +| `nd_host_*.go` | Host function wrappers (copied from `plugins/host/go/`) | + +## Host Services Used + +| Service | Purpose | +|---------|---------| +| Cache | Store Discord sequence numbers and processed image URLs | +| Scheduler | Schedule heartbeats (recurring) and activity clearing (one-time) | +| WebSocket | Maintain persistent connection to Discord gateway | +| Artwork | Get track artwork URLs for rich presence display | + +## Implementation Details + +See `main.go` and `rpc.go` for the complete implementation. diff --git a/plugins/examples/discord-rich-presence/go.mod b/plugins/examples/discord-rich-presence/go.mod new file mode 100644 index 000000000..c982a9e01 --- /dev/null +++ b/plugins/examples/discord-rich-presence/go.mod @@ -0,0 +1,5 @@ +module discord-rich-presence + +go 1.22.1 + +require github.com/extism/go-pdk v1.1.0 diff --git a/plugins/examples/discord-rich-presence/go.sum b/plugins/examples/discord-rich-presence/go.sum new file mode 100644 index 000000000..e0fb44c64 --- /dev/null +++ b/plugins/examples/discord-rich-presence/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.0 h1:K2On6XOERxrYdsgu0uLzCxeu/FYRHE8jId/hdEVSYoY= +github.com/extism/go-pdk v1.1.0/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/plugins/examples/discord-rich-presence/main.go b/plugins/examples/discord-rich-presence/main.go new file mode 100644 index 000000000..48de2fd73 --- /dev/null +++ b/plugins/examples/discord-rich-presence/main.go @@ -0,0 +1,290 @@ +// 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/extism/go-pdk" +) + +// Manifest contains plugin metadata. +type Manifest struct { + Name string `json:"name"` + Author string `json:"author"` + Version string `json:"version"` + Description string `json:"description"` + Website string `json:"website,omitempty"` + Permissions *Permissions `json:"permissions,omitempty"` +} + +type Permissions struct { + HTTP *HTTPPermission `json:"http,omitempty"` + WebSocket *WebSocketPermission `json:"websocket,omitempty"` + Cache *PermissionReason `json:"cache,omitempty"` + Scheduler *PermissionReason `json:"scheduler,omitempty"` + Artwork *PermissionReason `json:"artwork,omitempty"` +} + +type HTTPPermission struct { + Reason string `json:"reason,omitempty"` + AllowedHosts []string `json:"allowedHosts,omitempty"` +} + +type WebSocketPermission struct { + Reason string `json:"reason,omitempty"` + AllowedHosts []string `json:"allowedHosts,omitempty"` +} + +type PermissionReason struct { + Reason string `json:"reason,omitempty"` +} + +// nd_manifest returns the plugin manifest. +// +//export nd_manifest +func ndManifest() int32 { + manifest := Manifest{ + Name: "Discord Rich Presence", + Author: "Navidrome Team", + Version: "1.0.0", + Description: "Discord Rich Presence integration for Navidrome", + Website: "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence", + Permissions: &Permissions{ + HTTP: &HTTPPermission{ + Reason: "To communicate with Discord API for gateway discovery and image uploads", + AllowedHosts: []string{"discord.com"}, + }, + WebSocket: &WebSocketPermission{ + Reason: "To maintain real-time connection with Discord gateway", + AllowedHosts: []string{"gateway.discord.gg"}, + }, + Cache: &PermissionReason{ + Reason: "To store connection state and sequence numbers", + }, + Scheduler: &PermissionReason{ + Reason: "To schedule heartbeat messages and activity clearing", + }, + Artwork: &PermissionReason{ + Reason: "To get track artwork URLs for rich presence display", + }, + }, + } + + out, err := json.Marshal(manifest) + if err != nil { + pdk.SetError(err) + return 1 + } + pdk.Output(out) + return 0 +} + +// Configuration keys +const ( + clientIDKey = "clientid" + usersKey = "users" +) + +// 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 + } + + cfgUsers, ok := pdk.GetConfig(usersKey) + if !ok || cfgUsers == "" { + pdk.Log(pdk.LogWarn, "no users configured") + return clientID, nil, nil + } + + users = make(map[string]string) + for _, user := range strings.Split(cfgUsers, ",") { + tuple := strings.Split(user, ":") + if len(tuple) != 2 { + return clientID, nil, fmt.Errorf("invalid user config: %s", user) + } + users[strings.TrimSpace(tuple[0])] = strings.TrimSpace(tuple[1]) + } + return clientID, users, nil +} + +// getImageURL retrieves the track artwork URL. +func getImageURL(trackID string) string { + resp, err := ArtworkGetTrackUrl(trackID, 300) + if err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %v", err)) + return "" + } + if resp.Error != "" { + pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %s", resp.Error)) + return "" + } + + // Don't use localhost URLs + if strings.HasPrefix(resp.Url, "http://localhost") { + return "" + } + return resp.Url +} + +// ============================================================================ +// Scrobbler Implementation +// ============================================================================ + +// NdScrobblerIsAuthorized checks if a user is authorized for Discord Rich Presence. +func NdScrobblerIsAuthorized(input AuthInput) (AuthOutput, error) { + _, users, err := getConfig() + if err != nil { + return AuthOutput{}, 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 AuthOutput{Authorized: authorized}, nil +} + +// NdScrobblerNowPlaying sends a now playing notification to Discord. +func NdScrobblerNowPlaying(input NowPlayingInput) (ScrobblerOutput, 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 ScrobblerOutput{}, fmt.Errorf("failed to get config: %w", err) + } + + // Check authorization + userToken, authorized := users[input.Username] + if !authorized { + errMsg := fmt.Sprintf("user '%s' not authorized", input.Username) + return ScrobblerOutput{Error: &errMsg, ErrorType: ScrobblerErrorTypeNotAuthorized}, nil + } + + // Connect to Discord + if err := connect(input.Username, userToken); err != nil { + errMsg := fmt.Sprintf("failed to connect to Discord: %v", err) + return ScrobblerOutput{Error: &errMsg, ErrorType: ScrobblerErrorTypeRetryLater}, nil + } + + // Cancel any existing completion schedule + _ = 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 := 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 { + errMsg := fmt.Sprintf("failed to send activity: %v", err) + return ScrobblerOutput{Error: &errMsg, ErrorType: ScrobblerErrorTypeRetryLater}, nil + } + + // Schedule a timer to clear the activity after the track completes + remainingSeconds := int32(input.Track.Duration) - input.Position + 5 + _, err = 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 ScrobblerOutput{}, nil +} + +// NdScrobblerScrobble handles scrobble requests (no-op for Discord). +func NdScrobblerScrobble(_ ScrobbleInput) (ScrobblerOutput, error) { + // Discord Rich Presence doesn't need scrobble events + return ScrobblerOutput{}, nil +} + +// ============================================================================ +// Scheduler Callback Implementation +// ============================================================================ + +// NdSchedulerCallback handles scheduler callbacks. +func NdSchedulerCallback(input SchedulerCallbackInput) (SchedulerCallbackOutput, 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 := handleHeartbeatCallback(input.ScheduleId); err != nil { + errMsg := err.Error() + return SchedulerCallbackOutput{Error: &errMsg}, nil + } + + case payloadClearActivity: + // Clear activity callback - scheduleId is "username-clear" + username := strings.TrimSuffix(input.ScheduleId, "-clear") + if err := handleClearActivityCallback(username); err != nil { + errMsg := err.Error() + return SchedulerCallbackOutput{Error: &errMsg}, nil + } + + default: + pdk.Log(pdk.LogWarn, fmt.Sprintf("Unknown scheduler callback payload: %s", input.Payload)) + } + + return SchedulerCallbackOutput{}, nil +} + +// ============================================================================ +// WebSocket Callback Implementation +// ============================================================================ + +// NdWebsocketOnTextMessage handles incoming WebSocket text messages. +func NdWebsocketOnTextMessage(input OnTextMessageInput) (OnTextMessageOutput, error) { + if err := handleWebSocketMessage(input.ConnectionId, input.Message); err != nil { + errMsg := err.Error() + return OnTextMessageOutput{Error: &errMsg}, nil + } + return OnTextMessageOutput{}, nil +} + +// NdWebsocketOnBinaryMessage handles incoming WebSocket binary messages. +func NdWebsocketOnBinaryMessage(input OnBinaryMessageInput) (OnBinaryMessageOutput, error) { + pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionId)) + return OnBinaryMessageOutput{}, nil +} + +// NdWebsocketOnError handles WebSocket errors. +func NdWebsocketOnError(input OnErrorInput) (OnErrorOutput, error) { + pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionId, input.Error)) + return OnErrorOutput{}, nil +} + +// NdWebsocketOnClose handles WebSocket connection closure. +func NdWebsocketOnClose(input OnCloseInput) (OnCloseOutput, error) { + pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionId, input.Code, input.Reason)) + return OnCloseOutput{}, nil +} + +func main() {} diff --git a/plugins/examples/discord-rich-presence/nd_host_artwork.go b/plugins/examples/discord-rich-presence/nd_host_artwork.go new file mode 100644 index 000000000..0943c794c --- /dev/null +++ b/plugins/examples/discord-rich-presence/nd_host_artwork.go @@ -0,0 +1,170 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Artwork host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + + "github.com/extism/go-pdk" +) + +// artwork_getartisturl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_getartisturl +func artwork_getartisturl(uint64, int32) uint64 + +// artwork_getalbumurl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_getalbumurl +func artwork_getalbumurl(uint64, int32) uint64 + +// artwork_gettrackurl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_gettrackurl +func artwork_gettrackurl(uint64, int32) uint64 + +// artwork_getplaylisturl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_getplaylisturl +func artwork_getplaylisturl(uint64, int32) uint64 + +// ArtworkGetArtistUrlResponse is the response type for Artwork.GetArtistUrl. +type ArtworkGetArtistUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetAlbumUrlResponse is the response type for Artwork.GetAlbumUrl. +type ArtworkGetAlbumUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetTrackUrlResponse is the response type for Artwork.GetTrackUrl. +type ArtworkGetTrackUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetPlaylistUrlResponse is the response type for Artwork.GetPlaylistUrl. +type ArtworkGetPlaylistUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetArtistUrl calls the artwork_getartisturl host function. +// GetArtistUrl generates a public URL for an artist's artwork. +// +// Parameters: +// - id: The artist's unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetArtistUrl(id string, size int32) (*ArtworkGetArtistUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_getartisturl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetArtistUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// ArtworkGetAlbumUrl calls the artwork_getalbumurl host function. +// GetAlbumUrl generates a public URL for an album's artwork. +// +// Parameters: +// - id: The album's unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetAlbumUrl(id string, size int32) (*ArtworkGetAlbumUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_getalbumurl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetAlbumUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// ArtworkGetTrackUrl calls the artwork_gettrackurl host function. +// GetTrackUrl generates a public URL for a track's artwork. +// +// Parameters: +// - id: The track's (media file) unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetTrackUrl(id string, size int32) (*ArtworkGetTrackUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_gettrackurl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetTrackUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// ArtworkGetPlaylistUrl calls the artwork_getplaylisturl host function. +// GetPlaylistUrl generates a public URL for a playlist's artwork. +// +// Parameters: +// - id: The playlist's unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetPlaylistUrl(id string, size int32) (*ArtworkGetPlaylistUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_getplaylisturl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetPlaylistUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} diff --git a/plugins/examples/discord-rich-presence/nd_host_cache.go b/plugins/examples/discord-rich-presence/nd_host_cache.go new file mode 100644 index 000000000..c9798f2b2 --- /dev/null +++ b/plugins/examples/discord-rich-presence/nd_host_cache.go @@ -0,0 +1,375 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Cache host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// cache_setstring is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setstring +func cache_setstring(uint64, uint64, int64) uint64 + +// cache_getstring is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getstring +func cache_getstring(uint64) uint64 + +// cache_setint is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setint +func cache_setint(uint64, int64, int64) uint64 + +// cache_getint is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getint +func cache_getint(uint64) uint64 + +// cache_setfloat is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setfloat +func cache_setfloat(uint64, float64, int64) uint64 + +// cache_getfloat is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getfloat +func cache_getfloat(uint64) uint64 + +// cache_setbytes is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setbytes +func cache_setbytes(uint64, uint64, int64) uint64 + +// cache_getbytes is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getbytes +func cache_getbytes(uint64) uint64 + +// cache_has is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_has +func cache_has(uint64) uint64 + +// cache_remove is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_remove +func cache_remove(uint64) uint64 + +// CacheGetStringResponse is the response type for Cache.GetString. +type CacheGetStringResponse struct { + Value string `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetIntResponse is the response type for Cache.GetInt. +type CacheGetIntResponse struct { + Value int64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetFloatResponse is the response type for Cache.GetFloat. +type CacheGetFloatResponse struct { + Value float64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetBytesResponse is the response type for Cache.GetBytes. +type CacheGetBytesResponse struct { + Value []byte `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheHasResponse is the response type for Cache.Has. +type CacheHasResponse struct { + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheSetString calls the cache_setstring host function. +// SetString stores a string value in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The string value to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetString(key string, value string, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + valueMem := pdk.AllocateString(value) + defer valueMem.Free() + + // Call the host function + responsePtr := cache_setstring(keyMem.Offset(), valueMem.Offset(), ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetString calls the cache_getstring host function. +// GetString retrieves a string value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not a string, exists will be false. +func CacheGetString(key string) (*CacheGetStringResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getstring(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetStringResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheSetInt calls the cache_setint host function. +// SetInt stores an integer value in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The integer value to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetInt(key string, value int64, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_setint(keyMem.Offset(), value, ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetInt calls the cache_getint host function. +// GetInt retrieves an integer value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not an integer, exists will be false. +func CacheGetInt(key string) (*CacheGetIntResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getint(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetIntResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheSetFloat calls the cache_setfloat host function. +// SetFloat stores a float value in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The float value to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetFloat(key string, value float64, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_setfloat(keyMem.Offset(), value, ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetFloat calls the cache_getfloat host function. +// GetFloat retrieves a float value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not a float, exists will be false. +func CacheGetFloat(key string) (*CacheGetFloatResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getfloat(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetFloatResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheSetBytes calls the cache_setbytes host function. +// SetBytes stores a byte slice in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The byte slice to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetBytes(key string, value []byte, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + valueMem := pdk.AllocateBytes(value) + defer valueMem.Free() + + // Call the host function + responsePtr := cache_setbytes(keyMem.Offset(), valueMem.Offset(), ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetBytes calls the cache_getbytes host function. +// GetBytes retrieves a byte slice from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not a byte slice, exists will be false. +func CacheGetBytes(key string) (*CacheGetBytesResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getbytes(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetBytesResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheHas calls the cache_has host function. +// Has checks if a key exists in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns true if the key exists and has not expired. +func CacheHas(key string) (*CacheHasResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_has(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheHasResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheRemove calls the cache_remove host function. +// Remove deletes a value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns an error if the operation fails. Does not return an error if the key doesn't exist. +func CacheRemove(key string) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_remove(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} diff --git a/plugins/examples/discord-rich-presence/nd_host_scheduler.go b/plugins/examples/discord-rich-presence/nd_host_scheduler.go new file mode 100644 index 000000000..1aef70972 --- /dev/null +++ b/plugins/examples/discord-rich-presence/nd_host_scheduler.go @@ -0,0 +1,133 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Scheduler host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// scheduler_scheduleonetime is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scheduler_scheduleonetime +func scheduler_scheduleonetime(int32, uint64, uint64) uint64 + +// scheduler_schedulerecurring is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scheduler_schedulerecurring +func scheduler_schedulerecurring(uint64, uint64, uint64) uint64 + +// scheduler_cancelschedule is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scheduler_cancelschedule +func scheduler_cancelschedule(uint64) uint64 + +// SchedulerScheduleOneTimeResponse is the response type for Scheduler.ScheduleOneTime. +type SchedulerScheduleOneTimeResponse struct { + NewScheduleID string `json:"newScheduleID,omitempty"` + Error string `json:"error,omitempty"` +} + +// SchedulerScheduleRecurringResponse is the response type for Scheduler.ScheduleRecurring. +type SchedulerScheduleRecurringResponse struct { + NewScheduleID string `json:"newScheduleID,omitempty"` + Error string `json:"error,omitempty"` +} + +// SchedulerScheduleOneTime calls the scheduler_scheduleonetime host function. +// ScheduleOneTime schedules a one-time event to be triggered after the specified delay. +// Plugins that use this function must also implement the SchedulerCallback capability +// +// Parameters: +// - delaySeconds: Number of seconds to wait before triggering the event +// - payload: Data to be passed to the scheduled event handler +// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated +// +// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. +func SchedulerScheduleOneTime(delaySeconds int32, payload string, scheduleID string) (*SchedulerScheduleOneTimeResponse, error) { + payloadMem := pdk.AllocateString(payload) + defer payloadMem.Free() + scheduleIDMem := pdk.AllocateString(scheduleID) + defer scheduleIDMem.Free() + + // Call the host function + responsePtr := scheduler_scheduleonetime(delaySeconds, payloadMem.Offset(), scheduleIDMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response SchedulerScheduleOneTimeResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// SchedulerScheduleRecurring calls the scheduler_schedulerecurring host function. +// ScheduleRecurring schedules a recurring event using a cron expression. +// Plugins that use this function must also implement the SchedulerCallback capability +// +// Parameters: +// - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) +// - payload: Data to be passed to each scheduled event handler invocation +// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated +// +// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. +func SchedulerScheduleRecurring(cronExpression string, payload string, scheduleID string) (*SchedulerScheduleRecurringResponse, error) { + cronExpressionMem := pdk.AllocateString(cronExpression) + defer cronExpressionMem.Free() + payloadMem := pdk.AllocateString(payload) + defer payloadMem.Free() + scheduleIDMem := pdk.AllocateString(scheduleID) + defer scheduleIDMem.Free() + + // Call the host function + responsePtr := scheduler_schedulerecurring(cronExpressionMem.Offset(), payloadMem.Offset(), scheduleIDMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response SchedulerScheduleRecurringResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// SchedulerCancelSchedule calls the scheduler_cancelschedule host function. +// CancelSchedule cancels a scheduled job identified by its schedule ID. +// +// This works for both one-time and recurring schedules. Once cancelled, the job will not trigger +// any future events. +// +// Returns an error if the schedule ID is not found or if cancellation fails. +func SchedulerCancelSchedule(scheduleID string) error { + scheduleIDMem := pdk.AllocateString(scheduleID) + defer scheduleIDMem.Free() + + // Call the host function + responsePtr := scheduler_cancelschedule(scheduleIDMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} diff --git a/plugins/examples/discord-rich-presence/nd_host_websocket.go b/plugins/examples/discord-rich-presence/nd_host_websocket.go new file mode 100644 index 000000000..462c00754 --- /dev/null +++ b/plugins/examples/discord-rich-presence/nd_host_websocket.go @@ -0,0 +1,177 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the WebSocket host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// websocket_connect is the host function provided by Navidrome. +// Takes a single JSON request pointer containing url, headers, and connectionID. +// +//go:wasmimport extism:host/user websocket_connect +func websocket_connect(uint64) uint64 + +// websocket_sendtext is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user websocket_sendtext +func websocket_sendtext(uint64, uint64) uint64 + +// websocket_sendbinary is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user websocket_sendbinary +func websocket_sendbinary(uint64, uint64) uint64 + +// websocket_closeconnection is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user websocket_closeconnection +func websocket_closeconnection(uint64, int32, uint64) uint64 + +// WebSocketConnectRequest is the request type for WebSocket.Connect. +type WebSocketConnectRequest struct { + Url string `json:"url"` + Headers map[string]string `json:"headers"` + ConnectionID string `json:"connectionID"` +} + +// WebSocketConnectResponse is the response type for WebSocket.Connect. +type WebSocketConnectResponse struct { + NewConnectionID string `json:"newConnectionID,omitempty"` + Error string `json:"error,omitempty"` +} + +// WebSocketConnect calls the websocket_connect host function. +// Connect establishes a WebSocket connection to the specified URL. +// +// Plugins that use this function must also implement the WebSocketCallback capability +// to receive incoming messages and connection events. +// +// Parameters: +// - url: The WebSocket URL to connect to (ws:// or wss://) +// - headers: Optional HTTP headers to include in the handshake request +// - connectionID: Optional unique identifier for the connection. If empty, one will be generated +// +// Returns the connection ID that can be used to send messages or close the connection, +// or an error if the connection fails. +func WebSocketConnect(url string, headers map[string]string, connectionID string) (*WebSocketConnectResponse, error) { + // Create JSON request with all parameters + req := WebSocketConnectRequest{ + Url: url, + Headers: headers, + ConnectionID: connectionID, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function with single JSON request + responsePtr := websocket_connect(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response WebSocketConnectResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// WebSocketSendText calls the websocket_sendtext host function. +// SendText sends a text message over an established WebSocket connection. +// +// Parameters: +// - connectionID: The connection identifier returned by Connect +// - message: The text message to send +// +// Returns an error if the connection is not found or if sending fails. +func WebSocketSendText(connectionID string, message string) error { + connectionIDMem := pdk.AllocateString(connectionID) + defer connectionIDMem.Free() + messageMem := pdk.AllocateString(message) + defer messageMem.Free() + + // Call the host function + responsePtr := websocket_sendtext(connectionIDMem.Offset(), messageMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// WebSocketSendBinary calls the websocket_sendbinary host function. +// SendBinary sends binary data over an established WebSocket connection. +// +// Parameters: +// - connectionID: The connection identifier returned by Connect +// - data: The binary data to send +// +// Returns an error if the connection is not found or if sending fails. +func WebSocketSendBinary(connectionID string, data []byte) error { + connectionIDMem := pdk.AllocateString(connectionID) + defer connectionIDMem.Free() + dataMem := pdk.AllocateBytes(data) + defer dataMem.Free() + + // Call the host function + responsePtr := websocket_sendbinary(connectionIDMem.Offset(), dataMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// WebSocketCloseConnection calls the websocket_closeconnection host function. +// CloseConnection gracefully closes a WebSocket connection. +// +// Parameters: +// - connectionID: The connection identifier returned by Connect +// - code: WebSocket close status code (e.g., 1000 for normal closure) +// - reason: Optional human-readable reason for closing +// +// Returns an error if the connection is not found or if closing fails. +func WebSocketCloseConnection(connectionID string, code int32, reason string) error { + connectionIDMem := pdk.AllocateString(connectionID) + defer connectionIDMem.Free() + reasonMem := pdk.AllocateString(reason) + defer reasonMem.Free() + + // Call the host function + responsePtr := websocket_closeconnection(connectionIDMem.Offset(), code, reasonMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} diff --git a/plugins/examples/discord-rich-presence/pdk.gen.go b/plugins/examples/discord-rich-presence/pdk.gen.go new file mode 100644 index 000000000..35660620c --- /dev/null +++ b/plugins/examples/discord-rich-presence/pdk.gen.go @@ -0,0 +1,399 @@ +// THIS FILE WAS GENERATED BY `xtp-go-bindgen`. DO NOT EDIT. +// Combined from: scrobbler.yaml, scheduler_callback.yaml, websocket_callback.yaml +package main + +import ( + "errors" + + pdk "github.com/extism/go-pdk" +) + +// ============================================================================ +// Scrobbler Capability Functions +// ============================================================================ + +//export nd_scrobbler_is_authorized +func _NdScrobblerIsAuthorized() int32 { + var input AuthInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := NdScrobblerIsAuthorized(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +//export nd_scrobbler_now_playing +func _NdScrobblerNowPlaying() int32 { + var input NowPlayingInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := NdScrobblerNowPlaying(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +//export nd_scrobbler_scrobble +func _NdScrobblerScrobble() int32 { + var input ScrobbleInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := NdScrobblerScrobble(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +// ============================================================================ +// Scheduler Callback Capability Functions +// ============================================================================ + +//export nd_scheduler_callback +func _NdSchedulerCallback() int32 { + var input SchedulerCallbackInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := NdSchedulerCallback(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +// ============================================================================ +// WebSocket Callback Capability Functions +// ============================================================================ + +//export nd_websocket_on_text_message +func _NdWebsocketOnTextMessage() int32 { + var input OnTextMessageInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := NdWebsocketOnTextMessage(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +//export nd_websocket_on_binary_message +func _NdWebsocketOnBinaryMessage() int32 { + var input OnBinaryMessageInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := NdWebsocketOnBinaryMessage(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +//export nd_websocket_on_error +func _NdWebsocketOnError() int32 { + var input OnErrorInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := NdWebsocketOnError(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +//export nd_websocket_on_close +func _NdWebsocketOnClose() int32 { + var input OnCloseInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := NdWebsocketOnClose(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +// ============================================================================ +// Scrobbler Types +// ============================================================================ + +// AuthInput is the input for authorization check +type AuthInput struct { + // The internal Navidrome user ID + UserId string `json:"user_id"` + // The username of the user + Username string `json:"username"` +} + +// AuthOutput is the output for authorization check +type AuthOutput struct { + // Whether the user is authorized to scrobble + Authorized bool `json:"authorized"` +} + +// NowPlayingInput is the input for now playing notification +type NowPlayingInput struct { + // Current playback position in seconds + Position int32 `json:"position"` + // The track currently playing + Track TrackInfo `json:"track"` + // The internal Navidrome user ID + UserId string `json:"user_id"` + // The username of the user + Username string `json:"username"` +} + +// ScrobbleInput is the input for submitting a scrobble +type ScrobbleInput struct { + // Unix timestamp when the track started playing + Timestamp int64 `json:"timestamp"` + // The track that was played + Track TrackInfo `json:"track"` + // The internal Navidrome user ID + UserId string `json:"user_id"` + // The username of the user + Username string `json:"username"` +} + +// ScrobblerErrorType indicates how Navidrome should handle the error +type ScrobblerErrorType string + +const ( + ScrobblerErrorTypeNone ScrobblerErrorType = "none" + ScrobblerErrorTypeNotAuthorized ScrobblerErrorType = "not_authorized" + ScrobblerErrorTypeRetryLater ScrobblerErrorType = "retry_later" + ScrobblerErrorTypeUnrecoverable ScrobblerErrorType = "unrecoverable" +) + +func (v ScrobblerErrorType) String() string { + switch v { + case ScrobblerErrorTypeNone: + return `none` + case ScrobblerErrorTypeNotAuthorized: + return `not_authorized` + case ScrobblerErrorTypeRetryLater: + return `retry_later` + case ScrobblerErrorTypeUnrecoverable: + return `unrecoverable` + default: + return "" + } +} + +func stringToScrobblerErrorType(s string) (ScrobblerErrorType, error) { + switch s { + case `none`: + return ScrobblerErrorTypeNone, nil + case `not_authorized`: + return ScrobblerErrorTypeNotAuthorized, nil + case `retry_later`: + return ScrobblerErrorTypeRetryLater, nil + case `unrecoverable`: + return ScrobblerErrorTypeUnrecoverable, nil + default: + return ScrobblerErrorType(""), errors.New("unable to convert string to ScrobblerErrorType") + } +} + +// ScrobblerOutput is the output for scrobbler operations (now_playing and scrobble) +type ScrobblerOutput struct { + // Error message if the operation failed + Error *string `json:"error,omitempty"` + // Type of error for handling + ErrorType ScrobblerErrorType `json:"error_type,omitempty"` +} + +// TrackInfo contains track metadata for scrobbling +type TrackInfo struct { + // Album name + Album string `json:"album"` + // Album artist + AlbumArtist string `json:"album_artist"` + // Track artist + Artist string `json:"artist"` + // Disc number + DiscNumber int32 `json:"disc_number"` + // Track duration in seconds + Duration float32 `json:"duration"` + // The internal Navidrome track ID + Id string `json:"id"` + // MusicBrainz album artist ID + MbzAlbumArtistId *string `json:"mbz_album_artist_id,omitempty"` + // MusicBrainz album/release ID + MbzAlbumId *string `json:"mbz_album_id,omitempty"` + // MusicBrainz artist ID + MbzArtistId *string `json:"mbz_artist_id,omitempty"` + // MusicBrainz recording ID + MbzRecordingId *string `json:"mbz_recording_id,omitempty"` + // MusicBrainz release group ID + MbzReleaseGroupId *string `json:"mbz_release_group_id,omitempty"` + // MusicBrainz release track ID + MbzReleaseTrackId *string `json:"mbz_release_track_id,omitempty"` + // Track title + Title string `json:"title"` + // Track number on the album + TrackNumber int32 `json:"track_number"` +} + +// ============================================================================ +// Scheduler Callback Types +// ============================================================================ + +// SchedulerCallbackInput is provided when a scheduled task fires +type SchedulerCallbackInput struct { + // True if this is a recurring schedule (created via ScheduleRecurring), + // false if it's a one-time schedule (created via ScheduleOneTime). + IsRecurring bool `json:"is_recurring"` + // The payload data that was provided when the task was scheduled. + // Can be used to pass context or parameters to the callback handler. + Payload string `json:"payload"` + // The unique identifier for this scheduled task. This is either the ID + // provided when scheduling, or an auto-generated UUID if none was specified. + ScheduleId string `json:"schedule_id"` +} + +// SchedulerCallbackOutput is the output from the scheduler callback +type SchedulerCallbackOutput struct { + // Error message if the callback failed to process the scheduled task. + // Empty or null indicates success. The error is logged but does not + // affect the scheduling system. + Error *string `json:"error,omitempty"` +} + +// ============================================================================ +// WebSocket Callback Types +// ============================================================================ + +// OnTextMessageInput is provided when a text message is received +type OnTextMessageInput struct { + // The unique identifier for the WebSocket connection that received the message. + ConnectionId string `json:"connection_id"` + // The text message content received from the WebSocket. + Message string `json:"message"` +} + +// OnTextMessageOutput is the output from the text message handler +type OnTextMessageOutput struct { + // Error message if the callback failed. Empty or null indicates success. + Error *string `json:"error,omitempty"` +} + +// OnBinaryMessageInput is provided when a binary message is received +type OnBinaryMessageInput struct { + // The unique identifier for the WebSocket connection that received the message. + ConnectionId string `json:"connection_id"` + // The binary data received from the WebSocket, encoded as base64. + Data string `json:"data"` +} + +// OnBinaryMessageOutput is the output from the binary message handler +type OnBinaryMessageOutput struct { + // Error message if the callback failed. Empty or null indicates success. + Error *string `json:"error,omitempty"` +} + +// OnErrorInput is provided when an error occurs on a WebSocket connection +type OnErrorInput struct { + // The unique identifier for the WebSocket connection where the error occurred. + ConnectionId string `json:"connection_id"` + // The error message describing what went wrong. + Error string `json:"error"` +} + +// OnErrorOutput is the output from the error handler +type OnErrorOutput struct { + // Error message if the callback failed. Empty or null indicates success. + Error *string `json:"error,omitempty"` +} + +// OnCloseInput is provided when a WebSocket connection is closed +type OnCloseInput struct { + // The WebSocket close status code (e.g., 1000 for normal closure, + // 1001 for going away, 1006 for abnormal closure). + Code int32 `json:"code"` + // The unique identifier for the WebSocket connection that was closed. + ConnectionId string `json:"connection_id"` + // The human-readable reason for the connection closure, if provided. + Reason string `json:"reason"` +} + +// OnCloseOutput is the output from the close handler +type OnCloseOutput struct { + // Error message if the callback failed. Empty or null indicates success. + Error *string `json:"error,omitempty"` +} diff --git a/plugins/examples/discord-rich-presence/rpc.go b/plugins/examples/discord-rich-presence/rpc.go new file mode 100644 index 000000000..114c0eef1 --- /dev/null +++ b/plugins/examples/discord-rich-presence/rpc.go @@ -0,0 +1,363 @@ +// Discord Rich Presence Plugin - RPC Communication +// +// This file handles all Discord gateway communication including WebSocket connections, +// presence updates, and heartbeat management. +package main + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/extism/go-pdk" +) + +// 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" +) + +// 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"` +} + +// processImage processes an image URL for Discord, with fallback to default image. +func processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) { + if imageURL == "" { + if isDefaultImage { + return "", fmt.Errorf("default image URL is empty") + } + return processImage(defaultImage, clientID, token, true) + } + + if strings.HasPrefix(imageURL, "mp:") { + return imageURL, nil + } + + // Check cache first + cacheKey := fmt.Sprintf("discord.image.%x", imageURL) + cacheResp, err := CacheGetString(cacheKey) + if err == nil && cacheResp.Exists { + pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL)) + return cacheResp.Value, 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 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 processImage(defaultImage, clientID, token, true) + } + + if len(data) == 0 { + if isDefaultImage { + return "", fmt.Errorf("no data returned for default image") + } + return 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 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 + } + + _ = CacheSetString(cacheKey, processedImage, ttl) + pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl)) + + return processedImage, nil +} + +// sendActivity sends an activity update to Discord. +func 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 := 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 sendMessage(username, presenceOpCode, presence) +} + +// clearActivity clears the Discord activity for a user. +func clearActivity(username string) error { + pdk.Log(pdk.LogInfo, fmt.Sprintf("Clearing activity for user %s", username)) + return sendMessage(username, presenceOpCode, presencePayload{}) +} + +// sendMessage sends a message over the WebSocket connection. +func 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) + } + + if err := WebSocketSendText(username, string(b)); err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + return nil +} + +// getDiscordGateway retrieves the Discord gateway URL. +func 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 sendHeartbeat(username string) error { + cacheResp, err := 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, cacheResp.Value)) + return sendMessage(username, heartbeatOpCode, cacheResp.Value) +} + +// cleanupFailedConnection cleans up a failed Discord connection. +func cleanupFailedConnection(username string) { + pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaning up failed connection for user %s", username)) + + // Cancel the heartbeat schedule + if err := 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 := 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 + _ = 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 isConnected(username string) bool { + err := 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 connect(username, token string) error { + if 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 := 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 + resp, err := WebSocketConnect(gateway, nil, username) + if err != nil { + return fmt.Errorf("failed to connect to WebSocket: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("failed to connect to WebSocket: %s", resp.Error) + } + + // Send identify payload + payload := identifyPayload{ + Token: token, + Intents: 0, + Properties: identifyProperties{ + OS: "Windows 10", + Browser: "Discord Client", + Device: "Discord Client", + }, + } + if err := 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) + schedResp, err := 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, schedResp.NewScheduleID)) + + pdk.Log(pdk.LogInfo, fmt.Sprintf("Successfully authenticated user %s", username)) + return nil +} + +// disconnect closes the Discord connection for a user. +func disconnect(username string) error { + if err := SchedulerCancelSchedule(username); err != nil { + return fmt.Errorf("failed to cancel schedule: %w", err) + } + + if err := 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 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 := 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 handleHeartbeatCallback(username string) error { + if err := 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)) + cleanupFailedConnection(username) + return fmt.Errorf("heartbeat failed, connection cleaned up: %w", err) + } + return nil +} + +// handleClearActivityCallback processes clear activity scheduler callbacks. +func handleClearActivityCallback(username string) error { + pdk.Log(pdk.LogInfo, fmt.Sprintf("Removing presence for user %s", username)) + if err := 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 := disconnect(username); err != nil { + return fmt.Errorf("failed to disconnect from Discord: %w", err) + } + return nil +} + +// nowPlaying returns the current timestamp in milliseconds. +func nowMillis() int64 { + return time.Now().UnixMilli() +} diff --git a/plugins/host/go/nd_host_websocket.go b/plugins/host/go/nd_host_websocket.go index 4776e8740..462c00754 100644 --- a/plugins/host/go/nd_host_websocket.go +++ b/plugins/host/go/nd_host_websocket.go @@ -15,9 +15,10 @@ import ( ) // websocket_connect is the host function provided by Navidrome. +// Takes a single JSON request pointer containing url, headers, and connectionID. // //go:wasmimport extism:host/user websocket_connect -func websocket_connect(uint64, uint64, uint64) uint64 +func websocket_connect(uint64) uint64 // websocket_sendtext is the host function provided by Navidrome. // @@ -34,6 +35,13 @@ func websocket_sendbinary(uint64, uint64) uint64 //go:wasmimport extism:host/user websocket_closeconnection func websocket_closeconnection(uint64, int32, uint64) uint64 +// WebSocketConnectRequest is the request type for WebSocket.Connect. +type WebSocketConnectRequest struct { + Url string `json:"url"` + Headers map[string]string `json:"headers"` + ConnectionID string `json:"connectionID"` +} + // WebSocketConnectResponse is the response type for WebSocket.Connect. type WebSocketConnectResponse struct { NewConnectionID string `json:"newConnectionID,omitempty"` @@ -54,19 +62,21 @@ type WebSocketConnectResponse struct { // Returns the connection ID that can be used to send messages or close the connection, // or an error if the connection fails. func WebSocketConnect(url string, headers map[string]string, connectionID string) (*WebSocketConnectResponse, error) { - urlMem := pdk.AllocateString(url) - defer urlMem.Free() - headersBytes, err := json.Marshal(headers) + // Create JSON request with all parameters + req := WebSocketConnectRequest{ + Url: url, + Headers: headers, + ConnectionID: connectionID, + } + reqBytes, err := json.Marshal(req) if err != nil { return nil, err } - headersMem := pdk.AllocateBytes(headersBytes) - defer headersMem.Free() - connectionIDMem := pdk.AllocateString(connectionID) - defer connectionIDMem.Free() + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() - // Call the host function - responsePtr := websocket_connect(urlMem.Offset(), headersMem.Offset(), connectionIDMem.Offset()) + // Call the host function with single JSON request + responsePtr := websocket_connect(reqMem.Offset()) // Read the response from memory responseMem := pdk.FindMemory(responsePtr)