From 3f38fb0d250037b4423c4807455ca628c4dccf66 Mon Sep 17 00:00:00 2001 From: LargeModGames Date: Fri, 23 Jan 2026 12:46:13 +0100 Subject: [PATCH 1/5] feat(ci): add cargo-deb installation and Debian packaging steps --- .github/workflows/cd.yml | 22 ++++++++++++++++++++++ Cargo.toml | 8 ++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 171031c..e4cb018 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -57,6 +57,10 @@ jobs: sudo apt-get update sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libpipewire-0.3-dev libspa-0.2-dev libasound2-dev + - name: Install cargo-deb + if: runner.os == 'Linux' + run: cargo install cargo-deb --locked + - name: Install macOS dependencies if: runner.os == 'macOS' run: brew install openssl@3 portaudio @@ -82,6 +86,15 @@ jobs: cd ../../.. shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.tar.gz.sha256 + - name: Package Debian (.deb) + if: runner.os == 'Linux' + shell: bash + run: | + cargo deb --no-build --target ${{ matrix.target }} + DEB_PATH=$(ls target/${{ matrix.target }}/debian/*.deb) + cp "$DEB_PATH" . + sha256sum "$(basename "$DEB_PATH")" > "$(basename "$DEB_PATH").sha256" + - name: Package binary (Windows) if: runner.os == 'Windows' shell: bash @@ -102,6 +115,15 @@ jobs: ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.tar.gz ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.tar.gz.sha256 + - name: Upload artifact (Linux .deb) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v6 + with: + name: ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}-deb + path: | + *.deb + *.deb.sha256 + - name: Upload artifact (Windows) if: runner.os == 'Windows' uses: actions/upload-artifact@v6 diff --git a/Cargo.toml b/Cargo.toml index 623f173..ee3a35d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,3 +113,11 @@ codegen-units = 1 # Better optimization lto = true # Link-time optimization opt-level = "s" # Prioritize small binary size strip = true # Remove debug symbols + +[package.metadata.deb] +maintainer = "LargeModGames " +copyright = "2025, LargeModGames" +license-file = ["LICENSE", "4"] +depends = "$auto" +section = "utils" +priority = "optional" From dd2f1ae8149233a1c2036c38d7e25263ac2aec40 Mon Sep 17 00:00:00 2001 From: LargeModGames Date: Fri, 23 Jan 2026 14:35:01 +0100 Subject: [PATCH 2/5] feat(discord): add Discord Rich Presence integration and configuration options --- .gitignore | 1 + CHANGELOG.md | 3 +- Cargo.lock | 35 ++++++-- Cargo.toml | 6 +- README.md | 15 ++++ src/app.rs | 11 +++ src/discord_rpc.rs | 144 +++++++++++++++++++++++++++++++ src/main.rs | 208 ++++++++++++++++++++++++++++++++++++++++++++- src/user_config.rs | 16 ++++ 9 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 src/discord_rpc.rs diff --git a/.gitignore b/.gitignore index e73ac89..7eb418f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ secrets.tar tags .idea +.vscode # Local cargo config (platform-specific settings) .cargo/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cd32c1d..b71ad52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog -## [0.35.3] - 2026-01-22 +## [0.35.3] - 2026-01-23 ### Added - **Playbar Status Messages**: Added transient status messages in the playbar, used to notify when the saved playback device is unavailable and spotatui falls back to native streaming. +- **Discord Rich Presence**: Show track info, album art, and a GitHub callout in Discord; enabled by default with a built-in application ID and optional overrides via config/env. ### Fixed diff --git a/Cargo.lock b/Cargo.lock index cecaf52..af4bb96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,6 +1114,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "discord-rich-presence" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ead3c5edc7e048c317c6fc4a7e24aff0c7e4c136918e2ba38106a385b2cc53a5" +dependencies = [ + "log", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "uuid 0.8.2", +] + [[package]] name = "dispatch2" version = "0.3.0" @@ -2486,7 +2501,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", - "uuid", + "uuid 1.19.0", ] [[package]] @@ -2542,7 +2557,7 @@ dependencies = [ "tokio-tungstenite", "tokio-util", "url", - "uuid", + "uuid 1.19.0", "vergen-gitcl", ] @@ -2561,7 +2576,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "uuid", + "uuid 1.19.0", ] [[package]] @@ -4615,7 +4630,7 @@ dependencies = [ [[package]] name = "spotatui" -version = "0.35.2" +version = "0.35.3" dependencies = [ "anyhow", "arboard", @@ -4628,6 +4643,7 @@ dependencies = [ "cpal 0.17.1", "crossterm", "dirs", + "discord-rich-presence", "futures", "librespot-connect", "librespot-core", @@ -5431,6 +5447,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "uuid" version = "1.19.0" @@ -6260,7 +6285,7 @@ dependencies = [ "serde_repr", "tracing", "uds_windows", - "uuid", + "uuid 1.19.0", "windows-sys 0.61.2", "winnow", "zbus_macros", diff --git a/Cargo.toml b/Cargo.toml index ee3a35d..0a01054 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ documentation = "https://github.com/LargeModGames/spotatui" repository = "https://github.com/LargeModGames/spotatui" keywords = ["spotify", "tui", "cli", "terminal"] categories = ["command-line-utilities"] -version = "0.35.2" +version = "0.35.3" authors = ["LargeModGames "] edition = "2021" license = "MIT" @@ -46,6 +46,7 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-feature openssl = { version = "0.10", optional = true } cpal = { version = "0.17", optional = true } realfft = { version = "3.4", optional = true } +discord-rich-presence = { version = "1.0", optional = true } # Streaming dependencies (librespot) librespot-core = { version = "0.8", optional = true } @@ -83,7 +84,7 @@ objc2 = { version = "0.6", optional = true } block2 = { version = "0.6", optional = true } [features] -default = ["telemetry", "streaming", "audio-viz-cpal", "mpris", "macos-media"] +default = ["telemetry", "streaming", "audio-viz-cpal", "mpris", "macos-media", "discord-rpc"] telemetry = ["reqwest", "reqwest/rustls-tls"] streaming = ["librespot-core", "librespot-playback", "librespot-connect", "librespot-oauth", "librespot-metadata"] # Audio backend features @@ -99,6 +100,7 @@ audio-viz = ["realfft", "pipewire"] audio-viz-cpal = ["realfft", "cpal"] # Alternative for Windows/macOS or if pipewire issues mpris = ["mpris-server", "streaming"] # MPRIS D-Bus integration (Linux only, requires streaming) macos-media = ["objc2-media-player", "objc2-foundation", "objc2", "block2", "streaming"] # macOS Now Playing integration +discord-rpc = ["discord-rich-presence"] [target.'cfg(target_env = "musl")'.dependencies] openssl-sys = { version = "0.9", features = ["vendored"] } diff --git a/README.md b/README.md index c8cfe7c..e06e8ab 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - [Usage](#usage) - [Native Streaming](#native-streaming) - [Configuration](#configuration) +- [Discord Rich Presence](#discord-rich-presence) - [Limitations](#limitations) - [Deprecated Spotify API Features](#deprecated-spotify-api-features) - [Using with spotifyd](#using-with-spotifyd) @@ -166,6 +167,20 @@ You can also configure spotatui in-app by pressing `Alt-,` to open Settings. See [Themes Wiki](https://github.com/LargeModGames/spotatui/wiki/Themes) for built-in presets (Spotify, Dracula, Nord, etc.). +### Discord Rich Presence + +Discord Rich Presence is enabled by default and uses the built-in spotatui application ID, so no extra setup is required. + +Overrides (optional): + +```yaml +behavior: + enable_discord_rpc: true + discord_rpc_client_id: "your_client_id" +``` + +You can also override via `SPOTATUI_DISCORD_APP_ID` or disable in the setting or by setting `behavior.enable_discord_rpc: false` in ~/.config/spotatui/config.yml. + ## Limitations This app uses the [Web API](https://developer.spotify.com/documentation/web-api/) from Spotify, which doesn't handle streaming itself. You have three options for audio playback: diff --git a/src/app.rs b/src/app.rs index 2db055d..e411d0a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1551,6 +1551,12 @@ impl App { description: "Update terminal window title with track info".to_string(), value: SettingValue::Bool(self.user_config.behavior.set_window_title), }, + SettingItem { + id: "behavior.enable_discord_rpc".to_string(), + name: "Discord Rich Presence".to_string(), + description: "Show your current track in Discord".to_string(), + value: SettingValue::Bool(self.user_config.behavior.enable_discord_rpc), + }, SettingItem { id: "behavior.liked_icon".to_string(), name: "Liked Icon".to_string(), @@ -1794,6 +1800,11 @@ impl App { self.user_config.behavior.set_window_title = *v; } } + "behavior.enable_discord_rpc" => { + if let SettingValue::Bool(v) = &setting.value { + self.user_config.behavior.enable_discord_rpc = *v; + } + } "behavior.liked_icon" => { if let SettingValue::String(v) = &setting.value { self.user_config.behavior.liked_icon = v.clone(); diff --git a/src/discord_rpc.rs b/src/discord_rpc.rs new file mode 100644 index 0000000..791aa06 --- /dev/null +++ b/src/discord_rpc.rs @@ -0,0 +1,144 @@ +use anyhow::Result; +use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const REPO_URL: &str = "https://github.com/LargeModGames/spotatui"; +const REPO_TAGLINE: &str = "Open-source on GitHub"; + +#[derive(Clone, Debug)] +pub struct DiscordPlayback { + pub title: String, + pub artist: String, + pub album: String, + pub state: String, + pub image_url: Option, + pub duration_ms: u32, + pub progress_ms: u128, + pub is_playing: bool, +} + +enum DiscordRpcCommand { + SetActivity(DiscordPlayback), + ClearActivity, +} + +pub struct DiscordRpcManager { + command_tx: Sender, +} + +impl DiscordRpcManager { + pub fn new(app_id: String) -> Result { + let (command_tx, command_rx) = mpsc::channel(); + + thread::spawn(move || run_discord_rpc_loop(app_id, command_rx)); + + Ok(Self { command_tx }) + } + + pub fn set_activity(&self, playback: &DiscordPlayback) { + let _ = self + .command_tx + .send(DiscordRpcCommand::SetActivity(playback.clone())); + } + + pub fn clear(&self) { + let _ = self.command_tx.send(DiscordRpcCommand::ClearActivity); + } +} + +fn run_discord_rpc_loop(app_id: String, command_rx: Receiver) { + let mut client: Option = None; + let mut last_connect_attempt = Instant::now() - Duration::from_secs(30); + + for command in command_rx { + if !ensure_connected(&app_id, &mut client, &mut last_connect_attempt) { + continue; + } + + let mut disconnect = false; + + if let Some(ref mut ipc_client) = client { + let result = match command { + DiscordRpcCommand::SetActivity(playback) => { + let activity = build_activity(&playback); + ipc_client.set_activity(activity) + } + DiscordRpcCommand::ClearActivity => ipc_client.clear_activity(), + }; + + if result.is_err() { + let _ = ipc_client.close(); + disconnect = true; + } + } + + if disconnect { + client = None; + } + } + + if let Some(ref mut client) = client { + let _ = client.clear_activity(); + let _ = client.close(); + } +} + +fn ensure_connected( + app_id: &str, + client: &mut Option, + last_connect_attempt: &mut Instant, +) -> bool { + if client.is_some() { + return true; + } + + if last_connect_attempt.elapsed() < Duration::from_secs(5) { + return false; + } + + *last_connect_attempt = Instant::now(); + + let mut new_client = DiscordIpcClient::new(app_id); + match new_client.connect() { + Ok(()) => { + *client = Some(new_client); + true + } + Err(_) => false, + } +} + +fn build_activity(playback: &DiscordPlayback) -> activity::Activity<'_> { + let mut activity = activity::Activity::new() + .details(&playback.title) + .details_url(REPO_URL) + .state(&playback.state) + .state_url(REPO_URL) + .activity_type(activity::ActivityType::Listening); + + if let Some(image_url) = playback.image_url.as_deref() { + let assets = activity::Assets::new() + .large_image(image_url) + .large_text(REPO_URL) + .small_text(REPO_TAGLINE); + activity = activity.assets(assets); + } + + if playback.is_playing && playback.duration_ms > 0 { + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let progress_secs = (playback.progress_ms / 1000) as i64; + let duration_secs = (playback.duration_ms as i64) / 1000; + let start = now_secs.saturating_sub(progress_secs); + let end = start.saturating_add(duration_secs); + + let timestamps = activity::Timestamps::new().start(start).end(end); + activity = activity.timestamps(timestamps); + } + + activity +} diff --git a/src/main.rs b/src/main.rs index 5e7db20..e3980dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,8 @@ mod audio; mod banner; mod cli; mod config; +#[cfg(feature = "discord-rpc")] +mod discord_rpc; mod event; mod handlers; #[cfg(all(feature = "macos-media", target_os = "macos"))] @@ -82,6 +84,11 @@ use std::{ use tokio::sync::Mutex; use user_config::{UserConfig, UserConfigPaths}; +#[cfg(feature = "discord-rpc")] +type DiscordRpcHandle = Option; +#[cfg(not(feature = "discord-rpc"))] +type DiscordRpcHandle = Option<()>; + const SCOPES: [&str; 16] = [ "playlist-read-collaborative", "playlist-read-private", @@ -101,6 +108,153 @@ const SCOPES: [&str; 16] = [ "streaming", // Required for native playback ]; +#[cfg(feature = "discord-rpc")] +const DEFAULT_DISCORD_CLIENT_ID: &str = "1464235043462447166"; + +#[cfg(feature = "discord-rpc")] +#[derive(Clone, Debug, PartialEq)] +struct DiscordTrackInfo { + title: String, + artist: String, + album: String, + image_url: Option, + duration_ms: u32, +} + +#[cfg(feature = "discord-rpc")] +#[derive(Default)] +struct DiscordPresenceState { + last_track: Option, + last_is_playing: Option, + last_progress_ms: u128, +} + +#[cfg(feature = "discord-rpc")] +fn resolve_discord_app_id(user_config: &UserConfig) -> Option { + std::env::var("SPOTATUI_DISCORD_APP_ID") + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| user_config.behavior.discord_rpc_client_id.clone()) + .or_else(|| Some(DEFAULT_DISCORD_CLIENT_ID.to_string())) +} + +#[cfg(feature = "discord-rpc")] +fn build_discord_playback(app: &App) -> Option { + use crate::ui::util::create_artist_string; + use rspotify::model::PlayableItem; + + let (track_info, is_playing) = if let Some(native_info) = &app.native_track_info { + let is_playing = app.native_is_playing.unwrap_or(true); + ( + DiscordTrackInfo { + title: native_info.name.clone(), + artist: native_info.artists_display.clone(), + album: native_info.album.clone(), + image_url: None, + duration_ms: native_info.duration_ms, + }, + is_playing, + ) + } else if let Some(context) = &app.current_playback_context { + let is_playing = if app.is_streaming_active { + app.native_is_playing.unwrap_or(context.is_playing) + } else { + context.is_playing + }; + + let item = context.item.as_ref()?; + match item { + PlayableItem::Track(track) => ( + DiscordTrackInfo { + title: track.name.clone(), + artist: create_artist_string(&track.artists), + album: track.album.name.clone(), + image_url: track.album.images.first().map(|image| image.url.clone()), + duration_ms: track.duration.num_milliseconds() as u32, + }, + is_playing, + ), + PlayableItem::Episode(episode) => ( + DiscordTrackInfo { + title: episode.name.clone(), + artist: episode.show.name.clone(), + album: String::new(), + image_url: episode.images.first().map(|image| image.url.clone()), + duration_ms: episode.duration.num_milliseconds() as u32, + }, + is_playing, + ), + } + } else { + return None; + }; + + let base_state = if track_info.album.is_empty() { + track_info.artist.clone() + } else { + format!("{} - {}", track_info.artist, track_info.album) + }; + let state = if is_playing { + base_state + } else if base_state.is_empty() { + "Paused".to_string() + } else { + format!("Paused: {}", base_state) + }; + + Some(discord_rpc::DiscordPlayback { + title: track_info.title, + artist: track_info.artist, + album: track_info.album, + state, + image_url: track_info.image_url, + duration_ms: track_info.duration_ms, + progress_ms: app.song_progress_ms, + is_playing, + }) +} + +#[cfg(feature = "discord-rpc")] +fn update_discord_presence( + manager: &discord_rpc::DiscordRpcManager, + state: &mut DiscordPresenceState, + app: &App, +) { + let playback = build_discord_playback(app); + + match playback { + Some(playback) => { + let track_info = DiscordTrackInfo { + title: playback.title.clone(), + artist: playback.artist.clone(), + album: playback.album.clone(), + image_url: playback.image_url.clone(), + duration_ms: playback.duration_ms, + }; + + let track_changed = state.last_track.as_ref() != Some(&track_info); + let playing_changed = state.last_is_playing != Some(playback.is_playing); + let progress_delta = playback.progress_ms.abs_diff(state.last_progress_ms); + let progress_changed = progress_delta > 5000; + + if track_changed || playing_changed || progress_changed { + manager.set_activity(&playback); + state.last_track = Some(track_info); + state.last_is_playing = Some(playback.is_playing); + state.last_progress_ms = playback.progress_ms; + } + } + None => { + if state.last_track.is_some() { + manager.clear(); + state.last_track = None; + state.last_is_playing = None; + state.last_progress_ms = 0; + } + } + } +} + // Manual token cache helpers since rspotify's built-in caching isn't working async fn save_token_to_file(spotify: &AuthCodeSpotify, path: &PathBuf) -> Result<()> { let token_lock = spotify.token.lock().await.expect("Failed to lock token"); @@ -614,6 +768,16 @@ of the app. Beware that this comes at a CPU cost!", None }; + #[cfg(feature = "discord-rpc")] + let discord_rpc_manager: DiscordRpcHandle = if user_config.behavior.enable_discord_rpc { + resolve_discord_app_id(&user_config) + .and_then(|app_id| discord_rpc::DiscordRpcManager::new(app_id).ok()) + } else { + None + }; + #[cfg(not(feature = "discord-rpc"))] + let discord_rpc_manager: DiscordRpcHandle = None; + // Spawn MPRIS event handler to process external control requests (media keys, playerctl) #[cfg(all(feature = "mpris", target_os = "linux"))] if let Some(ref mpris) = mpris_manager { @@ -762,15 +926,23 @@ of the app. Beware that this comes at a CPU cost!", &cloned_app, Some(shared_position_for_ui), mpris_for_ui, + discord_rpc_manager, ) .await?; #[cfg(all( feature = "streaming", not(all(feature = "mpris", target_os = "linux")) ))] - start_ui(user_config, &cloned_app, Some(shared_position_for_ui), None).await?; + start_ui( + user_config, + &cloned_app, + Some(shared_position_for_ui), + None, + discord_rpc_manager, + ) + .await?; #[cfg(not(feature = "streaming"))] - start_ui(user_config, &cloned_app, None, None).await?; + start_ui(user_config, &cloned_app, None, None, discord_rpc_manager).await?; } Ok(()) @@ -1276,7 +1448,10 @@ async fn start_ui( app: &Arc>, shared_position: Option>, mpris_manager: Option>, + discord_rpc_manager: DiscordRpcHandle, ) -> Result<()> { + #[cfg(not(feature = "discord-rpc"))] + let _ = discord_rpc_manager; // Terminal initialization let mut terminal = ratatui::init(); execute!(stdout(), EnableMouseCapture)?; @@ -1296,6 +1471,9 @@ async fn start_ui( #[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] let mut audio_capture: Option = None; + #[cfg(feature = "discord-rpc")] + let mut discord_presence_state = DiscordPresenceState::default(); + // Check for updates SYNCHRONOUSLY before starting the event loop // This ensures the update prompt appears before any user interaction { @@ -1454,6 +1632,11 @@ async fn start_ui( let mut app = app.lock().await; app.update_on_tick(); + #[cfg(feature = "discord-rpc")] + if let Some(ref manager) = discord_rpc_manager { + update_discord_presence(manager, &mut discord_presence_state, &app); + } + // Read position from shared atomic if native streaming is active // This provides lock-free real-time updates from player events if let Some(ref pos) = shared_position { @@ -1513,6 +1696,11 @@ async fn start_ui( execute!(stdout(), DisableMouseCapture)?; ratatui::restore(); + #[cfg(feature = "discord-rpc")] + if let Some(ref manager) = discord_rpc_manager { + manager.clear(); + } + Ok(()) } @@ -1523,7 +1711,10 @@ async fn start_ui( app: &Arc>, shared_position: Option>, _mpris_manager: Option<()>, + discord_rpc_manager: DiscordRpcHandle, ) -> Result<()> { + #[cfg(not(feature = "discord-rpc"))] + let _ = discord_rpc_manager; use ratatui::{prelude::Style, widgets::Block}; // Terminal initialization @@ -1553,6 +1744,9 @@ async fn start_ui( #[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] let mut audio_capture: Option = None; + #[cfg(feature = "discord-rpc")] + let mut discord_presence_state = DiscordPresenceState::default(); + let mut is_first_render = true; loop { @@ -1656,6 +1850,11 @@ async fn start_ui( let mut app = app.lock().await; app.update_on_tick(); + #[cfg(feature = "discord-rpc")] + if let Some(ref manager) = discord_rpc_manager { + update_discord_presence(manager, &mut discord_presence_state, &app); + } + #[cfg(feature = "streaming")] if let Some(ref pos) = shared_position { let pos_ms = pos.load(Ordering::Relaxed) as u128; @@ -1709,5 +1908,10 @@ async fn start_ui( execute!(stdout(), DisableMouseCapture)?; ratatui::restore(); + #[cfg(feature = "discord-rpc")] + if let Some(ref manager) = discord_rpc_manager { + manager.clear(); + } + Ok(()) } diff --git a/src/user_config.rs b/src/user_config.rs index d0230b8..762cb27 100644 --- a/src/user_config.rs +++ b/src/user_config.rs @@ -514,6 +514,8 @@ pub struct BehaviorConfigString { pub show_loading_indicator: Option, pub enforce_wide_search_bar: Option, pub enable_global_song_count: Option, + pub enable_discord_rpc: Option, + pub discord_rpc_client_id: Option, pub shuffle_enabled: Option, pub liked_icon: Option, pub shuffle_icon: Option, @@ -535,6 +537,8 @@ pub struct BehaviorConfig { pub show_loading_indicator: bool, pub enforce_wide_search_bar: bool, pub enable_global_song_count: bool, + pub enable_discord_rpc: bool, + pub discord_rpc_client_id: Option, pub shuffle_enabled: bool, pub liked_icon: String, pub shuffle_icon: String, @@ -602,6 +606,8 @@ impl UserConfig { show_loading_indicator: true, enforce_wide_search_bar: false, enable_global_song_count: true, + enable_discord_rpc: true, + discord_rpc_client_id: None, shuffle_enabled: false, liked_icon: "♥".to_string(), shuffle_icon: "🔀".to_string(), @@ -782,6 +788,14 @@ impl UserConfig { self.behavior.enable_global_song_count = enable_global_song_count; } + if let Some(enable_discord_rpc) = behavior_config.enable_discord_rpc { + self.behavior.enable_discord_rpc = enable_discord_rpc; + } + + if let Some(discord_rpc_client_id) = behavior_config.discord_rpc_client_id { + self.behavior.discord_rpc_client_id = Some(discord_rpc_client_id); + } + if let Some(shuffle_enabled) = behavior_config.shuffle_enabled { self.behavior.shuffle_enabled = shuffle_enabled; } @@ -844,6 +858,8 @@ impl UserConfig { show_loading_indicator: Some(self.behavior.show_loading_indicator), enforce_wide_search_bar: Some(self.behavior.enforce_wide_search_bar), enable_global_song_count: Some(self.behavior.enable_global_song_count), + enable_discord_rpc: Some(self.behavior.enable_discord_rpc), + discord_rpc_client_id: self.behavior.discord_rpc_client_id.clone(), shuffle_enabled: Some(self.behavior.shuffle_enabled), liked_icon: Some(self.behavior.liked_icon.clone()), shuffle_icon: Some(self.behavior.shuffle_icon.clone()), From d80a6304db414916e4d342d9b9387c062351a576 Mon Sep 17 00:00:00 2001 From: LargeModGames Date: Sat, 24 Jan 2026 13:40:01 +0100 Subject: [PATCH 3/5] feat(cd): add workflow to publish pre-built AUR binaries (spotatui-bin) --- .github/workflows/cd.yml | 80 +++++++++++++++++++++++++++++ .github/workflows/publish-aur.yml | 83 +++++++++++++++++++++++++++++++ CHANGELOG.md | 10 +++- README.md | 5 +- 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e4cb018..75f4204 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -254,3 +254,83 @@ jobs: git add PKGBUILD .SRCINFO git commit -m "Update to ${{ steps.version.outputs.version }}" GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push + + publish-aur-bin: + name: Publish to AUR (spotatui-bin) + needs: release + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v6 + + - name: Extract version + id: version + run: | + VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Calculate SHA256 checksums + id: sha256 + run: | + VERSION="${{ steps.version.outputs.version }}" + + # SHA256 for the binary tarball + BINARY_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/spotatui-linux-x86_64.tar.gz" + SHA256_BINARY=$(curl -sL "$BINARY_URL" | sha256sum | awk '{print $1}') + echo "sha256_binary=$SHA256_BINARY" >> $GITHUB_OUTPUT + echo "Binary SHA256: $SHA256_BINARY" + + # SHA256 for LICENSE + LICENSE_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/LICENSE" + SHA256_LICENSE=$(curl -sL "$LICENSE_URL" | sha256sum | awk '{print $1}') + echo "sha256_license=$SHA256_LICENSE" >> $GITHUB_OUTPUT + echo "LICENSE SHA256: $SHA256_LICENSE" + + # SHA256 for README.md + README_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/README.md" + SHA256_README=$(curl -sL "$README_URL" | sha256sum | awk '{print $1}') + echo "sha256_readme=$SHA256_README" >> $GITHUB_OUTPUT + echo "README SHA256: $SHA256_README" + + - name: Setup SSH for AUR + run: | + mkdir -p ~/.ssh + echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts + git config --global user.name "${{ secrets.AUR_USERNAME }}" + git config --global user.email "${{ secrets.AUR_EMAIL }}" + + - name: Clone AUR repository + run: | + GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git clone ssh://aur@aur.archlinux.org/spotatui-bin.git aur-bin + + - name: Update PKGBUILD + working-directory: aur-bin + run: | + sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" PKGBUILD + sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD + # Remove old multi-line sha256sums and insert new single-line version + sed -i '/^sha256sums=/,/)/d' PKGBUILD + sed -i "/^sha256sums_x86_64=/i sha256sums=('${{ steps.sha256.outputs.sha256_license }}' '${{ steps.sha256.outputs.sha256_readme }}')" PKGBUILD + sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${{ steps.sha256.outputs.sha256_binary }}')/" PKGBUILD + + - name: Generate .SRCINFO + working-directory: aur-bin + run: | + docker run --rm -v $PWD:/pkg archlinux:latest /bin/bash -c " + pacman -Syu --noconfirm --needed base-devel && + useradd -m builder && + chown -R builder:builder /pkg && + cd /pkg && + su builder -c 'makepkg --printsrcinfo' > .SRCINFO + " + sudo chown -R $(id -u):$(id -g) . + + - name: Commit and push to AUR + working-directory: aur-bin + run: | + git add PKGBUILD .SRCINFO + git commit -m "Update to ${{ steps.version.outputs.version }}" + GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 4e48a59..577a9e0 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -76,3 +76,86 @@ jobs: git add PKGBUILD .SRCINFO git commit -m "Update to ${{ steps.version.outputs.version }}" GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push + + publish-aur-bin: + name: Publish to AUR (spotatui-bin) + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v6 + + - name: Extract version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Calculate SHA256 checksums + id: sha256 + run: | + VERSION="${{ steps.version.outputs.version }}" + + # SHA256 for the binary tarball + BINARY_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/spotatui-linux-x86_64.tar.gz" + SHA256_BINARY=$(curl -sL "$BINARY_URL" | sha256sum | awk '{print $1}') + echo "sha256_binary=$SHA256_BINARY" >> $GITHUB_OUTPUT + echo "Binary SHA256: $SHA256_BINARY" + + # SHA256 for LICENSE + LICENSE_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/LICENSE" + SHA256_LICENSE=$(curl -sL "$LICENSE_URL" | sha256sum | awk '{print $1}') + echo "sha256_license=$SHA256_LICENSE" >> $GITHUB_OUTPUT + echo "LICENSE SHA256: $SHA256_LICENSE" + + # SHA256 for README.md + README_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/README.md" + SHA256_README=$(curl -sL "$README_URL" | sha256sum | awk '{print $1}') + echo "sha256_readme=$SHA256_README" >> $GITHUB_OUTPUT + echo "README SHA256: $SHA256_README" + + - name: Setup SSH for AUR + run: | + mkdir -p ~/.ssh + echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts + git config --global user.name "${{ secrets.AUR_USERNAME }}" + git config --global user.email "${{ secrets.AUR_EMAIL }}" + + - name: Clone AUR repository + run: | + GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git clone ssh://aur@aur.archlinux.org/spotatui-bin.git aur-bin + + - name: Update PKGBUILD + working-directory: aur-bin + run: | + sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" PKGBUILD + sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD + # Remove old multi-line sha256sums and insert new single-line version + sed -i '/^sha256sums=/,/)/d' PKGBUILD + sed -i "/^sha256sums_x86_64=/i sha256sums=('${{ steps.sha256.outputs.sha256_license }}' '${{ steps.sha256.outputs.sha256_readme }}')" PKGBUILD + sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${{ steps.sha256.outputs.sha256_binary }}')/" PKGBUILD + + - name: Generate .SRCINFO + working-directory: aur-bin + run: | + docker run --rm -v $PWD:/pkg archlinux:latest /bin/bash -c " + pacman -Syu --noconfirm --needed base-devel && + useradd -m builder && + chown -R builder:builder /pkg && + cd /pkg && + su builder -c 'makepkg --printsrcinfo' > .SRCINFO + " + sudo chown -R $(id -u):$(id -g) . + + - name: Commit and push to AUR + working-directory: aur-bin + run: | + git add PKGBUILD .SRCINFO + git commit -m "Update to ${{ steps.version.outputs.version }}" + GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push diff --git a/CHANGELOG.md b/CHANGELOG.md index b71ad52..10346d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,17 @@ # Changelog -## [0.35.3] - 2026-01-23 +## [0.35.4] - 2026-01-24 ### Added -- **Playbar Status Messages**: Added transient status messages in the playbar, used to notify when the saved playback device is unavailable and spotatui falls back to native streaming. - **Discord Rich Presence**: Show track info, album art, and a GitHub callout in Discord; enabled by default with a built-in application ID and optional overrides via config/env. +- **AUR Binary Package**: `spotatui-bin` is now automatically published alongside releases for faster installation on Arch Linux. + +## [0.35.3] - 2026-01-24 + +### Added + +- **Playbar Status Messages**: Added transient status messages in the playbar, used to notify when the saved playback device is unavailable and spotatui falls back to native streaming. ### Fixed diff --git a/README.md b/README.md index e06e8ab..3925539 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,10 @@ You may be asked to re-authenticate with Spotify the first time. # Cargo (recommended) cargo install spotatui -# Arch Linux (AUR) +# Arch Linux (AUR) - pre-built binary (faster) +yay -S spotatui-bin + +# Arch Linux (AUR) - build from source yay -S spotatui ``` From 6cdebffbcf0bcf79b9a0a87b402f0f6b3f1ba0dc Mon Sep 17 00:00:00 2001 From: LargeModGames Date: Sat, 24 Jan 2026 13:41:15 +0100 Subject: [PATCH 4/5] chore(version): bump version to 0.35.4 --- Cargo.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0a01054..7722cfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,16 +6,16 @@ documentation = "https://github.com/LargeModGames/spotatui" repository = "https://github.com/LargeModGames/spotatui" keywords = ["spotify", "tui", "cli", "terminal"] categories = ["command-line-utilities"] -version = "0.35.3" +version = "0.35.4" authors = ["LargeModGames "] edition = "2021" license = "MIT" exclude = [ - ".github/demo.gif", - ".github/workflows", - ".github/ISSUE_TEMPLATE", - "target/", - "snap/", + ".github/demo.gif", + ".github/workflows", + ".github/ISSUE_TEMPLATE", + "target/", + "snap/", ] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From a36d83b70ede2c9da7078cbedc5f321043c254c1 Mon Sep 17 00:00:00 2001 From: LargeModGames Date: Sat, 24 Jan 2026 14:01:47 +0100 Subject: [PATCH 5/5] chore(version): bump version to 0.35.4 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index af4bb96..4fd897a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4630,7 +4630,7 @@ dependencies = [ [[package]] name = "spotatui" -version = "0.35.3" +version = "0.35.4" dependencies = [ "anyhow", "arboard",