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/daemon.rs | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 160 insertions(+), 23 deletions(-) (limited to 'src/daemon.rs') 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), + } } } } -- cgit v1.2.3 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 --- Cargo.lock | 2 +- Cargo.toml | 2 +- completions/_rsp | 67 +++++++------- install.sh | 20 +++-- src/cli.rs | 43 +++++---- src/daemon.rs | 113 +++++++---------------- src/database.rs | 259 ++++++++++++++++++++++++++++++++++++++++++----------- src/queue.rs | 14 ++- src/scrobbler.rs | 22 +---- tests/daemon.rs | 79 ++++++++++++++++ tests/database.rs | 112 +++++++++++++---------- tests/scrobbler.rs | 17 ++++ 12 files changed, 488 insertions(+), 262 deletions(-) create mode 100644 tests/daemon.rs (limited to 'src/daemon.rs') diff --git a/Cargo.lock b/Cargo.lock index f58156d..846ed82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1948,7 +1948,7 @@ dependencies = [ [[package]] name = "rsp" -version = "0.8.3" +version = "0.8.4" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index ef61127..8710c51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rsp" -version = "0.8.3" +version = "0.8.4" edition = "2024" description = "Rust music player daemon with Unix socket IPC" license = "GPL-3.0 or later" diff --git a/completions/_rsp b/completions/_rsp index 1ddc1fe..9bf0579 100644 --- a/completions/_rsp +++ b/completions/_rsp @@ -1,55 +1,62 @@ -# Dynamic completion overrides for rsp play/queue arguments. - # Save clap-generated function so we can delegate non-dynamic cases. functions[_rsp_clap]=$functions[_rsp] _rsp() { case $words[2] in play|queue) - _rsp_complete_args - ;; - *) - _rsp_clap "$@" + if (( CURRENT > 2 )); then + _rsp_complete_args + return 0 + fi ;; esac + _rsp_clap "$@" } _rsp_complete_args() { setopt localoptions setopt nobanghist - # Reconstruct full query from all argument words after subcommand. - # This handles spaces in names (e.g. "Master of Puppets") that get - # split across multiple $words entries when backslash escaping breaks. local -a args=("${(@)words[3,$CURRENT]}") local cur="${(j: :)args}" + local cur_clean="${cur//\\/}" local -a suggestions - local n_slashes=${#cur//[^\/]/} - case $n_slashes in - 0) suggestions=(${(f)"$(rsp db-complete artists "$cur" 2>/dev/null)"}) ;; - 1) suggestions=(${(f)"$(rsp db-complete albums "$cur" 2>/dev/null)"}) ;; - *) suggestions=(${(f)"$(rsp db-complete tracks "$cur" 2>/dev/null)"}) ;; - esac - # display: what the user sees in the completion menu (raw values) - local -a display=("${suggestions[@]}") - # vals: what gets inserted (with ZSH backslash escaping + ! escape) - local -a vals=() - local slash_idx=$cur[(I)/] - local s + suggestions=("${(@f)"$(rsp db-complete "$cur" 2>/dev/null)"}") + # Filter out empty entries (e.g. from empty command output) + local -a filtered=() for s in "${suggestions[@]}"; do - local esc="${(q)s:gs/\!/\\\!/}" - if (( slash_idx )); then - local dir_part=$cur[1,$slash_idx] - local edir="${(q)dir_part:gs/\!/\\\!/}" - vals+=("${edir}${esc}") - else - vals+=("${esc}") + if [[ -n "$s" ]]; then + filtered+=("$s") fi done + suggestions=("${filtered[@]}") if (( ${#suggestions} )); then + local noslash="${cur_clean//\/}" + local n_slashes=$(( ${#cur_clean} - ${#noslash} )) + local -a display=() + local -a vals=() + for s in "${suggestions[@]}"; do + vals+=("$s") + if [[ "$cur_clean" == */ ]]; then + local stripped="$s" + local i=0 + while (( i < n_slashes )); do + stripped="${stripped#*/}" + (( i++ )) + done + display+=("$stripped") + else + display+=("$s") + fi + done + if (( ${#suggestions} > 1 )); then + compstate[insert]=menu + fi if [[ ${vals[1]} == */ ]]; then - compadd -U -S '' -d display -- "${vals[@]}" + compadd -U -S '' -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}" else - compadd -U -d display -- "${vals[@]}" + compadd -U -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}" fi + else + compadd -U -S '' fi } diff --git a/install.sh b/install.sh index 280be8a..25a1f52 100755 --- a/install.sh +++ b/install.sh @@ -1,13 +1,16 @@ +#!/bin/bash + # This file is a part of RSPlayer # Licensed under the GPL-3.0 License # Copyright (C) 2026 Salmonik @ salmonik@tuta.io # https://codeberg.org/salmonik/RSPlayer -#!/bin/bash set -e -echo "Updating the repo..." -git pull 2>/dev/null +if ping -W 2 -c 1 codeberg.org >&/dev/null; then + echo "Updating the repo..." + git pull 2>/dev/null +fi echo "Building rsplayer in release mode..." cargo build --release @@ -17,8 +20,7 @@ systemctl --user stop rsplayer 2>/dev/null || true rsp stop-daemon 2>/dev/null || true echo "Installing to /usr/bin/rsp..." -sudo cp target/release/rsp /usr/bin/rsp -sudo chmod +x /usr/bin/rsp +sudo install target/release/rsp /usr/bin/rsp echo "" echo "Installing systemd service..." @@ -41,12 +43,16 @@ case "$shell_name" in bash) dest="${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions" mkdir -p "$dest" - /usr/bin/rsp completions bash > "$dest/rsp" + cp completions/rsp.bash "$dest/rsp" echo " → Bash completions: $dest/rsp" ;; zsh) dest="/usr/share/zsh/site-functions/_rsp" - (/usr/bin/rsp completions zsh; echo; cat completions/_rsp) | sudo tee "$dest" > /dev/null + tmpfile=$(mktemp) + /usr/bin/rsp completions zsh > "$tmpfile" + cat completions/_rsp >> "$tmpfile" + sudo cp "$tmpfile" "$dest" + rm -f "$tmpfile" echo " → Zsh completions: $dest" ;; fish) 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())); - } -} diff --git a/tests/daemon.rs b/tests/daemon.rs new file mode 100644 index 0000000..e5cb270 --- /dev/null +++ b/tests/daemon.rs @@ -0,0 +1,79 @@ +/* + * This file is a part of RSPlayer + * Licensed under the GPL-3.0 License + * Copyright (C) 2026 Salmonik @ salmonik@tuta.io + * https://codeberg.org/salmonik/RSPlayer +*/ + +use std::path::PathBuf; + +use rsp::daemon::ScrobbleState; +use rsp::queue::Track; + +#[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))); +} diff --git a/tests/database.rs b/tests/database.rs index adbf535..84eb663 100644 --- a/tests/database.rs +++ b/tests/database.rs @@ -114,115 +114,131 @@ fn seed_test_db(db: &Database) { fn test_complete_artists_prefix() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_artists("M").unwrap(); - assert_eq!(results.len(), 2); - assert!(results.contains(&"Metallica".to_string())); - assert!(results.contains(&"Miles Davis".to_string())); + let results = db.complete("M").unwrap(); + assert!(results.contains(&"Metallica/".to_string())); + assert!(results.contains(&"Miles Davis/".to_string())); } #[test] fn test_complete_artists_prefix_lowercase() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_artists("m").unwrap(); + let results = db.complete("m").unwrap(); assert!( - results.contains(&"Metallica".to_string()), + results.contains(&"Metallica/".to_string()), "LIKE should be case-insensitive" ); } #[test] -fn test_complete_artists_no_match() { +fn test_complete_no_match() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_artists("Z").unwrap(); + let results = db.complete("Z").unwrap(); assert!(results.is_empty()); } #[test] -fn test_complete_artists_empty_prefix() { +fn test_complete_empty_prefix() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_artists("").unwrap(); + let results = db.complete("").unwrap(); assert_eq!(results.len(), 3); + assert!(results.contains(&"Metallica/".to_string())); + assert!(results.contains(&"Miles Davis/".to_string())); + assert!(results.contains(&"Aphex Twin/".to_string())); } #[test] -fn test_complete_albums_with_artist_prefix() { +fn test_complete_albums_for_artist() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_albums("Metallica/").unwrap(); + let results = db.complete("Metallica/").unwrap(); assert_eq!(results.len(), 2); - assert!(results.contains(&"Master of Puppets".to_string())); - assert!(results.contains(&"Ride the Lightning".to_string())); + assert!(results.contains(&"Metallica/Master of Puppets/".to_string())); + assert!(results.contains(&"Metallica/Ride the Lightning/".to_string())); +} + +#[test] +fn test_complete_albums_artist_lowercase() { + let db = Database::open_in_memory().unwrap(); + seed_test_db(&db); + let results = db.complete("metallica/").unwrap(); + assert_eq!(results.len(), 2, "artist match should be case-insensitive"); + assert!(results.contains(&"metallica/Master of Puppets/".to_string())); } #[test] fn test_complete_albums_with_album_prefix() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_albums("Metallica/R").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0], "Ride the Lightning"); + // "R" matches "Ride the Lightning" (prefix) and "Master of Puppets" (contains 'r') + let results = db.complete("Metallica/R").unwrap(); + assert_eq!(results.len(), 2); + assert_eq!(results[0], "Metallica/Ride the Lightning/"); + assert_eq!(results[1], "Metallica/Master of Puppets/"); } #[test] -fn test_complete_albums_unknown_artist() { +fn test_complete_albums_substring_match() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_albums("Unknown/").unwrap(); - assert!(results.is_empty()); + // "ind" matches "Kind of Blue" (contains "ind") + let results = db.complete("Miles Davis/ind").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0], "Miles Davis/Kind of Blue/"); } #[test] -fn test_complete_albums_no_slash() { +fn test_complete_artists_substring_match() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_albums("Metallica").unwrap(); - assert!(results.is_empty(), "needs trailing slash"); + // "x" matches "Aphex Twin" (contains 'x') + let results = db.complete("x").unwrap(); + assert!(results.contains(&"Aphex Twin/".to_string())); } #[test] -fn test_complete_tracks_full_path() { +fn test_complete_albums_unknown_artist() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_tracks("Metallica/Master of Puppets/").unwrap(); - assert_eq!(results.len(), 2); - assert!(results.contains(&"Battery".to_string())); - assert!(results.contains(&"Master of Puppets".to_string())); + let results = db.complete("Unknown/").unwrap(); + assert!(results.is_empty()); } #[test] -fn test_complete_tracks_with_prefix() { +fn test_complete_tracks_for_album() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_tracks("Metallica/Master of Puppets/B").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0], "Battery"); + let results = db.complete("Metallica/Master of Puppets/").unwrap(); + assert_eq!(results.len(), 2); + assert!(results.contains(&"Metallica/Master of Puppets/Battery".to_string())); + assert!(results.contains(&"Metallica/Master of Puppets/Master of Puppets".to_string())); } #[test] -fn test_complete_tracks_unknown_album() { +fn test_complete_tracks_with_prefix() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_tracks("Metallica/Nonexistent/").unwrap(); - assert!(results.is_empty()); + let results = db.complete("Metallica/Master of Puppets/B").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0], "Metallica/Master of Puppets/Battery"); } #[test] -fn test_complete_tracks_too_few_parts() { +fn test_complete_tracks_unknown_album() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - let results = db.complete_tracks("Metallica").unwrap(); + let results = db.complete("Metallica/Nonexistent/").unwrap(); assert!(results.is_empty()); } #[test] fn test_complete_empty_db() { let db = Database::open_in_memory().unwrap(); - assert!(db.complete_artists("").unwrap().is_empty()); - assert!(db.complete_albums("x/").unwrap().is_empty()); - assert!(db.complete_tracks("x/y/").unwrap().is_empty()); + assert!(db.complete("").unwrap().is_empty()); + assert!(db.complete("x/").unwrap().is_empty()); + assert!(db.complete("x/y/").unwrap().is_empty()); } // --- find_tracks_by_album tests --- @@ -271,17 +287,18 @@ fn test_find_tracks_by_album_unknown_album() { } #[test] -fn test_find_tracks_by_album_case_sensitive_artist() { +fn test_find_tracks_by_album_case_insensitive_artist() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); - // SQLite = is case-sensitive for TEXT (unless NOCASE collation) let tracks = db .find_tracks_by_album("metallica", "Master of Puppets") .unwrap(); - assert!( - tracks.is_empty(), - "artist name match should be case-sensitive" + assert_eq!( + tracks.len(), + 2, + "artist name match should be case-insensitive" ); + assert_eq!(tracks[0].title, "Battery"); } #[test] @@ -353,13 +370,14 @@ fn test_find_track_by_parts_no_match_artist() { } #[test] -fn test_find_track_by_parts_case_sensitive() { +fn test_find_track_by_parts_case_insensitive() { let db = Database::open_in_memory().unwrap(); seed_test_db(&db); let track = db .find_track_by_parts("metallica", "Master of Puppets", "Battery") .unwrap(); - assert!(track.is_none(), "artist match should be case-sensitive"); + assert!(track.is_some(), "artist match should be case-insensitive"); + assert_eq!(track.unwrap().title, "Battery"); } // --- resolve_path tests --- diff --git a/tests/scrobbler.rs b/tests/scrobbler.rs index a4b3fda..237a024 100644 --- a/tests/scrobbler.rs +++ b/tests/scrobbler.rs @@ -5,6 +5,8 @@ * https://codeberg.org/salmonik/RSPlayer */ +use std::collections::BTreeMap; + use rsp::scrobbler::Scrobbler; #[test] @@ -80,3 +82,18 @@ fn test_scrobbler_skip_empty_credentials() { scrobbler.now_playing("Artist", "Track", Some("Album")); scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); } + +#[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