From a2ace6e84ee0fae2e211dec80ad791f4e0e3afc5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 31 Dec 2025 21:02:01 -0500 Subject: [PATCH] refactor: Discord RPC struct to encapsulate WebSocket logic Signed-off-by: Deluan --- plugins/examples/discord-rich-presence/go.mod | 11 ++ plugins/examples/discord-rich-presence/go.sum | 22 ++++ .../examples/discord-rich-presence/main.go | 52 ++------ plugins/examples/discord-rich-presence/rpc.go | 112 ++++++++++++------ 4 files changed, 117 insertions(+), 80 deletions(-) diff --git a/plugins/examples/discord-rich-presence/go.mod b/plugins/examples/discord-rich-presence/go.mod index bffb4b8c6..723598854 100644 --- a/plugins/examples/discord-rich-presence/go.mod +++ b/plugins/examples/discord-rich-presence/go.mod @@ -7,4 +7,15 @@ require ( github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 ) +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/examples/discord-rich-presence/go.sum b/plugins/examples/discord-rich-presence/go.sum index c15d38292..9baba6d50 100644 --- a/plugins/examples/discord-rich-presence/go.sum +++ b/plugins/examples/discord-rich-presence/go.sum @@ -1,2 +1,24 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/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/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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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= +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/plugins/examples/discord-rich-presence/main.go b/plugins/examples/discord-rich-presence/main.go index caefb3898..10099d87d 100644 --- a/plugins/examples/discord-rich-presence/main.go +++ b/plugins/examples/discord-rich-presence/main.go @@ -28,26 +28,19 @@ const ( usersKey = "users" ) -// discordPlugin implements the scrobbler, scheduler, and websocket interfaces. +// 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(&discordPlugin{}) + websocket.Register(rpc) } -// Ensure discordPlugin implements the required provider interfaces -var ( - _ scrobbler.Scrobbler = (*discordPlugin)(nil) - _ scheduler.CallbackProvider = (*discordPlugin)(nil) - _ websocket.TextMessageProvider = (*discordPlugin)(nil) - _ websocket.BinaryMessageProvider = (*discordPlugin)(nil) - _ websocket.ErrorProvider = (*discordPlugin)(nil) - _ websocket.CloseProvider = (*discordPlugin)(nil) -) - // getConfig loads the plugin configuration. func getConfig() (clientID string, users map[string]string, err error) { clientID, ok := pdk.GetConfig(clientIDKey) @@ -121,7 +114,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { } // Connect to Discord - if err := connect(input.Username, userToken); err != nil { + if err := rpc.connect(input.Username, userToken); err != nil { return fmt.Errorf("%w: failed to connect to Discord: %v", scrobbler.ScrobblerErrorRetryLater, err) } @@ -134,7 +127,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { endTime := startTime + int64(input.Track.Duration)*1000 // Send activity update - if err := sendActivity(clientID, input.Username, userToken, activity{ + if err := rpc.sendActivity(clientID, input.Username, userToken, activity{ Application: clientID, Name: "Navidrome", Type: 2, // Listening @@ -180,14 +173,14 @@ func (p *discordPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) err switch input.Payload { case payloadHeartbeat: // Heartbeat callback - scheduleId is the username - if err := handleHeartbeatCallback(input.ScheduleID); err != nil { + 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 := handleClearActivityCallback(username); err != nil { + if err := rpc.handleClearActivityCallback(username); err != nil { return err } @@ -198,31 +191,4 @@ func (p *discordPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) err return nil } -// ============================================================================ -// WebSocket Callback Implementation -// ============================================================================ - -// OnTextMessage handles incoming WebSocket text messages. -func (p *discordPlugin) OnTextMessage(input websocket.OnTextMessageRequest) error { - return handleWebSocketMessage(input.ConnectionID, input.Message) -} - -// OnBinaryMessage handles incoming WebSocket binary messages. -func (p *discordPlugin) 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 (p *discordPlugin) 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 (p *discordPlugin) 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 -} - func main() {} diff --git a/plugins/examples/discord-rich-presence/rpc.go b/plugins/examples/discord-rich-presence/rpc.go index 20a24e165..142d26484 100644 --- a/plugins/examples/discord-rich-presence/rpc.go +++ b/plugins/examples/discord-rich-presence/rpc.go @@ -1,17 +1,18 @@ // Discord Rich Presence Plugin - RPC Communication // // This file handles all Discord gateway communication including WebSocket connections, -// presence updates, and heartbeat management. +// 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" - "time" "github.com/extism/go-pdk" host "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/websocket" ) // Discord WebSocket Gateway constants @@ -32,6 +33,36 @@ const ( 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"` @@ -74,13 +105,17 @@ type identifyProperties struct { Device string `json:"device"` } +// ============================================================================ +// Image Processing +// ============================================================================ + // processImage processes an image URL for Discord, with fallback to default image. -func processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) { +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 processImage(defaultImage, clientID, token, true) + return r.processImage(defaultImage, clientID, token, true) } if strings.HasPrefix(imageURL, "mp:") { @@ -107,7 +142,7 @@ func processImage(imageURL, clientID, token string, isDefaultImage bool) (string if isDefaultImage { return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status()) } - return processImage(defaultImage, clientID, token, true) + return r.processImage(defaultImage, clientID, token, true) } var data []map[string]string @@ -115,14 +150,14 @@ func processImage(imageURL, clientID, token string, isDefaultImage bool) (string if isDefaultImage { return "", fmt.Errorf("failed to unmarshal default image response: %w", err) } - return processImage(defaultImage, clientID, token, true) + return r.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) + return r.processImage(defaultImage, clientID, token, true) } image := data[0]["external_asset_path"] @@ -130,7 +165,7 @@ func processImage(imageURL, clientID, token string, isDefaultImage bool) (string if isDefaultImage { return "", fmt.Errorf("empty external_asset_path for default image") } - return processImage(defaultImage, clientID, token, true) + return r.processImage(defaultImage, clientID, token, true) } processedImage := fmt.Sprintf("mp:%s", image) @@ -147,11 +182,15 @@ func processImage(imageURL, clientID, token string, isDefaultImage bool) (string return processedImage, nil } +// ============================================================================ +// Activity Management +// ============================================================================ + // sendActivity sends an activity update to Discord. -func sendActivity(clientID, username, token string, data activity) error { +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 := processImage(data.Assets.LargeImage, clientID, token, false) + 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 = "" @@ -164,17 +203,21 @@ func sendActivity(clientID, username, token string, data activity) error { Status: "dnd", Afk: false, } - return sendMessage(username, presenceOpCode, presence) + return r.sendMessage(username, presenceOpCode, presence) } // clearActivity clears the Discord activity for a user. -func clearActivity(username string) error { +func (r *discordRPC) clearActivity(username string) error { pdk.Log(pdk.LogInfo, fmt.Sprintf("Clearing activity for user %s", username)) - return sendMessage(username, presenceOpCode, presencePayload{}) + return r.sendMessage(username, presenceOpCode, presencePayload{}) } +// ============================================================================ +// Low-level Communication +// ============================================================================ + // sendMessage sends a message over the WebSocket connection. -func sendMessage(username string, opCode int, payload any) error { +func (r *discordRPC) sendMessage(username string, opCode int, payload any) error { message := map[string]any{ "op": opCode, "d": payload, @@ -192,7 +235,7 @@ func sendMessage(username string, opCode int, payload any) error { } // getDiscordGateway retrieves the Discord gateway URL. -func getDiscordGateway() (string, error) { +func (r *discordRPC) getDiscordGateway() (string, error) { req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway") resp := req.Send() if resp.Status() != 200 { @@ -207,18 +250,18 @@ func getDiscordGateway() (string, error) { } // sendHeartbeat sends a heartbeat to Discord. -func sendHeartbeat(username string) error { +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 sendMessage(username, heartbeatOpCode, seqNum) + return r.sendMessage(username, heartbeatOpCode, seqNum) } // cleanupFailedConnection cleans up a failed Discord connection. -func cleanupFailedConnection(username string) { +func (r *discordRPC) cleanupFailedConnection(username string) { pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaning up failed connection for user %s", username)) // Cancel the heartbeat schedule @@ -238,8 +281,8 @@ func cleanupFailedConnection(username string) { } // isConnected checks if a user is connected to Discord by testing the heartbeat. -func isConnected(username string) bool { - err := sendHeartbeat(username) +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 @@ -248,15 +291,15 @@ func isConnected(username string) bool { } // connect establishes a connection to Discord for a user. -func connect(username, token string) error { - if isConnected(username) { +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 := getDiscordGateway() + gateway, err := r.getDiscordGateway() if err != nil { return fmt.Errorf("failed to get Discord gateway: %w", err) } @@ -278,7 +321,7 @@ func connect(username, token string) error { Device: "Discord Client", }, } - if err := sendMessage(username, gateOpCode, payload); err != nil { + if err := r.sendMessage(username, gateOpCode, payload); err != nil { return fmt.Errorf("failed to send identify payload: %w", err) } @@ -295,7 +338,7 @@ func connect(username, token string) error { } // disconnect closes the Discord connection for a user. -func disconnect(username string) error { +func (r *discordRPC) disconnect(username string) error { if err := host.SchedulerCancelSchedule(username); err != nil { return fmt.Errorf("failed to cancel schedule: %w", err) } @@ -307,7 +350,7 @@ func disconnect(username string) error { } // handleWebSocketMessage processes incoming WebSocket messages from Discord. -func handleWebSocketMessage(connectionID, message string) error { +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 { @@ -332,31 +375,26 @@ func handleWebSocketMessage(connectionID, message string) error { } // handleHeartbeatCallback processes heartbeat scheduler callbacks. -func handleHeartbeatCallback(username string) error { - if err := sendHeartbeat(username); err != nil { +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)) - cleanupFailedConnection(username) + r.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 { +func (r *discordRPC) handleClearActivityCallback(username string) error { pdk.Log(pdk.LogInfo, fmt.Sprintf("Removing presence for user %s", username)) - if err := clearActivity(username); err != nil { + 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 := disconnect(username); err != nil { + if err := r.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() -}