Files
navidrome/plugins/examples/crypto-ticker/main.go
Deluan Quintão f1e75c40dc feat(plugins): add JSONForms-based plugin configuration UI (#4911)
* 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>
2026-01-19 20:51:00 -05:00

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", &currentFloat)
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() {}