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>
This commit is contained in:
Deluan Quintão
2026-01-19 20:51:00 -05:00
committed by GitHub
parent 66474fc9f4
commit f1e75c40dc
40 changed files with 5430 additions and 2007 deletions
+81 -43
View File
@@ -25,6 +25,14 @@ const (
// 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
@@ -74,36 +82,67 @@ var (
func (p *cryptoTickerPlugin) OnInit() error {
pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...")
// Get ticker configuration
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH" // Default tickers
}
tickers := parseTickerSymbols(tickerConfig)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured tickers: %v", tickers))
// 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(tickers)
return connectAndSubscribe(symbols)
}
// parseTickerSymbols parses a comma-separated list of ticker symbols
func parseTickerSymbols(tickerConfig string) []string {
parts := strings.Split(tickerConfig, ",")
tickers := make([]string, 0, len(parts))
for _, ticker := range parts {
ticker = strings.TrimSpace(ticker)
if ticker == "" {
continue
}
// Add -USD suffix if not present
if !strings.Contains(ticker, "-") {
ticker = ticker + "-USD"
}
tickers = append(tickers, ticker)
// getSymbols reads the symbols array from config
func getSymbols() []string {
defaultSymbols := []string{"BTC-USD"}
symbolsJSON, ok := pdk.GetConfig(symbolsKey)
if !ok || symbolsJSON == "" {
return defaultSymbols
}
return tickers
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
@@ -164,14 +203,16 @@ func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest)
// Calculate 24h change percentage
change := calculatePercentChange(ticker.Open24h, ticker.Price)
// Log ticker information
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
ticker.ProductID,
ticker.Price,
change,
ticker.BestBid,
ticker.BestAsk,
))
// 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
}
@@ -196,10 +237,11 @@ func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error {
// Only attempt reconnect for our connection
if input.ConnectionID == connectionID {
pdk.Log(pdk.LogInfo, "Scheduling reconnection attempt in 5 seconds...")
delay := getReconnectDelay()
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduling reconnection attempt in %d seconds...", delay))
// Schedule a one-time reconnection attempt
_, err := host.SchedulerScheduleOneTime(5, "reconnect", reconnectScheduleID)
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err))
}
@@ -218,20 +260,16 @@ func (p *cryptoTickerPlugin) OnCallback(input scheduler.SchedulerCallbackRequest
pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...")
// Get ticker configuration
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH"
}
tickers := parseTickerSymbols(tickerConfig)
symbols := getSymbols()
// Try to connect and subscribe
err := connectAndSubscribe(tickers)
err := connectAndSubscribe(symbols)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in 10 seconds", err))
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(10, "reconnect", reconnectScheduleID)
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err))
}
@@ -4,6 +4,61 @@
"version": "1.0.0",
"description": "Real-time cryptocurrency price ticker using Coinbase WebSocket API",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
"config": {
"schema": {
"type": "object",
"properties": {
"symbols": {
"type": "array",
"title": "Trading Pairs",
"description": "Cryptocurrency trading pairs to track (default: BTC-USD)",
"items": {
"type": "string",
"title": "Trading Pair",
"pattern": "^[A-Z]{3,5}-[A-Z]{3,5}$",
"description": "Trading pair in the format BASE-QUOTE (e.g., BTC-USD, ETH-USD)"
},
"default": ["BTC-USD"]
},
"reconnectDelay": {
"type": "integer",
"title": "Reconnect Delay",
"description": "Delay in seconds before attempting to reconnect after connection loss",
"default": 5,
"minimum": 1,
"maximum": 60
},
"logPrices": {
"type": "boolean",
"title": "Log Prices",
"description": "Whether to log price updates to the server log",
"default": false
}
}
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/symbols"
},
{
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/reconnectDelay"
},
{
"type": "Control",
"scope": "#/properties/logPrices"
}
]
}
]
}
},
"permissions": {
"config": {
"reason": "To read ticker symbols configuration"
@@ -25,5 +25,74 @@
"artwork": {
"reason": "To get track artwork URLs for rich presence display"
}
},
"config": {
"schema": {
"type": "object",
"properties": {
"clientid": {
"type": "string",
"title": "Discord Application Client ID",
"description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications",
"minLength": 17,
"maxLength": 20,
"pattern": "^[0-9]+$"
},
"users": {
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
"default": [{}],
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Navidrome Username",
"description": "The Navidrome username to associate with this Discord token",
"minLength": 1
},
"token": {
"type": "string",
"title": "Discord Token",
"description": "The user's Discord token (keep this secret!)",
"minLength": 1
}
},
"required": ["username", "token"]
}
}
},
"required": ["clientid"]
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/clientid"
},
{
"type": "Control",
"scope": "#/properties/users",
"options": {
"elementLabelProp": "username",
"detail": {
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/username"
},
{
"type": "Control",
"scope": "#/properties/token"
}
]
}
}
}
]
}
}
}
@@ -8,12 +8,9 @@
//!
//! ## Configuration
//!
//! ```toml
//! [PluginConfig.discord-rich-presence-rs]
//! clientid = "YOUR_DISCORD_APPLICATION_ID"
//! "user.username1" = "discord_token1"
//! "user.username2" = "discord_token2"
//! ```
//! Configure this plugin through the Navidrome UI with:
//! - Discord Application Client ID
//! - User tokens array mapping Navidrome usernames to Discord tokens
//!
//! **WARNING**: This plugin is for demonstration purposes only. Storing Discord tokens
//! in configuration files is not secure and may violate Discord's terms of service.
@@ -32,6 +29,7 @@ use nd_pdk::websocket::{
OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest,
TextMessageProvider,
};
use serde::Deserialize;
mod rpc;
@@ -48,7 +46,7 @@ nd_pdk::register_websocket_close!(DiscordPlugin);
// ============================================================================
const CLIENT_ID_KEY: &str = "clientid";
const USER_KEY_PREFIX: &str = "user.";
const USERS_KEY: &str = "users";
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity";
@@ -64,19 +62,31 @@ struct DiscordPlugin;
// Configuration
// ============================================================================
/// User token entry from the config schema
#[derive(Debug, Deserialize)]
struct UserToken {
username: String,
token: String,
}
fn get_config() -> Result<(String, std::collections::HashMap<String, String>), Error> {
let client_id = config::get(CLIENT_ID_KEY)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("missing clientid in configuration"))?;
// Get all user keys with the "user." prefix
let user_keys = config::keys(USER_KEY_PREFIX)?;
// Get users array from config (JSON format)
let users_json = config::get(USERS_KEY)?.unwrap_or_default();
let mut users = std::collections::HashMap::new();
for key in user_keys {
let username = key.strip_prefix(USER_KEY_PREFIX).unwrap_or(&key);
if let Some(token) = config::get(&key)?.filter(|s| !s.is_empty()) {
users.insert(username.to_string(), token);
if !users_json.is_empty() {
// Parse JSON array of user tokens
let user_tokens: Vec<UserToken> = serde_json::from_str(&users_json)
.map_err(|e| Error::msg(format!("failed to parse users config: {}", e)))?;
for user_token in user_tokens {
if !user_token.username.is_empty() && !user_token.token.is_empty() {
users.insert(user_token.username, user_token.token);
}
}
}
+29 -11
View File
@@ -11,6 +11,7 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
@@ -24,10 +25,16 @@ import (
// Configuration keys
const (
clientIDKey = "clientid"
userKeyPrefix = "user."
clientIDKey = "clientid"
usersKey = "users"
)
// userToken represents a user-token mapping from the config
type userToken struct {
Username string `json:"username"`
Token string `json:"token"`
}
// discordPlugin implements the scrobbler and scheduler interfaces.
type discordPlugin struct{}
@@ -49,24 +56,35 @@ func getConfig() (clientID string, users map[string]string, err error) {
return "", nil, nil
}
// Get all user keys with the "user." prefix
userKeys := host.ConfigKeys(userKeyPrefix)
if len(userKeys) == 0 {
// Get the users array from config
usersJSON, ok := pdk.GetConfig(usersKey)
if !ok || usersJSON == "" {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
// Parse the JSON array
var userTokens []userToken
if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err))
return clientID, nil, nil
}
if len(userTokens) == 0 {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
// Build the users map
users = make(map[string]string)
for _, key := range userKeys {
username := strings.TrimPrefix(key, userKeyPrefix)
token, exists := host.ConfigGet(key)
if exists && token != "" {
users[username] = token
for _, ut := range userTokens {
if ut.Username != "" && ut.Token != "" {
users[ut.Username] = ut.Token
}
}
if len(users) == 0 {
pdk.Log(pdk.LogWarn, "no users configured")
pdk.Log(pdk.LogWarn, "no valid users configured")
return clientID, nil, nil
}
@@ -29,5 +29,74 @@
"artwork": {
"reason": "To get track artwork URLs for rich presence display"
}
},
"config": {
"schema": {
"type": "object",
"properties": {
"clientid": {
"type": "string",
"title": "Discord Application Client ID",
"description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications",
"minLength": 17,
"maxLength": 20,
"pattern": "^[0-9]+$"
},
"users": {
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
"default": [{}],
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Navidrome Username",
"description": "The Navidrome username to associate with this Discord token",
"minLength": 1
},
"token": {
"type": "string",
"title": "Discord Token",
"description": "The user's Discord token (keep this secret!)",
"minLength": 1
}
},
"required": ["username", "token"]
}
}
},
"required": ["clientid"]
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/clientid"
},
{
"type": "Control",
"scope": "#/properties/users",
"options": {
"elementLabelProp": "username",
"detail": {
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/username"
},
{
"type": "Control",
"scope": "#/properties/token"
}
]
}
}
}
]
}
}
}