use std::process; use std::{ collections::{HashMap, HashSet}, io::{BufRead, BufReader, Cursor, Read, Write}, net::TcpStream, os::unix::net::UnixStream, path::PathBuf, sync::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, Mutex, }, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use clap::Parser; use discord_presence::{ models::{ActivityAssets, ActivityTimestamps, ActivityType}, Client, Event, }; use image::{GenericImageView, ImageFormat}; use mpd::Idle as _; use mpd::{song::Id, Song, State, Subsystem}; use reqwest::blocking::Client as HttpClient; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] struct Config { address: String, client_id: u64, small_image: Option, small_text: Option, } impl Default for Config { fn default() -> Self { Self { address: "localhost:6600".into(), client_id: 1464985070992363645, small_image: None, small_text: None, } } } #[derive(Debug, Parser)] struct Args { #[arg(short, long)] config: Option, } struct Application { mpdc: Arc>, client: Client, config: Config, album_art_cache: HashMap, album_art_pending: HashSet, update_tx: mpsc::Sender, update_rx: mpsc::Receiver, running: Arc, player_update_pending: Arc, mpd_last_ok: Arc>, mpd_disconnected: Arc, mpd_connected: Arc, } const MPD_LOGO: &str = "https://www.musicpd.org/logo.png"; const PASTE_EXPIRY: &str = "4h"; const PASTE_TTL: Duration = Duration::from_secs(4 * 60 * 60); #[derive(Debug)] enum UpdateMessage { Player, AlbumArt { cache_key: String, cover_url: Option, ttl: Option, }, ClearActivity, Stop, } #[derive(Clone, Debug)] struct AlbumArtCacheEntry { cover_url: Option, expires_at: Option, } #[derive(Debug)] struct AlbumArtFetch { cover_url: Option, ttl: Option, } enum MpdStream { Tcp(TcpStream), Unix(UnixStream), } impl Read for MpdStream { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { match self { MpdStream::Tcp(stream) => stream.read(buf), MpdStream::Unix(stream) => stream.read(buf), } } } impl Write for MpdStream { fn write(&mut self, buf: &[u8]) -> std::io::Result { match self { MpdStream::Tcp(stream) => stream.write(buf), MpdStream::Unix(stream) => stream.write(buf), } } fn flush(&mut self) -> std::io::Result<()> { match self { MpdStream::Tcp(stream) => stream.flush(), MpdStream::Unix(stream) => stream.flush(), } } } impl Application { pub fn new(config: Config) -> Self { let (update_tx, update_rx) = mpsc::channel(); let mut ret = Self { mpdc: Arc::new(Mutex::new( mpd::Client::connect(config.address.clone()).unwrap(), )), client: Client::new(config.client_id), album_art_cache: HashMap::new(), album_art_pending: HashSet::new(), update_tx, update_rx, running: Arc::new(AtomicBool::new(true)), player_update_pending: Arc::new(AtomicBool::new(false)), mpd_last_ok: Arc::new(Mutex::new(Instant::now())), mpd_disconnected: Arc::new(AtomicBool::new(false)), mpd_connected: Arc::new(AtomicBool::new(true)), config, }; let _error = ret.client.on_error(|ctx| { eprintln!("An error occured, {:?}", ctx.event); }); ret.client.start(); ret.client.block_until_event(Event::Ready).unwrap(); let _ = ret.mpdc.lock().unwrap().idle(&[Subsystem::Player]); assert!(Client::is_ready()); ret } fn tag(song: &Song, key: &str) -> Option { song.tags .iter() .find(|(k, _)| k.eq_ignore_ascii_case(key)) .map(|(_, v)| v.clone()) } fn fetch_album_art(&mut self, song: &mpd::Song) -> Option { let cache_key = format!("file:{}", song.file); if let Some(cached) = self.album_art_cache.get(&cache_key).cloned() { if cached .expires_at .map_or(true, |expires_at| Instant::now() < expires_at) { return cached.cover_url; } self.album_art_cache.remove(&cache_key); } if self.album_art_pending.contains(&cache_key) { return None; } self.album_art_pending.insert(cache_key.clone()); let mpd_address = self.config.address.clone(); let song_file = song.file.clone(); let tx = self.update_tx.clone(); std::thread::spawn(move || { let fetch = Self::fetch_album_art_blocking(&mpd_address, &song_file); let _ = tx.send(UpdateMessage::AlbumArt { cache_key, cover_url: fetch.cover_url, ttl: fetch.ttl, }); }); None } fn http_client() -> Option { HttpClient::builder() .user_agent("mpd-discord-presence") .build() .ok() } fn fetch_embedded_album_art(mpd_address: &str, song_file: &str) -> Option> { Self::fetch_mpd_binary(mpd_address, "albumart", song_file) .or_else(|| Self::fetch_mpd_binary(mpd_address, "readpicture", song_file)) } fn upload_album_art_to_paste(data: &[u8]) -> Option { let client = Self::http_client()?; let url = format!("https://paste.slendi.dev/?expiry={PASTE_EXPIRY}"); let response = client .post(url) .header("Content-Type", "application/octet-stream") .body(data.to_vec()) .send() .ok()? .error_for_status() .ok()?; let response_text = response.text().ok()?; let trimmed = response_text.trim(); if trimmed.is_empty() { return None; } Some(trimmed.to_string()) } fn resize_album_art(data: &[u8]) -> Option> { let image = image::load_from_memory(data).ok()?; let (width, height) = image.dimensions(); if width <= 256 && height <= 256 { return Some(data.to_vec()); } let scale = 256.0 / width.max(height) as f32; let new_width = ((width as f32) * scale).round().max(1.0) as u32; let new_height = ((height as f32) * scale).round().max(1.0) as u32; let resized = image.resize(new_width, new_height, image::imageops::FilterType::Lanczos3); let mut output = Vec::new(); let mut cursor = Cursor::new(&mut output); resized.write_to(&mut cursor, ImageFormat::Png).ok()?; Some(output) } fn fetch_album_art_blocking(mpd_address: &str, song_file: &str) -> AlbumArtFetch { if let Some(album_art) = Self::fetch_embedded_album_art(mpd_address, song_file) { if let Some(resized) = Self::resize_album_art(&album_art) { if let Some(cover_url) = Self::upload_album_art_to_paste(&resized) { return AlbumArtFetch { cover_url: Some(cover_url), ttl: Some(PASTE_TTL), }; } } } AlbumArtFetch { cover_url: None, ttl: None, } } fn fetch_mpd_binary(mpd_address: &str, command: &str, song_file: &str) -> Option> { let mut reader = Self::open_mpd_stream(mpd_address)?; let mut greeting = String::new(); reader.read_line(&mut greeting).ok()?; if !greeting.starts_with("OK MPD") { return None; } let mut buffer = Vec::new(); let mut offset: usize = 0; loop { let quoted = Self::mpd_quote(song_file); let command_line = format!("{command} {quoted} {offset}\n"); reader.get_mut().write_all(command_line.as_bytes()).ok()?; reader.get_mut().flush().ok()?; let mut size: Option = None; let binary_len = loop { let mut line = String::new(); reader.read_line(&mut line).ok()?; if line.starts_with("ACK") { return None; } let line = line.trim_end(); if line.is_empty() { continue; } let mut parts = line.splitn(2, ": "); let key = parts.next()?; let value = parts.next().unwrap_or(""); match key { "size" => { size = value.parse::().ok(); } "binary" => { break value.parse::().ok()?; } _ => {} } }; let mut chunk = vec![0u8; binary_len]; reader.read_exact(&mut chunk).ok()?; buffer.extend_from_slice(&chunk); let mut line = String::new(); reader.read_line(&mut line).ok()?; line.clear(); reader.read_line(&mut line).ok()?; if line.starts_with("ACK") { return None; } let size = size?; if buffer.len() >= size { break; } offset = buffer.len(); } Some(buffer) } fn open_mpd_stream(mpd_address: &str) -> Option> { if mpd_address.contains('/') { let stream = UnixStream::connect(mpd_address).ok()?; Some(BufReader::new(MpdStream::Unix(stream))) } else { let stream = TcpStream::connect(mpd_address).ok()?; Some(BufReader::new(MpdStream::Tcp(stream))) } } fn mpd_quote(value: &str) -> String { let mut quoted = String::with_capacity(value.len() + 2); quoted.push('"'); for ch in value.chars() { if ch == '"' || ch == '\\' { quoted.push('\\'); } quoted.push(ch); } quoted.push('"'); quoted } fn run_update(&mut self) { self.player_update_pending.store(false, Ordering::Relaxed); let (status, song) = { let mut mpdc = match self.mpdc.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; let status = match mpdc.status() { Ok(status) => status, Err(_) => { if self.mpd_connected.swap(false, Ordering::Relaxed) { eprintln!("Lost MPD connection."); } eprintln!("Connecting to MPD..."); let Ok(client) = mpd::Client::connect(self.config.address.clone()) else { return; }; *mpdc = client; if !self.mpd_connected.swap(true, Ordering::Relaxed) { eprintln!("Regained MPD connection."); } match mpdc.status() { Ok(status) => status, Err(_) => return, } } }; let song = match mpdc.currentsong() { Ok(song) => song, Err(_) => { if self.mpd_connected.swap(false, Ordering::Relaxed) { eprintln!("Lost MPD connection."); } eprintln!("Connecting to MPD..."); let Ok(client) = mpd::Client::connect(self.config.address.clone()) else { return; }; *mpdc = client; if !self.mpd_connected.swap(true, Ordering::Relaxed) { eprintln!("Regained MPD connection."); } match mpdc.currentsong() { Ok(song) => song, Err(_) => return, } } }; (status, song) }; self.mpd_disconnected.store(false, Ordering::Relaxed); if !self.mpd_connected.swap(true, Ordering::Relaxed) { eprintln!("Gained MPD connection."); } if let Ok(mut last_ok) = self.mpd_last_ok.lock() { *last_ok = Instant::now(); } if song.is_none() && status.state == State::Play { if !self.player_update_pending.swap(true, Ordering::Relaxed) { let tx = self.update_tx.clone(); let pending = self.player_update_pending.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(500)); pending.store(false, Ordering::Relaxed); let _ = tx.send(UpdateMessage::Player); }); } return; } let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); let start_dur: Duration; let end_dur: Duration; if let Some((position, end)) = status.time { start_dur = now .checked_sub(position) .unwrap_or_else(|| std::time::Duration::from_secs(0)); end_dur = start_dur + end; } else { start_dur = std::time::Duration::from_secs(0); end_dur = std::time::Duration::from_secs(0); } let started_ms = start_dur.as_millis() as u64; let ends_ms = end_dur.as_millis() as u64; if let Some(song) = song { if status.state == State::Play { let album = Self::tag(&song, "Album"); let album_art = self.fetch_album_art(&song); let namae = format!( "{} / {}", song.artist.unwrap_or("Unknown".into()), album.clone().unwrap_or("Unknown".into()) ); let _err = self.client.set_activity(|a| { a.state(namae.clone()) .name(namae) .details(song.title.unwrap_or("Unknown".into())) .activity_type(ActivityType::Listening) .timestamps(|_| ActivityTimestamps::new().start(started_ms).end(ends_ms)) .assets(|_| { let mut assets = ActivityAssets::new() .small_image( self.config.small_image.clone().unwrap_or(MPD_LOGO.into()), ) .small_text( self.config .small_text .clone() .unwrap_or("Music Player Daemon".into()), ); if let Some(album_art) = album_art { eprintln!("URL: {}", album_art); assets = assets.large_image(album_art); assets = assets.large_text(album.unwrap_or("Unknown".into())); } else { eprintln!("No album art URL found"); } assets }) }); } else { let _err = self.client.clear_activity(); } } else { let _err = self.client.clear_activity(); } } fn run(&mut self) { self.running.store(true, Ordering::Relaxed); let running = self.running.clone(); let mpdc = self.mpdc.clone(); let mpd_address = self.config.address.clone(); let mpd_last_ok = self.mpd_last_ok.clone(); let mpd_disconnected = self.mpd_disconnected.clone(); let mpd_connected = self.mpd_connected.clone(); { let tx = self.update_tx.clone(); let running = running.clone(); let mpd_address = mpd_address.clone(); let mpd_last_ok = mpd_last_ok.clone(); let mpd_disconnected = mpd_disconnected.clone(); let mpd_connected = mpd_connected.clone(); std::thread::spawn(move || { let mut last_state: Option = None; let mut last_song_id: Option = None; while running.load(Ordering::Relaxed) { let idle_ok = { let mut guard = match mpdc.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; guard.idle(&[Subsystem::Player]).is_ok() }; if !idle_ok { if mpd_connected.swap(false, Ordering::Relaxed) { eprintln!("Lost MPD connection."); } eprintln!("Connecting to MPD..."); if let Ok(client) = mpd::Client::connect(mpd_address.clone()) { if let Ok(mut guard) = mpdc.lock() { *guard = client; } if !mpd_connected.swap(true, Ordering::Relaxed) { eprintln!("Regained MPD connection."); } let _ = tx.send(UpdateMessage::Player); } let disconnected_for = mpd_last_ok .lock() .ok() .map(|last_ok| last_ok.elapsed()) .unwrap_or_default(); if disconnected_for > Duration::from_secs(5) && !mpd_disconnected.swap(true, Ordering::Relaxed) { let _ = tx.send(UpdateMessage::ClearActivity); } std::thread::sleep(Duration::from_millis(200)); continue; } if !running.load(Ordering::Relaxed) { break; } let status = { let mut guard = match mpdc.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; guard.status() }; let status = match status { Ok(status) => status, Err(_) => { if mpd_connected.swap(false, Ordering::Relaxed) { eprintln!("Lost MPD connection."); } eprintln!("Connecting to MPD..."); if let Ok(client) = mpd::Client::connect(mpd_address.clone()) { if let Ok(mut guard) = mpdc.lock() { *guard = client; } if !mpd_connected.swap(true, Ordering::Relaxed) { eprintln!("Regained MPD connection."); } let _ = tx.send(UpdateMessage::Player); } let disconnected_for = mpd_last_ok .lock() .ok() .map(|last_ok| last_ok.elapsed()) .unwrap_or_default(); if disconnected_for > Duration::from_secs(5) && !mpd_disconnected.swap(true, Ordering::Relaxed) { let _ = tx.send(UpdateMessage::ClearActivity); } continue; } }; if !mpd_connected.swap(true, Ordering::Relaxed) { eprintln!("Gained MPD connection."); let _ = tx.send(UpdateMessage::Player); } if let Ok(mut last_ok) = mpd_last_ok.lock() { *last_ok = Instant::now(); } let song_id = status.song.map(|place| place.id); if last_state != Some(status.state) || last_song_id != song_id { last_state = Some(status.state); last_song_id = song_id; let _ = tx.send(UpdateMessage::Player); } } }); } { let tx = self.update_tx.clone(); let running = running.clone(); let last_ctrlc = Arc::new(Mutex::new(None::)); let last_ctrlc_handler = last_ctrlc.clone(); ctrlc::set_handler(move || { let mut last = last_ctrlc_handler .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some(previous) = *last { if previous.elapsed() <= Duration::from_secs(2) { eprintln!("Force quitting after second Ctrl-C."); process::exit(1); } } *last = Some(Instant::now()); running.store(false, Ordering::Relaxed); let _ = tx.send(UpdateMessage::Stop); }) .expect("Failed to set Ctrl-C handler"); } self.run_update(); while self.running.load(Ordering::Relaxed) { let Ok(message) = self.update_rx.recv() else { break; }; match message { UpdateMessage::Player => self.run_update(), UpdateMessage::AlbumArt { cache_key, cover_url, ttl, } => { self.album_art_pending.remove(&cache_key); let expires_at = ttl.map(|ttl| Instant::now() + ttl); self.album_art_cache.insert( cache_key, AlbumArtCacheEntry { cover_url, expires_at, }, ); self.run_update(); } UpdateMessage::ClearActivity => { let _ = self.client.clear_activity(); } UpdateMessage::Stop => break, } } self.run_update(); } } impl Drop for Application { fn drop(&mut self) { let _ = self.client.clear_activity(); } } fn main() { let args = Args::parse(); let config = args .config .and_then(|path| std::fs::read_to_string(path).ok()) .and_then(|t| toml::from_str::(&t).ok()) .unwrap_or_default(); let mut app = Application::new(config); app.run(); }