mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-14 08:51:13 -05:00
* feat(plugins): add JSONForms schema for plugin configuration Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance error handling by formatting validation errors with field names Signed-off-by: Deluan <deluan@navidrome.org> * feat: enforce required fields in config validation and improve error handling Signed-off-by: Deluan <deluan@navidrome.org> * format JS code Signed-off-by: Deluan <deluan@navidrome.org> * feat: add config schema validation and enhance manifest structure Signed-off-by: Deluan <deluan@navidrome.org> * feat: refactor plugin config parsing and add unit tests Signed-off-by: Deluan <deluan@navidrome.org> * feat: add config validation error message in Portuguese * feat: enhance AlwaysExpandedArrayLayout with description support and improve array control testing Signed-off-by: Deluan <deluan@navidrome.org> * feat: update Discord Rust plugin configuration to use JSONForm for user tokens and enhance schema validation Signed-off-by: Deluan <deluan@navidrome.org> * fix: resolve React Hooks linting issues in plugin UI components * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * format code Signed-off-by: Deluan <deluan@navidrome.org> * feat: migrate schema validation to use santhosh-tekuri/jsonschema and improve error formatting Signed-off-by: Deluan <deluan@navidrome.org> * address PR comments Signed-off-by: Deluan <deluan@navidrome.org> * fix flaky test Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance array layout and configuration handling with AJV defaults Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement custom tester to exclude enum arrays from AlwaysExpandedArrayLayout Signed-off-by: Deluan <deluan@navidrome.org> * feat: add error boundary for schema rendering and improve error messages Signed-off-by: Deluan <deluan@navidrome.org> * feat: refine non-enum array control logic by utilizing JSONForms schema resolution Signed-off-by: Deluan <deluan@navidrome.org> * feat: add error styling to ToggleEnabledSwitch for disabled state Signed-off-by: Deluan <deluan@navidrome.org> * feat: adjust label positioning and styling in SchemaConfigEditor for improved layout Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement outlined input controls renderers to replace custom fragile CSS Signed-off-by: Deluan <deluan@navidrome.org> * feat: remove margin from last form control inside array items for better spacing Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance AJV error handling to transform required errors for field-level validation Signed-off-by: Deluan <deluan@navidrome.org> * feat: set default value for User Tokens in manifest.json to improve user experience Signed-off-by: Deluan <deluan@navidrome.org> * format Signed-off-by: Deluan <deluan@navidrome.org> * feat: add margin to outlined input controls for improved spacing Signed-off-by: Deluan <deluan@navidrome.org> * feat: remove redundant margin rule for last form control in array items Signed-off-by: Deluan <deluan@navidrome.org> * feat: adjust font size of label elements in SchemaConfigEditor for improved readability Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
303 lines
8.9 KiB
Go
Executable File
303 lines
8.9 KiB
Go
Executable File
// Crypto Ticker Plugin - Demonstrates WebSocket host service capabilities.
|
|
//
|
|
// This plugin connects to Coinbase's WebSocket API to receive real-time
|
|
// cryptocurrency price updates and logs them to the Navidrome console.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/lifecycle"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
|
)
|
|
|
|
const (
|
|
// Coinbase WebSocket API endpoint
|
|
coinbaseWSEndpoint = "wss://ws-feed.exchange.coinbase.com"
|
|
|
|
// Connection ID for our WebSocket connection
|
|
connectionID = "crypto-ticker-conn"
|
|
|
|
// ID for the reconnection schedule
|
|
reconnectScheduleID = "crypto-ticker-reconnect"
|
|
|
|
// Config keys (must match manifest.json schema property names)
|
|
symbolsKey = "symbols"
|
|
reconnectDelayKey = "reconnectDelay"
|
|
logPricesKey = "logPrices"
|
|
|
|
// Default values
|
|
defaultReconnectDelay = 5
|
|
)
|
|
|
|
// CoinbaseSubscription message structure
|
|
type CoinbaseSubscription struct {
|
|
Type string `json:"type"`
|
|
ProductIDs []string `json:"product_ids"`
|
|
Channels []string `json:"channels"`
|
|
}
|
|
|
|
// CoinbaseTicker message structure
|
|
type CoinbaseTicker struct {
|
|
Type string `json:"type"`
|
|
Sequence int64 `json:"sequence"`
|
|
ProductID string `json:"product_id"`
|
|
Price string `json:"price"`
|
|
Open24h string `json:"open_24h"`
|
|
Volume24h string `json:"volume_24h"`
|
|
Low24h string `json:"low_24h"`
|
|
High24h string `json:"high_24h"`
|
|
BestBid string `json:"best_bid"`
|
|
BestAsk string `json:"best_ask"`
|
|
Time string `json:"time"`
|
|
}
|
|
|
|
// cryptoTickerPlugin implements the lifecycle, websocket and scheduler interfaces.
|
|
type cryptoTickerPlugin struct{}
|
|
|
|
// init registers the plugin capabilities
|
|
func init() {
|
|
lifecycle.Register(&cryptoTickerPlugin{})
|
|
websocket.Register(&cryptoTickerPlugin{})
|
|
scheduler.Register(&cryptoTickerPlugin{})
|
|
}
|
|
|
|
// Ensure cryptoTickerPlugin implements the required provider interfaces
|
|
var (
|
|
_ lifecycle.InitProvider = (*cryptoTickerPlugin)(nil)
|
|
_ websocket.TextMessageProvider = (*cryptoTickerPlugin)(nil)
|
|
_ websocket.BinaryMessageProvider = (*cryptoTickerPlugin)(nil)
|
|
_ websocket.ErrorProvider = (*cryptoTickerPlugin)(nil)
|
|
_ websocket.CloseProvider = (*cryptoTickerPlugin)(nil)
|
|
_ scheduler.CallbackProvider = (*cryptoTickerPlugin)(nil)
|
|
)
|
|
|
|
// OnInit is called when the plugin is loaded.
|
|
// We use this to establish the initial WebSocket connection.
|
|
func (p *cryptoTickerPlugin) OnInit() error {
|
|
pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...")
|
|
|
|
// Get ticker configuration from JSON schema config
|
|
symbols := getSymbols()
|
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured symbols: %v", symbols))
|
|
|
|
// Connect to WebSocket
|
|
// Errors won't fail init - reconnect logic will handle it
|
|
return connectAndSubscribe(symbols)
|
|
}
|
|
|
|
// getSymbols reads the symbols array from config
|
|
func getSymbols() []string {
|
|
defaultSymbols := []string{"BTC-USD"}
|
|
symbolsJSON, ok := pdk.GetConfig(symbolsKey)
|
|
if !ok || symbolsJSON == "" {
|
|
return defaultSymbols
|
|
}
|
|
|
|
var symbols []string
|
|
if err := json.Unmarshal([]byte(symbolsJSON), &symbols); err != nil {
|
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("failed to parse symbols config: %v, using defaults", err))
|
|
return defaultSymbols
|
|
}
|
|
|
|
if len(symbols) == 0 {
|
|
return defaultSymbols
|
|
}
|
|
|
|
// Normalize symbols - add -USD suffix if not present
|
|
for i, s := range symbols {
|
|
s = strings.TrimSpace(s)
|
|
if !strings.Contains(s, "-") {
|
|
symbols[i] = s + "-USD"
|
|
} else {
|
|
symbols[i] = s
|
|
}
|
|
}
|
|
|
|
return symbols
|
|
}
|
|
|
|
// getReconnectDelay reads the reconnect delay from config
|
|
func getReconnectDelay() int32 {
|
|
delayStr, ok := pdk.GetConfig(reconnectDelayKey)
|
|
if !ok || delayStr == "" {
|
|
return defaultReconnectDelay
|
|
}
|
|
|
|
var delay int
|
|
if _, err := fmt.Sscanf(delayStr, "%d", &delay); err != nil || delay < 1 {
|
|
return defaultReconnectDelay
|
|
}
|
|
return int32(delay)
|
|
}
|
|
|
|
// shouldLogPrices reads the logPrices setting from config
|
|
func shouldLogPrices() bool {
|
|
logStr, ok := pdk.GetConfig(logPricesKey)
|
|
if !ok || logStr == "" {
|
|
return false
|
|
}
|
|
return logStr == "true"
|
|
}
|
|
|
|
// connectAndSubscribe connects to Coinbase WebSocket and subscribes to tickers
|
|
func connectAndSubscribe(tickers []string) error {
|
|
// Connect to WebSocket using host function
|
|
newConnID, err := host.WebSocketConnect(coinbaseWSEndpoint, nil, connectionID)
|
|
if err != nil {
|
|
return fmt.Errorf("WebSocket connection error: %w", err)
|
|
}
|
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("Connected to Coinbase WebSocket API (connection: %s)", newConnID))
|
|
|
|
// Subscribe to ticker channel
|
|
subscription := CoinbaseSubscription{
|
|
Type: "subscribe",
|
|
ProductIDs: tickers,
|
|
Channels: []string{"ticker"},
|
|
}
|
|
|
|
subscriptionJSON, err := json.Marshal(subscription)
|
|
if err != nil {
|
|
return fmt.Errorf("JSON marshal error: %v", err)
|
|
}
|
|
|
|
// Send subscription message
|
|
err = host.WebSocketSendText(connectionID, string(subscriptionJSON))
|
|
if err != nil {
|
|
return fmt.Errorf("WebSocket send error: %w", err)
|
|
}
|
|
|
|
pdk.Log(pdk.LogInfo, "Subscription message sent to Coinbase WebSocket API")
|
|
return nil
|
|
}
|
|
|
|
// OnTextMessage is called when a text message is received
|
|
func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest) error {
|
|
// Only process messages from our connection
|
|
if input.ConnectionID != connectionID {
|
|
return nil
|
|
}
|
|
|
|
// Try to parse as a ticker message
|
|
var ticker CoinbaseTicker
|
|
err := json.Unmarshal([]byte(input.Message), &ticker)
|
|
if err != nil {
|
|
// Not a valid JSON message, ignore
|
|
return nil
|
|
}
|
|
|
|
// Only process ticker messages
|
|
if ticker.Type != "ticker" {
|
|
// Could be subscription confirmation or heartbeat
|
|
if ticker.Type != "" {
|
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received %s message", ticker.Type))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Calculate 24h change percentage
|
|
change := calculatePercentChange(ticker.Open24h, ticker.Price)
|
|
|
|
// Log ticker information (only if enabled in config)
|
|
if shouldLogPrices() {
|
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
|
|
ticker.ProductID,
|
|
ticker.Price,
|
|
change,
|
|
ticker.BestBid,
|
|
ticker.BestAsk,
|
|
))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// OnBinaryMessage is called when a binary message is received
|
|
func (p *cryptoTickerPlugin) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error {
|
|
// Coinbase doesn't send binary messages, but we implement the handler anyway
|
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Received unexpected binary message on connection %s", input.ConnectionID))
|
|
return nil
|
|
}
|
|
|
|
// OnError is called when an error occurs on the WebSocket connection
|
|
func (p *cryptoTickerPlugin) OnError(input websocket.OnErrorRequest) error {
|
|
pdk.Log(pdk.LogError, fmt.Sprintf("WebSocket error on connection %s: %s", input.ConnectionID, input.Error))
|
|
return nil
|
|
}
|
|
|
|
// OnClose is called when the WebSocket connection is closed
|
|
func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error {
|
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection %s closed (code: %d, reason: %s)",
|
|
input.ConnectionID, input.Code, input.Reason))
|
|
|
|
// Only attempt reconnect for our connection
|
|
if input.ConnectionID == connectionID {
|
|
delay := getReconnectDelay()
|
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduling reconnection attempt in %d seconds...", delay))
|
|
|
|
// Schedule a one-time reconnection attempt
|
|
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
|
|
if err != nil {
|
|
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// OnCallback is called when a scheduled task fires
|
|
func (p *cryptoTickerPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error {
|
|
// Only handle our reconnection schedule
|
|
if input.ScheduleID != reconnectScheduleID {
|
|
return nil
|
|
}
|
|
|
|
pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...")
|
|
|
|
// Get ticker configuration
|
|
symbols := getSymbols()
|
|
|
|
// Try to connect and subscribe
|
|
err := connectAndSubscribe(symbols)
|
|
if err != nil {
|
|
delay := getReconnectDelay() * 2 // Double delay on failure
|
|
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in %d seconds", err, delay))
|
|
|
|
// Schedule another attempt
|
|
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
|
|
if err != nil {
|
|
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err))
|
|
}
|
|
} else {
|
|
pdk.Log(pdk.LogInfo, "Successfully reconnected!")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// calculatePercentChange calculates the percentage change between open and current price
|
|
func calculatePercentChange(open, current string) string {
|
|
var openFloat, currentFloat float64
|
|
_, err := fmt.Sscanf(open, "%f", &openFloat)
|
|
if err != nil || openFloat == 0 {
|
|
return "N/A"
|
|
}
|
|
_, err = fmt.Sscanf(current, "%f", ¤tFloat)
|
|
if err != nil {
|
|
return "N/A"
|
|
}
|
|
|
|
change := ((currentFloat - openFloat) / openFloat) * 100
|
|
if change >= 0 {
|
|
return fmt.Sprintf("+%.2f", change)
|
|
}
|
|
return fmt.Sprintf("%.2f", change)
|
|
}
|
|
|
|
func main() {}
|