From d347d89dfeef54d648ffdea054897617c0016509 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 6 Jun 2026 19:48:22 +0300 Subject: correctly working auto completion for db --- src/cli.rs | 43 +++++---- src/daemon.rs | 113 +++++++----------------- src/database.rs | 259 +++++++++++++++++++++++++++++++++++++++++++------------ src/queue.rs | 14 ++- src/scrobbler.rs | 22 +---- 5 files changed, 275 insertions(+), 176 deletions(-) (limited to 'src') diff --git a/src/cli.rs b/src/cli.rs index 5a53eeb..e4bb484 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -95,12 +95,9 @@ pub enum Commands { #[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, + /// Query string (e.g. \"J\", \"Artist/\", \"Artist/Album/\") + #[arg(value_name = "QUERY")] + query: String, }, } @@ -245,7 +242,23 @@ impl Cli { } } } - Commands::DbComplete { entity, prefix } => { + Commands::DbComplete { query } => { + // Unescape shell escapes in the input query + let query = { + let mut unescaped = String::with_capacity(query.len()); + let mut chars = query.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(next) = chars.next() { + unescaped.push(next); + } + } else { + unescaped.push(c); + } + } + unescaped + }; + let config = match Config::load() { Ok(c) => c, Err(_) => return Ok(()), @@ -254,19 +267,11 @@ impl Cli { 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" => "/", - _ => "", - }; + + let results = db.complete(&query).unwrap_or_default(); + for r in &results { - println!("{r}{suffix}"); + println!("{r}"); } } Commands::Completions { shell } => { diff --git a/src/daemon.rs b/src/daemon.rs index d34305a..5ad6654 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -18,14 +18,20 @@ use std::sync::{Arc, Mutex, RwLock}; use std::thread; use std::time::{Duration, Instant, SystemTime}; -struct ScrobbleState { - track: Option, - start_time: Option, - timestamp: Option, +pub struct ScrobbleState { + pub track: Option, + pub start_time: Option, + pub timestamp: Option, +} + +impl Default for ScrobbleState { + fn default() -> Self { + Self::new() + } } impl ScrobbleState { - fn new() -> Self { + pub fn new() -> Self { ScrobbleState { track: None, start_time: None, @@ -33,7 +39,7 @@ impl ScrobbleState { } } - fn start(&mut self, track: Track) { + pub fn start(&mut self, track: Track) { if track.is_stream { crate::debug_eprintln(format_args!( "scrobble: not tracking stream '{}'", @@ -57,7 +63,7 @@ impl ScrobbleState { self.timestamp = Some(ts); } - fn should_scrobble(&self) -> bool { + pub fn should_scrobble(&self) -> bool { let (Some(track), Some(start)) = (&self.track, self.start_time) else { crate::debug_eprintln(format_args!( "scrobble: should_scrobble false — no track or start_time" @@ -78,7 +84,7 @@ impl ScrobbleState { result } - fn should_scrobble_with_params(elapsed: u64, duration_secs: Option) -> bool { + pub fn should_scrobble_with_params(elapsed: u64, duration_secs: Option) -> bool { if elapsed < 30 { return false; } @@ -89,86 +95,13 @@ impl ScrobbleState { elapsed >= threshold } - fn clear(&mut self) { + pub fn clear(&mut self) { self.track = None; self.start_time = None; self.timestamp = None; } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_scrobble_state_new() { - let s = ScrobbleState::new(); - assert!(s.track.is_none()); - assert!(s.start_time.is_none()); - assert!(s.timestamp.is_none()); - } - - #[test] - fn test_scrobble_state_clear() { - let mut s = ScrobbleState::new(); - let t = Track { - path: PathBuf::from("/test/song.mp3"), - title: Some("Test".into()), - artist: Some("Artist".into()), - album: None, - duration_secs: Some(200.0), - is_stream: false, - }; - s.start(t); - assert!(s.track.is_some()); - s.clear(); - assert!(s.track.is_none()); - assert!(s.start_time.is_none()); - assert!(s.timestamp.is_none()); - } - - #[test] - fn test_should_scrobble_without_track_returns_false() { - assert!(!ScrobbleState::new().should_scrobble()); - } - - #[test] - fn test_should_scrobble_threshold_below_30_seconds() { - assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0))); - } - - #[test] - fn test_should_scrobble_threshold_half_duration() { - assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0))); - } - - #[test] - fn test_should_scrobble_threshold_below_half_duration() { - assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0))); - } - - #[test] - fn test_should_scrobble_threshold_capped_at_4min() { - assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0))); - assert!(!ScrobbleState::should_scrobble_with_params( - 239, - Some(600.0) - )); - } - - #[test] - fn test_should_scrobble_no_duration_requires_4min() { - assert!(ScrobbleState::should_scrobble_with_params(240, None)); - assert!(!ScrobbleState::should_scrobble_with_params(239, None)); - } - - #[test] - fn test_should_scrobble_short_song() { - assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0))); - assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0))); - } -} - fn try_scrobble_current(scrobbler: &RwLock, state: &mut ScrobbleState) { if let Some(ref track) = state.track && track.is_stream @@ -875,6 +808,22 @@ fn process_command( // Strip trailing slash so autocompleted Artist/Album/ resolves cleanly let path_str = path_str.trim_end_matches('/').to_string(); + // Unescape backslash sequences (e.g., \! → !) so shell escaping works + let path_str = { + let mut unescaped = String::with_capacity(path_str.len()); + let mut chars = path_str.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(next) = chars.next() { + unescaped.push(next); + } + } else { + unescaped.push(c); + } + } + unescaped + }; + if path_str.is_empty() { return "Usage: play [now|next|last] \n".to_string(); } diff --git a/src/database.rs b/src/database.rs index 00d970a..01bee92 100644 --- a/src/database.rs +++ b/src/database.rs @@ -63,7 +63,29 @@ impl Database { fs::create_dir_all(&data_dir).ok(); let db_path = data_dir.join("library.db"); - let conn = Connection::open(&db_path)?; + let debug_log = std::env::temp_dir().join("rsp-completion-debug.log"); + macro_rules! debug_log { + ($($arg:tt)*) => { + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&debug_log) + .and_then(|mut f| { + use std::io::Write; + writeln!(f, $($arg)*) + }); + }; + } + debug_log!("[Database::open] db_path: {:?}", db_path); + debug_log!("[Database::open] db exists: {}", db_path.exists()); + + let conn = match Connection::open(&db_path) { + Ok(c) => c, + Err(e) => { + debug_log!("[Database::open] Connection::open failed: {e}"); + return Err(e); + } + }; let db = Database { conn }; db.init_schema()?; Ok(db) @@ -273,51 +295,174 @@ 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(&self, query: &str) -> SqlResult> { + let mut results: Vec = Vec::new(); - 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() - } + if query.is_empty() { + let mut stmt = self + .conn + .prepare("SELECT name FROM artists ORDER BY name LIMIT 25")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + for name in rows.flatten() { + results.push(format!("{name}/")); + } + return Ok(results); + } - 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 has_trailing_slash = query.ends_with('/'); + + if has_trailing_slash { + let prefix = query.trim_end_matches('/'); + let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect(); + + match segments.len() { + 1 => { + let artist = segments[0]; + let mut stmt = self.conn.prepare( + "SELECT a.title FROM albums a + JOIN artists ar ON a.artist_id = ar.id + WHERE ar.name LIKE ?1 + ORDER BY a.title LIMIT 25", + )?; + let rows = stmt.query_map([artist], |row| row.get::<_, String>(0))?; + for album in rows.flatten() { + results.push(format!("{artist}/{album}/")); + } + } + 2 => { + let artist = segments[0]; + let album = segments[1]; + 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 LIKE ?1 AND a.title LIKE ?2 + ORDER BY t.track_number, t.title LIMIT 25", + )?; + let rows = stmt.query_map(rusqlite::params![artist, album], |row| { + row.get::<_, String>(0) + })?; + for track in rows.flatten() { + results.push(format!("{artist}/{album}/{track}")); + } + } + _ => {} + } + } else { + let segments: Vec<&str> = query.split('/').collect(); + + match segments.len() { + 1 => { + let prefix = segments[0]; + let like_pattern = format!("%{prefix}%"); + let prefix_pattern = format!("{prefix}%"); + + let mut stmt = self.conn.prepare( + "SELECT name FROM artists + WHERE name LIKE ?1 + ORDER BY + CASE WHEN name LIKE ?2 THEN 0 ELSE 1 END, + name + LIMIT 25", + )?; + let rows = stmt + .query_map(rusqlite::params![&like_pattern, &prefix_pattern], |row| { + row.get::<_, String>(0) + })?; + for name in rows.flatten() { + results.push(format!("{name}/")); + } + } + 2 => { + let artist = segments[0]; + let album_prefix = segments[1]; + if artist.is_empty() { + return Ok(results); + } + + let artist_pattern = format!("%{artist}%"); + let artist_prefix = format!("{artist}%"); + let album_pattern = format!("%{album_prefix}%"); + let album_prefix_pattern = format!("{album_prefix}%"); + + let mut stmt = self.conn.prepare( + "SELECT ar.name, a.title FROM albums a + JOIN artists ar ON a.artist_id = ar.id + WHERE ar.name LIKE ?1 AND a.title LIKE ?2 + ORDER BY + CASE WHEN ar.name LIKE ?3 THEN 0 ELSE 1 END, + CASE WHEN a.title LIKE ?4 THEN 0 ELSE 1 END, + ar.name, a.title + LIMIT 25", + )?; + let rows = stmt.query_map( + rusqlite::params![ + &artist_pattern, + &album_pattern, + &artist_prefix, + &album_prefix_pattern, + ], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + )?; + for (name, album) in rows.flatten() { + results.push(format!("{name}/{album}/")); + } + } + 3 => { + let artist = segments[0]; + let album = segments[1]; + let track_prefix = segments[2]; + if artist.is_empty() || album.is_empty() { + return Ok(results); + } + + let artist_pattern = format!("%{artist}%"); + let artist_prefix = format!("{artist}%"); + let album_pattern = format!("%{album}%"); + let album_prefix = format!("{album}%"); + let track_pattern = format!("%{track_prefix}%"); + let track_prefix_pattern = format!("{track_prefix}%"); + + let mut stmt = self.conn.prepare( + "SELECT ar.name, a.title, 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 LIKE ?1 AND a.title LIKE ?2 AND t.title LIKE ?3 + ORDER BY + CASE WHEN ar.name LIKE ?4 THEN 0 ELSE 1 END, + CASE WHEN a.title LIKE ?5 THEN 0 ELSE 1 END, + CASE WHEN t.title LIKE ?6 THEN 0 ELSE 1 END, + t.track_number, t.title + LIMIT 25", + )?; + let rows = stmt.query_map( + rusqlite::params![ + &artist_pattern, + &album_pattern, + &track_pattern, + &artist_prefix, + &album_prefix, + &track_prefix_pattern, + ], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + )?; + for (name, album_title, track) in rows.flatten() { + results.push(format!("{name}/{album_title}/{track}")); + } + } + _ => {} + } } - 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() + + results.truncate(25); + + Ok(results) } #[allow(dead_code)] @@ -328,14 +473,14 @@ impl Database { Ok(db) } - /// Find all tracks in an album by exact artist name and album title. + /// Find all tracks in an album by case-insensitive artist and album match. 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 + WHERE ar.name COLLATE NOCASE = ?1 AND a.title COLLATE NOCASE = ?2 ORDER BY t.track_number", )?; let rows = stmt.query_map(rusqlite::params![artist, album], |row| { @@ -367,7 +512,7 @@ impl Database { 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 + WHERE ar.name COLLATE NOCASE = ?1 AND a.title COLLATE NOCASE = ?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| { @@ -462,11 +607,19 @@ pub fn extract_metadata(path: &Path) -> (String, String, String, Option, Op .and_then(|s| s.to_str()) .unwrap_or("Unknown"); - ( - grandparent.to_string(), - parent.to_string(), - file_stem.to_string(), - None, - None, - ) + let fs_artist = grandparent.to_string(); + let fs_album = parent.to_string(); + let fs_title = file_stem.to_string(); + + if let Some(audio_meta) = crate::queue::extract_metadata(path) { + ( + audio_meta.artist.unwrap_or(fs_artist), + audio_meta.album.unwrap_or(fs_album), + audio_meta.title.unwrap_or(fs_title), + audio_meta.track_number, + audio_meta.duration_secs.map(|d| d as u64), + ) + } else { + (fs_artist, fs_album, fs_title, None, None) + } } diff --git a/src/queue.rs b/src/queue.rs index 3c1c6fa..7675ddd 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -21,6 +21,7 @@ pub struct AudioMetadata { pub title: Option, pub artist: Option, pub album: Option, + pub track_number: Option, pub duration_secs: Option, } @@ -64,16 +65,27 @@ pub fn extract_metadata(path: &Path) -> Option { Some(StandardTag::Artist(value)) => { metadata.artist = Some(value.to_string()); } + Some(StandardTag::AlbumArtist(value)) => { + metadata.artist = Some(value.to_string()); + } Some(StandardTag::Album(value)) => { metadata.album = Some(value.to_string()); } + Some(StandardTag::TrackNumber(value)) => { + metadata.track_number = value.to_string().parse().ok(); + } _ => {} } } } } - if metadata.title.is_some() || metadata.artist.is_some() || metadata.album.is_some() { + if metadata.title.is_some() + || metadata.artist.is_some() + || metadata.album.is_some() + || metadata.track_number.is_some() + || metadata.duration_secs.is_some() + { Some(metadata) } else { None diff --git a/src/scrobbler.rs b/src/scrobbler.rs index e133669..40d3ce6 100644 --- a/src/scrobbler.rs +++ b/src/scrobbler.rs @@ -162,7 +162,7 @@ impl Scrobbler { } } - fn sign_lastfm(params: &BTreeMap<&str, &str>, secret: &str) -> String { + pub fn sign_lastfm(params: &BTreeMap<&str, &str>, secret: &str) -> String { let mut concat = String::new(); for (key, value) in params { concat.push_str(key); @@ -333,23 +333,3 @@ impl Scrobbler { Ok(()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sign_lastfm() { - let params = BTreeMap::from([ - ("api_key", "abc123"), - ("method", "track.scrobble"), - ("sk", "session_key"), - ("track", "Test Track"), - ("artist", "Test Artist"), - ("timestamp", "1234567890"), - ]); - let sig = Scrobbler::sign_lastfm(¶ms, "sekrit"); - assert_eq!(sig.len(), 32); - assert!(sig.chars().all(|c| c.is_ascii_hexdigit())); - } -} -- cgit v1.2.3