From b87a0cf5d9570cfb63b2891b8967307fbf805d36 Mon Sep 17 00:00:00 2001 From: Ryze Date: Mon, 6 Feb 2023 23:27:40 +0300 Subject: [PATCH] Added cover art, buffering, config, string truncating --- Cargo.toml | 3 + src/config.rs | 76 +++++++++++++++++ src/discord_client.rs | 132 ++++++++++++++++++++++++----- src/discord_client/music_brainz.rs | 39 +++++++++ src/lib.rs | 7 +- src/logging.rs | 4 +- src/mpv_event_queue.rs | 62 +++++++++----- src/mpv_event_queue/events.rs | 26 ++---- src/plugin.rs | 20 ++++- src/utils.rs | 15 ++++ 10 files changed, 320 insertions(+), 64 deletions(-) create mode 100644 src/config.rs create mode 100644 src/discord_client/music_brainz.rs create mode 100644 src/utils.rs diff --git a/Cargo.toml b/Cargo.toml index a221056..9f1246d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,9 @@ crate-type = ["cdylib"] [dependencies] mpv-client = "0.4.1" discord-rich-presence="0.2.3" +serde = { version = "1.0.152", features = ["derive"] } +serde_json = "1.0.92" +musicbrainz_rs = { version = "0.5.0", default-features = false, features = ["blocking"] } [profile.release-full] inherits = "release" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..fcf9f7d --- /dev/null +++ b/src/config.rs @@ -0,0 +1,76 @@ +use std::env; +use std::fs; +use serde::{self, Serialize, Deserialize}; +use crate::logging::{self, Logger}; + +enum ConfigError { + CannotLoad, + ParseError(serde_json::Error) +} + +#[derive(Serialize, Deserialize)] +pub struct Config { + #[serde(default = "active_default")] + pub active: bool, + + #[serde(default = "cover_art_default")] + pub cover_art: bool +} + +const fn active_default() -> bool { + false +} + +const fn cover_art_default() -> bool { + true +} + +impl Config { + pub fn default() -> Self { + Self { + active: active_default(), + cover_art: cover_art_default() + } + } + + pub fn from_config_file(logger: &Logger) -> Self { + let path = Config::get_config_path(); + logging::info!(logger, "Config path {path}"); + + match Config::parse_config_from_file(&path) { + Ok(config) => config, + Err(ConfigError::CannotLoad) => { + logging::info!(logger, "Cannot load config. Using default options"); + Config::default() + } + Err(ConfigError::ParseError(e)) => { + logging::error!(logger, "Cannot parse config: {e}"); + Config::default() + } + } + } + + fn parse_config_from_file(path: &str) -> Result { + match fs::read_to_string(path) { + Ok(json) => Config::parse_config(&json), + Err(_) => Err(ConfigError::CannotLoad) + } + } + + fn parse_config(json: &str) -> Result{ + match serde_json::from_str(json) { + Ok(config) => Ok(config), + Err(e) => Err(ConfigError::ParseError(e)) + } + } + + fn get_config_path() -> String { + let mpv_home = env::var("MPV_HOME").or( + env::var("HOME").and_then(|home| Ok(home + "/.config/mpv/")).or( + env::var("XDG_CONFIG_HOME").and_then(|home| Ok(home + "/.mpv/")) + ) + ).unwrap_or("/etc/mpv/".to_string()); + + return mpv_home + "rpc.json" + } +} \ No newline at end of file diff --git a/src/discord_client.rs b/src/discord_client.rs index 6db7d2a..470467f 100644 --- a/src/discord_client.rs +++ b/src/discord_client.rs @@ -1,21 +1,56 @@ use std::rc::Rc; use std::time::SystemTime; -use crate::logging::{self, Logger}; -use crate::mpv_event_queue::events::{MpvEventHandler, MpvEvent, FileInfo}; +use std::collections::VecDeque; use discord_rich_presence::{DiscordIpcClient, DiscordIpc}; use discord_rich_presence::activity::{Activity, Assets, Timestamps}; +use crate::utils; +use crate::logging::{self, Logger}; +use crate::mpv_event_queue::events::{MpvEventHandler, MpvEvent, FileInfo, MpvRequester, MpvRequest, FileMetadata}; + +const MAX_STR_LEN: usize = 128; + +mod music_brainz; struct ActivityInfo { details: String, state: String, + assets: AssetsInfo, timestamps: Timestamps } +impl AssetsInfo { + pub fn new(large_image: String, large_text: String) -> Self { + Self { + large_image, + large_text + } + } + + pub fn empty() -> Self { + Self { + large_image: String::new(), + large_text: String::new(), + } + } + + pub fn get_assets(&self) -> Assets { + Assets::new() + .large_image(&self.large_image) + .large_text(&self.large_text) + } +} + +struct AssetsInfo { + large_image: String, + large_text: String +} + impl ActivityInfo { - pub fn new(details: String, state: String, timestamps: Timestamps) -> Self { + pub fn new(details: String, state: String, assets: AssetsInfo, timestamps: Timestamps) -> Self { Self { details, state, + assets, timestamps } } @@ -24,15 +59,14 @@ impl ActivityInfo { Self { details: String::new(), state: String::new(), + assets: AssetsInfo::empty(), timestamps: Timestamps::new() } } pub fn get_activity(&self) -> Activity { - let assets = Assets::new() - .large_image("logo") - .large_text("mpv"); + let assets = self.assets.get_assets(); Activity::new() .assets(assets) @@ -43,26 +77,36 @@ impl ActivityInfo { } + pub struct DiscordClient { - logger: Rc, discord: DiscordIpcClient, activity_info: ActivityInfo, - active: bool + active: bool, + cover_art: bool, + mpv_requests: VecDeque, + logger: Rc } impl DiscordClient { - pub fn new(client_id: &str, logger: Rc) -> Result { + pub fn new(client_id: &str, active: bool, cover_art: bool, logger: Rc) -> Result { let discord = match DiscordIpcClient::new(client_id) { Ok(discord) => discord, Err(_) => return Err("cannot init discord client") }; - Ok(Self { - logger, + let mut new_self = Self { discord, activity_info: ActivityInfo::empty(), - active: false - }) + active: false, + cover_art, + mpv_requests: VecDeque::new(), + logger + }; + + if active { + new_self.open()?; + } + Ok(new_self) } fn get_state(file_info: &FileInfo) -> String { @@ -76,6 +120,7 @@ impl DiscordClient { if let Some(album) = &metadata.album { state += &format!(" on {album}"); } + utils::truncate_string_fmt(&mut state, MAX_STR_LEN); state } @@ -86,12 +131,38 @@ impl DiscordClient { None => return file_info.filename.clone() }; - if let Some(track) = &metadata.track { + let mut details = if let Some(track) = &metadata.track { format!("{title} [T{track}] ") } else { title.clone() + }; + + utils::truncate_string_fmt(&mut details, MAX_STR_LEN); + details + } + + fn get_assets_info(cover_art: bool, metadata: FileMetadata) -> AssetsInfo { + let (large_image, large_text) = DiscordClient::get_large_info(cover_art, metadata); + AssetsInfo::new(large_image, large_text) + } + + fn get_large_info(cover_art: bool, metadata: FileMetadata) -> (String, String) { + if !cover_art { + return ("logo".to_string(), "mpv".to_string()) } + + let cover_art_url = music_brainz::get_cover_art_url(&metadata.title, &metadata.album, &metadata.artist); + let large_image = match cover_art_url { + Some(url) => url, + None => "logo".to_string() + }; + let large_text = match metadata.title.or(metadata.album) { + Some(text) => text, + None => "mpv".to_string() + }; + + (large_image, large_text) } fn update_presence(&mut self) -> Result<(), &'static str> { @@ -105,19 +176,23 @@ impl DiscordClient { Ok(()) => { Ok(()) } - Err(_) => Err("cannot set presence") + Err(_) => { + self.active = false; + Err("cannot set presence") + } } } fn set_presence(&mut self, file_info: FileInfo) -> Result<(), &'static str> { let details = DiscordClient::get_details(&file_info); let state = DiscordClient::get_state(&file_info); - self.activity_info = ActivityInfo::new(details, state, Timestamps::new()); + let assets_info = DiscordClient::get_assets_info(self.cover_art, file_info.metadata); + self.activity_info = ActivityInfo::new(details, state, assets_info, Timestamps::new()); self.update_presence() } - fn update_timestamps(&mut self, remaining_time: i64) -> Result<(), &'static str> { + fn set_timestamps(&mut self, remaining_time: i64) -> Result<(), &'static str> { let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH); let current_time = match current_time { Ok(time) => time.as_secs() as i64, @@ -130,6 +205,11 @@ impl DiscordClient { self.update_presence() } + fn clear_timestamps(&mut self) -> Result<(), &'static str> { + self.activity_info.timestamps = Timestamps::new(); + self.update_presence() + } + fn open(&mut self) -> Result<(), &'static str> { if self.active { return Ok(()); @@ -139,6 +219,7 @@ impl DiscordClient { match self.discord.connect() { Ok(()) => { self.active = true; + self.request_osd_message("Discord RPC started"); self.update_presence() } Err(_) => Err("cannot connect to Discord") @@ -154,6 +235,7 @@ impl DiscordClient { match self.discord.close() { Ok(()) => { self.active = false; + self.request_osd_message("Discord RPC stopped"); Ok(()) } Err(_) => Err("cannot disconnect from Discord") @@ -166,16 +248,28 @@ impl DiscordClient { true => self.close() } } + + fn request_osd_message(&mut self, message: &'static str) { + self.mpv_requests.push_front(MpvRequest::OSDMessage(message)); + } } impl MpvEventHandler for DiscordClient { fn handle_event(&mut self, event: MpvEvent) -> Result<(), &'static str> { match event { MpvEvent::FileLoaded(file_info) => self.set_presence(file_info), - MpvEvent::Seek(remaining_time) => self.update_timestamps(remaining_time), + MpvEvent::Seek(remaining_time) => self.set_timestamps(remaining_time), + MpvEvent::Play(remaining_time) => self.set_timestamps(remaining_time), + MpvEvent::Pause(_) => self.clear_timestamps(), + MpvEvent::Buffering => self.clear_timestamps(), MpvEvent::Toggle => self.toggle_activity(), MpvEvent::Exit => self.close(), - _ => Ok(()) } } +} + +impl MpvRequester for DiscordClient { + fn next_request<'a>(&mut self) -> Option { + self.mpv_requests.pop_back() + } } \ No newline at end of file diff --git a/src/discord_client/music_brainz.rs b/src/discord_client/music_brainz.rs new file mode 100644 index 0000000..cba0663 --- /dev/null +++ b/src/discord_client/music_brainz.rs @@ -0,0 +1,39 @@ +use musicbrainz_rs::entity::release::{Release, ReleaseSearchQuery}; +use musicbrainz_rs::entity::CoverartResponse; +use musicbrainz_rs::{Search, FetchCoverart}; + +pub fn get_cover_art_url(title: &Option, album: &Option, artist: &Option) -> Option{ + let mut builder = ReleaseSearchQuery::query_builder(); + if let Some(ref title) = title { + builder.release(title); + } + + if let Some(ref album) = album { + builder.or().release(album); + } + + if let Some(ref artist) = artist { + builder.and().artist(artist); + } + + let query = builder.build(); + + let result = match Release::search(query).execute() { + Ok(res) => res, + Err(_) => return None + }; + + let release = match result.entities.get(0) { + Some(group) => group, + None => return None + }; + + let cover_art = match release.get_coverart().front().execute() { + Ok(art) => art, + Err(_) => return None + }; + match cover_art { + CoverartResponse::Url(url) => Some(url), + _ => None + } +} diff --git a/src/lib.rs b/src/lib.rs index 6e38196..6548670 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,11 @@ use mpv_client::mpv_handle; +mod config; mod logging; mod mpv_event_queue; mod discord_client; mod plugin; +mod utils; use plugin::RPCPlugin; @@ -13,7 +15,10 @@ const DISCORD_APPID: &str = "1071519995588264016"; fn mpv_open_cplugin(handle: *mut mpv_handle) -> std::os::raw::c_int { let plugin = match RPCPlugin::new(handle, DISCORD_APPID) { Ok(plugin) => plugin, - Err(_) => return -1 + Err(e) => { + println!("Error creating RPC plugin: {e}"); + return -1; + } }; plugin.run(); diff --git a/src/logging.rs b/src/logging.rs index 9e429d8..7af0461 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -19,12 +19,11 @@ impl From for LogLevel { 1 => LogLevel::Error, 2 => LogLevel::Warn, 3 => LogLevel::Info, - _ => LogLevel::Error + _ => LogLevel::Info } } } - #[allow(dead_code)] pub struct Logger { log_level: LogLevel @@ -65,5 +64,4 @@ impl Logger { println!("[mpv-rpc (ERROR)] {}", message); } } - } \ No newline at end of file diff --git a/src/mpv_event_queue.rs b/src/mpv_event_queue.rs index b602017..c33e186 100644 --- a/src/mpv_event_queue.rs +++ b/src/mpv_event_queue.rs @@ -1,9 +1,9 @@ -use std::rc::Rc; +use std::{rc::Rc, time::Duration}; use mpv_client::{Handle, Event, Property, Format, mpv_handle, ClientMessage}; use crate::logging::{self, Logger}; pub mod events; -use events::{MpvEvent, FileInfo, FileMetadata}; +use events::{MpvEvent, MpvRequest, FileInfo, FileMetadata}; const NAME_PAUSE_PROP: &str = "pause"; @@ -11,7 +11,7 @@ const REPL_PAUSE_PROP: u64 = 1; pub struct MpvEventQueue { mpv: Handle, - logger: Rc, + logger: Rc } impl MpvEventQueue { @@ -40,18 +40,31 @@ impl MpvEventQueue { } } - pub fn next_event(&self) -> Option { + pub fn next_event(&mut self) -> Option { let event = self.mpv.wait_event(0.0); let mpv_event = self.convert_event(event); - - if let Some(ref event) = mpv_event { - logging::info!(self.logger, "Event: {event}"); - } - mpv_event } + pub fn handle_request(&self, request: MpvRequest) -> Result<(), &'static str> { + match request { + MpvRequest::OSDMessage(message) => self.display_osd_message(message) + } + } + + pub fn display_osd_message(&self, message: &str) -> Result<(), &'static str> { + match self.mpv.osd_message(message, Duration::from_secs(1)) { + Ok(()) => Ok(()), + Err(_) => Err("cannot print OSD message") + } + } + fn convert_event(&self, event: Event) -> Option { + match event { + Event::None => (), + ref event => logging::info!(self.logger, "Event: {event}") + } + match event { Event::FileLoaded => self.get_file_info_event(), Event::PlaybackRestart => self.get_seek_event(), @@ -87,27 +100,33 @@ impl MpvEventQueue { fn get_property_event(&self, prop_id: u64, prop: Property) -> Option { logging::info!(self.logger, "Property changed: {prop_id}"); match prop_id { - 1 => Some(self.convert_pause_prop(prop.data().unwrap())), + 1 => self.convert_pause_prop(prop.data().unwrap()), + 2 => self.convert_buffering_prop(prop.data().unwrap()), _ => None } } - fn convert_pause_prop(&self, pause: bool) -> MpvEvent { + fn convert_pause_prop(&self, pause: bool) -> Option { + let time = self.get_remaining_time(); match pause { - false => MpvEvent::Play, - true => MpvEvent::Pause + false => Some(MpvEvent::Play(time)), + true => Some(MpvEvent::Pause(time)) + } + } + + pub fn convert_buffering_prop(&self, buffering: bool) -> Option { + match buffering { + true => Some(MpvEvent::Buffering), + false => None } } fn get_seek_event(&self) -> Option { - let remaining_time = self.mpv.get_property("time-remaining").unwrap_or_else(|_| { - logging::warning!(self.logger, "Failed retrieving remaing-time."); - logging::warning!(self.logger, "This usually happens seeking into file end. Possibly mpv bug?"); - logging::warning!(self.logger, "Defaulting to 0."); - 0 - }); + Some(MpvEvent::Seek(self.get_remaining_time())) + } - Some(MpvEvent::Seek(remaining_time)) + fn get_remaining_time(&self) -> i64 { + self.mpv.get_property("time-remaining").unwrap_or_default() } fn get_toggle_event(&self, message: ClientMessage) -> Option { @@ -115,10 +134,11 @@ impl MpvEventQueue { logging::info!(self.logger, "Client message: {command}"); if command.starts_with("key-binding toggle-rpc d-") { - return Some(MpvEvent::Toggle) + Some(MpvEvent::Toggle) } else { None } } + } \ No newline at end of file diff --git a/src/mpv_event_queue/events.rs b/src/mpv_event_queue/events.rs index ef33b6c..020cdc6 100644 --- a/src/mpv_event_queue/events.rs +++ b/src/mpv_event_queue/events.rs @@ -1,5 +1,3 @@ -use std::fmt::Display; - pub struct FileInfo { pub filename: String, pub metadata: FileMetadata @@ -14,28 +12,22 @@ pub struct FileMetadata { pub enum MpvEvent { Toggle, - Play, - Pause, + Buffering, Exit, FileLoaded(FileInfo), + Play(i64), + Pause(i64), Seek(i64) } -impl Display for MpvEvent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let event_name = match self { - MpvEvent::Toggle => "Toggle", - MpvEvent::Play => "Play", - MpvEvent::Pause => "Pause", - MpvEvent::Exit => "Exit", - MpvEvent::FileLoaded(_) => "FileLoaded", - MpvEvent::Seek(_) => "Seek" - }; - write!(f, "{}", event_name) - } +pub enum MpvRequest { + OSDMessage(&'static str) } - pub trait MpvEventHandler { fn handle_event(&mut self, event: MpvEvent) -> Result<(), &'static str>; +} + +pub trait MpvRequester { + fn next_request<'a>(&mut self) -> Option; } \ No newline at end of file diff --git a/src/plugin.rs b/src/plugin.rs index 65da59a..4f9e924 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -1,9 +1,10 @@ use std::rc::Rc; use mpv_client::mpv_handle; use crate::logging::{self, Logger}; +use crate::config::Config; use crate::discord_client::DiscordClient; use crate::mpv_event_queue::MpvEventQueue; -use crate::mpv_event_queue::events::{MpvEventHandler, MpvEvent}; +use crate::mpv_event_queue::events::{MpvEventHandler, MpvRequester, MpvEvent, MpvRequest}; pub struct RPCPlugin { logger: Rc, @@ -14,8 +15,9 @@ pub struct RPCPlugin { impl RPCPlugin { pub fn new(handle: *mut mpv_handle, client_id: &str) -> Result { let logger = Rc::new(Logger::from_env()); + let config = Config::from_config_file(&logger); let mpv = MpvEventQueue::from_ptr(handle, Rc::clone(&logger))?; - let discord = DiscordClient::new(client_id, Rc::clone(&logger))?; + let discord = DiscordClient::new(client_id, config.active, config.cover_art, Rc::clone(&logger))?; Ok(Self { logger, @@ -28,7 +30,7 @@ impl RPCPlugin { loop { let event = self.mpv.next_event(); match event { - None => continue, + None => (), Some(event) => { if self.handle_event(event) { @@ -36,6 +38,12 @@ impl RPCPlugin { } } } + + let request = self.discord.next_request(); + match request { + None => (), + Some(request) => self.handle_request(request) + } } } @@ -51,4 +59,10 @@ impl RPCPlugin { exit } + + fn handle_request(&self, request: MpvRequest) { + if let Err(e) = self.mpv.handle_request(request) { + logging::error!(self.logger, "Failed to handle mpv request: {e}"); + } + } } \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..a9482a0 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,15 @@ +pub fn truncate_string(current: &mut String, length: usize) { + match current.char_indices().nth(length) { + None => (), + Some((index, _)) => current.truncate(index) + } +} + +pub fn truncate_string_fmt(current: &mut String, length: usize) { + if current.chars().count() <= length { + return; + } + + truncate_string(current, length - 3); + current.push_str("..."); +} \ No newline at end of file