From 1e48765ab39a679abcc4eac6927ad3bb58f7b438 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 4 Jun 2026 00:21:46 +0200 Subject: dirty, broken commit --- src/cli.rs | 36 ++++++++++- src/daemon.rs | 183 +++++++++++++++++++++++++++++++++++++++++++++++++------- src/database.rs | 136 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 331 insertions(+), 24 deletions(-) (limited to 'src') diff --git a/src/cli.rs b/src/cli.rs index f5fdd2c..5a53eeb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -92,6 +92,16 @@ pub enum Commands { #[arg(value_name = "SHELL")] shell: Option, }, + #[clap(hide = true)] + /// Complete artists, albums, or tracks from database + DbComplete { + /// Entity type: artists, albums, or tracks + #[arg(value_name = "ENTITY")] + entity: String, + /// Optional prefix to match + #[arg(value_name = "PREFIX")] + prefix: Option, + }, } #[derive(Subcommand)] @@ -235,11 +245,35 @@ impl Cli { } } } + Commands::DbComplete { entity, prefix } => { + let config = match Config::load() { + Ok(c) => c, + Err(_) => return Ok(()), + }; + let db = match Database::open(&config) { + Ok(d) => d, + Err(_) => return Ok(()), + }; + let prefix = prefix.as_deref().unwrap_or(""); + let results: Vec = match entity.as_str() { + "artists" => db.complete_artists(prefix).unwrap_or_default(), + "albums" => db.complete_albums(prefix).unwrap_or_default(), + "tracks" => db.complete_tracks(prefix).unwrap_or_default(), + _ => Vec::new(), + }; + let suffix = match entity.as_str() { + "artists" | "albums" => "/", + _ => "", + }; + for r in &results { + println!("{r}{suffix}"); + } + } Commands::Completions { shell } => { let full = Cli::command(); let subcommands: Vec = full .get_subcommands() - .filter(|s| s.get_name() != "completions") + .filter(|s| s.get_name() != "completions" && s.get_name() != "db-complete") .cloned() .collect(); let cmd_name = full.get_name().to_string(); diff --git a/src/daemon.rs b/src/daemon.rs index c794495..d34305a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -747,30 +747,40 @@ fn process_command( } else { match Config::load() { Ok(config) => match crate::database::Database::open(&config) { - Ok(db) => match db.search_tracks(query_str) { - Ok(tracks) if !tracks.is_empty() => { - let db_track = &tracks[0]; - let album_tracks = db - .get_album_tracks_after( - db_track.album_id, - db_track.track_number, - &db_track.path, - ) - .unwrap_or_default(); - let display = format!( - "{} - {}", - db_track.artist_name.as_deref().unwrap_or("unknown"), - db_track.title, - ); - let paths: Vec = - album_tracks.into_iter().map(|t| t.path).collect(); - (db_track.path.clone(), paths, display) - } - Ok(_) => { + Ok(db) => { + let tracks = match db.resolve_path(query_str) { + Ok(Some(t)) => t, + Ok(None) => match db.search_tracks(query_str) { + Ok(t) => t, + Err(e) => return format!("Database error: {}\n", e), + }, + Err(e) => return format!("Database error: {}\n", e), + }; + if tracks.is_empty() { return format!("No tracks found matching '{}'\n", query_str); } - Err(e) => return format!("Database error: {}\n", e), - }, + let db_track = &tracks[0]; + let album_paths: Vec = if tracks.len() > 1 { + // resolve_path returned full album — use remaining tracks + tracks[1..].iter().map(|t| t.path.clone()).collect() + } else { + db.get_album_tracks_after( + db_track.album_id, + db_track.track_number, + &db_track.path, + ) + .unwrap_or_default() + .into_iter() + .map(|t| t.path) + .collect() + }; + let display = format!( + "{} - {}", + db_track.artist_name.as_deref().unwrap_or("unknown"), + db_track.title, + ); + (db_track.path.clone(), album_paths, display) + } Err(e) => return format!("Database error: {}\n", e), }, Err(e) => return format!("Config error: {}\n", e), @@ -862,6 +872,8 @@ fn process_command( } else { path_str.to_string() }; + // Strip trailing slash so autocompleted Artist/Album/ resolves cleanly + let path_str = path_str.trim_end_matches('/').to_string(); if path_str.is_empty() { return "Usage: play [now|next|last] \n".to_string(); @@ -1052,7 +1064,132 @@ fn process_command( _ => format!("Error: No audio files found in '{}'\n", path_str), } } else { - format!("File not found: {}\n", path_str) + match Config::load() { + Ok(config) => match crate::database::Database::open(&config) { + Ok(db) => { + // Collect tracks: try hierarchical path first, fallback to search + let tracks = match db.resolve_path(&path_str) { + Ok(Some(t)) => t, + Ok(None) => { + let query = path_str + .rsplit_once('/') + .and_then(|(_, after)| { + (!after.is_empty()).then_some(after) + }) + .unwrap_or(&path_str); + match db.search_tracks(query) { + Ok(t) => t, + Err(e) => return format!("Database error: {}\n", e), + } + } + Err(e) => return format!("Database error: {}\n", e), + }; + + if tracks.is_empty() { + return format!("No tracks found matching '{}'\n", path_str); + } + + let is_album_play = tracks.len() > 1; + if let Ok(mut q) = queue.lock() { + let was_empty = q.is_empty(); + match position { + "now" => { + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(scrobbler, &mut s); + } + for t in tracks.iter().rev() { + q.insert_after_current(t.path.clone()); + } + if !was_empty { + let new_pos = q.position() + 1; + q.set_position(new_pos); + } + if let Some(track) = q.track_at(q.position()) { + audio.play(track.path.clone()); + send_now_playing(scrobbler, track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } + let fmt = Config::load() + .ok() + .and_then(|c| c.display_format.clone()); + notification::notify_track(track, fmt.as_deref()); + } + if is_album_play { + format!( + "Playing now: {} tracks from {}\n", + tracks.len(), + path_str + ) + } else { + format!("Playing now: {}\n", path_str) + } + } + "next" => { + for t in tracks.iter().rev() { + q.insert_after_current(t.path.clone()); + } + if is_album_play { + format!( + "Queued next: {} tracks from {}\n", + tracks.len(), + path_str + ) + } else { + format!("Queued next: {}\n", path_str) + } + } + _ => { + let was_playing = audio.is_playing(); + for t in &tracks { + q.add_track(t.path.clone()); + } + if was_empty && !was_playing { + if is_album_play { + q.shuffle_initial(); + } + if let Some(track) = q.current() { + audio.play(track.path.clone()); + send_now_playing(scrobbler, track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } + let fmt = Config::load() + .ok() + .and_then(|c| c.display_format.clone()); + notification::notify_track( + track, + fmt.as_deref(), + ); + } + if is_album_play { + format!( + "Playing: {} tracks from {}\n", + tracks.len(), + path_str + ) + } else { + format!("Playing: {}\n", path_str) + } + } else if is_album_play { + format!( + "Queued {} tracks from: {}\n", + tracks.len(), + path_str + ) + } else { + format!("Queued: {}\n", path_str) + } + } + } + } else { + "Error accessing queue\n".to_string() + } + } + Err(e) => format!("Database error: {}\n", e), + }, + Err(e) => format!("Config error: {}\n", e), + } } } } diff --git a/src/database.rs b/src/database.rs index b80ad4a..00d970a 100644 --- a/src/database.rs +++ b/src/database.rs @@ -45,6 +45,10 @@ pub struct Database { #[allow(dead_code)] impl Database { + pub fn conn(&self) -> &Connection { + &self.conn + } + pub fn open(_config: &crate::config::Config) -> SqlResult { let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { rusqlite::Error::InvalidPath("Failed to get project directories".into()) @@ -269,6 +273,138 @@ impl Database { self.get_album_tracks_after(album_id, track_number, &path) } + pub fn complete_artists(&self, prefix: &str) -> SqlResult> { + let pattern = format!("%{}%", prefix); + let mut stmt = self + .conn + .prepare("SELECT name FROM artists WHERE name LIKE ?1 ORDER BY name LIMIT 25")?; + let rows = stmt.query_map([&pattern], |row| row.get::<_, String>(0))?; + rows.collect() + } + + pub fn complete_albums(&self, prefix: &str) -> SqlResult> { + let (artist, album_prefix) = match prefix.split_once('/') { + Some((a, ap)) => (a, ap), + None => return Ok(Vec::new()), + }; + let pattern = format!("{}%", album_prefix); + let mut stmt = self.conn.prepare( + "SELECT a.title FROM albums a + JOIN artists ar ON a.artist_id = ar.id + WHERE ar.name = ?1 AND a.title LIKE ?2 + ORDER BY a.title LIMIT 25", + )?; + let rows = stmt.query_map(rusqlite::params![artist, pattern], |row| { + row.get::<_, String>(0) + })?; + rows.collect() + } + + pub fn complete_tracks(&self, prefix: &str) -> SqlResult> { + let parts: Vec<&str> = prefix.splitn(3, '/').collect(); + if parts.len() < 3 { + return Ok(Vec::new()); + } + let (artist, album, track_prefix) = (parts[0], parts[1], parts[2]); + let pattern = format!("{}%", track_prefix); + let mut stmt = self.conn.prepare( + "SELECT t.title FROM tracks t + JOIN albums a ON t.album_id = a.id + JOIN artists ar ON a.artist_id = ar.id + WHERE ar.name = ?1 AND a.title = ?2 AND t.title LIKE ?3 + ORDER BY t.track_number, t.title LIMIT 25", + )?; + let rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| { + row.get::<_, String>(0) + })?; + rows.collect() + } + + #[allow(dead_code)] + pub fn open_in_memory() -> SqlResult { + let conn = Connection::open_in_memory()?; + let db = Database { conn }; + db.init_schema()?; + Ok(db) + } + + /// Find all tracks in an album by exact artist name and album title. + pub fn find_tracks_by_album(&self, artist: &str, album: &str) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title + FROM tracks t + JOIN albums a ON t.album_id = a.id + JOIN artists ar ON a.artist_id = ar.id + WHERE ar.name = ?1 AND a.title = ?2 + ORDER BY t.track_number", + )?; + let rows = stmt.query_map(rusqlite::params![artist, album], |row| { + Ok(Track { + id: row.get(0)?, + album_id: row.get(1)?, + title: row.get(2)?, + path: PathBuf::from(row.get::<_, String>(3)?), + duration: row.get(4)?, + track_number: row.get(5)?, + artist_name: Some(row.get(6)?), + album_title: Some(row.get(7)?), + }) + })?; + rows.collect() + } + + /// Find a single track by exact artist, exact album, and track title prefix. + /// Returns None if no match found. + pub fn find_track_by_parts( + &self, + artist: &str, + album: &str, + track_prefix: &str, + ) -> SqlResult> { + let pattern = format!("{}%", track_prefix); + let mut stmt = self.conn.prepare( + "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title + FROM tracks t + JOIN albums a ON t.album_id = a.id + JOIN artists ar ON a.artist_id = ar.id + WHERE ar.name = ?1 AND a.title = ?2 AND t.title LIKE ?3 + ORDER BY t.track_number LIMIT 1", + )?; + let mut rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| { + Ok(Track { + id: row.get(0)?, + album_id: row.get(1)?, + title: row.get(2)?, + path: PathBuf::from(row.get::<_, String>(3)?), + duration: row.get(4)?, + track_number: row.get(5)?, + artist_name: Some(row.get(6)?), + album_title: Some(row.get(7)?), + }) + })?; + match rows.next() { + Some(Ok(t)) => Ok(Some(t)), + Some(Err(e)) => Err(e), + None => Ok(None), + } + } + + /// Resolve a hierarchical path like "Artist/Album" or "Artist/Album/Track" + /// to DB tracks. Returns None if the path isn't hierarchical (single part + /// or 4+ parts) — the caller should fall back to search_tracks(). + pub fn resolve_path(&self, path: &str) -> SqlResult>> { + let path = path.strip_suffix('/').unwrap_or(path); + let parts: Vec<&str> = path.splitn(3, '/').collect(); + match parts.len() { + 2 => Ok(Some(self.find_tracks_by_album(parts[0], parts[1])?)), + 3 => match self.find_track_by_parts(parts[0], parts[1], parts[2])? { + Some(t) => Ok(Some(vec![t])), + None => Ok(Some(vec![])), + }, + _ => Ok(None), + } + } + pub fn search_tracks(&self, query: &str) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title -- cgit v1.2.3