diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 00000000..d433d7fe --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,2 @@ +[formatting] +indent_string = " " diff --git a/Cargo.lock b/Cargo.lock index 866b7f3b..f44521b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,6 +657,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.41" @@ -1135,6 +1141,29 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" +[[package]] +name = "discord-presence" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806b031712b2d07bb83d077c1d66d1d7c4209e2b812cb5d35a6aadfc2670f91f" +dependencies = [ + "byteorder", + "bytes", + "cfg-if", + "crossbeam-channel", + "log", + "num-derive", + "num-traits", + "parking_lot", + "paste", + "quork", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.12", + "uuid", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -3168,6 +3197,18 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -3783,6 +3824,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -3847,6 +3910,7 @@ dependencies = [ "crossbeam-channel", "ctr", "cubeb", + "discord-presence", "git-version", "gix-config", "hmac", @@ -3882,6 +3946,7 @@ version = "0.1.0" dependencies = [ "crossbeam-channel", "directories", + "discord-presence", "druid", "druid-enums", "druid-shell", @@ -3962,6 +4027,32 @@ dependencies = [ "byteorder", ] +[[package]] +name = "quork" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4282adb2f46ab23e5e5c7540c5e91f71fb5cb00101a29280b1b77a6e9b2be33" +dependencies = [ + "cfg-if", + "nix 0.29.0", + "quork-proc", + "thiserror 2.0.12", + "windows 0.58.0", +] + +[[package]] +name = "quork-proc" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0386bc87f6a1770b849cd7b1127499d9fe6526ea24cc267e0c89874bc7ae5e0c" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "quote" version = "1.0.40" @@ -5655,6 +5746,9 @@ name = "uuid" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", +] [[package]] name = "v_frame" @@ -6387,7 +6481,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.26.4", "once_cell", "ordered-stream", "rand 0.8.5", diff --git a/psst-core/Cargo.toml b/psst-core/Cargo.toml index 8f2fd440..b29af0ef 100644 --- a/psst-core/Cargo.toml +++ b/psst-core/Cargo.toml @@ -12,6 +12,7 @@ time = { version = "0.3.36", features = ["local-offset"] } [dependencies] psst-protocol = { path = "../psst-protocol" } rustfm-scrobble = "1.1.1" +discord-presence = { version = "1.5", features = ["activity_type"] } # Common byteorder = { version = "1.5.0" } diff --git a/psst-core/src/discord_rpc.rs b/psst-core/src/discord_rpc.rs new file mode 100644 index 00000000..b975339c --- /dev/null +++ b/psst-core/src/discord_rpc.rs @@ -0,0 +1,177 @@ +use crate::error::Error; +use discord_presence::{models::ActivityType, Client as DiscordClient, DiscordError}; + +use std::{ + sync::Arc, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use crossbeam_channel::{unbounded, Receiver, Sender}; + +pub enum DiscordRpcCmd { + Update { + track: Arc, + artist: Arc, + album: Option, + cover_url: Option, + duration: Option, + position: Option, + }, + Shutdown, + Clear, + UpdateAppId(u64), +} + +pub struct DiscordRPCClient { + client: Option, +} + +impl DiscordRPCClient { + #[inline] + fn with_client(&mut self, f: F) + where + F: FnOnce(&mut DiscordClient), + { + if let Some(c) = self.client.as_mut() { + f(c); + } + } + /// Creates a Discord Rich Presence client for Spotify with the provided application ID. + pub fn create_client(app_id: u64) -> Result { + let mut client = DiscordClient::new(app_id); + client.start(); + log::info!("discord rpc client created and started"); + Ok(client) + } + + /// Spawns a worker thread to handle Discord RPC commands. + pub fn spawn_rpc_worker(app_id: u64) -> Result, Error> { + let mut rpc = DiscordRPCClient { + client: Some(Self::create_client(app_id)?), + }; + let (tx, rx): (Sender, Receiver) = unbounded(); + + thread::spawn(move || { + for cmd in rx { + match cmd { + DiscordRpcCmd::Update { + track, + artist, + album, + cover_url, + duration, + position, + } => { + while !discord_presence::Client::is_ready() { + std::thread::sleep(Duration::from_millis(10)); + } + rpc.with_client(|c| { + let _ = Self::now_playing_song( + c, // <- &mut DiscordClient + &track, + &artist, + album.as_deref(), + cover_url.as_deref(), + duration, + position, + ); + }); + } + DiscordRpcCmd::Clear => { + rpc.with_client(|c| { + let _ = DiscordRPCClient::clear_presence(c); + }); + } + DiscordRpcCmd::Shutdown => { + if let Some(client) = rpc.client.take() { + if let Err(e) = client.shutdown() { + log::warn!("shutdown failed: {}", e); + } + } + // Exit the loop + break; + } + DiscordRpcCmd::UpdateAppId(new_id) => { + // take the old client out + if let Some(old) = rpc.client.take() { + if let Err(e) = old.shutdown() { + log::warn!("shutdown failed: {}", e); + } + } + // create replacement + match Self::create_client(new_id) { + Ok(new_cli) => rpc.client = Some(new_cli), + Err(e) => log::warn!("failed to create new client: {}", e), + } + } + } + } + // when tx is dropped everywhere, rx returns Err -> loop ends, rpc is dropped + }); + + Ok(tx) + } + + fn get_current_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::from_secs(0)) + .as_secs() + } + + /// Update the Discord Rich Presence with currently playing Spotify track information. + pub fn now_playing_song( + client: &mut DiscordClient, + track_name: &str, + artist: &str, + album: Option<&str>, + album_cover_url: Option<&str>, + track_duration: Option, + playback_position: Option, + ) -> Result<(), Error> { + client + .set_activity(|act| { + let mut act = act + .details(track_name) + .state(artist) + ._type(ActivityType::Listening); + + if let Some(cover_url) = album_cover_url { + act = act.assets(|assets| { + let mut assets = assets.large_image(cover_url); + if let Some(album_name) = album { + assets = assets.large_text(album_name); + } + assets + }); + } + + if let Some(duration) = track_duration { + let now = Self::get_current_timestamp(); + let position_secs = playback_position + .unwrap_or(Duration::from_secs(0)) + .as_secs(); + + let start_time = now.saturating_sub(position_secs); + let end_time = start_time + duration.as_secs(); + act = act.timestamps(|timestamps| timestamps.start(start_time).end(end_time)); + } + + act + }) + .map(|_| ()) + .map_err(Error::from) + } + + /// Stop displaying Rich Presence by clearing the activity. + pub fn clear_presence(client: &mut DiscordClient) -> Result<(), Error> { + client.clear_activity().map(|_| ()).map_err(Error::from) + } +} + +impl From for Error { + fn from(value: DiscordError) -> Self { + Self::DiscordRPCError(Box::new(value)) + } +} diff --git a/psst-core/src/error.rs b/psst-core/src/error.rs index d22664dc..85119101 100644 --- a/psst-core/src/error.rs +++ b/psst-core/src/error.rs @@ -15,6 +15,7 @@ pub enum Error { AudioOutputError(Box), AudioProbeError(Box), ScrobblerError(Box), + DiscordRPCError(Box), ResamplingError(i32), ConfigError(String), IoError(io::Error), @@ -57,6 +58,7 @@ impl fmt::Display for Error { | Self::AudioDecodingError(err) | Self::AudioOutputError(err) | Self::ScrobblerError(err) + | Self::DiscordRPCError(err) | Self::AudioProbeError(err) => err.fmt(f), Self::IoError(err) => err.fmt(f), Self::SendError => write!(f, "Failed to send into a channel"), diff --git a/psst-core/src/lib.rs b/psst-core/src/lib.rs index d2e0dad2..f7e1ee3b 100644 --- a/psst-core/src/lib.rs +++ b/psst-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod audio; pub mod cache; pub mod cdn; pub mod connection; +pub mod discord_rpc; pub mod error; pub mod item_id; pub mod lastfm; diff --git a/psst-gui/Cargo.toml b/psst-gui/Cargo.toml index 769d5572..19b174b1 100644 --- a/psst-gui/Cargo.toml +++ b/psst-gui/Cargo.toml @@ -53,6 +53,7 @@ raw-window-handle = "0.5.2" # Must stay compatible with Druid souvlaki = { version = "0.8.2", default-features = false, features = ["use_zbus"] } sanitize_html = "0.9.0" rustfm-scrobble = "1.1.1" + [target.'cfg(windows)'.build-dependencies] winres = { version = "0.1.12" } image = { version = "0.25.6" } diff --git a/psst-gui/src/controller/playback.rs b/psst-gui/src/controller/playback.rs index 9913b9a1..b98eaa01 100644 --- a/psst-gui/src/controller/playback.rs +++ b/psst-gui/src/controller/playback.rs @@ -13,6 +13,7 @@ use psst_core::{ audio::{normalize::NormalizationLevel, output::DefaultAudioOutput}, cache::Cache, cdn::Cdn, + discord_rpc::{DiscordRPCClient, DiscordRpcCmd}, lastfm::LastFmClient, player::{item::PlaybackItem, PlaybackConfig, Player, PlayerCommand, PlayerEvent}, session::SessionService, @@ -40,6 +41,7 @@ pub struct PlaybackController { media_controls: Option, has_scrobbled: bool, scrobbler: Option, + discord_rpc_sender: Option>, startup: bool, } fn init_scrobbler_instance(data: &AppState) -> Option { @@ -68,6 +70,55 @@ fn init_scrobbler_instance(data: &AppState) -> Option { None } +fn parse_valid_app_id(id_str: &str) -> Option { + let trimmed = id_str.trim(); + + if trimmed.is_empty() { + log::info!("discord rpc app id not provided"); + return None; + } + + if !trimmed.chars().all(|c| c.is_ascii_digit()) { + log::warn!("discord rpc app id contains non-digit characters"); + return None; + } + // Check if the client ID has a valid length for a snowflake 17-19 + if !(17..=19).contains(&trimmed.len()) { + log::warn!( + "discord rpc app id has invalid length ({} characters)", + trimmed.len() + ); + return None; + } + + match trimmed.parse::() { + Ok(id) => Some(id), + Err(e) => { + log::warn!("failed to parse discord rpc app id '{}': {}", trimmed, e); + None + } + } +} + +fn init_discord_rpc_instance(data: &AppState) -> Option> { + if data.config.discord_rpc_enable { + if let Some(client_id) = parse_valid_app_id(&data.config.discord_rpc_app_id) { + match DiscordRPCClient::spawn_rpc_worker(client_id) { + Ok(sender) => Some(sender), + Err(e) => { + log::warn!("failed to create discord rpc: {}", e); + None + } + } + } else { + None + } + } else { + log::info!("discord rpc is disabled"); + None + } +} + impl PlaybackController { pub fn new() -> Self { Self { @@ -77,6 +128,7 @@ impl PlaybackController { media_controls: None, has_scrobbled: false, scrobbler: None, + discord_rpc_sender: None, startup: true, } } @@ -262,6 +314,99 @@ impl PlaybackController { } } + fn clear_discord_rpc(&mut self) { + if let Some(sender) = &self.discord_rpc_sender { + let _ = sender + .send(DiscordRpcCmd::Clear) + .map_err(|e| log::error!("error clearing discord rpc: {:?}", e)); + } + } + fn update_discord_rpc(&mut self, playback: &Playback) { + if let Some(now_playing) = playback.now_playing.as_ref() { + if let Some(discord_rpc_sender) = &mut self.discord_rpc_sender { + let (title, artist, album_name, images, duration, progress) = + match &now_playing.item { + Playable::Track(track) => ( + track.name.clone(), + track.artist_name(), + track.album.as_ref().map(|a| &a.name), + track.album.as_ref().map(|a| &a.images), + track.duration, + now_playing.progress, + ), + Playable::Episode(episode) => ( + episode.name.clone(), + episode.show.name.clone(), + None, + Some(&episode.images), + episode.duration, + now_playing.progress, + ), + }; + + let album_cover_url = images.and_then(|imgs| { + imgs.iter() + .find(|img| img.width == Some(64)) + .or_else(|| imgs.get(0)) + .map(|img| img.url.as_ref()) + }); + + log::info!( + "updating discord rpc with track/episode: {} by {}", + title, + artist, + ); + + let _ = discord_rpc_sender + .send(DiscordRpcCmd::Update { + track: title.to_owned(), + artist: artist.to_owned(), + album: album_name.map(|a| a.as_ref().to_owned()), + cover_url: album_cover_url.map(str::to_owned), + duration: Some(duration), + position: Some(progress), + }) + .map_err(|e| log::error!("error updating discord rpc: {:?}", e)); + } + } + } + + fn reconcile_discord_rpc(&mut self, old: &AppState, new: &AppState, playback: &Playback) { + let was_enabled = old.config.discord_rpc_enable; + let is_enabled = new.config.discord_rpc_enable; + let rpc_running = self.discord_rpc_sender.is_some(); + let app_id_changed = old.config.discord_rpc_app_id != new.config.discord_rpc_app_id; + + // Shut down if RPC was disabled + if was_enabled && !is_enabled && rpc_running { + log::info!("shutting down discord rpc"); + if let Some(ref tx) = self.discord_rpc_sender { + let _ = tx.send(DiscordRpcCmd::Shutdown); + } + self.discord_rpc_sender = None; + } + + // Start if RPC is enabled and no worker running + if is_enabled && !rpc_running { + log::info!("starting discord rpc"); + self.discord_rpc_sender = init_discord_rpc_instance(new); + self.update_discord_rpc(playback); + } + + // Update App ID if RPC is running and App ID changed + if is_enabled && rpc_running && app_id_changed { + if let Some(app_id) = parse_valid_app_id(&new.config.discord_rpc_app_id) { + log::info!("updating discord rpc app id to {}", app_id); + if let Some(ref tx) = self.discord_rpc_sender { + let _ = tx.send(DiscordRpcCmd::UpdateAppId(app_id)); + self.update_discord_rpc(playback); + } + } else { + log::warn!("app id changed but new id is invalid; not updating"); + } + } + } + fn report_now_playing(&mut self, playback: &Playback) { if let Some(now_playing) = playback.now_playing.as_ref() { if let Playable::Track(track) = &now_playing.item { @@ -444,6 +589,7 @@ where // Song has changed, so we reset the has_scrobbled value self.has_scrobbled = false; self.report_now_playing(&data.playback); + self.update_discord_rpc(&data.playback); if let Some(queued) = data.queued_entry(*item) { data.start_playback(queued.item, queued.origin, progress.to_owned()); @@ -468,11 +614,13 @@ where Event::Command(cmd) if cmd.is(cmd::PLAYBACK_PAUSING) => { data.pause_playback(); self.update_media_control_playback(&data.playback); + self.clear_discord_rpc(); ctx.set_handled(); } Event::Command(cmd) if cmd.is(cmd::PLAYBACK_RESUMING) => { data.resume_playback(); self.update_media_control_playback(&data.playback); + self.update_discord_rpc(&data.playback); ctx.set_handled(); } Event::Command(cmd) if cmd.is(cmd::PLAYBACK_BLOCKED) => { @@ -482,6 +630,7 @@ where Event::Command(cmd) if cmd.is(cmd::PLAYBACK_STOPPED) => { data.stop_playback(); self.update_media_control_playback(&data.playback); + self.clear_discord_rpc(); ctx.set_handled(); } Event::Command(cmd) if cmd.is(cmd::PLAY_TRACKS) => { @@ -540,6 +689,7 @@ where ); self.seek(position); } + self.update_discord_rpc(&data.playback); ctx.set_handled(); } Event::Command(cmd) if cmd.is(cmd::SKIP_TO_POSITION) => { @@ -615,6 +765,7 @@ where if self.startup { self.startup = false; self.scrobbler = init_scrobbler_instance(data); + self.discord_rpc_sender = init_discord_rpc_instance(data); } child.lifecycle(ctx, event, data, env); } @@ -636,6 +787,8 @@ where || old_data.config.lastfm_session_key != data.config.lastfm_session_key || old_data.config.lastfm_enable != data.config.lastfm_enable; + self.reconcile_discord_rpc(old_data, data, &data.playback); + if lastfm_changed { self.scrobbler = init_scrobbler_instance(data); } diff --git a/psst-gui/src/data/config.rs b/psst-gui/src/data/config.rs index fc96b839..f88891a1 100644 --- a/psst-gui/src/data/config.rs +++ b/psst-gui/src/data/config.rs @@ -126,6 +126,8 @@ pub struct Config { pub lastfm_api_key: Option, pub lastfm_api_secret: Option, pub lastfm_enable: bool, + pub discord_rpc_app_id: String, + pub discord_rpc_enable: bool, } impl Default for Config { @@ -148,6 +150,8 @@ impl Default for Config { lastfm_api_key: None, lastfm_api_secret: None, lastfm_enable: false, + discord_rpc_app_id: String::new(), + discord_rpc_enable: false, } } } diff --git a/psst-gui/src/ui/preferences.rs b/psst-gui/src/ui/preferences.rs index 7eee352c..870f3038 100644 --- a/psst-gui/src/ui/preferences.rs +++ b/psst-gui/src/ui/preferences.rs @@ -429,6 +429,41 @@ fn account_tab_widget(tab: AccountTab) -> impl Widget { }, )); } + if matches!(tab, AccountTab::InPreferences) { + col = col + .with_spacer(theme::grid(2.0)) + .with_child(Label::new("Discord RPC")) + .with_spacer(theme::grid(1.0)) + .with_child( + Label::new( + "Connects to a running Discord client to display currently playing music", + ) + .with_text_color(theme::PLACEHOLDER_COLOR) + .with_line_break_mode(LineBreaking::WordWrap), + ) + .with_spacer(theme::grid(2.0)) + .with_child( + Flex::row().with_child( + Checkbox::new("Enable Discord RPC") + .lens(AppState::config.then(Config::discord_rpc_enable)) + .padding((0.0, 0.0, theme::grid(1.0), 0.0)), + ), + ) + .with_spacer(theme::grid(1.0)) + .with_child(make_input_row( + "App ID", + "Enter your Discord App ID", + AppState::config.then(Config::discord_rpc_app_id), + )) + .with_spacer(theme::grid(1.0)) + .with_child( + Button::new("Create a Discord App →") + .on_click(|_, _, _| { + open::that("https://discord.com/developers/applications").ok(); + }) + .padding((0.0, theme::grid(0.5))), + ) + } col.controller(Authenticate::new(tab)) } diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index bdf36b46..fce2ad7a 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -942,9 +942,12 @@ impl WebApi { .query("market", "from_token"); let mut results = Vector::new(); - self.for_all_pages(request, |page: Page| { + self.for_all_pages(request, |page: Page>| { if !page.items.is_empty() { - let ids = page.items.into_iter().map(|link| link.id); + let ids = page + .items + .into_iter() + .filter_map(|link| link.map(|link| link.id)); let episodes = self.get_episodes(ids)?; results.append(episodes); }