diff --git a/plugins/examples/Makefile b/plugins/examples/Makefile index 8dc5cd3ea..7a181a04a 100644 --- a/plugins/examples/Makefile +++ b/plugins/examples/Makefile @@ -5,6 +5,9 @@ PLUGINS := $(patsubst %/go.mod,%,$(wildcard */go.mod)) # Auto-discover Python plugins (folders containing plugin/__init__.py) PYTHON_PLUGINS := $(patsubst %/plugin/__init__.py,%,$(wildcard */plugin/__init__.py)) +# Auto-discover Rust plugins (folders containing Cargo.toml) +RUST_PLUGINS := $(patsubst %/Cargo.toml,%,$(wildcard */Cargo.toml)) + # Prefer tinygo if available, it produces smaller wasm binaries. TINYGO := $(shell command -v tinygo 2> /dev/null) EXTISM_PY := $(shell command -v extism-py 2> /dev/null) @@ -19,21 +22,28 @@ help: @echo "Available Python plugins:" @$(foreach p,$(PYTHON_PLUGINS),echo " $(p)";) @echo "" + @echo "Available Rust plugins:" + @$(foreach p,$(RUST_PLUGINS),echo " $(p)";) + @echo "" @echo "Usage:" @echo " make .wasm Build a specific plugin (e.g., make $(firstword $(PLUGINS)).wasm)" @echo " make all Build all plugins" @echo " make all-go Build all Go plugins" @echo " make all-python Build all Python plugins (requires extism-py)" + @echo " make all-rust Build all Rust plugins (requires cargo)" @echo " make clean Remove all built plugins" -all: all-go all-python +all: all-go all-python all-rust all-go: $(PLUGINS:%=%.wasm) all-python: $(PYTHON_PLUGINS:%=%.wasm) +all-rust: $(RUST_PLUGINS:%=%.wasm) + clean: - rm -f $(PLUGINS:%=%.wasm) $(PYTHON_PLUGINS:%=%.wasm) + rm -f $(PLUGINS:%=%.wasm) $(PYTHON_PLUGINS:%=%.wasm) $(RUST_PLUGINS:%=%.wasm) + $(foreach p,$(RUST_PLUGINS),cd $(p) && cargo clean 2>/dev/null || true;) %.wasm: %/*.go %/go.mod ifdef TINYGO @@ -50,3 +60,12 @@ ifndef EXTISM_PY $(error extism-py is not installed. Install from https://github.com/extism/python-pdk) endif cd $* && PYTHONPATH=plugin extism-py plugin/__init__.py -o ../$@ + +# 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 +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 $@ diff --git a/plugins/examples/README.md b/plugins/examples/README.md index 429178637..e8457651c 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -8,6 +8,7 @@ This folder contains example plugins for Navidrome that demonstrate how to build - [TinyGo](https://tinygo.org/getting-started/install/) (recommended) or Go 1.23+ (for Go plugins) - [extism-py](https://github.com/extism/python-pdk) (for Python plugins) +- [Rust](https://rustup.rs/) with `wasm32-unknown-unknown` target (for Rust plugins) - [Extism CLI](https://extism.org/docs/install) (optional, for testing) ### Build all plugins @@ -41,6 +42,7 @@ make clean | [discord-rich-presence](discord-rich-presence/) | Go | Discord Rich Presence integration using Scrobbler, WebSocket, Scheduler | | [coverartarchive-py](coverartarchive-py/) | Python | Album cover art from Cover Art Archive (Python example) | | [nowplaying-py](nowplaying-py/) | Python | Logs currently playing tracks using Scheduler and SubsonicAPI | +| [webhook-rs](webhook-rs/) | Rust | Sends HTTP webhooks on scrobble events (Rust example) | ## Testing with Extism CLI @@ -88,8 +90,8 @@ Agents = "lastfm,spotify,wikimedia" ## Creating Your Own Plugin The plugin system supports multiple languages. See the [minimal](minimal/) example for the simplest Go starting point, -[discord-rich-presence](discord-rich-presence/) for a more complete Go example with HTTP requests, or [coverartarchive-py](coverartarchive-py/) -for a Python example. +[discord-rich-presence](discord-rich-presence/) for a more complete Go example with HTTP requests, [coverartarchive-py](coverartarchive-py/) +for a Python example, or [webhook-rs](webhook-rs/) for a Rust example. ### Bootstrapping a New Plugin Use the XTP CLI to bootstrap a new plugin from a schema: diff --git a/plugins/examples/webhook-rs/.cargo/config.toml b/plugins/examples/webhook-rs/.cargo/config.toml new file mode 100644 index 000000000..f4e8c002f --- /dev/null +++ b/plugins/examples/webhook-rs/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-unknown-unknown" diff --git a/plugins/examples/webhook-rs/.gitignore b/plugins/examples/webhook-rs/.gitignore new file mode 100644 index 000000000..8f0c20473 --- /dev/null +++ b/plugins/examples/webhook-rs/.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/webhook-rs/Cargo.toml b/plugins/examples/webhook-rs/Cargo.toml new file mode 100644 index 000000000..69c38b12b --- /dev/null +++ b/plugins/examples/webhook-rs/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "webhook-rs" +version = "1.0.0" +edition = "2021" +description = "Navidrome webhook plugin that sends HTTP requests on scrobble events" +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/webhook-rs/README.md b/plugins/examples/webhook-rs/README.md new file mode 100644 index 000000000..0295464cd --- /dev/null +++ b/plugins/examples/webhook-rs/README.md @@ -0,0 +1,88 @@ +# Webhook Scrobbler Plugin (Rust) + +A Navidrome plugin written in Rust that sends HTTP webhook notifications when tracks are scrobbled. This is useful for integrating with external services like home automation systems, Discord bots, monitoring tools, or any service that can receive HTTP requests. + +## Features + +- Sends HTTP GET requests to configured URLs on every scrobble event +- Includes track metadata (title, artist, album, username, timestamp) as query parameters +- Supports multiple webhook URLs (comma-separated) +- All users are automatically authorized (no external service authentication required) +- Now playing events are ignored (webhooks fire only on completed scrobbles) + +## Prerequisites + +- [Rust](https://rustup.rs/) toolchain +- WebAssembly target: `rustup target add wasm32-unknown-unknown` + +## Building + +From the `plugins/examples` directory: + +```bash +make webhook-rs.wasm +``` + +Or build directly with cargo: + +```bash +cd webhook-rs +cargo build --release +cp target/wasm32-unknown-unknown/release/webhook_rs.wasm ../webhook-rs.wasm +``` + +## Installation + +Copy `webhook-rs.wasm` to your Navidrome plugins folder (configured via `Plugins.Folder` in your config). + +## Configuration + +Add the plugin configuration to your `navidrome.toml`: + +```toml +[Plugins] +Enabled = true +Folder = "/path/to/plugins" + +[PluginConfig.webhook-rs] +urls = "https://example.com/webhook,https://another.example.com/notify" +``` + +### Configuration Options + +| Key | Description | Example | +|--------|--------------------------------------|---------------------------------------------------------| +| `urls` | Comma-separated list of webhook URLs | `"https://example.com/hook1,https://example.com/hook2"` | + +## Webhook Request Format + +When a scrobble occurs, the plugin sends an HTTP GET request to each configured URL with the following query parameters: + +| Parameter | Description | +|-------------|-----------------------------------------------| +| `title` | Track title | +| `artist` | Track artist | +| `album` | Album name | +| `user` | Username who scrobbled | +| `timestamp` | Unix timestamp when the track started playing | + +Example request: +``` +GET https://example.com/webhook?title=Song%20Name&artist=Artist%20Name&album=Album%20Name&user=john×tamp=1703270400 +``` + +## Use Cases + +- **Home Automation**: Trigger lights or displays when music starts playing +- **Discord/Slack Notifications**: Post currently playing tracks to a channel +- **Logging/Analytics**: Track listening history in an external system +- **IFTTT/Zapier Integration**: Connect to thousands of services via webhook triggers + +## Development + +The plugin is built using the [Extism Rust PDK](https://github.com/extism/rust-pdk). Key exports: + +- `nd_manifest` - Returns plugin metadata and permissions +- `nd_scrobbler_is_authorized` - Always returns `true` (all users authorized) +- `nd_scrobbler_now_playing` - No-op (returns success without action) +- `nd_scrobbler_scrobble` - Sends webhooks to configured URLs diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs new file mode 100644 index 000000000..c4d49681e --- /dev/null +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -0,0 +1,221 @@ +//! Webhook Scrobbler Plugin for Navidrome +//! +//! This plugin demonstrates how to build a Navidrome plugin in Rust using the Extism PDK. +//! It implements the Scrobbler capability and sends HTTP GET requests to configured URLs +//! whenever a track is scrobbled. +//! +//! ## Configuration +//! +//! Set the `urls` config key to a comma-separated list of webhook URLs: +//! ```toml +//! [PluginConfig.webhook-rs] +//! urls = "https://example.com/webhook1,https://example.com/webhook2" +//! ``` + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// Manifest Types +// ============================================================================ + +#[derive(Serialize)] +struct Manifest { + name: String, + author: String, + version: String, + description: String, + website: Option, + permissions: Option, +} + +#[derive(Serialize)] +struct Permissions { + http: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct HttpPermission { + reason: String, + allowed_hosts: Vec, +} + +// ============================================================================ +// Scrobbler Types +// ============================================================================ + +#[derive(Deserialize)] +struct AuthInput { + user_id: String, + username: String, +} + +#[derive(Serialize)] +struct AuthOutput { + authorized: bool, +} + +#[derive(Deserialize)] +#[allow(dead_code)] // Fields are deserialized from JSON but not all are used +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, + #[serde(default)] + mbz_album_id: Option, + #[serde(default)] + mbz_artist_id: Option, +} + +#[derive(Deserialize)] +#[allow(dead_code)] // Fields are deserialized from JSON but not all are used +struct NowPlayingInput { + user_id: String, + username: String, + track: TrackInfo, + position: i32, +} + +#[derive(Deserialize)] +#[allow(dead_code)] // Fields are deserialized from JSON but not all are used +struct ScrobbleInput { + user_id: String, + username: String, + track: TrackInfo, + timestamp: i64, +} + +#[derive(Serialize, Default)] +struct ScrobblerOutput { + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_type: Option, +} + +// ============================================================================ +// Plugin Exports +// ============================================================================ + +/// Returns the plugin manifest with metadata and permissions. +#[plugin_fn] +pub fn nd_manifest() -> FnResult> { + let manifest = Manifest { + name: "Webhook Scrobbler".to_string(), + author: "Navidrome Team".to_string(), + version: "1.0.0".to_string(), + description: "Sends HTTP webhooks on scrobble events".to_string(), + website: Some( + "https://github.com/navidrome/navidrome/tree/master/plugins/examples/webhook-rs" + .to_string(), + ), + permissions: Some(Permissions { + http: Some(HttpPermission { + reason: "To send webhook notifications to configured URLs".to_string(), + allowed_hosts: vec!["*".to_string()], + }), + }), + }; + Ok(Json(manifest)) +} + +/// Checks if a user is authorized. This plugin authorizes all users. +#[plugin_fn] +pub fn nd_scrobbler_is_authorized(Json(input): Json) -> FnResult> { + info!( + "Authorization check for user: {} ({})", + input.username, input.user_id + ); + Ok(Json(AuthOutput { authorized: true })) +} + +/// Handles now playing notifications. This plugin ignores them (webhooks only on scrobble). +#[plugin_fn] +pub fn nd_scrobbler_now_playing(Json(input): Json) -> FnResult> { + info!( + "Now playing (ignored): {} - {} for user {}", + input.track.artist, input.track.title, input.username + ); + Ok(Json(ScrobblerOutput::default())) +} + +/// Handles scrobble events by sending HTTP GET requests to configured URLs. +#[plugin_fn] +pub fn nd_scrobbler_scrobble(Json(input): Json) -> FnResult> { + // Get configured URLs + let urls_config = match config::get("urls") { + Ok(Some(urls)) if !urls.is_empty() => urls, + _ => { + warn!("No webhook URLs configured. Set 'urls' in plugin config."); + return Ok(Json(ScrobblerOutput::default())); + } + }; + + info!( + "Scrobble: {} - {} by user {}", + input.track.artist, input.track.title, input.username + ); + + // Build query parameters + let query = format!( + "?title={}&artist={}&album={}&user={}×tamp={}", + urlencod(&input.track.title), + urlencod(&input.track.artist), + urlencod(&input.track.album), + urlencod(&input.username), + input.timestamp + ); + + // Send requests to each configured URL + for url in urls_config.split(',') { + let url = url.trim(); + if url.is_empty() { + continue; + } + + let full_url = format!("{}{}", url, query); + info!("Sending webhook to: {}", full_url); + + let req = HttpRequest::new(&full_url); + match http::request::<()>(&req, None) { + Ok(res) => { + let status = res.status_code(); + if status >= 200 && status < 300 { + info!("Webhook succeeded: {} (status {})", url, status); + } else { + warn!("Webhook returned non-2xx status: {} (status {})", url, status); + } + } + Err(e) => { + error!("Webhook failed for {}: {:?}", url, e); + } + } + } + + Ok(Json(ScrobblerOutput::default())) +} + +/// Simple URL encoding for query parameters. +fn urlencod(s: &str) -> String { + let mut result = String::with_capacity(s.len() * 3); + for c in s.chars() { + match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c), + ' ' => result.push_str("%20"), + _ => { + for b in c.to_string().as_bytes() { + result.push_str(&format!("%{:02X}", b)); + } + } + } + } + result +}