diff options
| author | salmonik <raisersoft@proton.me> | 2026-06-04 00:21:46 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-06-04 00:21:46 +0200 |
| commit | 1e48765ab39a679abcc4eac6927ad3bb58f7b438 (patch) | |
| tree | 5554f2a06bde693d4d9c39ed26f80677d61da991 | |
| parent | fix no metadata songs handling (diff) | |
| download | rsplayer-1e48765ab39a679abcc4eac6927ad3bb58f7b438.tar.gz rsplayer-1e48765ab39a679abcc4eac6927ad3bb58f7b438.zip | |
dirty, broken commit
| -rw-r--r-- | .zed/tasks.json | 2 | ||||
| -rw-r--r-- | PLAN.md | 135 | ||||
| -rw-r--r-- | TODO.md | 4 | ||||
| -rw-r--r-- | completions/_rsp | 55 | ||||
| -rwxr-xr-x | install.sh | 2 | ||||
| -rw-r--r-- | src/cli.rs | 36 | ||||
| -rw-r--r-- | src/daemon.rs | 183 | ||||
| -rw-r--r-- | src/database.rs | 136 | ||||
| -rw-r--r-- | tests/database.rs | 393 |
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", |
| @@ -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 | |||
| 9 | Hidden CLI subcommand `rsp complete <entity> [prefix]` that: | ||
| 10 | 1. Opens DB directly (same as `rsp scan` at cli.rs:204-205) | ||
| 11 | 2. Queries with prefix match | ||
| 12 | 3. Prints newline-separated results to stdout | ||
| 13 | 4. Returns empty on error (never crash completion) | ||
| 14 | |||
| 15 | Works 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 | ``` | ||
| 22 | pub fn complete_artists(&self, prefix: &str) -> SqlResult<Vec<String>> | ||
| 23 | pub fn complete_albums(&self, prefix: &str) -> SqlResult<Vec<String>> | ||
| 24 | pub fn complete_tracks(&self, prefix: &str) -> SqlResult<Vec<String>> | ||
| 25 | ``` | ||
| 26 | |||
| 27 | SQL: | ||
| 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 | |||
| 32 | Prefix `"a"` → `LIKE 'a%'`. Limit results (e.g. 25). | ||
| 33 | |||
| 34 | ### 2. Add hidden `DbComplete` subcommand (`src/cli.rs`) | ||
| 35 | |||
| 36 | ``` | ||
| 37 | #[clap(hide = true)] | ||
| 38 | DbComplete { | ||
| 39 | entity: String, // "artists", "albums", "tracks" | ||
| 40 | prefix: Option<String>, | ||
| 41 | } | ||
| 42 | ``` | ||
| 43 | |||
| 44 | Dispatch in `Cli::run()`: | ||
| 45 | ``` | ||
| 46 | Commands::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 | |||
| 54 | Errors → silent empty (stderr) so completion never breaks. | ||
| 55 | |||
| 56 | ### 3. Filter `DbComplete` out of generated completions (like `Completions`) | ||
| 57 | |||
| 58 | In `Completions` generation (cli.rs:238-253): filter `s.get_name() != "db-complete"`. | ||
| 59 | |||
| 60 | ### 4. Dynamic zsh completion function | ||
| 61 | |||
| 62 | Replace `/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 | |||
| 91 | For `play <query>`, complete artists + albums + tracks. | ||
| 92 | Where artists have the biggest priority, then comes albums and at the end tracks. | ||
| 93 | Additionally, 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 | |||
| 97 | In `cli.rs`, after `generate()` for zsh, append the dynamic wrapper code. The generated file becomes: `clap-generated-code` + `_rsp() { ... dynamic ... }`. | ||
| 98 | |||
| 99 | Or simpler: write the wrapper as `completions/_rsp.dynamic` and concatenate in install.sh. | ||
| 100 | |||
| 101 | ### 6. Update `install.sh` | ||
| 102 | |||
| 103 | Replace `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 | |||
| 110 | Mark "[x] Dynamic autocompletion of artists, albums and tracks" | ||
| 111 | |||
| 112 | ### 8. Tests | ||
| 113 | |||
| 114 | Add 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 | |||
| 121 | Need 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 | |||
| 135 | Same 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). | ||
| @@ -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. | ||
| 4 | functions[_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 | } | ||
| @@ -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) |
| @@ -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)] |
| 47 | impl Database { | 47 | impl 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 | ||
| 8 | use rsp::database::{extract_metadata, is_audio_file}; | 8 | use rsp::database::{Database, extract_metadata, is_audio_file}; |
| 9 | use std::path::PathBuf; | 9 | use 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 | |||
| 94 | fn 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] | ||
| 114 | fn 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] | ||
| 124 | fn 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] | ||
| 135 | fn 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] | ||
| 143 | fn 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] | ||
| 151 | fn 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] | ||
| 161 | fn 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] | ||
| 170 | fn 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] | ||
| 178 | fn 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] | ||
| 186 | fn 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] | ||
| 196 | fn 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] | ||
| 205 | fn 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] | ||
| 213 | fn 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] | ||
| 221 | fn 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] | ||
| 231 | fn 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] | ||
| 245 | fn 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] | ||
| 256 | fn 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] | ||
| 266 | fn 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] | ||
| 274 | fn 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] | ||
| 288 | fn 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] | ||
| 301 | fn 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] | ||
| 315 | fn 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] | ||
| 326 | fn 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] | ||
| 336 | fn 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] | ||
| 346 | fn 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] | ||
| 356 | fn 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] | ||
| 368 | fn 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] | ||
| 379 | fn 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] | ||
| 389 | fn 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] | ||
| 402 | fn 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] | ||
| 413 | fn 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] | ||
| 424 | fn 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] | ||
| 433 | fn 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] | ||
| 442 | fn 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] | ||
| 457 | fn 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] | ||
| 472 | fn 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 | } | ||
