use extism_pdk::*;
use nd_pdk::host::{artwork, config, scheduler};
use nd_pdk::scrobbler::{
Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest,
ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER,
};
use nd_pdk::scheduler::{
CallbackProvider, Error as SchedulerError, SchedulerCallbackRequest,
};
use nd_pdk::websocket::{
BinaryMessageProvider, CloseProvider, Error as WebSocketError, ErrorProvider,
OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest,
TextMessageProvider,
};
use serde::Deserialize;
mod rpc;
nd_pdk::register_scrobbler!(DiscordPlugin);
nd_pdk::register_scheduler_callback!(DiscordPlugin);
nd_pdk::register_websocket_text_message!(DiscordPlugin);
nd_pdk::register_websocket_binary_message!(DiscordPlugin);
nd_pdk::register_websocket_error!(DiscordPlugin);
nd_pdk::register_websocket_close!(DiscordPlugin);
const CLIENT_ID_KEY: &str = "clientid";
const USERS_KEY: &str = "users";
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity";
#[derive(Default)]
struct DiscordPlugin;
#[derive(Debug, Deserialize)]
struct UserToken {
username: String,
token: String,
}
fn get_config() -> Result<(String, std::collections::HashMap<String, String>), Error> {
let client_id = config::get(CLIENT_ID_KEY)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("missing clientid in configuration"))?;
let users_json = config::get(USERS_KEY)?.unwrap_or_default();
let mut users = std::collections::HashMap::new();
if !users_json.is_empty() {
let user_tokens: Vec<UserToken> = serde_json::from_str(&users_json)
.map_err(|e| Error::msg(format!("failed to parse users config: {}", e)))?;
for user_token in user_tokens {
if !user_token.username.is_empty() && !user_token.token.is_empty() {
users.insert(user_token.username, user_token.token);
}
}
}
Ok((client_id, users))
}
fn get_image_url(track_id: &str) -> String {
match artwork::get_track_url(track_id, 300) {
Ok(url) => {
if url.starts_with("http://localhost") {
String::new()
} else {
url
}
}
Err(e) => {
warn!("Failed to get artwork URL: {:?}", e);
String::new()
}
}
}
impl Scrobbler for DiscordPlugin {
fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, ScrobblerError> {
let (_, users) = match get_config() {
Ok(config) => config,
Err(e) => {
error!("Failed to get config: {:?}", e);
return Ok(false);
}
};
let authorized = users.contains_key(&req.username);
info!("IsAuthorized for user {}: {}", req.username, authorized);
Ok(authorized)
}
fn now_playing(&self, req: NowPlayingRequest) -> Result<(), ScrobblerError> {
info!(
"Setting presence for user {}, track: {}",
req.username, req.track.title
);
let (client_id, users) = get_config()
.map_err(|e| ScrobblerError::new(format!("{}: failed to get config: {:?}", SCROBBLER_ERROR_RETRY_LATER, e)))?;
let user_token = users.get(&req.username).cloned().ok_or_else(|| {
ScrobblerError::new(format!(
"{}: user '{}' not authorized",
SCROBBLER_ERROR_NOT_AUTHORIZED, req.username
))
})?;
rpc::connect(&req.username, &user_token)
.map_err(|e| ScrobblerError::new(format!(
"{}: failed to connect to Discord: {:?}",
SCROBBLER_ERROR_RETRY_LATER, e
)))?;
let _ = scheduler::cancel_schedule(&format!("{}-clear", req.username));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let start_time = (now - req.position as i64) * 1000;
let end_time = start_time + (req.track.duration as i64) * 1000;
rpc::send_activity(
&client_id,
&req.username,
&user_token,
rpc::Activity {
application: client_id.clone(),
name: "Navidrome".to_string(),
activity_type: 2,
details: req.track.title.clone(),
state: req.track.artist.clone(),
timestamps: rpc::ActivityTimestamps {
start: start_time,
end: end_time,
},
assets: rpc::ActivityAssets {
large_image: get_image_url(&req.track.id),
large_text: req.track.album.clone(),
},
},
)
.map_err(|e| ScrobblerError::new(format!(
"{}: failed to send activity: {:?}",
SCROBBLER_ERROR_RETRY_LATER, e
)))?;
let remaining_seconds = (req.track.duration as i32) - req.position + 5;
if let Err(e) = scheduler::schedule_one_time(
remaining_seconds,
PAYLOAD_CLEAR_ACTIVITY,
&format!("{}-clear", req.username),
) {
warn!("Failed to schedule completion timer: {:?}", e);
}
Ok(())
}
fn scrobble(&self, _req: ScrobbleRequest) -> Result<(), ScrobblerError> {
Ok(())
}
fn playback_report(&self, _req: PlaybackReportRequest) -> Result<(), ScrobblerError> {
Ok(())
}
}
impl CallbackProvider for DiscordPlugin {
fn on_callback(&self, req: SchedulerCallbackRequest) -> Result<(), SchedulerError> {
match req.payload.as_str() {
PAYLOAD_HEARTBEAT => {
if let Err(e) = rpc::handle_heartbeat_callback(&req.schedule_id) {
warn!("Heartbeat failed for user {}, cleaning up connection: {:?}", req.schedule_id, e);
rpc::cleanup_connection(&req.schedule_id);
return Err(SchedulerError::new(format!("heartbeat failed, connection cleaned up: {}", e)));
}
}
PAYLOAD_CLEAR_ACTIVITY => {
let username = req.schedule_id.trim_end_matches("-clear");
info!("Removing presence for user {}", username);
rpc::handle_clear_activity_callback(username)
.map_err(|e| SchedulerError::new(e.to_string()))?;
info!("Disconnecting user {}", username);
rpc::disconnect(username)
.map_err(|e| SchedulerError::new(e.to_string()))?;
}
_ => {
warn!("Unknown scheduler callback payload: {}", req.payload);
}
}
Ok(())
}
}
impl TextMessageProvider for DiscordPlugin {
fn on_text_message(&self, req: OnTextMessageRequest) -> Result<(), WebSocketError> {
rpc::handle_websocket_message(&req.connection_id, &req.message)
.map_err(|e| WebSocketError::new(e.to_string()))?;
Ok(())
}
}
impl BinaryMessageProvider for DiscordPlugin {
fn on_binary_message(&self, _req: OnBinaryMessageRequest) -> Result<(), WebSocketError> {
Ok(())
}
}
impl ErrorProvider for DiscordPlugin {
fn on_error(&self, req: OnErrorRequest) -> Result<(), WebSocketError> {
warn!(
"WebSocket error for connection '{}': {}",
req.connection_id, req.error
);
rpc::handle_connection_close(&req.connection_id);
Ok(())
}
}
impl CloseProvider for DiscordPlugin {
fn on_close(&self, req: OnCloseRequest) -> Result<(), WebSocketError> {
info!(
"WebSocket connection '{}' closed with code {}: {}",
req.connection_id, req.code, req.reason
);
rpc::handle_connection_close(&req.connection_id);
Ok(())
}
}