f1e75c40dc
* 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>
290 lines
11 KiB
Rust
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(())
|
|
}
|
|
}
|