refactor(plugins): update Discord Rich Presence and Library Inspector plugins to use nd-pdk for service calls and implement lifecycle management

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-30 17:04:37 -05:00
parent 2b2b673581
commit 3bfce4cc9c
10 changed files with 220 additions and 343 deletions

View File

@ -880,7 +880,7 @@ See [examples/](examples/) for complete working plugins:
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
| [library-inspector](examples/library-inspector/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |

View File

@ -13,7 +13,7 @@ This folder contains example plugins demonstrating various capabilities and lang
| [coverartarchive-py](coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive |
| [nowplaying-py](nowplaying-py/) | Python | Scheduler, SubsonicAPI | Now playing logger |
| [webhook-rs](webhook-rs/) | Rust | Scrobbler | HTTP webhook on scrobble |
| [library-inspector](library-inspector/) | Rust | Library, Scheduler | Periodic library stats logging |
| [library-inspector](library-inspector-rs/) | Rust | Library, Scheduler | Periodic library stats logging |
| [discord-rich-presence-rs](discord-rich-presence-rs/) | Rust | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration (Rust) |
## Building

View File

@ -1,7 +1,7 @@
//! Discord Rich Presence Plugin for Navidrome - Rust Implementation
//!
//! This plugin integrates Navidrome with Discord Rich Presence. It demonstrates how to:
//! - Use the generated nd-host wrappers for host service calls
//! - 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
@ -19,10 +19,29 @@
use extism_pdk::*;
use nd_pdk::host::{artwork, scheduler};
use serde::{Deserialize, Serialize};
use nd_pdk::scrobbler::{
Error as ScrobblerError, IsAuthorizedRequest, IsAuthorizedResponse, NowPlayingRequest,
ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER,
};
use nd_pdk::scheduler::{
Error as SchedulerError, SchedulerCallbackProvider, SchedulerCallbackRequest,
};
use nd_pdk::websocket::{
BinaryMessageProvider, CloseProvider, Error as WebSocketError, ErrorProvider,
OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest,
TextMessageProvider,
};
mod rpc;
// Register capabilities using PDK macros
nd_pdk::register_scrobbler!(DiscordPlugin);
nd_pdk::register_scheduler_callback!(DiscordPlugin);
nd_pdk::register_text_message!(DiscordPlugin);
nd_pdk::register_binary_message!(DiscordPlugin);
nd_pdk::register_error!(DiscordPlugin);
nd_pdk::register_close!(DiscordPlugin);
// ============================================================================
// Constants
// ============================================================================
@ -32,6 +51,14 @@ 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
// ============================================================================
@ -73,302 +100,167 @@ fn get_image_url(track_id: &str) -> String {
}
// ============================================================================
// Scrobbler Types
// Scrobbler Implementation
// ============================================================================
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthInput {
#[allow(dead_code)]
user_id: String,
username: String,
}
impl Scrobbler for DiscordPlugin {
fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<IsAuthorizedResponse, ScrobblerError> {
let (_, users) = match get_config() {
Ok(config) => config,
Err(e) => {
error!("Failed to get config: {:?}", e);
return Ok(IsAuthorizedResponse { authorized: false });
}
};
#[derive(Serialize)]
struct AuthOutput {
authorized: bool,
}
let authorized = users.contains_key(&req.username);
info!("IsAuthorized for user {}: {}", req.username, authorized);
Ok(IsAuthorizedResponse { authorized })
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct TrackInfo {
id: String,
title: String,
album: String,
artist: String,
album_artist: String,
duration: f32,
track_number: i32,
disc_number: i32,
#[serde(default)]
mbz_recording_id: Option<String>,
#[serde(default)]
mbz_album_id: Option<String>,
#[serde(default)]
mbz_artist_id: Option<String>,
}
fn now_playing(&self, req: NowPlayingRequest) -> Result<(), ScrobblerError> {
info!(
"Setting presence for user {}, track: {}",
req.username, req.track.title
);
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct NowPlayingInput {
user_id: String,
username: String,
track: TrackInfo,
position: i32,
}
// Load configuration
let (client_id, users) = get_config()
.map_err(|e| ScrobblerError::new(format!("{}: failed to get config: {:?}", SCROBBLER_ERROR_RETRY_LATER, e)))?;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct ScrobbleInput {
user_id: String,
username: String,
track: TrackInfo,
timestamp: i64,
}
// 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
))
})?;
const ERROR_TYPE_NOT_AUTHORIZED: &str = "not_authorized";
const ERROR_TYPE_RETRY_LATER: &str = "retry_later";
// ============================================================================
// Scheduler Callback Types
// ============================================================================
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct SchedulerCallbackInput {
schedule_id: String,
payload: String,
is_recurring: bool,
}
// ============================================================================
// WebSocket Callback Types
// ============================================================================
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct OnTextMessageInput {
connection_id: String,
message: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct OnBinaryMessageInput {
connection_id: String,
message: Vec<u8>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct OnErrorInput {
connection_id: String,
error: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct OnCloseInput {
connection_id: String,
code: i32,
reason: String,
}
// ============================================================================
// Scrobbler Plugin Exports
// ============================================================================
/// Checks if a user is authorized for Discord Rich Presence.
#[plugin_fn]
pub fn nd_scrobbler_is_authorized(Json(input): Json<AuthInput>) -> FnResult<Json<AuthOutput>> {
let (_, users) = match get_config() {
Ok(config) => config,
Err(e) => {
error!("Failed to get config: {:?}", e);
return Ok(Json(AuthOutput { authorized: false }));
}
};
let authorized = users.contains_key(&input.username);
info!(
"IsAuthorized for user {}: {}",
input.username, authorized
);
Ok(Json(AuthOutput { authorized }))
}
/// Sends a now playing notification to Discord.
#[plugin_fn]
pub fn nd_scrobbler_now_playing(Json(input): Json<NowPlayingInput>) -> FnResult<()> {
info!(
"Setting presence for user {}, track: {}",
input.username, input.track.title
);
// Load configuration
let (client_id, users) = match get_config() {
Ok(config) => config,
Err(e) => {
return Err(WithReturnCode::new(
Error::msg(format!("{}: failed to get config: {:?}", ERROR_TYPE_RETRY_LATER, e)),
-1,
));
}
};
// Check authorization
let user_token = match users.get(&input.username) {
Some(token) => token.clone(),
None => {
return Err(WithReturnCode::new(
Error::msg(format!(
"{}: user '{}' not authorized",
ERROR_TYPE_NOT_AUTHORIZED, input.username
)),
-1,
));
}
};
// Connect to Discord
if let Err(e) = rpc::connect(&input.username, &user_token) {
return Err(WithReturnCode::new(
Error::msg(format!(
// Connect to Discord
rpc::connect(&req.username, &user_token)
.map_err(|e| ScrobblerError::new(format!(
"{}: failed to connect to Discord: {:?}",
ERROR_TYPE_RETRY_LATER, e
)),
-1,
));
}
SCROBBLER_ERROR_RETRY_LATER, e
)))?;
// Cancel any existing completion schedule
let _ = scheduler::cancel_schedule(&format!("{}-clear", input.username));
// 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 - input.position as i64) * 1000;
let end_time = start_time + (input.track.duration as i64) * 1000;
// 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
if let Err(e) = rpc::send_activity(
&client_id,
&input.username,
&user_token,
rpc::Activity {
application: client_id.clone(),
name: "Navidrome".to_string(),
activity_type: 2, // Listening
details: input.track.title.clone(),
state: input.track.artist.clone(),
timestamps: rpc::ActivityTimestamps {
start: start_time,
end: end_time,
// 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(),
},
},
assets: rpc::ActivityAssets {
large_image: get_image_url(&input.track.id),
large_text: input.track.album.clone(),
},
},
) {
return Err(WithReturnCode::new(
Error::msg(format!(
"{}: failed to send activity: {:?}",
ERROR_TYPE_RETRY_LATER, e
)),
-1,
));
)
.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(())
}
// Schedule a timer to clear the activity after the track completes
let remaining_seconds = (input.track.duration as i32) - input.position + 5;
if let Err(e) = scheduler::schedule_one_time(
remaining_seconds,
PAYLOAD_CLEAR_ACTIVITY,
&format!("{}-clear", input.username),
) {
warn!("Failed to schedule completion timer: {:?}", e);
fn scrobble(&self, _req: ScrobbleRequest) -> Result<(), ScrobblerError> {
// Discord Rich Presence doesn't need scrobble events - success
Ok(())
}
Ok(())
}
/// Handles scrobble requests (no-op for Discord Rich Presence).
#[plugin_fn]
pub fn nd_scrobbler_scrobble(_input: Json<ScrobbleInput>) -> FnResult<()> {
// Discord Rich Presence doesn't need scrobble events - success
Ok(())
}
// ============================================================================
// Scheduler Callback Export
// Scheduler Callback Implementation
// ============================================================================
/// Handles scheduler callbacks for heartbeat and activity clearing.
#[plugin_fn]
pub fn nd_scheduler_callback(Json(input): Json<SchedulerCallbackInput>) -> FnResult<()> {
match input.payload.as_str() {
PAYLOAD_HEARTBEAT => {
// Heartbeat callback - schedule_id is the username
rpc::handle_heartbeat_callback(&input.schedule_id)?;
}
PAYLOAD_CLEAR_ACTIVITY => {
// Clear activity callback - schedule_id is "username-clear"
let username = input.schedule_id.trim_end_matches("-clear");
rpc::handle_clear_activity_callback(username)?;
}
_ => {
warn!("Unknown scheduler callback payload: {}", input.payload);
impl SchedulerCallbackProvider for DiscordPlugin {
fn on_scheduler_callback(&self, req: SchedulerCallbackRequest) -> Result<(), SchedulerError> {
match req.payload.as_str() {
PAYLOAD_HEARTBEAT => {
// Heartbeat callback - schedule_id is the username
rpc::handle_heartbeat_callback(&req.schedule_id)
.map_err(|e| SchedulerError::new(e.to_string()))?;
}
PAYLOAD_CLEAR_ACTIVITY => {
// Clear activity callback - schedule_id is "username-clear"
let username = req.schedule_id.trim_end_matches("-clear");
rpc::handle_clear_activity_callback(username)
.map_err(|e| SchedulerError::new(e.to_string()))?;
}
_ => {
warn!("Unknown scheduler callback payload: {}", req.payload);
}
}
Ok(())
}
Ok(())
}
// ============================================================================
// WebSocket Callback Exports
// WebSocket Callback Implementations
// ============================================================================
/// Handles incoming WebSocket text messages.
#[plugin_fn]
pub fn nd_websocket_on_text_message(Json(input): Json<OnTextMessageInput>) -> FnResult<()> {
rpc::handle_websocket_message(&input.connection_id, &input.message)?;
Ok(())
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(())
}
}
/// Handles incoming WebSocket binary messages.
#[plugin_fn]
pub fn nd_websocket_on_binary_message(Json(_input): Json<OnBinaryMessageInput>) -> FnResult<()> {
// Binary messages are not expected from Discord
Ok(())
impl BinaryMessageProvider for DiscordPlugin {
fn on_binary_message(&self, _req: OnBinaryMessageRequest) -> Result<(), WebSocketError> {
// Binary messages are not expected from Discord
Ok(())
}
}
/// Handles WebSocket errors.
#[plugin_fn]
pub fn nd_websocket_on_error(Json(input): Json<OnErrorInput>) -> FnResult<()> {
warn!(
"WebSocket error for connection '{}': {}",
input.connection_id, input.error
);
Ok(())
impl ErrorProvider for DiscordPlugin {
fn on_error(&self, req: OnErrorRequest) -> Result<(), WebSocketError> {
warn!(
"WebSocket error for connection '{}': {}",
req.connection_id, req.error
);
Ok(())
}
}
/// Handles WebSocket connection closure.
#[plugin_fn]
pub fn nd_websocket_on_close(Json(input): Json<OnCloseInput>) -> FnResult<()> {
info!(
"WebSocket connection '{}' closed with code {}: {}",
input.connection_id, input.code, input.reason
);
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
);
Ok(())
}
}

View File

@ -1,5 +1,5 @@
[package]
name = "library-inspector"
name = "library-inspector-rs"
version = "1.0.0"
edition = "2021"
description = "Navidrome plugin that periodically logs library details and finds largest files"

View File

@ -1,8 +1,8 @@
//! Library Inspector Plugin for Navidrome
//!
//! This plugin demonstrates how to use the nd-host library for accessing Navidrome
//! host services in Rust. It periodically logs details about all music libraries
//! and finds the largest file in the root of each library directory.
//! This plugin demonstrates how to use the nd-pdk crate for accessing Navidrome
//! host services and implementing capabilities in Rust. It periodically logs details
//! about all music libraries and finds the largest file in the root of each library.
//!
//! ## Configuration
//!
@ -14,29 +14,67 @@
use extism_pdk::*;
use nd_pdk::host::{library, scheduler};
use serde::{Deserialize, Serialize};
use nd_pdk::lifecycle::{Error as LifecycleError, InitProvider};
use nd_pdk::scheduler::{Error as SchedulerError, SchedulerCallbackProvider, SchedulerCallbackRequest};
use std::fs;
// Register capabilities using PDK macros
nd_pdk::register_init!(LibraryInspector);
nd_pdk::register_scheduler_callback!(LibraryInspector);
// ============================================================================
// Scheduler Types
// Plugin Implementation
// ============================================================================
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SchedulerCallbackInput {
schedule_id: String,
payload: String,
is_recurring: bool,
/// The library inspector plugin type.
#[derive(Default)]
struct LibraryInspector;
impl InitProvider for LibraryInspector {
fn on_init(&self) -> Result<(), LifecycleError> {
info!("Library Inspector plugin initializing...");
// Get cron expression from config, default to every minute
let cron = config::get("cron")
.ok()
.flatten()
.unwrap_or_else(|| "@every 1m".to_string());
info!("Scheduling library inspection with cron: {}", cron);
// Schedule the recurring task using nd-pdk host scheduler
match scheduler::schedule_recurring(&cron, "inspect", "library-inspect") {
Ok(schedule_id) => {
info!("Scheduled inspection task with ID: {}", schedule_id);
}
Err(e) => {
let error_msg = format!("Failed to schedule inspection: {}", e);
error!("{}", error_msg);
return Err(LifecycleError::new(error_msg));
}
}
// Run an initial inspection
inspect_libraries();
info!("Library Inspector plugin initialized successfully");
Ok(())
}
}
// ============================================================================
// Lifecycle Types
// ============================================================================
impl SchedulerCallbackProvider for LibraryInspector {
fn on_scheduler_callback(&self, req: SchedulerCallbackRequest) -> Result<(), SchedulerError> {
info!(
"Scheduler callback fired: schedule_id={}, payload={}, recurring={}",
req.schedule_id, req.payload, req.is_recurring
);
#[derive(Serialize, Default)]
struct InitOutput {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
if req.payload == "inspect" {
inspect_libraries();
}
Ok(())
}
}
// ============================================================================
@ -90,7 +128,7 @@ fn find_largest_file(mount_point: &str) -> Option<(String, u64)> {
for entry in entries.flatten() {
let path = entry.path();
// Only consider files, not directories
if !path.is_file() {
continue;
@ -167,56 +205,3 @@ fn inspect_libraries() {
info!("=== Library Inspection Complete ===");
}
// ============================================================================
// Plugin Exports
// ============================================================================
/// Called when the plugin is initialized. Schedules the recurring inspection task.
#[plugin_fn]
pub fn nd_on_init() -> FnResult<Json<InitOutput>> {
info!("Library Inspector plugin initializing...");
// Get cron expression from config, default to every minute
let cron = config::get("cron")
.ok()
.flatten()
.unwrap_or_else(|| "@every 1m".to_string());
info!("Scheduling library inspection with cron: {}", cron);
// Schedule the recurring task using nd-host scheduler
match scheduler::schedule_recurring(&cron, "inspect", "library-inspect") {
Ok(schedule_id) => {
info!("Scheduled inspection task with ID: {}", schedule_id);
}
Err(e) => {
let error_msg = format!("Failed to schedule inspection: {}", e);
error!("{}", error_msg);
return Ok(Json(InitOutput {
error: Some(error_msg),
}));
}
}
// Run an initial inspection
inspect_libraries();
info!("Library Inspector plugin initialized successfully");
Ok(Json(InitOutput::default()))
}
/// Called when a scheduled task fires.
#[plugin_fn]
pub fn nd_scheduler_callback(Json(input): Json<SchedulerCallbackInput>) -> FnResult<()> {
info!(
"Scheduler callback fired: schedule_id={}, payload={}, recurring={}",
input.schedule_id, input.payload, input.is_recurring
);
if input.payload == "inspect" {
inspect_libraries();
}
Ok(())
}

View File

@ -132,7 +132,7 @@ See the example plugins for complete implementations:
- [webhook-rs](../../examples/webhook-rs/) - Simple scrobbler using the PDK
- [discord-rich-presence-rs](../../examples/discord-rich-presence-rs/) - Complex plugin with multiple capabilities
- [library-inspector](../../examples/library-inspector/) - Host service demonstration
- [library-inspector](../../examples/library-inspector-rs/) - Host service demonstration
## Code Generation