From 2a43ee91b51ef498c1122e77fcaa7c3d4dfef3b5 Mon Sep 17 00:00:00 2001 From: Slendi Date: Wed, 28 Jan 2026 01:07:03 +0200 Subject: [PATCH] Album art Signed-off-by: Slendi --- Cargo.lock | 125 ++++++++++++++ Cargo.toml | 4 +- src/main.rs | 470 +++++++++++++++++++++++++++------------------------- 3 files changed, 375 insertions(+), 224 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d64d99..06b6989 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -128,12 +134,24 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -265,6 +283,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -349,12 +376,31 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -692,6 +738,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -842,6 +903,16 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -853,6 +924,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "mpd" version = "0.1.0" @@ -869,6 +950,7 @@ dependencies = [ "clap", "ctrlc", "discord-presence", + "image", "mpd", "musicbrainz_rs", "reqwest 0.13.1", @@ -1017,6 +1099,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1075,6 +1170,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1514,6 +1618,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.11" @@ -2424,3 +2534,18 @@ name = "zmij" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index f0b946a..926e3a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,9 +6,11 @@ edition = "2024" [dependencies] clap = { version = "4.5.54", features = ["derive"] } ctrlc = "3.5.1" -discord-presence = "3.2" +discord-presence = { version = "3.2", features = ["unstable_name"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } mpd = "*" musicbrainz_rs = { version = "0.12.0", default-features = false, features = ["blocking", "rustls"] } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } serde = { version = "1.0.228", features = ["derive"] } toml = "0.9.11" + diff --git a/src/main.rs b/src/main.rs index 9f7a01e..1dcd33b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,15 @@ use std::{ collections::{HashMap, HashSet}, + io::{BufRead, BufReader, Cursor, Read, Write}, + net::TcpStream, + os::unix::net::UnixStream, path::PathBuf, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, mpsc, }, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use clap::Parser; @@ -14,16 +17,9 @@ use discord_presence::{ Client, Event, models::{ActivityAssets, ActivityTimestamps, ActivityType}, }; +use image::{GenericImageView, ImageFormat}; use mpd::Idle as _; use mpd::{Song, State, Subsystem, song::Id}; -use musicbrainz_rs::{ - FetchCoverart, - api::search_query::Search, - entity::{ - recording::{Recording, RecordingSearchQuery}, - release::ReleaseSearchQuery, - }, -}; use reqwest::blocking::Client as HttpClient; use serde::{Deserialize, Serialize}; @@ -56,7 +52,7 @@ struct Application { mpdc: Arc>, client: Client, config: Config, - album_art_cache: HashMap>, + album_art_cache: HashMap, album_art_pending: HashSet, update_tx: mpsc::Sender, update_rx: mpsc::Receiver, @@ -64,6 +60,8 @@ struct Application { } 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 { @@ -71,10 +69,53 @@ enum UpdateMessage { AlbumArt { cache_key: String, cover_url: Option, + ttl: Option, }, 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(); @@ -111,39 +152,16 @@ impl Application { } fn fetch_album_art(&mut self, song: &mpd::Song) -> Option { - let album = Self::tag(song, "Album"); - let title = song.title.clone(); - let artist = match song.artist.clone() { - Some(artist) => artist, - None => return self.cache_missing_album_art(song), - }; + let cache_key = format!("file:{}", song.file); - if album.is_none() && title.is_none() { - return self.cache_missing_album_art(song); - } - - let tagged_mbid = Self::tag(song, "MUSICBRAINZ_RELEASEID") - .or_else(|| Self::tag(song, "MusicBrainz Release Id")) - .or_else(|| Self::tag(song, "MusicBrainz Release ID")) - .or_else(|| Self::tag(song, "MBID")); - - let cache_key = tagged_mbid - .as_ref() - .map(|mbid| format!("mbid:{mbid}")) - .or_else(|| { - album - .as_ref() - .map(|album| format!("artist:{artist}|album:{album}")) - }) - .or_else(|| { - title - .as_ref() - .map(|title| format!("artist:{artist}|title:{title}")) - }) - .unwrap_or_else(|| format!("file:{}", song.file)); - - if let Some(cached) = self.album_art_cache.get(&cache_key) { - return cached.clone(); + 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) { @@ -151,178 +169,172 @@ impl Application { } self.album_art_pending.insert(cache_key.clone()); - let album = album.clone(); - let title = title.clone(); - let artist = artist.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 cover_url = Self::fetch_album_art_blocking( - album.as_deref(), - &artist, - title.as_deref(), - tagged_mbid, - ); + let fetch = Self::fetch_album_art_blocking(&mpd_address, &song_file); let _ = tx.send(UpdateMessage::AlbumArt { cache_key, - cover_url, + cover_url: fetch.cover_url, + ttl: fetch.ttl, }); }); None } - fn cache_missing_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) { - return cached.clone(); - } - - self.album_art_cache.insert(cache_key, None); - None + fn http_client() -> Option { + HttpClient::builder() + .user_agent("mpd-discord-presence") + .build() + .ok() } - fn resolve_coverart_url(url: String) -> String { - let client = match HttpClient::builder().build() { - Ok(client) => client, - Err(_) => return url, - }; - - if let Ok(response) = client.head(&url).send() { - return response.url().to_string(); - } - - if let Ok(response) = client.get(&url).send() { - return response.url().to_string(); - } - - url + 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 fallback_variants(value: &str) -> Vec { - let mut variants = Vec::new(); - let mut seen = HashSet::new(); - - let trimmed = value.trim(); - if seen.insert(trimmed.to_string()) { - variants.push(trimmed.to_string()); - } - - if let Some(without_parens) = Self::strip_parenthetical_suffix(trimmed) { - if seen.insert(without_parens.clone()) { - variants.push(without_parens); - } - } - - if let Some(without_ep) = Self::strip_dash_suffix(trimmed, "ep") { - if seen.insert(without_ep.clone()) { - variants.push(without_ep); - } - } - - variants - } - - fn strip_parenthetical_suffix(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.ends_with(')') { - if let Some(idx) = trimmed.rfind(" (") { - let without = trimmed[..idx].trim(); - if !without.is_empty() && without != trimmed { - return Some(without.to_string()); - } - } - } - None - } - - fn strip_dash_suffix(value: &str, suffix: &str) -> Option { - let trimmed = value.trim(); - let suffix_lower = format!(" - {}", suffix.to_ascii_lowercase()); - let trimmed_lower = trimmed.to_ascii_lowercase(); - if trimmed_lower.ends_with(&suffix_lower) { - let without = trimmed[..trimmed.len() - suffix_lower.len()].trim(); - if !without.is_empty() && without != trimmed { - return Some(without.to_string()); - } - } - None - } - - fn fetch_album_art_blocking( - album: Option<&str>, - artist: &str, - title: Option<&str>, - tagged_mbid: Option, - ) -> Option { - let mbid = match tagged_mbid { - Some(mbid) => Some(mbid), - None => match album { - Some(album) => { - let mut found = None; - for candidate in Self::fallback_variants(album) { - let query = ReleaseSearchQuery::query_builder() - .artist(artist) - .and() - .release(&candidate) - .build(); - let results = musicbrainz_rs::entity::release::Release::search(query) - .execute() - .ok(); - found = results - .and_then(|results| results.entities.into_iter().next()) - .map(|release| release.id); - if found.is_some() { - break; - } - } - found - } - None => title.and_then(|title| { - let mut found = None; - for candidate in Self::fallback_variants(title) { - let query = RecordingSearchQuery::query_builder() - .artist(artist) - .and() - .recording(&candidate) - .build(); - let results = Recording::search(query).with_releases().execute().ok(); - found = results - .and_then(|results| results.entities.into_iter().next()) - .and_then(|recording| recording.releases) - .and_then(|mut releases| releases.into_iter().next()) - .map(|release| release.id); - if found.is_some() { - break; - } - } - found - }), - }, - }; - - let Some(mbid) = mbid else { + 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()) + } - let cover = match musicbrainz_rs::entity::release::Release::fetch_coverart() - .id(&mbid) - .execute() - { - Ok(cover) => cover, - Err(_) => return None, - }; + 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 cover_url = match cover { - musicbrainz_rs::entity::CoverartResponse::Url(url) => Some(url), - musicbrainz_rs::entity::CoverartResponse::Json(json) => json - .images - .iter() - .find(|image| image.front) - .or_else(|| json.images.first()) - .map(|image| image.image.clone()), - }; + 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); - cover_url.map(Self::resolve_coverart_url) + 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) { @@ -351,33 +363,37 @@ impl Application { 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.unwrap_or("Unknown".into()) + ); let _err = self.client.set_activity(|a| { - a.state(format!( - "{} / {}", - song.artist.unwrap_or("Unknown".into()), - album.unwrap_or("Unknown".into()) - )) - .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); - } else { - eprintln!("No album art URL found"); - } - assets - }) + 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(); @@ -438,9 +454,17 @@ impl Application { UpdateMessage::AlbumArt { cache_key, cover_url, + ttl, } => { self.album_art_pending.remove(&cache_key); - self.album_art_cache.insert(cache_key, cover_url); + 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::Stop => break,