aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-06-04 00:21:46 +0200
committersalmonik <raisersoft@proton.me>2026-06-04 00:21:46 +0200
commit1e48765ab39a679abcc4eac6927ad3bb58f7b438 (patch)
tree5554f2a06bde693d4d9c39ed26f80677d61da991
parentfix no metadata songs handling (diff)
downloadrsplayer-1e48765ab39a679abcc4eac6927ad3bb58f7b438.tar.gz
rsplayer-1e48765ab39a679abcc4eac6927ad3bb58f7b438.zip
dirty, broken commit
-rw-r--r--.zed/tasks.json2
-rw-r--r--PLAN.md135
-rw-r--r--TODO.md4
-rw-r--r--completions/_rsp55
-rwxr-xr-xinstall.sh2
-rw-r--r--src/cli.rs36
-rw-r--r--src/daemon.rs183
-rw-r--r--src/database.rs136
-rw-r--r--tests/database.rs393
9 files changed, 916 insertions, 30 deletions
diff --git a/.zed/tasks.json b/.zed/tasks.json
index 70d637f..70f3bfe 100644
--- a/.zed/tasks.json
+++ b/.zed/tasks.json
@@ -15,7 +15,7 @@
15 }, 15 },
16 { 16 {
17 "label": "install", 17 "label": "install",
18 "command": "./install.sh", 18 "command": "./install.sh && ls -lha $ZED_WORKTREE_ROOT/target/release/rsp | awk '{print $5}'",
19 "show_command": false, 19 "show_command": false,
20 "show_summary": false, 20 "show_summary": false,
21 "save": "all", 21 "save": "all",
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..baff505
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,135 @@
1# Dynamic Shell Autocompletion for Artists/Albums/Tracks
2
3## Problem
4
5`rsp play <query>` and `rsp queue <query>` accept freeform strings. No tab-completion for artists, albums, or tracks from the SQLite library DB. User must type full path or query blindly.
6
7## Approach: Direct DB read (no daemon dependency)
8
9Hidden CLI subcommand `rsp complete <entity> [prefix]` that:
101. Opens DB directly (same as `rsp scan` at cli.rs:204-205)
112. Queries with prefix match
123. Prints newline-separated results to stdout
134. Returns empty on error (never crash completion)
14
15Works with or without daemon running. SQLite prefix query is ~1ms.
16
17## Steps
18
19### 1. Add completion query methods to `Database` (`src/database.rs`)
20
21```
22pub fn complete_artists(&self, prefix: &str) -> SqlResult<Vec<String>>
23pub fn complete_albums(&self, prefix: &str) -> SqlResult<Vec<String>>
24pub fn complete_tracks(&self, prefix: &str) -> SqlResult<Vec<String>>
25```
26
27SQL:
28- artists: `SELECT name FROM artists WHERE name LIKE ? ORDER BY name`
29- albums: `SELECT title FROM albums WHERE title LIKE ? ORDER BY title`
30- tracks: `SELECT title FROM tracks WHERE title LIKE ? ORDER BY title`
31
32Prefix `"a"` → `LIKE 'a%'`. Limit results (e.g. 25).
33
34### 2. Add hidden `DbComplete` subcommand (`src/cli.rs`)
35
36```
37#[clap(hide = true)]
38DbComplete {
39 entity: String, // "artists", "albums", "tracks"
40 prefix: Option<String>,
41}
42```
43
44Dispatch in `Cli::run()`:
45```
46Commands::DbComplete { entity, prefix } => {
47 let config = Config::load()?;
48 let db = Database::open(&config)?;
49 let results = match entity.as_str() { ... };
50 for r in results { println!("{r}"); }
51}
52```
53
54Errors → silent empty (stderr) so completion never breaks.
55
56### 3. Filter `DbComplete` out of generated completions (like `Completions`)
57
58In `Completions` generation (cli.rs:238-253): filter `s.get_name() != "db-complete"`.
59
60### 4. Dynamic zsh completion function
61
62Replace `/usr/share/zsh/site-functions/_rsp` with a wrapper that:
63
64```zsh
65#compdef rsp
66# Static part (generated by clap)
67_rsp_command() { ... } # clap-generated
68
69# Dynamic overrides
70_rsp() {
71 case $line[1] in
72 play|queue)
73 if compset -P 1; then
74 # Subcommand → complete tracks/artists/albums
75 local -a suggestions
76 suggestions=(${(f)"$(rsp complete tracks "$words[CURRENT]")"})
77 suggestions+=(${(f)"$(rsp complete artists "$words[CURRENT]")"})
78 suggestions+=(${(f)"$(rsp complete albums "$words[CURRENT]")"})
79 compadd -a suggestions
80 else
81 _rsp_command
82 fi
83 ;;
84 *)
85 _rsp_command
86 ;;
87 esac
88}
89```
90
91For `play <query>`, complete artists + albums + tracks.
92Where artists have the biggest priority, then comes albums and at the end tracks.
93Additionally, when we have autocompleted an artist, put a '/' after the name. Next autocompletions will autocomplete albums/tracks, do the same for albums.
94
95### 5. Update `Completions` generation to produce the wrapper
96
97In `cli.rs`, after `generate()` for zsh, append the dynamic wrapper code. The generated file becomes: `clap-generated-code` + `_rsp() { ... dynamic ... }`.
98
99Or simpler: write the wrapper as `completions/_rsp.dynamic` and concatenate in install.sh.
100
101### 6. Update `install.sh`
102
103Replace `rsp completions zsh | sudo tee ...` with:
104```bash
105(rsp completions zsh; echo; cat completions/_rsp) | sudo tee "$dest"
106```
107
108### 7. Update `TODO.md`
109
110Mark "[x] Dynamic autocompletion of artists, albums and tracks"
111
112### 8. Tests
113
114Add tests in `tests/database.rs`:
115- `test_complete_artists_prefix`
116- `test_complete_albums_prefix`
117- `test_complete_tracks_prefix`
118- `test_complete_empty_db`
119- `test_complete_no_match`
120
121Need a test helper that creates a temp DB with known data, runs queries, checks results.
122
123## Files changed
124
125| File | Change |
126|------|--------|
127| `src/database.rs` | +3 pub methods (complete_artists/albums/tracks) |
128| `src/cli.rs` | +DbComplete variant, +dispatch, +filter in Completions |
129| `completions/_rsp.dynamic` (new) | zsh dynamic completion wrapper |
130| `install.sh` | concatenate static + dynamic |
131| `tests/database.rs` | +completion query tests |
132
133## Future: fish/bash
134
135Same pattern. Fish uses `complete -c rsp -n '__fish_seen_subcommand_from play' -a '(rsp complete tracks (commandline -ct))'`. Bash uses `COMPREPLY=($(compgen -W "$(rsp complete tracks "$cur")" -- "$cur"))`. Start with zsh only (user's shell).
diff --git a/TODO.md b/TODO.md
index 9bc36c2..55f7d52 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,10 +2,8 @@
2 2
3- **Persist queue state** - Save queue to disk so it survives daemon restarts. 3- **Persist queue state** - Save queue to disk so it survives daemon restarts.
4 4
5- **Dynamic autocompletion of artists, albums and tracks** - Let shell communicate with the daemon so it returns autocompletions for shell 5- [x] **Dynamic autocompletion of artists, albums and tracks** - `rsp complete <entity> [prefix]` queries DB directly; zsh completion wrapper concatenated via install.sh
6 6
7- **Cache config in daemon** - Avoid repeated Config::load() calls in daemon hot paths; pass display_format through command chain instead 7- **Cache config in daemon** - Avoid repeated Config::load() calls in daemon hot paths; pass display_format through command chain instead
8 8
9- **Test last.fm scrobbling** - Current scrobbling is only tested for listenbrainz 9- **Test last.fm scrobbling** - Current scrobbling is only tested for listenbrainz
10
11- **REPL mode** - like in bluetoothctl
diff --git a/completions/_rsp b/completions/_rsp
new file mode 100644
index 0000000..1ddc1fe
--- /dev/null
+++ b/completions/_rsp
@@ -0,0 +1,55 @@
1# Dynamic completion overrides for rsp play/queue arguments.
2
3# Save clap-generated function so we can delegate non-dynamic cases.
4functions[_rsp_clap]=$functions[_rsp]
5
6_rsp() {
7 case $words[2] in
8 play|queue)
9 _rsp_complete_args
10 ;;
11 *)
12 _rsp_clap "$@"
13 ;;
14 esac
15}
16
17_rsp_complete_args() {
18 setopt localoptions
19 setopt nobanghist
20 # Reconstruct full query from all argument words after subcommand.
21 # This handles spaces in names (e.g. "Master of Puppets") that get
22 # split across multiple $words entries when backslash escaping breaks.
23 local -a args=("${(@)words[3,$CURRENT]}")
24 local cur="${(j: :)args}"
25 local -a suggestions
26 local n_slashes=${#cur//[^\/]/}
27 case $n_slashes in
28 0) suggestions=(${(f)"$(rsp db-complete artists "$cur" 2>/dev/null)"}) ;;
29 1) suggestions=(${(f)"$(rsp db-complete albums "$cur" 2>/dev/null)"}) ;;
30 *) suggestions=(${(f)"$(rsp db-complete tracks "$cur" 2>/dev/null)"}) ;;
31 esac
32 # display: what the user sees in the completion menu (raw values)
33 local -a display=("${suggestions[@]}")
34 # vals: what gets inserted (with ZSH backslash escaping + ! escape)
35 local -a vals=()
36 local slash_idx=$cur[(I)/]
37 local s
38 for s in "${suggestions[@]}"; do
39 local esc="${(q)s:gs/\!/\\\!/}"
40 if (( slash_idx )); then
41 local dir_part=$cur[1,$slash_idx]
42 local edir="${(q)dir_part:gs/\!/\\\!/}"
43 vals+=("${edir}${esc}")
44 else
45 vals+=("${esc}")
46 fi
47 done
48 if (( ${#suggestions} )); then
49 if [[ ${vals[1]} == */ ]]; then
50 compadd -U -S '' -d display -- "${vals[@]}"
51 else
52 compadd -U -d display -- "${vals[@]}"
53 fi
54 fi
55}
diff --git a/install.sh b/install.sh
index 11af71d..280be8a 100755
--- a/install.sh
+++ b/install.sh
@@ -46,7 +46,7 @@ case "$shell_name" in
46 ;; 46 ;;
47 zsh) 47 zsh)
48 dest="/usr/share/zsh/site-functions/_rsp" 48 dest="/usr/share/zsh/site-functions/_rsp"
49 /usr/bin/rsp completions zsh | sudo tee "$dest" > /dev/null 49 (/usr/bin/rsp completions zsh; echo; cat completions/_rsp) | sudo tee "$dest" > /dev/null
50 echo " → Zsh completions: $dest" 50 echo " → Zsh completions: $dest"
51 ;; 51 ;;
52 fish) 52 fish)
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 {
92 #[arg(value_name = "SHELL")] 92 #[arg(value_name = "SHELL")]
93 shell: Option<String>, 93 shell: Option<String>,
94 }, 94 },
95 #[clap(hide = true)]
96 /// Complete artists, albums, or tracks from database
97 DbComplete {
98 /// Entity type: artists, albums, or tracks
99 #[arg(value_name = "ENTITY")]
100 entity: String,
101 /// Optional prefix to match
102 #[arg(value_name = "PREFIX")]
103 prefix: Option<String>,
104 },
95} 105}
96 106
97#[derive(Subcommand)] 107#[derive(Subcommand)]
@@ -235,11 +245,35 @@ impl Cli {
235 } 245 }
236 } 246 }
237 } 247 }
248 Commands::DbComplete { entity, prefix } => {
249 let config = match Config::load() {
250 Ok(c) => c,
251 Err(_) => return Ok(()),
252 };
253 let db = match Database::open(&config) {
254 Ok(d) => d,
255 Err(_) => return Ok(()),
256 };
257 let prefix = prefix.as_deref().unwrap_or("");
258 let results: Vec<String> = match entity.as_str() {
259 "artists" => db.complete_artists(prefix).unwrap_or_default(),
260 "albums" => db.complete_albums(prefix).unwrap_or_default(),
261 "tracks" => db.complete_tracks(prefix).unwrap_or_default(),
262 _ => Vec::new(),
263 };
264 let suffix = match entity.as_str() {
265 "artists" | "albums" => "/",
266 _ => "",
267 };
268 for r in &results {
269 println!("{r}{suffix}");
270 }
271 }
238 Commands::Completions { shell } => { 272 Commands::Completions { shell } => {
239 let full = Cli::command(); 273 let full = Cli::command();
240 let subcommands: Vec<Command> = full 274 let subcommands: Vec<Command> = full
241 .get_subcommands() 275 .get_subcommands()
242 .filter(|s| s.get_name() != "completions") 276 .filter(|s| s.get_name() != "completions" && s.get_name() != "db-complete")
243 .cloned() 277 .cloned()
244 .collect(); 278 .collect();
245 let cmd_name = full.get_name().to_string(); 279 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(
747 } else { 747 } else {
748 match Config::load() { 748 match Config::load() {
749 Ok(config) => match crate::database::Database::open(&config) { 749 Ok(config) => match crate::database::Database::open(&config) {
750 Ok(db) => match db.search_tracks(query_str) { 750 Ok(db) => {
751 Ok(tracks) if !tracks.is_empty() => { 751 let tracks = match db.resolve_path(query_str) {
752 let db_track = &tracks[0]; 752 Ok(Some(t)) => t,
753 let album_tracks = db 753 Ok(None) => match db.search_tracks(query_str) {
754 .get_album_tracks_after( 754 Ok(t) => t,
755 db_track.album_id, 755 Err(e) => return format!("Database error: {}\n", e),
756 db_track.track_number, 756 },
757 &db_track.path, 757 Err(e) => return format!("Database error: {}\n", e),
758 ) 758 };
759 .unwrap_or_default(); 759 if tracks.is_empty() {
760 let display = format!(
761 "{} - {}",
762 db_track.artist_name.as_deref().unwrap_or("unknown"),
763 db_track.title,
764 );
765 let paths: Vec<PathBuf> =
766 album_tracks.into_iter().map(|t| t.path).collect();
767 (db_track.path.clone(), paths, display)
768 }
769 Ok(_) => {
770 return format!("No tracks found matching '{}'\n", query_str); 760 return format!("No tracks found matching '{}'\n", query_str);
771 } 761 }
772 Err(e) => return format!("Database error: {}\n", e), 762 let db_track = &tracks[0];
773 }, 763 let album_paths: Vec<PathBuf> = if tracks.len() > 1 {
764 // resolve_path returned full album — use remaining tracks
765 tracks[1..].iter().map(|t| t.path.clone()).collect()
766 } else {
767 db.get_album_tracks_after(
768 db_track.album_id,
769 db_track.track_number,
770 &db_track.path,
771 )
772 .unwrap_or_default()
773 .into_iter()
774 .map(|t| t.path)
775 .collect()
776 };
777 let display = format!(
778 "{} - {}",
779 db_track.artist_name.as_deref().unwrap_or("unknown"),
780 db_track.title,
781 );
782 (db_track.path.clone(), album_paths, display)
783 }
774 Err(e) => return format!("Database error: {}\n", e), 784 Err(e) => return format!("Database error: {}\n", e),
775 }, 785 },
776 Err(e) => return format!("Config error: {}\n", e), 786 Err(e) => return format!("Config error: {}\n", e),
@@ -862,6 +872,8 @@ fn process_command(
862 } else { 872 } else {
863 path_str.to_string() 873 path_str.to_string()
864 }; 874 };
875 // Strip trailing slash so autocompleted Artist/Album/ resolves cleanly
876 let path_str = path_str.trim_end_matches('/').to_string();
865 877
866 if path_str.is_empty() { 878 if path_str.is_empty() {
867 return "Usage: play [now|next|last] <path>\n".to_string(); 879 return "Usage: play [now|next|last] <path>\n".to_string();
@@ -1052,7 +1064,132 @@ fn process_command(
1052 _ => format!("Error: No audio files found in '{}'\n", path_str), 1064 _ => format!("Error: No audio files found in '{}'\n", path_str),
1053 } 1065 }
1054 } else { 1066 } else {
1055 format!("File not found: {}\n", path_str) 1067 match Config::load() {
1068 Ok(config) => match crate::database::Database::open(&config) {
1069 Ok(db) => {
1070 // Collect tracks: try hierarchical path first, fallback to search
1071 let tracks = match db.resolve_path(&path_str) {
1072 Ok(Some(t)) => t,
1073 Ok(None) => {
1074 let query = path_str
1075 .rsplit_once('/')
1076 .and_then(|(_, after)| {
1077 (!after.is_empty()).then_some(after)
1078 })
1079 .unwrap_or(&path_str);
1080 match db.search_tracks(query) {
1081 Ok(t) => t,
1082 Err(e) => return format!("Database error: {}\n", e),
1083 }
1084 }
1085 Err(e) => return format!("Database error: {}\n", e),
1086 };
1087
1088 if tracks.is_empty() {
1089 return format!("No tracks found matching '{}'\n", path_str);
1090 }
1091
1092 let is_album_play = tracks.len() > 1;
1093 if let Ok(mut q) = queue.lock() {
1094 let was_empty = q.is_empty();
1095 match position {
1096 "now" => {
1097 if let Ok(mut s) = scrobble_state.lock() {
1098 try_scrobble_current(scrobbler, &mut s);
1099 }
1100 for t in tracks.iter().rev() {
1101 q.insert_after_current(t.path.clone());
1102 }
1103 if !was_empty {
1104 let new_pos = q.position() + 1;
1105 q.set_position(new_pos);
1106 }
1107 if let Some(track) = q.track_at(q.position()) {
1108 audio.play(track.path.clone());
1109 send_now_playing(scrobbler, track);
1110 if let Ok(mut s) = scrobble_state.lock() {
1111 s.start(track.clone());
1112 }
1113 let fmt = Config::load()
1114 .ok()
1115 .and_then(|c| c.display_format.clone());
1116 notification::notify_track(track, fmt.as_deref());
1117 }
1118 if is_album_play {
1119 format!(
1120 "Playing now: {} tracks from {}\n",
1121 tracks.len(),
1122 path_str
1123 )
1124 } else {
1125 format!("Playing now: {}\n", path_str)
1126 }
1127 }
1128 "next" => {
1129 for t in tracks.iter().rev() {
1130 q.insert_after_current(t.path.clone());
1131 }
1132 if is_album_play {
1133 format!(
1134 "Queued next: {} tracks from {}\n",
1135 tracks.len(),
1136 path_str
1137 )
1138 } else {
1139 format!("Queued next: {}\n", path_str)
1140 }
1141 }
1142 _ => {
1143 let was_playing = audio.is_playing();
1144 for t in &tracks {
1145 q.add_track(t.path.clone());
1146 }
1147 if was_empty && !was_playing {
1148 if is_album_play {
1149 q.shuffle_initial();
1150 }
1151 if let Some(track) = q.current() {
1152 audio.play(track.path.clone());
1153 send_now_playing(scrobbler, track);
1154 if let Ok(mut s) = scrobble_state.lock() {
1155 s.start(track.clone());
1156 }
1157 let fmt = Config::load()
1158 .ok()
1159 .and_then(|c| c.display_format.clone());
1160 notification::notify_track(
1161 track,
1162 fmt.as_deref(),
1163 );
1164 }
1165 if is_album_play {
1166 format!(
1167 "Playing: {} tracks from {}\n",
1168 tracks.len(),
1169 path_str
1170 )
1171 } else {
1172 format!("Playing: {}\n", path_str)
1173 }
1174 } else if is_album_play {
1175 format!(
1176 "Queued {} tracks from: {}\n",
1177 tracks.len(),
1178 path_str
1179 )
1180 } else {
1181 format!("Queued: {}\n", path_str)
1182 }
1183 }
1184 }
1185 } else {
1186 "Error accessing queue\n".to_string()
1187 }
1188 }
1189 Err(e) => format!("Database error: {}\n", e),
1190 },
1191 Err(e) => format!("Config error: {}\n", e),
1192 }
1056 } 1193 }
1057 } 1194 }
1058 } 1195 }
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 {
45 45
46#[allow(dead_code)] 46#[allow(dead_code)]
47impl Database { 47impl Database {
48 pub fn conn(&self) -> &Connection {
49 &self.conn
50 }
51
48 pub fn open(_config: &crate::config::Config) -> SqlResult<Self> { 52 pub fn open(_config: &crate::config::Config) -> SqlResult<Self> {
49 let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { 53 let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| {
50 rusqlite::Error::InvalidPath("Failed to get project directories".into()) 54 rusqlite::Error::InvalidPath("Failed to get project directories".into())
@@ -269,6 +273,138 @@ impl Database {
269 self.get_album_tracks_after(album_id, track_number, &path) 273 self.get_album_tracks_after(album_id, track_number, &path)
270 } 274 }
271 275
276 pub fn complete_artists(&self, prefix: &str) -> SqlResult<Vec<String>> {
277 let pattern = format!("%{}%", prefix);
278 let mut stmt = self
279 .conn
280 .prepare("SELECT name FROM artists WHERE name LIKE ?1 ORDER BY name LIMIT 25")?;
281 let rows = stmt.query_map([&pattern], |row| row.get::<_, String>(0))?;
282 rows.collect()
283 }
284
285 pub fn complete_albums(&self, prefix: &str) -> SqlResult<Vec<String>> {
286 let (artist, album_prefix) = match prefix.split_once('/') {
287 Some((a, ap)) => (a, ap),
288 None => return Ok(Vec::new()),
289 };
290 let pattern = format!("{}%", album_prefix);
291 let mut stmt = self.conn.prepare(
292 "SELECT a.title FROM albums a
293 JOIN artists ar ON a.artist_id = ar.id
294 WHERE ar.name = ?1 AND a.title LIKE ?2
295 ORDER BY a.title LIMIT 25",
296 )?;
297 let rows = stmt.query_map(rusqlite::params![artist, pattern], |row| {
298 row.get::<_, String>(0)
299 })?;
300 rows.collect()
301 }
302
303 pub fn complete_tracks(&self, prefix: &str) -> SqlResult<Vec<String>> {
304 let parts: Vec<&str> = prefix.splitn(3, '/').collect();
305 if parts.len() < 3 {
306 return Ok(Vec::new());
307 }
308 let (artist, album, track_prefix) = (parts[0], parts[1], parts[2]);
309 let pattern = format!("{}%", track_prefix);
310 let mut stmt = self.conn.prepare(
311 "SELECT t.title FROM tracks t
312 JOIN albums a ON t.album_id = a.id
313 JOIN artists ar ON a.artist_id = ar.id
314 WHERE ar.name = ?1 AND a.title = ?2 AND t.title LIKE ?3
315 ORDER BY t.track_number, t.title LIMIT 25",
316 )?;
317 let rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| {
318 row.get::<_, String>(0)
319 })?;
320 rows.collect()
321 }
322
323 #[allow(dead_code)]
324 pub fn open_in_memory() -> SqlResult<Database> {
325 let conn = Connection::open_in_memory()?;
326 let db = Database { conn };
327 db.init_schema()?;
328 Ok(db)
329 }
330
331 /// Find all tracks in an album by exact artist name and album title.
332 pub fn find_tracks_by_album(&self, artist: &str, album: &str) -> SqlResult<Vec<Track>> {
333 let mut stmt = self.conn.prepare(
334 "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title
335 FROM tracks t
336 JOIN albums a ON t.album_id = a.id
337 JOIN artists ar ON a.artist_id = ar.id
338 WHERE ar.name = ?1 AND a.title = ?2
339 ORDER BY t.track_number",
340 )?;
341 let rows = stmt.query_map(rusqlite::params![artist, album], |row| {
342 Ok(Track {
343 id: row.get(0)?,
344 album_id: row.get(1)?,
345 title: row.get(2)?,
346 path: PathBuf::from(row.get::<_, String>(3)?),
347 duration: row.get(4)?,
348 track_number: row.get(5)?,
349 artist_name: Some(row.get(6)?),
350 album_title: Some(row.get(7)?),
351 })
352 })?;
353 rows.collect()
354 }
355
356 /// Find a single track by exact artist, exact album, and track title prefix.
357 /// Returns None if no match found.
358 pub fn find_track_by_parts(
359 &self,
360 artist: &str,
361 album: &str,
362 track_prefix: &str,
363 ) -> SqlResult<Option<Track>> {
364 let pattern = format!("{}%", track_prefix);
365 let mut stmt = self.conn.prepare(
366 "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title
367 FROM tracks t
368 JOIN albums a ON t.album_id = a.id
369 JOIN artists ar ON a.artist_id = ar.id
370 WHERE ar.name = ?1 AND a.title = ?2 AND t.title LIKE ?3
371 ORDER BY t.track_number LIMIT 1",
372 )?;
373 let mut rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| {
374 Ok(Track {
375 id: row.get(0)?,
376 album_id: row.get(1)?,
377 title: row.get(2)?,
378 path: PathBuf::from(row.get::<_, String>(3)?),
379 duration: row.get(4)?,
380 track_number: row.get(5)?,
381 artist_name: Some(row.get(6)?),
382 album_title: Some(row.get(7)?),
383 })
384 })?;
385 match rows.next() {
386 Some(Ok(t)) => Ok(Some(t)),
387 Some(Err(e)) => Err(e),
388 None => Ok(None),
389 }
390 }
391
392 /// Resolve a hierarchical path like "Artist/Album" or "Artist/Album/Track"
393 /// to DB tracks. Returns None if the path isn't hierarchical (single part
394 /// or 4+ parts) — the caller should fall back to search_tracks().
395 pub fn resolve_path(&self, path: &str) -> SqlResult<Option<Vec<Track>>> {
396 let path = path.strip_suffix('/').unwrap_or(path);
397 let parts: Vec<&str> = path.splitn(3, '/').collect();
398 match parts.len() {
399 2 => Ok(Some(self.find_tracks_by_album(parts[0], parts[1])?)),
400 3 => match self.find_track_by_parts(parts[0], parts[1], parts[2])? {
401 Some(t) => Ok(Some(vec![t])),
402 None => Ok(Some(vec![])),
403 },
404 _ => Ok(None),
405 }
406 }
407
272 pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { 408 pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> {
273 let mut stmt = self.conn.prepare( 409 let mut stmt = self.conn.prepare(
274 "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title 410 "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title
diff --git a/tests/database.rs b/tests/database.rs
index 6cd870e..adbf535 100644
--- a/tests/database.rs
+++ b/tests/database.rs
@@ -5,7 +5,7 @@
5 * https://codeberg.org/salmonik/RSPlayer 5 * https://codeberg.org/salmonik/RSPlayer
6*/ 6*/
7 7
8use rsp::database::{extract_metadata, is_audio_file}; 8use rsp::database::{Database, extract_metadata, is_audio_file};
9use std::path::PathBuf; 9use std::path::PathBuf;
10 10
11#[test] 11#[test]
@@ -90,3 +90,394 @@ fn test_extract_metadata_no_parent() {
90 assert_eq!(album, "Unknown"); 90 assert_eq!(album, "Unknown");
91 assert_eq!(title, "song"); 91 assert_eq!(title, "song");
92} 92}
93
94fn seed_test_db(db: &Database) {
95 let conn: &rusqlite::Connection = db.conn();
96 conn.execute_batch(
97 "INSERT INTO artists (id, name) VALUES (1, 'Metallica'), (2, 'Miles Davis'), (3, 'Aphex Twin');
98 INSERT INTO albums (id, artist_id, title) VALUES
99 (1, 1, 'Master of Puppets'),
100 (2, 1, 'Ride the Lightning'),
101 (3, 2, 'Kind of Blue'),
102 (4, 3, 'Selected Ambient Works');
103 INSERT INTO tracks (id, album_id, title, path, duration, track_number) VALUES
104 (1, 1, 'Battery', '/m/Metallica/Master of Puppets/01-Battery.mp3', 313, 1),
105 (2, 1, 'Master of Puppets', '/m/Metallica/Master of Puppets/02-Master of Puppets.mp3', 515, 2),
106 (3, 2, 'Fight Fire with Fire', '/m/Metallica/Ride the Lightning/01-Fight Fire with Fire.mp3', 285, 1),
107 (4, 3, 'So What', '/m/Miles Davis/Kind of Blue/01-So What.mp3', 562, 1),
108 (5, 4, 'Xtal', '/m/Aphex Twin/Selected Ambient Works/01-Xtal.mp3', 437, 1);",
109 )
110 .unwrap();
111}
112
113#[test]
114fn test_complete_artists_prefix() {
115 let db = Database::open_in_memory().unwrap();
116 seed_test_db(&db);
117 let results = db.complete_artists("M").unwrap();
118 assert_eq!(results.len(), 2);
119 assert!(results.contains(&"Metallica".to_string()));
120 assert!(results.contains(&"Miles Davis".to_string()));
121}
122
123#[test]
124fn test_complete_artists_prefix_lowercase() {
125 let db = Database::open_in_memory().unwrap();
126 seed_test_db(&db);
127 let results = db.complete_artists("m").unwrap();
128 assert!(
129 results.contains(&"Metallica".to_string()),
130 "LIKE should be case-insensitive"
131 );
132}
133
134#[test]
135fn test_complete_artists_no_match() {
136 let db = Database::open_in_memory().unwrap();
137 seed_test_db(&db);
138 let results = db.complete_artists("Z").unwrap();
139 assert!(results.is_empty());
140}
141
142#[test]
143fn test_complete_artists_empty_prefix() {
144 let db = Database::open_in_memory().unwrap();
145 seed_test_db(&db);
146 let results = db.complete_artists("").unwrap();
147 assert_eq!(results.len(), 3);
148}
149
150#[test]
151fn test_complete_albums_with_artist_prefix() {
152 let db = Database::open_in_memory().unwrap();
153 seed_test_db(&db);
154 let results = db.complete_albums("Metallica/").unwrap();
155 assert_eq!(results.len(), 2);
156 assert!(results.contains(&"Master of Puppets".to_string()));
157 assert!(results.contains(&"Ride the Lightning".to_string()));
158}
159
160#[test]
161fn test_complete_albums_with_album_prefix() {
162 let db = Database::open_in_memory().unwrap();
163 seed_test_db(&db);
164 let results = db.complete_albums("Metallica/R").unwrap();
165 assert_eq!(results.len(), 1);
166 assert_eq!(results[0], "Ride the Lightning");
167}
168
169#[test]
170fn test_complete_albums_unknown_artist() {
171 let db = Database::open_in_memory().unwrap();
172 seed_test_db(&db);
173 let results = db.complete_albums("Unknown/").unwrap();
174 assert!(results.is_empty());
175}
176
177#[test]
178fn test_complete_albums_no_slash() {
179 let db = Database::open_in_memory().unwrap();
180 seed_test_db(&db);
181 let results = db.complete_albums("Metallica").unwrap();
182 assert!(results.is_empty(), "needs trailing slash");
183}
184
185#[test]
186fn test_complete_tracks_full_path() {
187 let db = Database::open_in_memory().unwrap();
188 seed_test_db(&db);
189 let results = db.complete_tracks("Metallica/Master of Puppets/").unwrap();
190 assert_eq!(results.len(), 2);
191 assert!(results.contains(&"Battery".to_string()));
192 assert!(results.contains(&"Master of Puppets".to_string()));
193}
194
195#[test]
196fn test_complete_tracks_with_prefix() {
197 let db = Database::open_in_memory().unwrap();
198 seed_test_db(&db);
199 let results = db.complete_tracks("Metallica/Master of Puppets/B").unwrap();
200 assert_eq!(results.len(), 1);
201 assert_eq!(results[0], "Battery");
202}
203
204#[test]
205fn test_complete_tracks_unknown_album() {
206 let db = Database::open_in_memory().unwrap();
207 seed_test_db(&db);
208 let results = db.complete_tracks("Metallica/Nonexistent/").unwrap();
209 assert!(results.is_empty());
210}
211
212#[test]
213fn test_complete_tracks_too_few_parts() {
214 let db = Database::open_in_memory().unwrap();
215 seed_test_db(&db);
216 let results = db.complete_tracks("Metallica").unwrap();
217 assert!(results.is_empty());
218}
219
220#[test]
221fn test_complete_empty_db() {
222 let db = Database::open_in_memory().unwrap();
223 assert!(db.complete_artists("").unwrap().is_empty());
224 assert!(db.complete_albums("x/").unwrap().is_empty());
225 assert!(db.complete_tracks("x/y/").unwrap().is_empty());
226}
227
228// --- find_tracks_by_album tests ---
229
230#[test]
231fn test_find_tracks_by_album_all() {
232 let db = Database::open_in_memory().unwrap();
233 seed_test_db(&db);
234 let tracks = db
235 .find_tracks_by_album("Metallica", "Master of Puppets")
236 .unwrap();
237 assert_eq!(tracks.len(), 2);
238 assert_eq!(tracks[0].title, "Battery");
239 assert_eq!(tracks[1].title, "Master of Puppets");
240 assert_eq!(tracks[0].artist_name.as_deref(), Some("Metallica"));
241 assert_eq!(tracks[0].album_title.as_deref(), Some("Master of Puppets"));
242}
243
244#[test]
245fn test_find_tracks_by_album_single_track() {
246 let db = Database::open_in_memory().unwrap();
247 seed_test_db(&db);
248 let tracks = db
249 .find_tracks_by_album("Miles Davis", "Kind of Blue")
250 .unwrap();
251 assert_eq!(tracks.len(), 1);
252 assert_eq!(tracks[0].title, "So What");
253}
254
255#[test]
256fn test_find_tracks_by_album_unknown_artist() {
257 let db = Database::open_in_memory().unwrap();
258 seed_test_db(&db);
259 let tracks = db
260 .find_tracks_by_album("Nonexistent", "Master of Puppets")
261 .unwrap();
262 assert!(tracks.is_empty());
263}
264
265#[test]
266fn test_find_tracks_by_album_unknown_album() {
267 let db = Database::open_in_memory().unwrap();
268 seed_test_db(&db);
269 let tracks = db.find_tracks_by_album("Metallica", "Nonexistent").unwrap();
270 assert!(tracks.is_empty());
271}
272
273#[test]
274fn test_find_tracks_by_album_case_sensitive_artist() {
275 let db = Database::open_in_memory().unwrap();
276 seed_test_db(&db);
277 // SQLite = is case-sensitive for TEXT (unless NOCASE collation)
278 let tracks = db
279 .find_tracks_by_album("metallica", "Master of Puppets")
280 .unwrap();
281 assert!(
282 tracks.is_empty(),
283 "artist name match should be case-sensitive"
284 );
285}
286
287#[test]
288fn test_find_tracks_by_album_ordered_by_track_number() {
289 let db = Database::open_in_memory().unwrap();
290 seed_test_db(&db);
291 let tracks = db
292 .find_tracks_by_album("Metallica", "Master of Puppets")
293 .unwrap();
294 assert_eq!(tracks[0].track_number, Some(1));
295 assert_eq!(tracks[1].track_number, Some(2));
296}
297
298// --- find_track_by_parts tests ---
299
300#[test]
301fn test_find_track_by_parts_exact() {
302 let db = Database::open_in_memory().unwrap();
303 seed_test_db(&db);
304 let track = db
305 .find_track_by_parts("Metallica", "Master of Puppets", "Battery")
306 .unwrap();
307 assert!(track.is_some());
308 let t = track.unwrap();
309 assert_eq!(t.title, "Battery");
310 assert_eq!(t.artist_name.as_deref(), Some("Metallica"));
311 assert_eq!(t.album_title.as_deref(), Some("Master of Puppets"));
312}
313
314#[test]
315fn test_find_track_by_parts_prefix() {
316 let db = Database::open_in_memory().unwrap();
317 seed_test_db(&db);
318 let track = db
319 .find_track_by_parts("Metallica", "Master of Puppets", "B")
320 .unwrap();
321 assert!(track.is_some());
322 assert_eq!(track.unwrap().title, "Battery");
323}
324
325#[test]
326fn test_find_track_by_parts_no_match_track() {
327 let db = Database::open_in_memory().unwrap();
328 seed_test_db(&db);
329 let track = db
330 .find_track_by_parts("Metallica", "Master of Puppets", "Z")
331 .unwrap();
332 assert!(track.is_none());
333}
334
335#[test]
336fn test_find_track_by_parts_no_match_album() {
337 let db = Database::open_in_memory().unwrap();
338 seed_test_db(&db);
339 let track = db
340 .find_track_by_parts("Metallica", "Nonexistent", "B")
341 .unwrap();
342 assert!(track.is_none());
343}
344
345#[test]
346fn test_find_track_by_parts_no_match_artist() {
347 let db = Database::open_in_memory().unwrap();
348 seed_test_db(&db);
349 let track = db
350 .find_track_by_parts("Nonexistent", "Master of Puppets", "B")
351 .unwrap();
352 assert!(track.is_none());
353}
354
355#[test]
356fn test_find_track_by_parts_case_sensitive() {
357 let db = Database::open_in_memory().unwrap();
358 seed_test_db(&db);
359 let track = db
360 .find_track_by_parts("metallica", "Master of Puppets", "Battery")
361 .unwrap();
362 assert!(track.is_none(), "artist match should be case-sensitive");
363}
364
365// --- resolve_path tests ---
366
367#[test]
368fn test_resolve_path_two_parts_album() {
369 let db = Database::open_in_memory().unwrap();
370 seed_test_db(&db);
371 let result = db.resolve_path("Metallica/Master of Puppets").unwrap();
372 assert!(result.is_some());
373 let tracks = result.unwrap();
374 assert_eq!(tracks.len(), 2);
375 assert_eq!(tracks[0].title, "Battery");
376}
377
378#[test]
379fn test_resolve_path_two_parts_trailing_slash() {
380 let db = Database::open_in_memory().unwrap();
381 seed_test_db(&db);
382 let result = db.resolve_path("Metallica/Master of Puppets/").unwrap();
383 assert!(result.is_some());
384 let tracks = result.unwrap();
385 assert_eq!(tracks.len(), 2);
386}
387
388#[test]
389fn test_resolve_path_three_parts_track() {
390 let db = Database::open_in_memory().unwrap();
391 seed_test_db(&db);
392 let result = db
393 .resolve_path("Metallica/Master of Puppets/Battery")
394 .unwrap();
395 assert!(result.is_some());
396 let tracks = result.unwrap();
397 assert_eq!(tracks.len(), 1);
398 assert_eq!(tracks[0].title, "Battery");
399}
400
401#[test]
402fn test_resolve_path_three_parts_track_prefix() {
403 let db = Database::open_in_memory().unwrap();
404 seed_test_db(&db);
405 let result = db.resolve_path("Metallica/Master of Puppets/B").unwrap();
406 assert!(result.is_some());
407 let tracks = result.unwrap();
408 assert_eq!(tracks.len(), 1);
409 assert_eq!(tracks[0].title, "Battery");
410}
411
412#[test]
413fn test_resolve_path_single_part_returns_none() {
414 let db = Database::open_in_memory().unwrap();
415 seed_test_db(&db);
416 let result = db.resolve_path("Metallica").unwrap();
417 assert!(
418 result.is_none(),
419 "single part should return None for fallback"
420 );
421}
422
423#[test]
424fn test_resolve_path_no_match_album() {
425 let db = Database::open_in_memory().unwrap();
426 seed_test_db(&db);
427 let result = db.resolve_path("Metallica/Nonexistent").unwrap();
428 assert!(result.is_some());
429 assert!(result.unwrap().is_empty());
430}
431
432#[test]
433fn test_resolve_path_no_match_track() {
434 let db = Database::open_in_memory().unwrap();
435 seed_test_db(&db);
436 let result = db.resolve_path("Metallica/Master of Puppets/Z").unwrap();
437 assert!(result.is_some());
438 assert!(result.unwrap().is_empty());
439}
440
441#[test]
442fn test_resolve_path_empty_db() {
443 let db = Database::open_in_memory().unwrap();
444 assert!(db.resolve_path("Artist/Album").unwrap().unwrap().is_empty());
445 assert!(
446 db.resolve_path("Artist/Album/Track")
447 .unwrap()
448 .unwrap()
449 .is_empty()
450 );
451 assert!(db.resolve_path("Artist").unwrap().is_none());
452}
453
454// --- Track conversion helper tests ---
455
456#[test]
457fn test_find_tracks_by_album_returns_full_metadata() {
458 let db = Database::open_in_memory().unwrap();
459 seed_test_db(&db);
460 let tracks = db
461 .find_tracks_by_album("Metallica", "Ride the Lightning")
462 .unwrap();
463 assert_eq!(tracks.len(), 1);
464 let t = &tracks[0];
465 assert_eq!(t.artist_name.as_deref(), Some("Metallica"));
466 assert_eq!(t.album_title.as_deref(), Some("Ride the Lightning"));
467 assert_eq!(t.title, "Fight Fire with Fire");
468 assert!(t.path.to_string_lossy().contains("Ride the Lightning"));
469}
470
471#[test]
472fn test_find_track_by_parts_returns_full_metadata() {
473 let db = Database::open_in_memory().unwrap();
474 seed_test_db(&db);
475 let t = db
476 .find_track_by_parts("Aphex Twin", "Selected Ambient Works", "Xtal")
477 .unwrap()
478 .expect("track should be found");
479 assert_eq!(t.artist_name.as_deref(), Some("Aphex Twin"));
480 assert_eq!(t.album_title.as_deref(), Some("Selected Ambient Works"));
481 assert_eq!(t.title, "Xtal");
482 assert_eq!(t.track_number, Some(1));
483}