feat: add Webhook Scrobbler plugin in Rust to send HTTP notifications on scrobble events

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-26 21:07:54 -05:00
parent 9fbcf6ceb3
commit 980df67445
7 changed files with 356 additions and 4 deletions

View File

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

View File

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

View File

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

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

View File

@ -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&timestamp=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

View File

@ -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<String>,
permissions: Option<Permissions>,
}
#[derive(Serialize)]
struct Permissions {
http: Option<HttpPermission>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HttpPermission {
reason: String,
allowed_hosts: Vec<String>,
}
// ============================================================================
// 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<String>,
#[serde(default)]
mbz_album_id: Option<String>,
#[serde(default)]
mbz_artist_id: Option<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_type: Option<String>,
}
// ============================================================================
// Plugin Exports
// ============================================================================
/// Returns the plugin manifest with metadata and permissions.
#[plugin_fn]
pub fn nd_manifest() -> FnResult<Json<Manifest>> {
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<AuthInput>) -> FnResult<Json<AuthOutput>> {
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<NowPlayingInput>) -> FnResult<Json<ScrobblerOutput>> {
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<ScrobbleInput>) -> FnResult<Json<ScrobblerOutput>> {
// 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={}&timestamp={}",
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
}