Album art

Signed-off-by: Slendi <slendi@socopon.com>
This commit is contained in:
2026-01-28 01:07:03 +02:00
parent 8529bc3dbf
commit 2a43ee91b5
3 changed files with 375 additions and 224 deletions

View File

@@ -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<Mutex<mpd::Client>>,
client: Client,
config: Config,
album_art_cache: HashMap<String, Option<String>>,
album_art_cache: HashMap<String, AlbumArtCacheEntry>,
album_art_pending: HashSet<String>,
update_tx: mpsc::Sender<UpdateMessage>,
update_rx: mpsc::Receiver<UpdateMessage>,
@@ -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<String>,
ttl: Option<Duration>,
},
Stop,
}
#[derive(Clone, Debug)]
struct AlbumArtCacheEntry {
cover_url: Option<String>,
expires_at: Option<Instant>,
}
#[derive(Debug)]
struct AlbumArtFetch {
cover_url: Option<String>,
ttl: Option<Duration>,
}
enum MpdStream {
Tcp(TcpStream),
Unix(UnixStream),
}
impl Read for MpdStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
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<usize> {
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<String> {
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<String> {
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> {
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<Vec<u8>> {
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<String> {
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<String> {
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<String> {
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<String>,
) -> Option<String> {
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<String> {
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<Vec<u8>> {
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<Vec<u8>> {
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<usize> = 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::<usize>().ok();
}
"binary" => {
break value.parse::<usize>().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<BufReader<MpdStream>> {
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,