diff --git a/Cargo.lock b/Cargo.lock index c34412e..e468ba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1740,16 +1740,6 @@ dependencies = [ "jni-sys 0.3.1", ] -[[package]] -name = "niri-ipc" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa541cad3b426dd1ab72765ba71d1c20a1b8a17d80c692b099a759074f74242" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "nix" version = "0.29.0" @@ -2365,7 +2355,6 @@ dependencies = [ "riptune-cache", "riptune-core", "riptune-mpris", - "riptune-niri", "riptune-tui", "riptune-types", "rodio", @@ -2407,25 +2396,10 @@ dependencies = [ name = "riptune-mpris" version = "0.1.0" dependencies = [ - "riptune-types", "tokio", - "tracing", "zbus", ] -[[package]] -name = "riptune-niri" -version = "0.1.0" -dependencies = [ - "niri-ipc", - "riptune-types", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tracing", -] - [[package]] name = "riptune-tui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5f737d2..bc790a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ "crates/riptune-core", "crates/riptune-cache", "crates/riptune-mpris", - "crates/riptune-niri", "crates/riptune-tui", "crates/riptune-types", ] @@ -42,7 +41,6 @@ path = "src/main.rs" riptune-core = { path = "crates/riptune-core" } riptune-cache = { path = "crates/riptune-cache" } riptune-mpris = { path = "crates/riptune-mpris" } -riptune-niri = { path = "crates/riptune-niri" } riptune-tui = { path = "crates/riptune-tui" } riptune-types = { path = "crates/riptune-types" } tokio = { workspace = true } diff --git a/config.example.toml b/config.example.toml index 4c27a85..09b23fc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -3,10 +3,6 @@ url = "https://your-navidrome-server.com" username = "your_username" password = "your_password_or_api_key" -[niri] -enabled = true -workspace_notifications = true - [cache] path = "~/.cache/riptune/library.sqlite" diff --git a/crates/riptune-cache/src/lib.rs b/crates/riptune-cache/src/lib.rs index 44bfd25..5b75e40 100644 --- a/crates/riptune-cache/src/lib.rs +++ b/crates/riptune-cache/src/lib.rs @@ -102,18 +102,7 @@ impl Cache { .fetch_all(&self.pool) .await?; - let records = rows.into_iter().map(|row| { - let count: i64 = row.get("song_count"); - let year: Option = row.get("year"); - AlbumSummary { - id: row.get("id"), - name: row.get("name"), - song_count: count as u32, - year: year.map(|y| y as u32), - } - }).collect(); - - Ok(records) + Ok(rows.into_iter().map(row_to_album).collect()) } /// Tracklist for an album, in soundtrack order (disc, then track number) @@ -144,17 +133,7 @@ impl Cache { .fetch_all(&self.pool) .await?; - let records = rows.into_iter().map(|row| { - let count: i64 = row.get("album_count"); - Artist { - id: row.get("id"), - name: row.get("name"), - album_count: count as u32, - cover_art: None, - } - }).collect(); - - Ok(records) + Ok(rows.into_iter().map(row_to_artist).collect()) } pub async fn search_local_albums(&self, query: &str) -> Result, CacheError> { @@ -166,18 +145,7 @@ impl Cache { .fetch_all(&self.pool) .await?; - let records = rows.into_iter().map(|row| { - let count: i64 = row.get("song_count"); - let year: Option = row.get("year"); - AlbumSummary { - id: row.get("id"), - name: row.get("name"), - song_count: count as u32, - year: year.map(|y| y as u32), - } - }).collect(); - - Ok(records) + Ok(rows.into_iter().map(row_to_album).collect()) } pub async fn search_local_tracks(&self, query: &str) -> Result, CacheError> { @@ -198,6 +166,27 @@ impl Cache { } } +fn row_to_artist(row: sqlx::sqlite::SqliteRow) -> Artist { + let count: i64 = row.get("album_count"); + Artist { + id: row.get("id"), + name: row.get("name"), + album_count: count as u32, + cover_art: None, + } +} + +fn row_to_album(row: sqlx::sqlite::SqliteRow) -> AlbumSummary { + let count: i64 = row.get("song_count"); + let year: Option = row.get("year"); + AlbumSummary { + id: row.get("id"), + name: row.get("name"), + song_count: count as u32, + year: year.map(|y| y as u32), + } +} + fn row_to_track(row: sqlx::sqlite::SqliteRow) -> Track { let duration: Option = row.get("duration_secs"); let track_number: Option = row.get("track_number"); diff --git a/crates/riptune-core/src/lib.rs b/crates/riptune-core/src/lib.rs index e557acb..a16c08b 100644 --- a/crates/riptune-core/src/lib.rs +++ b/crates/riptune-core/src/lib.rs @@ -165,35 +165,25 @@ impl SubsonicClient { // We fetch the raw JSON value first so we can robustly extract the array // regardless of whether the server uses a flat index or nested structure. let response: serde_json::Value = self.call("getArtists", &[]).await?; - - let mut artists = Vec::new(); - // Navigate safely through 'artists' -> 'index' array - if let Some(index_array) = response.get("artists").and_then(|a| a.get("index")).and_then(|i| i.as_array()) { - for group in index_array { - if let Some(artist_list) = group.get("artist").and_then(|a| a.as_array()) { - for artist_val in artist_list { - if let Ok(artist) = serde_json::from_value::(artist_val.clone()) { - artists.push(artist); - } - } - } - } - } - // Fallback: Check if the server responds with a root level 'index' block directly - else if let Some(index_array) = response.get("index").and_then(|i| i.as_array()) { - for group in index_array { - if let Some(artist_list) = group.get("artist").and_then(|a| a.as_array()) { - for artist_val in artist_list { - if let Ok(artist) = serde_json::from_value::(artist_val.clone()) { - artists.push(artist); - } - } - } - } - } + // Some servers nest the index under 'artists', others put it at the + // root -- try 'artists.index' first, then fall back to root 'index'. + let index_array = response + .get("artists") + .and_then(|a| a.get("index")) + .or_else(|| response.get("index")) + .and_then(|i| i.as_array()); - Ok(artists) + let Some(index_array) = index_array else { + return Ok(Vec::new()); + }; + + Ok(index_array + .iter() + .filter_map(|group| group.get("artist").and_then(|a| a.as_array())) + .flatten() + .filter_map(|artist_val| serde_json::from_value::(artist_val.clone()).ok()) + .collect()) } pub async fn get_album(&self, album_id: &str) -> Result { diff --git a/crates/riptune-mpris/Cargo.toml b/crates/riptune-mpris/Cargo.toml index b77f68d..ecfe532 100644 --- a/crates/riptune-mpris/Cargo.toml +++ b/crates/riptune-mpris/Cargo.toml @@ -6,7 +6,5 @@ license.workspace = true description = "MPRIS2 D-Bus interface so waybar/notification daemons can control riptune" [dependencies] -riptune-types = { path = "../riptune-types" } tokio = { workspace = true } -tracing = { workspace = true } zbus = { version = "4", default-features = false, features = ["tokio"] } diff --git a/crates/riptune-mpris/src/lib.rs b/crates/riptune-mpris/src/lib.rs index 1819e35..49e3181 100644 --- a/crates/riptune-mpris/src/lib.rs +++ b/crates/riptune-mpris/src/lib.rs @@ -5,8 +5,6 @@ //! about Subsonic — they only ever speak the standard MPRIS interface. //! Spec: -use riptune_types::AudioCommand; -use std::sync::mpsc::Sender; use zbus::{interface, ConnectionBuilder}; pub struct MprisHandle { @@ -19,39 +17,28 @@ impl MprisHandle { } } -struct Player { - // Commands are relayed to the audio thread over this channel; MPRIS itself - // never touches audio state directly. - #[allow(dead_code)] - audio_tx: Sender, -} +// TODO: relay play_pause/next/previous to the audio thread's AudioCommand +// channel and reflect real playback state once that wiring exists. +struct Player; #[interface(name = "org.mpris.MediaPlayer2.Player")] impl Player { - async fn play_pause(&self) { - // TODO: relay to audio_tx based on current playback state - } + async fn play_pause(&self) {} - async fn next(&self) { - // TODO: emit AppEvent::NextTrack for app.rs to handle (advance in - // playlist, fetch new stream_url, send AudioCommand::Play) - } + async fn next(&self) {} - async fn previous(&self) { - // TODO - } + async fn previous(&self) {} #[zbus(property)] async fn playback_status(&self) -> String { - "Stopped".into() // TODO: reflect real state + "Stopped".into() } } -pub async fn spawn(audio_tx: Sender) -> zbus::Result { - let player = Player { audio_tx }; +pub async fn spawn() -> zbus::Result { let conn = ConnectionBuilder::session()? .name("org.mpris.MediaPlayer2.riptune")? - .serve_at("/org/mpris/MediaPlayer2", player)? + .serve_at("/org/mpris/MediaPlayer2", Player)? .build() .await?; Ok(MprisHandle { conn }) diff --git a/crates/riptune-niri/Cargo.toml b/crates/riptune-niri/Cargo.toml deleted file mode 100644 index 9790d6e..0000000 --- a/crates/riptune-niri/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "riptune-niri" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "niri IPC event-stream listener for workspace-aware behavior and custom bindings" - -[dependencies] -riptune-types = { path = "../riptune-types" } -tokio = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tracing = { workspace = true } -thiserror = { workspace = true } -niri-ipc = "0.1" diff --git a/crates/riptune-niri/src/lib.rs b/crates/riptune-niri/src/lib.rs deleted file mode 100644 index 379cfe5..0000000 --- a/crates/riptune-niri/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! niri IPC integration. -//! -//! Important distinction: `niri msg ` is for one-off commands (fine for -//! e.g. a keybind that shells out to `riptune favorite`). For *reactive* -//! behavior — adjusting the TUI or firing a notification when the focused -//! workspace changes — we want the persistent event-stream socket, not -//! polling `niri msg` in a loop. The `niri-ipc` crate exposes both; this -//! module only uses the event stream. -//! -//! NOTE: pin the `niri-ipc` crate version to match your installed niri -//! release — the IPC protocol has changed across niri versions and is not -//! guaranteed stable yet. - -use riptune_types::AudioCommand; -use std::sync::mpsc::Sender; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum NiriError { - #[error("failed to connect to niri IPC socket: {0}")] - Connect(#[from] std::io::Error), -} - -pub async fn listen(_audio_tx: Sender) -> Result<(), NiriError> { - // TODO: - // 1. Connect to the socket at $NIRI_SOCKET (niri sets this env var). - // 2. Send the `EventStream` request per niri-ipc's protocol. - // 3. Loop reading newline-delimited JSON events off the socket. - // 4. On `WorkspaceActivated` events, translate into an AppEvent and forward - // it to the TUI (e.g. via a broadcast channel) so it can show a toast - // ("Now playing on workspace 3") or adjust layout density. - // - // Custom bindings (e.g. "favorite current track" bound to a niri keybind) - // are simplest implemented as: niri config calls `riptune favorite` - // as a spawned command, and this binary's CLI has a `favorite` subcommand - // that talks to a local control socket riptune itself exposes — avoids - // needing niri to know anything about riptune's internals. - std::future::pending::<()>().await; - Ok(()) -} diff --git a/crates/riptune-tui/src/lib.rs b/crates/riptune-tui/src/lib.rs index ad14e05..ebf1b07 100644 --- a/crates/riptune-tui/src/lib.rs +++ b/crates/riptune-tui/src/lib.rs @@ -51,8 +51,6 @@ pub enum PlaybackStatus { Buffering, Playing, Paused, - Stopped, - Error(String), } enum InputMode { @@ -260,7 +258,6 @@ impl App { // Shuffle & Repeat Controls KeyCode::Char('s') => { self.shuffle = !self.shuffle; - self.notify_desktop_environment("Shuffle Toggled"); } KeyCode::Char('r') => { self.repeat = match self.repeat { @@ -268,7 +265,6 @@ impl App { RepeatMode::All => RepeatMode::One, RepeatMode::One => RepeatMode::Off, }; - self.notify_desktop_environment("Repeat Mode Changed"); } KeyCode::Esc | KeyCode::Backspace => self.go_back().await, KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), @@ -315,21 +311,6 @@ impl App { false } - // --- Option 2 Integration Hooks (MPRIS & Niri IPC Sync Channels) --- - fn notify_desktop_environment(&self, action_context: &str) { - // These hooks act as communication bridges for your riptune-mpris and riptune-niri subcrates[cite: 1] - // Example: Emits state logs that zbus / mpris handlers catch via standard asynchronous orchestration[cite: 1, 7] - if let Some(np) = &self.now_playing { - let _status_indicator = match np.status { - PlaybackStatus::Playing => "Playing", - PlaybackStatus::Paused => "Paused", - _ => "Stopped", - }; - // Under-the-hood bindings read this payload state to dynamically broadcast D-Bus properties[cite: 1, 7] - std::string::ToString::to_string(&format!("MPRIS/NIRI Sync Triggered: {} -> {} ({})", action_context, np.title, _status_indicator)); - } - } - async fn execute_db_search(&mut self, query: &str) { self.selected_idx = 0; @@ -452,7 +433,6 @@ impl App { playlist_context, cover_art_id: track.cover_art.clone(), }); - self.notify_desktop_environment("Track Started"); } Err(e) => { self.status_line = Some(format!("failed to build stream url: {e}")); @@ -493,7 +473,6 @@ impl App { } _ => {} } - self.notify_desktop_environment("Play/Pause Toggled"); } else if let Some(track) = self.tracks.get(self.selected_idx).cloned() { self.play_track(track, self.tracks.clone()); } @@ -549,7 +528,6 @@ impl App { } } self.now_playing = None; - self.notify_desktop_environment("Track Stopped/Finished"); } AudioEvent::Error(message) => { self.status_line = Some(format!("playback error: {message}")); @@ -948,7 +926,5 @@ fn playback_label(status: &PlaybackStatus) -> &str { PlaybackStatus::Buffering => "[...] buffering...", PlaybackStatus::Playing => "[>] playing", PlaybackStatus::Paused => "[||] paused", - PlaybackStatus::Stopped => "[.] stopped", - PlaybackStatus::Error(_) => "[X] error", } } \ No newline at end of file diff --git a/crates/riptune-types/src/lib.rs b/crates/riptune-types/src/lib.rs index 0df3529..3409bcc 100644 --- a/crates/riptune-types/src/lib.rs +++ b/crates/riptune-types/src/lib.rs @@ -1,11 +1,10 @@ //! Types shared across thread and crate boundaries. //! -//! `AudioCommand`/`AudioEvent` cross the audio-thread channel; `AppEvent` -//! crosses between the TUI, MPRIS, and niri IPC layers. They live here — -//! deliberately dependency-free — so `riptune-mpris`, `riptune-niri`, and the -//! `riptune` binary all share one definition instead of each inventing their -//! own (which is exactly the bug that broke the build previously: two -//! different `AudioCommand` types that looked identical but weren't). +//! `AudioCommand`/`AudioEvent` cross the audio-thread channel. They live here +//! — deliberately dependency-free — so `riptune-mpris` and the `riptune` +//! binary share one definition instead of each inventing their own (which is +//! exactly the bug that broke the build previously: two different +//! `AudioCommand` types that looked identical but weren't). use std::time::Duration; @@ -47,14 +46,3 @@ pub struct ThemeColors { #[serde(default)] pub selection_bg: Option, } - -#[derive(Debug, Clone)] -pub enum AppEvent { - /// User favorited/unfavorited a track (mirrors Subsonic star/unstar + can be - /// triggered from a niri keybind, MPRIS client, or the TUI itself). - ToggleFavorite { track_id: String }, - /// Fired by the niri IPC listener when the focused workspace changes. - WorkspaceChanged { name: Option }, - NextTrack, - PreviousTrack, -} diff --git a/src/app.rs b/src/app.rs index ac545b4..9b08581 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,6 @@ use std::sync::mpsc::{Receiver, Sender}; /// - riptune_core::SubsonicClient (REST API + streaming URLs) /// - riptune_cache::Cache (SQLite index, warmed on startup) /// - riptune_mpris::Server (D-Bus MPRIS2 interface) -/// - riptune_niri::EventListener (niri IPC event-stream socket) /// - riptune_tui::App (ratatui render loop) /// /// The TUI is the only piece that technically needs to run on the main thread on @@ -26,26 +25,11 @@ pub async fn run(cfg: Config, audio_tx: Sender, audio_events: Rece } }); - // Kept so we can abort this task once the TUI quits. - let niri_task = if cfg.niri.enabled { - let audio_tx = audio_tx.clone(); - Some(tokio::spawn(async move { - if let Err(e) = riptune_niri::listen(audio_tx).await { - tracing::warn!(error = %e, "niri IPC listener exited"); - } - })) - } else { - None - }; - - let mpris_handle = riptune_mpris::spawn(audio_tx.clone()).await?; + let mpris_handle = riptune_mpris::spawn().await?; riptune_tui::App::new(client, cache, audio_tx.clone(), audio_events, cfg.theme.clone()).run().await?; sync_task.abort(); - if let Some(task) = niri_task { - task.abort(); - } mpris_handle.shutdown().await; let _ = audio_tx.send(AudioCommand::Stop); diff --git a/src/config.rs b/src/config.rs index 8f76c40..9fee2e1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,8 +6,6 @@ use std::path::PathBuf; pub struct Config { pub server: ServerConfig, #[serde(default)] - pub niri: NiriConfig, - #[serde(default)] pub cache: CacheConfig, #[serde(default)] pub theme: riptune_types::ThemeColors, @@ -20,21 +18,6 @@ pub struct ServerConfig { pub password: String, } -#[derive(Debug, Clone, Deserialize)] -pub struct NiriConfig { - #[serde(default = "default_true")] - pub enabled: bool, - #[allow(dead_code)] - #[serde(default = "default_true")] - pub workspace_notifications: bool, -} - -impl Default for NiriConfig { - fn default() -> Self { - Self { enabled: true, workspace_notifications: true } - } -} - #[derive(Debug, Clone, Deserialize)] pub struct CacheConfig { #[serde(default = "default_cache_path")] @@ -47,10 +30,6 @@ impl Default for CacheConfig { } } -fn default_true() -> bool { - true -} - fn default_cache_path() -> PathBuf { dirs_cache_dir().join("riptune").join("library.sqlite") } @@ -114,11 +93,10 @@ mod tests { "#; let cfg: Config = toml::from_str(raw).expect("minimal config should parse"); assert_eq!(cfg.server.url, "https://music.example.com"); - assert!(cfg.niri.enabled); } #[test] - fn niri_and_cache_sections_are_optional() { + fn cache_section_is_optional() { let raw = r#" [server] url = "https://music.example.com" @@ -126,6 +104,6 @@ mod tests { password = "secret" "#; let cfg: Config = toml::from_str(raw).expect("minimal config should parse"); - assert!(cfg.niri.enabled, "niri.enabled should default to true"); + assert!(cfg.cache.path.ends_with("library.sqlite")); } } diff --git a/src/events.rs b/src/events.rs deleted file mode 100644 index 8d97148..0000000 --- a/src/events.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Thin re-export so binary-crate code can `use crate::events::AppEvent` -//! without every call site needing to know it lives in `riptune-types`. -//! The type itself is defined once, in `riptune-types`, and shared with -//! `riptune-mpris` and `riptune-niri`. - -// Not yet consumed — app.rs doesn't have an event loop wired up until the TUI -// is implemented. Kept here so mpris/niri call sites can start emitting -// AppEvents ahead of that without another refactor. Remove this allow once -// app::run() dispatches on it. -#[allow(unused_imports)] -pub use riptune_types::AppEvent; diff --git a/src/main.rs b/src/main.rs index 425bc24..9fc05e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,6 @@ mod app; mod audio; mod config; -mod events; use anyhow::Result; use clap::{Parser, Subcommand};