125
Cargo.lock
generated
125
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
424
src/main.rs
424
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<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);
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
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();
|
||||
fn http_client() -> Option<HttpClient> {
|
||||
HttpClient::builder()
|
||||
.user_agent("mpd-discord-presence")
|
||||
.build()
|
||||
.ok()
|
||||
}
|
||||
|
||||
self.album_art_cache.insert(cache_key, None);
|
||||
None
|
||||
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 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 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())
|
||||
}
|
||||
|
||||
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 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<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 cover = match musicbrainz_rs::entity::release::Release::fetch_coverart()
|
||||
.id(&mbid)
|
||||
.execute()
|
||||
{
|
||||
Ok(cover) => cover,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let mut chunk = vec![0u8; binary_len];
|
||||
reader.read_exact(&mut chunk).ok()?;
|
||||
buffer.extend_from_slice(&chunk);
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
cover_url.map(Self::resolve_coverart_url)
|
||||
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,19 +363,22 @@ impl Application {
|
||||
if status.state == State::Play {
|
||||
let album = Self::tag(&song, "Album");
|
||||
let album_art = self.fetch_album_art(&song);
|
||||
|
||||
let _err = self.client.set_activity(|a| {
|
||||
a.state(format!(
|
||||
let namae = format!(
|
||||
"{} / {}",
|
||||
song.artist.unwrap_or("Unknown".into()),
|
||||
album.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_image(
|
||||
self.config.small_image.clone().unwrap_or(MPD_LOGO.into()),
|
||||
)
|
||||
.small_text(
|
||||
self.config
|
||||
.small_text
|
||||
@@ -373,6 +388,7 @@ impl Application {
|
||||
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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user