mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
222 lines
6.4 KiB
Rust
222 lines
6.4 KiB
Rust
//! 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={}×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
|
|
}
|