diff --git a/plugins/README.md b/plugins/README.md index b6611d1f4..d8761c282 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -645,13 +645,14 @@ Generated SDKs for calling host services are in `plugins/host/go/` and `plugins/ See [examples/](examples/) for complete working plugins: -| Plugin | Language | Capabilities | Description | -|----------------------------------------------------------|----------|---------------------------------|--------------------------------| -| [minimal](examples/minimal/) | Go | MetadataAgent | Basic structure example | -| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia integration | -| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler, Scheduler, WebSocket | Discord integration | -| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive | -| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP webhooks | +| Plugin | Language | Capabilities | Description | +|----------------------------------------------------------|----------|------------------------------------------|--------------------------------| +| [minimal](examples/minimal/) | Go | MetadataAgent | Basic structure example | +| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia integration | +| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler, Scheduler, WebSocket | Discord integration | +| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive | +| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP webhooks | +| [library-inspector](examples/library-inspector/) | Rust | Library, Scheduler | Periodic library stats logging | --- diff --git a/plugins/examples/Makefile b/plugins/examples/Makefile index 7a181a04a..0a6b063e8 100644 --- a/plugins/examples/Makefile +++ b/plugins/examples/Makefile @@ -63,9 +63,10 @@ endif # Rust plugin builds (generic rule for any folder with Cargo.toml) # Note: Rust crate names use underscores, but plugin names use hyphens -# Uses rustup's toolchain to ensure wasm32-unknown-unknown target is available +# Uses the target specified in each plugin's .cargo/config.toml (defaults to wasm32-unknown-unknown) RUSTUP_CARGO := $(shell rustup which cargo 2>/dev/null || echo cargo) RUSTUP_RUSTC := $(shell rustup which rustc 2>/dev/null) $(RUST_PLUGINS:%=%.wasm): %.wasm: %/Cargo.toml $$(wildcard %/src/*.rs) - cd $* && CARGO_BUILD_RUSTC=$(RUSTUP_RUSTC) $(RUSTUP_CARGO) build --release --target wasm32-unknown-unknown - cp $*/target/wasm32-unknown-unknown/release/$(subst -,_,$*).wasm $@ + $(eval RUST_TARGET := $(shell grep -A1 '^\[build\]' $*/.cargo/config.toml 2>/dev/null | grep 'target' | sed 's/.*= *"\([^"]*\)".*/\1/' || echo "wasm32-unknown-unknown")) + cd $* && CARGO_BUILD_RUSTC=$(RUSTUP_RUSTC) $(RUSTUP_CARGO) build --release --target $(RUST_TARGET) + cp $*/target/$(RUST_TARGET)/release/$(subst -,_,$*).wasm $@ diff --git a/plugins/examples/README.md b/plugins/examples/README.md index d5ea6e273..944482267 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -13,6 +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 | ## Building diff --git a/plugins/examples/library-inspector/.cargo/config.toml b/plugins/examples/library-inspector/.cargo/config.toml new file mode 100644 index 000000000..6b509f5b7 --- /dev/null +++ b/plugins/examples/library-inspector/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" diff --git a/plugins/examples/library-inspector/.gitignore b/plugins/examples/library-inspector/.gitignore new file mode 100644 index 000000000..8f0c20473 --- /dev/null +++ b/plugins/examples/library-inspector/.gitignore @@ -0,0 +1,5 @@ +# Rust build artifacts +/target/ + +# Cargo.lock is not needed for library crates (this is a cdylib) +Cargo.lock \ No newline at end of file diff --git a/plugins/examples/library-inspector/Cargo.toml b/plugins/examples/library-inspector/Cargo.toml new file mode 100644 index 000000000..801f70521 --- /dev/null +++ b/plugins/examples/library-inspector/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "library-inspector" +version = "1.0.0" +edition = "2021" +description = "Navidrome plugin that periodically logs library details and finds largest files" +authors = ["Navidrome Team"] +license = "GPL-3.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +extism-pdk = "1.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/plugins/examples/library-inspector/README.md b/plugins/examples/library-inspector/README.md new file mode 100644 index 000000000..d87e64b74 --- /dev/null +++ b/plugins/examples/library-inspector/README.md @@ -0,0 +1,105 @@ +# Library Inspector Plugin + +A Navidrome plugin written in Rust that demonstrates the Library host service. It periodically logs details about all configured music libraries and finds the largest file in the root of each library directory. + +## Features + +- Logs comprehensive library statistics (songs, albums, artists, size, duration) +- Lists the largest file found in each library's root directory +- Configurable inspection interval via cron expression +- Runs an initial inspection on plugin load + +## Requirements + +- Rust toolchain with `wasm32-wasip1` target +- Navidrome with plugins enabled + +## Building + +```bash +# Install the WASM target if you haven't already +rustup target add wasm32-wasip1 + +# Build the plugin +cargo build --target wasm32-wasip1 --release + +# The output will be at target/wasm32-wasip1/release/library_inspector.wasm +``` + +Or use the provided Makefile from the examples directory: + +```bash +cd plugins/examples +make library-inspector.wasm +``` + +## Installation + +1. Copy the `.wasm` file to your Navidrome plugins folder +2. Enable plugins in your Navidrome configuration: + +```toml +[Plugins] +Enabled = true +Folder = "/path/to/plugins" +``` + +3. Restart Navidrome and enable the plugin in the UI + +## Configuration + +Configure the inspection interval in the Navidrome UI or via config file: + +```toml +[PluginConfig.library-inspector] +cron = "@every 5m" +``` + +### Cron Expression Examples + +| Expression | Description | +|------------|-------------| +| `@every 1m` | Every minute (default) | +| `@every 5m` | Every 5 minutes | +| `@every 1h` | Every hour | +| `@hourly` | Every hour at minute 0 | +| `@daily` | Every day at midnight | +| `0 */6 * * *` | Every 6 hours | +| `0 9 * * *` | Daily at 9:00 AM | + +## Permissions + +This plugin requires: + +- **Library** (with filesystem): To read library metadata and scan directories +- **Scheduler**: To schedule periodic inspections + +## Example Output + +``` +=== Library Inspection Started === +Found 2 libraries +---------------------------------------- +Library: My Music (ID: 1) + Songs: 5432 tracks + Albums: 456 + Artists: 234 + Size: 45.67 GB + Duration: 312h 45m + Mount: /libraries/1 + Largest file in root: cover.jpg (2.34 MB) +---------------------------------------- +Library: Podcasts (ID: 2) + Songs: 128 tracks + Albums: 12 + Artists: 8 + Size: 3.21 GB + Duration: 48h 15m + Mount: /libraries/2 + Largest file in root: episode-001.mp3 (156.78 MB) +=== Library Inspection Complete === +``` + +## License + +GPL-3.0 - Same as Navidrome diff --git a/plugins/examples/library-inspector/src/lib.rs b/plugins/examples/library-inspector/src/lib.rs new file mode 100644 index 000000000..de21182ce --- /dev/null +++ b/plugins/examples/library-inspector/src/lib.rs @@ -0,0 +1,378 @@ +//! Library Inspector Plugin for Navidrome +//! +//! This plugin demonstrates how to use the Library host service in Rust. +//! It periodically logs details about all music libraries and finds the largest +//! file in the root of each library directory. +//! +//! ## Configuration +//! +//! Set the `cron` config key to customize the schedule (default: "@every 1m"): +//! ```toml +//! [PluginConfig.library-inspector] +//! cron = "@every 5m" +//! ``` + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; +use std::fs; + +// ============================================================================ +// Manifest Types +// ============================================================================ + +#[derive(Serialize)] +struct Manifest { + name: String, + author: String, + version: String, + description: String, + website: Option, + permissions: Option, +} + +#[derive(Serialize)] +struct Permissions { + library: Option, + scheduler: Option, +} + +#[derive(Serialize)] +struct LibraryPermission { + reason: String, + filesystem: bool, +} + +#[derive(Serialize)] +struct SchedulerPermission { + reason: String, +} + +// ============================================================================ +// Library Types +// ============================================================================ + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +struct Library { + id: i32, + name: String, + #[serde(default)] + path: Option, + #[serde(default)] + mount_point: Option, + last_scan_at: i64, + total_songs: i32, + total_albums: i32, + total_artists: i32, + total_size: i64, + total_duration: f64, +} + +#[derive(Serialize)] +struct LibraryGetLibraryRequest { + id: i32, +} + +#[derive(Deserialize)] +struct LibraryGetLibraryResponse { + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct LibraryGetAllLibrariesResponse { + result: Option>, + #[serde(default)] + error: Option, +} + +// ============================================================================ +// Scheduler Types +// ============================================================================ + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SchedulerScheduleRecurringRequest { + cron_expression: String, + payload: String, + schedule_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SchedulerScheduleRecurringResponse { + #[serde(default)] + new_schedule_id: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct SchedulerCallbackInput { + schedule_id: String, + payload: String, + is_recurring: bool, +} + +// ============================================================================ +// Lifecycle Types +// ============================================================================ + +#[derive(Serialize, Default)] +struct InitOutput { + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +// ============================================================================ +// Host Function Imports +// ============================================================================ + +#[host_fn] +extern "ExtismHost" { + fn library_getalllibraries(input: Json) -> Json; + fn scheduler_schedulerecurring(input: Json) -> Json; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Get all libraries from Navidrome +fn get_all_libraries() -> Result, String> { + let response: Json = unsafe { + library_getalllibraries(Json(serde_json::json!({}))) + .map_err(|e| format!("Failed to call library_getalllibraries: {:?}", e))? + }; + + if let Some(err) = response.0.error { + return Err(err); + } + + Ok(response.0.result.unwrap_or_default()) +} + +/// Schedule a recurring task +fn schedule_recurring(cron: &str, payload: &str, id: &str) -> Result { + let request = SchedulerScheduleRecurringRequest { + cron_expression: cron.to_string(), + payload: payload.to_string(), + schedule_id: id.to_string(), + }; + + let response: Json = unsafe { + scheduler_schedulerecurring(Json(request)) + .map_err(|e| format!("Failed to schedule task: {:?}", e))? + }; + + if let Some(err) = response.0.error { + return Err(err); + } + + Ok(response.0.new_schedule_id.unwrap_or_default()) +} + +/// Format bytes into human-readable size +fn format_size(bytes: i64) -> String { + const KB: i64 = 1024; + const MB: i64 = KB * 1024; + const GB: i64 = MB * 1024; + const TB: i64 = GB * 1024; + + if bytes >= TB { + format!("{:.2} TB", bytes as f64 / TB as f64) + } else if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} bytes", bytes) + } +} + +/// Format duration in seconds to human-readable format +fn format_duration(seconds: f64) -> String { + let total_seconds = seconds as i64; + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + + if hours > 0 { + format!("{}h {}m", hours, minutes) + } else { + format!("{}m", minutes) + } +} + +/// Find the largest file in a directory (non-recursive) +fn find_largest_file(mount_point: &str) -> Option<(String, u64)> { + let entries = match fs::read_dir(mount_point) { + Ok(entries) => entries, + Err(e) => { + warn!("Failed to read directory {}: {}", mount_point, e); + return None; + } + }; + + let mut largest: Option<(String, u64)> = None; + + for entry in entries.flatten() { + let path = entry.path(); + + // Only consider files, not directories + if !path.is_file() { + continue; + } + + let metadata = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + + let size = metadata.len(); + let name = entry.file_name().to_string_lossy().to_string(); + + match &largest { + None => largest = Some((name, size)), + Some((_, current_size)) if size > *current_size => { + largest = Some((name, size)); + } + _ => {} + } + } + + largest +} + +/// Inspect and log all library details +fn inspect_libraries() { + info!("=== Library Inspection Started ==="); + + let libraries = match get_all_libraries() { + Ok(libs) => libs, + Err(e) => { + error!("Failed to get libraries: {}", e); + return; + } + }; + + if libraries.is_empty() { + info!("No libraries configured"); + return; + } + + info!("Found {} libraries", libraries.len()); + + for lib in &libraries { + info!("----------------------------------------"); + info!("Library: {} (ID: {})", lib.name, lib.id); + info!(" Songs: {} tracks", lib.total_songs); + info!(" Albums: {}", lib.total_albums); + info!(" Artists: {}", lib.total_artists); + info!(" Size: {}", format_size(lib.total_size)); + info!(" Duration: {}", format_duration(lib.total_duration)); + + // If we have filesystem access, find the largest file + if let Some(mount_point) = &lib.mount_point { + info!(" Mount: {}", mount_point); + + match find_largest_file(mount_point) { + Some((name, size)) => { + info!( + " Largest file in root: {} ({})", + name, + format_size(size as i64) + ); + } + None => { + info!(" Largest file in root: (no files found)"); + } + } + } else { + info!(" (Filesystem access not enabled)"); + } + } + + info!("=== Library Inspection Complete ==="); +} + +// ============================================================================ +// Plugin Exports +// ============================================================================ + +/// Returns the plugin manifest with metadata and permissions. +#[plugin_fn] +pub fn nd_manifest() -> FnResult> { + let manifest = Manifest { + name: "Library Inspector".to_string(), + author: "Navidrome Team".to_string(), + version: "1.0.0".to_string(), + description: "Periodically logs library details and finds largest files".to_string(), + website: Some( + "https://github.com/navidrome/navidrome/tree/master/plugins/examples/library-inspector" + .to_string(), + ), + permissions: Some(Permissions { + library: Some(LibraryPermission { + reason: "To read library metadata and scan directories for file sizes".to_string(), + filesystem: true, + }), + scheduler: Some(SchedulerPermission { + reason: "To schedule periodic library inspections".to_string(), + }), + }), + }; + Ok(Json(manifest)) +} + +/// Called when the plugin is initialized. Schedules the recurring inspection task. +#[plugin_fn] +pub fn nd_on_init() -> FnResult> { + 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 + match 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) -> FnResult<()> { + info!( + "Scheduler callback fired: schedule_id={}, payload={}, recurring={}", + input.schedule_id, input.payload, input.is_recurring + ); + + if input.payload == "inspect" { + inspect_libraries(); + } + + Ok(()) +}