Files
navidrome/plugins/examples/discord-rich-presence-rs/src/lib.rs
T
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

290 lines
11 KiB
Rust

//! Discord Rich Presence Plugin for Navidrome - Rust Implementation
//!
//! This plugin integrates Navidrome with Discord Rich Presence. It demonstrates how to:
//! - Use the nd-pdk crate for host service calls
//! - Implement the Scrobbler capability for now-playing updates
//! - Implement SchedulerCallback for heartbeat and activity clearing
//! - Implement WebSocketCallback for Discord gateway communication
//!
//! ## Configuration
//!
//! 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.
use extism_pdk::*;
use nd_pdk::host::{artwork, config, scheduler};
use nd_pdk::scrobbler::{
Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest,
ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER,
};
use nd_pdk::scheduler::{
CallbackProvider, Error as SchedulerError, SchedulerCallbackRequest,
};
use nd_pdk::websocket::{
BinaryMessageProvider, CloseProvider, Error as WebSocketError, ErrorProvider,
OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest,
TextMessageProvider,
};
use serde::Deserialize;
mod rpc;
// Register capabilities using PDK macros
nd_pdk::register_scrobbler!(DiscordPlugin);
nd_pdk::register_scheduler_callback!(DiscordPlugin);
nd_pdk::register_websocket_text_message!(DiscordPlugin);
nd_pdk::register_websocket_binary_message!(DiscordPlugin);
nd_pdk::register_websocket_error!(DiscordPlugin);
nd_pdk::register_websocket_close!(DiscordPlugin);
// ============================================================================
// Constants
// ============================================================================
const CLIENT_ID_KEY: &str = "clientid";
const USERS_KEY: &str = "users";
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity";
// ============================================================================
// Plugin Implementation
// ============================================================================
/// The Discord Rich Presence plugin type.
#[derive(Default)]
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 users array from config (JSON format)
let users_json = config::get(USERS_KEY)?.unwrap_or_default();
let mut users = std::collections::HashMap::new();
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);
}
}
}
Ok((client_id, users))
}
fn get_image_url(track_id: &str) -> String {
match artwork::get_track_url(track_id, 300) {
Ok(url) => {
if url.starts_with("http://localhost") {
String::new()
} else {
url
}
}
Err(e) => {
warn!("Failed to get artwork URL: {:?}", e);
String::new()
}
}
}
// ============================================================================
// Scrobbler Implementation
// ============================================================================
impl Scrobbler for DiscordPlugin {
fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, ScrobblerError> {
let (_, users) = match get_config() {
Ok(config) => config,
Err(e) => {
error!("Failed to get config: {:?}", e);
return Ok(false);
}
};
let authorized = users.contains_key(&req.username);
info!("IsAuthorized for user {}: {}", req.username, authorized);
Ok(authorized)
}
fn now_playing(&self, req: NowPlayingRequest) -> Result<(), ScrobblerError> {
info!(
"Setting presence for user {}, track: {}",
req.username, req.track.title
);
// Load configuration
let (client_id, users) = get_config()
.map_err(|e| ScrobblerError::new(format!("{}: failed to get config: {:?}", SCROBBLER_ERROR_RETRY_LATER, e)))?;
// Check authorization
let user_token = users.get(&req.username).cloned().ok_or_else(|| {
ScrobblerError::new(format!(
"{}: user '{}' not authorized",
SCROBBLER_ERROR_NOT_AUTHORIZED, req.username
))
})?;
// Connect to Discord
rpc::connect(&req.username, &user_token)
.map_err(|e| ScrobblerError::new(format!(
"{}: failed to connect to Discord: {:?}",
SCROBBLER_ERROR_RETRY_LATER, e
)))?;
// Cancel any existing completion schedule
let _ = scheduler::cancel_schedule(&format!("{}-clear", req.username));
// Calculate timestamps
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let start_time = (now - req.position as i64) * 1000;
let end_time = start_time + (req.track.duration as i64) * 1000;
// Send activity update
rpc::send_activity(
&client_id,
&req.username,
&user_token,
rpc::Activity {
application: client_id.clone(),
name: "Navidrome".to_string(),
activity_type: 2, // Listening
details: req.track.title.clone(),
state: req.track.artist.clone(),
timestamps: rpc::ActivityTimestamps {
start: start_time,
end: end_time,
},
assets: rpc::ActivityAssets {
large_image: get_image_url(&req.track.id),
large_text: req.track.album.clone(),
},
},
)
.map_err(|e| ScrobblerError::new(format!(
"{}: failed to send activity: {:?}",
SCROBBLER_ERROR_RETRY_LATER, e
)))?;
// Schedule a timer to clear the activity after the track completes
let remaining_seconds = (req.track.duration as i32) - req.position + 5;
if let Err(e) = scheduler::schedule_one_time(
remaining_seconds,
PAYLOAD_CLEAR_ACTIVITY,
&format!("{}-clear", req.username),
) {
warn!("Failed to schedule completion timer: {:?}", e);
}
Ok(())
}
fn scrobble(&self, _req: ScrobbleRequest) -> Result<(), ScrobblerError> {
// Discord Rich Presence doesn't need scrobble events - success
Ok(())
}
}
// ============================================================================
// Scheduler Callback Implementation
// ============================================================================
impl CallbackProvider for DiscordPlugin {
fn on_callback(&self, req: SchedulerCallbackRequest) -> Result<(), SchedulerError> {
match req.payload.as_str() {
PAYLOAD_HEARTBEAT => {
// Heartbeat callback - schedule_id is the username
if let Err(e) = rpc::handle_heartbeat_callback(&req.schedule_id) {
// On heartbeat failure, clean up the connection (like the original Go plugin)
// The next NowPlaying call will reconnect if needed
warn!("Heartbeat failed for user {}, cleaning up connection: {:?}", req.schedule_id, e);
rpc::cleanup_connection(&req.schedule_id);
return Err(SchedulerError::new(format!("heartbeat failed, connection cleaned up: {}", e)));
}
}
PAYLOAD_CLEAR_ACTIVITY => {
// Clear activity callback - schedule_id is "username-clear"
let username = req.schedule_id.trim_end_matches("-clear");
info!("Removing presence for user {}", username);
rpc::handle_clear_activity_callback(username)
.map_err(|e| SchedulerError::new(e.to_string()))?;
info!("Disconnecting user {}", username);
rpc::disconnect(username)
.map_err(|e| SchedulerError::new(e.to_string()))?;
}
_ => {
warn!("Unknown scheduler callback payload: {}", req.payload);
}
}
Ok(())
}
}
// ============================================================================
// WebSocket Callback Implementations
// ============================================================================
impl TextMessageProvider for DiscordPlugin {
fn on_text_message(&self, req: OnTextMessageRequest) -> Result<(), WebSocketError> {
rpc::handle_websocket_message(&req.connection_id, &req.message)
.map_err(|e| WebSocketError::new(e.to_string()))?;
Ok(())
}
}
impl BinaryMessageProvider for DiscordPlugin {
fn on_binary_message(&self, _req: OnBinaryMessageRequest) -> Result<(), WebSocketError> {
// Binary messages are not expected from Discord
Ok(())
}
}
impl ErrorProvider for DiscordPlugin {
fn on_error(&self, req: OnErrorRequest) -> Result<(), WebSocketError> {
warn!(
"WebSocket error for connection '{}': {}",
req.connection_id, req.error
);
// Clean up all state associated with this connection since it's likely broken
rpc::handle_connection_close(&req.connection_id);
Ok(())
}
}
impl CloseProvider for DiscordPlugin {
fn on_close(&self, req: OnCloseRequest) -> Result<(), WebSocketError> {
info!(
"WebSocket connection '{}' closed with code {}: {}",
req.connection_id, req.code, req.reason
);
// Clean up all state associated with this connection
rpc::handle_connection_close(&req.connection_id);
Ok(())
}
}