feat(plugins): add Library Inspector plugin for periodic library inspection and file size logging

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-28 15:32:29 -05:00
parent eef9599013
commit 766689428d
8 changed files with 518 additions and 10 deletions

View File

@ -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 |
---

View File

@ -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 $@

View File

@ -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

View File

@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"

View File

@ -0,0 +1,5 @@
# Rust build artifacts
/target/
# Cargo.lock is not needed for library crates (this is a cdylib)
Cargo.lock

View File

@ -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"

View File

@ -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

View File

@ -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<String>,
permissions: Option<Permissions>,
}
#[derive(Serialize)]
struct Permissions {
library: Option<LibraryPermission>,
scheduler: Option<SchedulerPermission>,
}
#[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<String>,
#[serde(default)]
mount_point: Option<String>,
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<Library>,
#[serde(default)]
error: Option<String>,
}
#[derive(Deserialize)]
struct LibraryGetAllLibrariesResponse {
result: Option<Vec<Library>>,
#[serde(default)]
error: Option<String>,
}
// ============================================================================
// 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<String>,
#[serde(default)]
error: Option<String>,
}
#[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<String>,
}
// ============================================================================
// Host Function Imports
// ============================================================================
#[host_fn]
extern "ExtismHost" {
fn library_getalllibraries(input: Json<serde_json::Value>) -> Json<LibraryGetAllLibrariesResponse>;
fn scheduler_schedulerecurring(input: Json<SchedulerScheduleRecurringRequest>) -> Json<SchedulerScheduleRecurringResponse>;
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Get all libraries from Navidrome
fn get_all_libraries() -> Result<Vec<Library>, String> {
let response: Json<LibraryGetAllLibrariesResponse> = 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<String, String> {
let request = SchedulerScheduleRecurringRequest {
cron_expression: cron.to_string(),
payload: payload.to_string(),
schedule_id: id.to_string(),
};
let response: Json<SchedulerScheduleRecurringResponse> = 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<Json<Manifest>> {
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<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
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<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(())
}