diff options
| author | salmonik <salmonik@noreply.codeberg.org> | 2026-06-06 19:07:09 +0200 |
|---|---|---|
| committer | salmonik <salmonik@noreply.codeberg.org> | 2026-06-06 19:07:09 +0200 |
| commit | 385c2a38eba689154b46d515f6a3a010c34d3119 (patch) | |
| tree | 8916681bafc9822ef371f949a176cf22307d2a2e | |
| parent | add verbosity to `rsp scan` (diff) | |
| parent | delete not needed md file (diff) | |
| download | rsplayer-385c2a38eba689154b46d515f6a3a010c34d3119.tar.gz rsplayer-385c2a38eba689154b46d515f6a3a010c34d3119.zip | |
Merge pull request 'autocompletion' (#1) from autocompletion into main
Reviewed-on: https://codeberg.org/salmonik/RSPlayer/pulls/1
| -rw-r--r-- | .zed/tasks.json | 2 | ||||
| -rw-r--r-- | Cargo.lock | 2 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | TODO.md | 4 | ||||
| -rw-r--r-- | completions/_rsp | 62 | ||||
| -rwxr-xr-x | install.sh | 20 | ||||
| -rw-r--r-- | src/cli.rs | 41 | ||||
| -rw-r--r-- | src/daemon.rs | 296 | ||||
| -rw-r--r-- | src/database.rs | 305 | ||||
| -rw-r--r-- | src/queue.rs | 14 | ||||
| -rw-r--r-- | src/scrobbler.rs | 22 | ||||
| -rw-r--r-- | tests/daemon.rs | 79 | ||||
| -rw-r--r-- | tests/database.rs | 411 | ||||
| -rw-r--r-- | tests/scrobbler.rs | 17 |
14 files changed, 1127 insertions, 150 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", |
| @@ -1948,7 +1948,7 @@ dependencies = [ | |||
| 1948 | 1948 | ||
| 1949 | [[package]] | 1949 | [[package]] |
| 1950 | name = "rsp" | 1950 | name = "rsp" |
| 1951 | version = "0.8.3" | 1951 | version = "0.8.4" |
| 1952 | dependencies = [ | 1952 | dependencies = [ |
| 1953 | "clap", | 1953 | "clap", |
| 1954 | "clap_complete", | 1954 | "clap_complete", |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "rsp" | 2 | name = "rsp" |
| 3 | version = "0.8.3" | 3 | version = "0.8.4" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | description = "Rust music player daemon with Unix socket IPC" | 5 | description = "Rust music player daemon with Unix socket IPC" |
| 6 | license = "GPL-3.0 or later" | 6 | license = "GPL-3.0 or later" |
| @@ -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..9bf0579 --- /dev/null +++ b/completions/_rsp | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | # Save clap-generated function so we can delegate non-dynamic cases. | ||
| 2 | functions[_rsp_clap]=$functions[_rsp] | ||
| 3 | |||
| 4 | _rsp() { | ||
| 5 | case $words[2] in | ||
| 6 | play|queue) | ||
| 7 | if (( CURRENT > 2 )); then | ||
| 8 | _rsp_complete_args | ||
| 9 | return 0 | ||
| 10 | fi | ||
| 11 | ;; | ||
| 12 | esac | ||
| 13 | _rsp_clap "$@" | ||
| 14 | } | ||
| 15 | |||
| 16 | _rsp_complete_args() { | ||
| 17 | setopt localoptions | ||
| 18 | setopt nobanghist | ||
| 19 | local -a args=("${(@)words[3,$CURRENT]}") | ||
| 20 | local cur="${(j: :)args}" | ||
| 21 | local cur_clean="${cur//\\/}" | ||
| 22 | local -a suggestions | ||
| 23 | suggestions=("${(@f)"$(rsp db-complete "$cur" 2>/dev/null)"}") | ||
| 24 | # Filter out empty entries (e.g. from empty command output) | ||
| 25 | local -a filtered=() | ||
| 26 | for s in "${suggestions[@]}"; do | ||
| 27 | if [[ -n "$s" ]]; then | ||
| 28 | filtered+=("$s") | ||
| 29 | fi | ||
| 30 | done | ||
| 31 | suggestions=("${filtered[@]}") | ||
| 32 | if (( ${#suggestions} )); then | ||
| 33 | local noslash="${cur_clean//\/}" | ||
| 34 | local n_slashes=$(( ${#cur_clean} - ${#noslash} )) | ||
| 35 | local -a display=() | ||
| 36 | local -a vals=() | ||
| 37 | for s in "${suggestions[@]}"; do | ||
| 38 | vals+=("$s") | ||
| 39 | if [[ "$cur_clean" == */ ]]; then | ||
| 40 | local stripped="$s" | ||
| 41 | local i=0 | ||
| 42 | while (( i < n_slashes )); do | ||
| 43 | stripped="${stripped#*/}" | ||
| 44 | (( i++ )) | ||
| 45 | done | ||
| 46 | display+=("$stripped") | ||
| 47 | else | ||
| 48 | display+=("$s") | ||
| 49 | fi | ||
| 50 | done | ||
| 51 | if (( ${#suggestions} > 1 )); then | ||
| 52 | compstate[insert]=menu | ||
| 53 | fi | ||
| 54 | if [[ ${vals[1]} == */ ]]; then | ||
| 55 | compadd -U -S '' -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}" | ||
| 56 | else | ||
| 57 | compadd -U -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}" | ||
| 58 | fi | ||
| 59 | else | ||
| 60 | compadd -U -S '' | ||
| 61 | fi | ||
| 62 | } | ||
| @@ -1,13 +1,16 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | |||
| 1 | # This file is a part of RSPlayer | 3 | # This file is a part of RSPlayer |
| 2 | # Licensed under the GPL-3.0 License | 4 | # Licensed under the GPL-3.0 License |
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | 5 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io |
| 4 | # https://codeberg.org/salmonik/RSPlayer | 6 | # https://codeberg.org/salmonik/RSPlayer |
| 5 | 7 | ||
| 6 | #!/bin/bash | ||
| 7 | set -e | 8 | set -e |
| 8 | 9 | ||
| 9 | echo "Updating the repo..." | 10 | if ping -W 2 -c 1 codeberg.org >&/dev/null; then |
| 10 | git pull 2>/dev/null | 11 | echo "Updating the repo..." |
| 12 | git pull 2>/dev/null | ||
| 13 | fi | ||
| 11 | 14 | ||
| 12 | echo "Building rsplayer in release mode..." | 15 | echo "Building rsplayer in release mode..." |
| 13 | cargo build --release | 16 | cargo build --release |
| @@ -17,8 +20,7 @@ systemctl --user stop rsplayer 2>/dev/null || true | |||
| 17 | rsp stop-daemon 2>/dev/null || true | 20 | rsp stop-daemon 2>/dev/null || true |
| 18 | 21 | ||
| 19 | echo "Installing to /usr/bin/rsp..." | 22 | echo "Installing to /usr/bin/rsp..." |
| 20 | sudo cp target/release/rsp /usr/bin/rsp | 23 | sudo install target/release/rsp /usr/bin/rsp |
| 21 | sudo chmod +x /usr/bin/rsp | ||
| 22 | 24 | ||
| 23 | echo "" | 25 | echo "" |
| 24 | echo "Installing systemd service..." | 26 | echo "Installing systemd service..." |
| @@ -41,12 +43,16 @@ case "$shell_name" in | |||
| 41 | bash) | 43 | bash) |
| 42 | dest="${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions" | 44 | dest="${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions" |
| 43 | mkdir -p "$dest" | 45 | mkdir -p "$dest" |
| 44 | /usr/bin/rsp completions bash > "$dest/rsp" | 46 | cp completions/rsp.bash "$dest/rsp" |
| 45 | echo " → Bash completions: $dest/rsp" | 47 | echo " → Bash completions: $dest/rsp" |
| 46 | ;; | 48 | ;; |
| 47 | zsh) | 49 | zsh) |
| 48 | dest="/usr/share/zsh/site-functions/_rsp" | 50 | dest="/usr/share/zsh/site-functions/_rsp" |
| 49 | /usr/bin/rsp completions zsh | sudo tee "$dest" > /dev/null | 51 | tmpfile=$(mktemp) |
| 52 | /usr/bin/rsp completions zsh > "$tmpfile" | ||
| 53 | cat completions/_rsp >> "$tmpfile" | ||
| 54 | sudo cp "$tmpfile" "$dest" | ||
| 55 | rm -f "$tmpfile" | ||
| 50 | echo " → Zsh completions: $dest" | 56 | echo " → Zsh completions: $dest" |
| 51 | ;; | 57 | ;; |
| 52 | fish) | 58 | fish) |
| @@ -92,6 +92,13 @@ 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 | /// Query string (e.g. \"J\", \"Artist/\", \"Artist/Album/\") | ||
| 99 | #[arg(value_name = "QUERY")] | ||
| 100 | query: String, | ||
| 101 | }, | ||
| 95 | } | 102 | } |
| 96 | 103 | ||
| 97 | #[derive(Subcommand)] | 104 | #[derive(Subcommand)] |
| @@ -235,11 +242,43 @@ impl Cli { | |||
| 235 | } | 242 | } |
| 236 | } | 243 | } |
| 237 | } | 244 | } |
| 245 | Commands::DbComplete { query } => { | ||
| 246 | // Unescape shell escapes in the input query | ||
| 247 | let query = { | ||
| 248 | let mut unescaped = String::with_capacity(query.len()); | ||
| 249 | let mut chars = query.chars(); | ||
| 250 | while let Some(c) = chars.next() { | ||
| 251 | if c == '\\' { | ||
| 252 | if let Some(next) = chars.next() { | ||
| 253 | unescaped.push(next); | ||
| 254 | } | ||
| 255 | } else { | ||
| 256 | unescaped.push(c); | ||
| 257 | } | ||
| 258 | } | ||
| 259 | unescaped | ||
| 260 | }; | ||
| 261 | |||
| 262 | let config = match Config::load() { | ||
| 263 | Ok(c) => c, | ||
| 264 | Err(_) => return Ok(()), | ||
| 265 | }; | ||
| 266 | let db = match Database::open(&config) { | ||
| 267 | Ok(d) => d, | ||
| 268 | Err(_) => return Ok(()), | ||
| 269 | }; | ||
| 270 | |||
| 271 | let results = db.complete(&query).unwrap_or_default(); | ||
| 272 | |||
| 273 | for r in &results { | ||
| 274 | println!("{r}"); | ||
| 275 | } | ||
| 276 | } | ||
| 238 | Commands::Completions { shell } => { | 277 | Commands::Completions { shell } => { |
| 239 | let full = Cli::command(); | 278 | let full = Cli::command(); |
| 240 | let subcommands: Vec<Command> = full | 279 | let subcommands: Vec<Command> = full |
| 241 | .get_subcommands() | 280 | .get_subcommands() |
| 242 | .filter(|s| s.get_name() != "completions") | 281 | .filter(|s| s.get_name() != "completions" && s.get_name() != "db-complete") |
| 243 | .cloned() | 282 | .cloned() |
| 244 | .collect(); | 283 | .collect(); |
| 245 | let cmd_name = full.get_name().to_string(); | 284 | let cmd_name = full.get_name().to_string(); |
diff --git a/src/daemon.rs b/src/daemon.rs index c794495..5ad6654 100644 --- a/src/daemon.rs +++ b/src/daemon.rs | |||
| @@ -18,14 +18,20 @@ use std::sync::{Arc, Mutex, RwLock}; | |||
| 18 | use std::thread; | 18 | use std::thread; |
| 19 | use std::time::{Duration, Instant, SystemTime}; | 19 | use std::time::{Duration, Instant, SystemTime}; |
| 20 | 20 | ||
| 21 | struct ScrobbleState { | 21 | pub struct ScrobbleState { |
| 22 | track: Option<Track>, | 22 | pub track: Option<Track>, |
| 23 | start_time: Option<Instant>, | 23 | pub start_time: Option<Instant>, |
| 24 | timestamp: Option<u64>, | 24 | pub timestamp: Option<u64>, |
| 25 | } | ||
| 26 | |||
| 27 | impl Default for ScrobbleState { | ||
| 28 | fn default() -> Self { | ||
| 29 | Self::new() | ||
| 30 | } | ||
| 25 | } | 31 | } |
| 26 | 32 | ||
| 27 | impl ScrobbleState { | 33 | impl ScrobbleState { |
| 28 | fn new() -> Self { | 34 | pub fn new() -> Self { |
| 29 | ScrobbleState { | 35 | ScrobbleState { |
| 30 | track: None, | 36 | track: None, |
| 31 | start_time: None, | 37 | start_time: None, |
| @@ -33,7 +39,7 @@ impl ScrobbleState { | |||
| 33 | } | 39 | } |
| 34 | } | 40 | } |
| 35 | 41 | ||
| 36 | fn start(&mut self, track: Track) { | 42 | pub fn start(&mut self, track: Track) { |
| 37 | if track.is_stream { | 43 | if track.is_stream { |
| 38 | crate::debug_eprintln(format_args!( | 44 | crate::debug_eprintln(format_args!( |
| 39 | "scrobble: not tracking stream '{}'", | 45 | "scrobble: not tracking stream '{}'", |
| @@ -57,7 +63,7 @@ impl ScrobbleState { | |||
| 57 | self.timestamp = Some(ts); | 63 | self.timestamp = Some(ts); |
| 58 | } | 64 | } |
| 59 | 65 | ||
| 60 | fn should_scrobble(&self) -> bool { | 66 | pub fn should_scrobble(&self) -> bool { |
| 61 | let (Some(track), Some(start)) = (&self.track, self.start_time) else { | 67 | let (Some(track), Some(start)) = (&self.track, self.start_time) else { |
| 62 | crate::debug_eprintln(format_args!( | 68 | crate::debug_eprintln(format_args!( |
| 63 | "scrobble: should_scrobble false — no track or start_time" | 69 | "scrobble: should_scrobble false — no track or start_time" |
| @@ -78,7 +84,7 @@ impl ScrobbleState { | |||
| 78 | result | 84 | result |
| 79 | } | 85 | } |
| 80 | 86 | ||
| 81 | fn should_scrobble_with_params(elapsed: u64, duration_secs: Option<f64>) -> bool { | 87 | pub fn should_scrobble_with_params(elapsed: u64, duration_secs: Option<f64>) -> bool { |
| 82 | if elapsed < 30 { | 88 | if elapsed < 30 { |
| 83 | return false; | 89 | return false; |
| 84 | } | 90 | } |
| @@ -89,86 +95,13 @@ impl ScrobbleState { | |||
| 89 | elapsed >= threshold | 95 | elapsed >= threshold |
| 90 | } | 96 | } |
| 91 | 97 | ||
| 92 | fn clear(&mut self) { | 98 | pub fn clear(&mut self) { |
| 93 | self.track = None; | 99 | self.track = None; |
| 94 | self.start_time = None; | 100 | self.start_time = None; |
| 95 | self.timestamp = None; | 101 | self.timestamp = None; |
| 96 | } | 102 | } |
| 97 | } | 103 | } |
| 98 | 104 | ||
| 99 | #[cfg(test)] | ||
| 100 | mod tests { | ||
| 101 | use super::*; | ||
| 102 | |||
| 103 | #[test] | ||
| 104 | fn test_scrobble_state_new() { | ||
| 105 | let s = ScrobbleState::new(); | ||
| 106 | assert!(s.track.is_none()); | ||
| 107 | assert!(s.start_time.is_none()); | ||
| 108 | assert!(s.timestamp.is_none()); | ||
| 109 | } | ||
| 110 | |||
| 111 | #[test] | ||
| 112 | fn test_scrobble_state_clear() { | ||
| 113 | let mut s = ScrobbleState::new(); | ||
| 114 | let t = Track { | ||
| 115 | path: PathBuf::from("/test/song.mp3"), | ||
| 116 | title: Some("Test".into()), | ||
| 117 | artist: Some("Artist".into()), | ||
| 118 | album: None, | ||
| 119 | duration_secs: Some(200.0), | ||
| 120 | is_stream: false, | ||
| 121 | }; | ||
| 122 | s.start(t); | ||
| 123 | assert!(s.track.is_some()); | ||
| 124 | s.clear(); | ||
| 125 | assert!(s.track.is_none()); | ||
| 126 | assert!(s.start_time.is_none()); | ||
| 127 | assert!(s.timestamp.is_none()); | ||
| 128 | } | ||
| 129 | |||
| 130 | #[test] | ||
| 131 | fn test_should_scrobble_without_track_returns_false() { | ||
| 132 | assert!(!ScrobbleState::new().should_scrobble()); | ||
| 133 | } | ||
| 134 | |||
| 135 | #[test] | ||
| 136 | fn test_should_scrobble_threshold_below_30_seconds() { | ||
| 137 | assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0))); | ||
| 138 | } | ||
| 139 | |||
| 140 | #[test] | ||
| 141 | fn test_should_scrobble_threshold_half_duration() { | ||
| 142 | assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0))); | ||
| 143 | } | ||
| 144 | |||
| 145 | #[test] | ||
| 146 | fn test_should_scrobble_threshold_below_half_duration() { | ||
| 147 | assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0))); | ||
| 148 | } | ||
| 149 | |||
| 150 | #[test] | ||
| 151 | fn test_should_scrobble_threshold_capped_at_4min() { | ||
| 152 | assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0))); | ||
| 153 | assert!(!ScrobbleState::should_scrobble_with_params( | ||
| 154 | 239, | ||
| 155 | Some(600.0) | ||
| 156 | )); | ||
| 157 | } | ||
| 158 | |||
| 159 | #[test] | ||
| 160 | fn test_should_scrobble_no_duration_requires_4min() { | ||
| 161 | assert!(ScrobbleState::should_scrobble_with_params(240, None)); | ||
| 162 | assert!(!ScrobbleState::should_scrobble_with_params(239, None)); | ||
| 163 | } | ||
| 164 | |||
| 165 | #[test] | ||
| 166 | fn test_should_scrobble_short_song() { | ||
| 167 | assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0))); | ||
| 168 | assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0))); | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { | 105 | fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { |
| 173 | if let Some(ref track) = state.track | 106 | if let Some(ref track) = state.track |
| 174 | && track.is_stream | 107 | && track.is_stream |
| @@ -747,30 +680,40 @@ fn process_command( | |||
| 747 | } else { | 680 | } else { |
| 748 | match Config::load() { | 681 | match Config::load() { |
| 749 | Ok(config) => match crate::database::Database::open(&config) { | 682 | Ok(config) => match crate::database::Database::open(&config) { |
| 750 | Ok(db) => match db.search_tracks(query_str) { | 683 | Ok(db) => { |
| 751 | Ok(tracks) if !tracks.is_empty() => { | 684 | let tracks = match db.resolve_path(query_str) { |
| 752 | let db_track = &tracks[0]; | 685 | Ok(Some(t)) => t, |
| 753 | let album_tracks = db | 686 | Ok(None) => match db.search_tracks(query_str) { |
| 754 | .get_album_tracks_after( | 687 | Ok(t) => t, |
| 755 | db_track.album_id, | 688 | Err(e) => return format!("Database error: {}\n", e), |
| 756 | db_track.track_number, | 689 | }, |
| 757 | &db_track.path, | 690 | Err(e) => return format!("Database error: {}\n", e), |
| 758 | ) | 691 | }; |
| 759 | .unwrap_or_default(); | 692 | 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); | 693 | return format!("No tracks found matching '{}'\n", query_str); |
| 771 | } | 694 | } |
| 772 | Err(e) => return format!("Database error: {}\n", e), | 695 | let db_track = &tracks[0]; |
| 773 | }, | 696 | let album_paths: Vec<PathBuf> = if tracks.len() > 1 { |
| 697 | // resolve_path returned full album — use remaining tracks | ||
| 698 | tracks[1..].iter().map(|t| t.path.clone()).collect() | ||
| 699 | } else { | ||
| 700 | db.get_album_tracks_after( | ||
| 701 | db_track.album_id, | ||
| 702 | db_track.track_number, | ||
| 703 | &db_track.path, | ||
| 704 | ) | ||
| 705 | .unwrap_or_default() | ||
| 706 | .into_iter() | ||
| 707 | .map(|t| t.path) | ||
| 708 | .collect() | ||
| 709 | }; | ||
| 710 | let display = format!( | ||
| 711 | "{} - {}", | ||
| 712 | db_track.artist_name.as_deref().unwrap_or("unknown"), | ||
| 713 | db_track.title, | ||
| 714 | ); | ||
| 715 | (db_track.path.clone(), album_paths, display) | ||
| 716 | } | ||
| 774 | Err(e) => return format!("Database error: {}\n", e), | 717 | Err(e) => return format!("Database error: {}\n", e), |
| 775 | }, | 718 | }, |
| 776 | Err(e) => return format!("Config error: {}\n", e), | 719 | Err(e) => return format!("Config error: {}\n", e), |
| @@ -862,6 +805,24 @@ fn process_command( | |||
| 862 | } else { | 805 | } else { |
| 863 | path_str.to_string() | 806 | path_str.to_string() |
| 864 | }; | 807 | }; |
| 808 | // Strip trailing slash so autocompleted Artist/Album/ resolves cleanly | ||
| 809 | let path_str = path_str.trim_end_matches('/').to_string(); | ||
| 810 | |||
| 811 | // Unescape backslash sequences (e.g., \! → !) so shell escaping works | ||
| 812 | let path_str = { | ||
| 813 | let mut unescaped = String::with_capacity(path_str.len()); | ||
| 814 | let mut chars = path_str.chars(); | ||
| 815 | while let Some(c) = chars.next() { | ||
| 816 | if c == '\\' { | ||
| 817 | if let Some(next) = chars.next() { | ||
| 818 | unescaped.push(next); | ||
| 819 | } | ||
| 820 | } else { | ||
| 821 | unescaped.push(c); | ||
| 822 | } | ||
| 823 | } | ||
| 824 | unescaped | ||
| 825 | }; | ||
| 865 | 826 | ||
| 866 | if path_str.is_empty() { | 827 | if path_str.is_empty() { |
| 867 | return "Usage: play [now|next|last] <path>\n".to_string(); | 828 | return "Usage: play [now|next|last] <path>\n".to_string(); |
| @@ -1052,7 +1013,132 @@ fn process_command( | |||
| 1052 | _ => format!("Error: No audio files found in '{}'\n", path_str), | 1013 | _ => format!("Error: No audio files found in '{}'\n", path_str), |
| 1053 | } | 1014 | } |
| 1054 | } else { | 1015 | } else { |
| 1055 | format!("File not found: {}\n", path_str) | 1016 | match Config::load() { |
| 1017 | Ok(config) => match crate::database::Database::open(&config) { | ||
| 1018 | Ok(db) => { | ||
| 1019 | // Collect tracks: try hierarchical path first, fallback to search | ||
| 1020 | let tracks = match db.resolve_path(&path_str) { | ||
| 1021 | Ok(Some(t)) => t, | ||
| 1022 | Ok(None) => { | ||
| 1023 | let query = path_str | ||
| 1024 | .rsplit_once('/') | ||
| 1025 | .and_then(|(_, after)| { | ||
| 1026 | (!after.is_empty()).then_some(after) | ||
| 1027 | }) | ||
| 1028 | .unwrap_or(&path_str); | ||
| 1029 | match db.search_tracks(query) { | ||
| 1030 | Ok(t) => t, | ||
| 1031 | Err(e) => return format!("Database error: {}\n", e), | ||
| 1032 | } | ||
| 1033 | } | ||
| 1034 | Err(e) => return format!("Database error: {}\n", e), | ||
| 1035 | }; | ||
| 1036 | |||
| 1037 | if tracks.is_empty() { | ||
| 1038 | return format!("No tracks found matching '{}'\n", path_str); | ||
| 1039 | } | ||
| 1040 | |||
| 1041 | let is_album_play = tracks.len() > 1; | ||
| 1042 | if let Ok(mut q) = queue.lock() { | ||
| 1043 | let was_empty = q.is_empty(); | ||
| 1044 | match position { | ||
| 1045 | "now" => { | ||
| 1046 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 1047 | try_scrobble_current(scrobbler, &mut s); | ||
| 1048 | } | ||
| 1049 | for t in tracks.iter().rev() { | ||
| 1050 | q.insert_after_current(t.path.clone()); | ||
| 1051 | } | ||
| 1052 | if !was_empty { | ||
| 1053 | let new_pos = q.position() + 1; | ||
| 1054 | q.set_position(new_pos); | ||
| 1055 | } | ||
| 1056 | if let Some(track) = q.track_at(q.position()) { | ||
| 1057 | audio.play(track.path.clone()); | ||
| 1058 | send_now_playing(scrobbler, track); | ||
| 1059 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 1060 | s.start(track.clone()); | ||
| 1061 | } | ||
| 1062 | let fmt = Config::load() | ||
| 1063 | .ok() | ||
| 1064 | .and_then(|c| c.display_format.clone()); | ||
| 1065 | notification::notify_track(track, fmt.as_deref()); | ||
| 1066 | } | ||
| 1067 | if is_album_play { | ||
| 1068 | format!( | ||
| 1069 | "Playing now: {} tracks from {}\n", | ||
| 1070 | tracks.len(), | ||
| 1071 | path_str | ||
| 1072 | ) | ||
| 1073 | } else { | ||
| 1074 | format!("Playing now: {}\n", path_str) | ||
| 1075 | } | ||
| 1076 | } | ||
| 1077 | "next" => { | ||
| 1078 | for t in tracks.iter().rev() { | ||
| 1079 | q.insert_after_current(t.path.clone()); | ||
| 1080 | } | ||
| 1081 | if is_album_play { | ||
| 1082 | format!( | ||
| 1083 | "Queued next: {} tracks from {}\n", | ||
| 1084 | tracks.len(), | ||
| 1085 | path_str | ||
| 1086 | ) | ||
| 1087 | } else { | ||
| 1088 | format!("Queued next: {}\n", path_str) | ||
| 1089 | } | ||
| 1090 | } | ||
| 1091 | _ => { | ||
| 1092 | let was_playing = audio.is_playing(); | ||
| 1093 | for t in &tracks { | ||
| 1094 | q.add_track(t.path.clone()); | ||
| 1095 | } | ||
| 1096 | if was_empty && !was_playing { | ||
| 1097 | if is_album_play { | ||
| 1098 | q.shuffle_initial(); | ||
| 1099 | } | ||
| 1100 | if let Some(track) = q.current() { | ||
| 1101 | audio.play(track.path.clone()); | ||
| 1102 | send_now_playing(scrobbler, track); | ||
| 1103 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 1104 | s.start(track.clone()); | ||
| 1105 | } | ||
| 1106 | let fmt = Config::load() | ||
| 1107 | .ok() | ||
| 1108 | .and_then(|c| c.display_format.clone()); | ||
| 1109 | notification::notify_track( | ||
| 1110 | track, | ||
| 1111 | fmt.as_deref(), | ||
| 1112 | ); | ||
| 1113 | } | ||
| 1114 | if is_album_play { | ||
| 1115 | format!( | ||
| 1116 | "Playing: {} tracks from {}\n", | ||
| 1117 | tracks.len(), | ||
| 1118 | path_str | ||
| 1119 | ) | ||
| 1120 | } else { | ||
| 1121 | format!("Playing: {}\n", path_str) | ||
| 1122 | } | ||
| 1123 | } else if is_album_play { | ||
| 1124 | format!( | ||
| 1125 | "Queued {} tracks from: {}\n", | ||
| 1126 | tracks.len(), | ||
| 1127 | path_str | ||
| 1128 | ) | ||
| 1129 | } else { | ||
| 1130 | format!("Queued: {}\n", path_str) | ||
| 1131 | } | ||
| 1132 | } | ||
| 1133 | } | ||
| 1134 | } else { | ||
| 1135 | "Error accessing queue\n".to_string() | ||
| 1136 | } | ||
| 1137 | } | ||
| 1138 | Err(e) => format!("Database error: {}\n", e), | ||
| 1139 | }, | ||
| 1140 | Err(e) => format!("Config error: {}\n", e), | ||
| 1141 | } | ||
| 1056 | } | 1142 | } |
| 1057 | } | 1143 | } |
| 1058 | } | 1144 | } |
diff --git a/src/database.rs b/src/database.rs index 7fd1797..e6f6f6e 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()) |
| @@ -59,7 +63,29 @@ impl Database { | |||
| 59 | fs::create_dir_all(&data_dir).ok(); | 63 | fs::create_dir_all(&data_dir).ok(); |
| 60 | let db_path = data_dir.join("library.db"); | 64 | let db_path = data_dir.join("library.db"); |
| 61 | 65 | ||
| 62 | let conn = Connection::open(&db_path)?; | 66 | let debug_log = std::env::temp_dir().join("rsp-completion-debug.log"); |
| 67 | macro_rules! debug_log { | ||
| 68 | ($($arg:tt)*) => { | ||
| 69 | let _ = std::fs::OpenOptions::new() | ||
| 70 | .create(true) | ||
| 71 | .append(true) | ||
| 72 | .open(&debug_log) | ||
| 73 | .and_then(|mut f| { | ||
| 74 | use std::io::Write; | ||
| 75 | writeln!(f, $($arg)*) | ||
| 76 | }); | ||
| 77 | }; | ||
| 78 | } | ||
| 79 | debug_log!("[Database::open] db_path: {:?}", db_path); | ||
| 80 | debug_log!("[Database::open] db exists: {}", db_path.exists()); | ||
| 81 | |||
| 82 | let conn = match Connection::open(&db_path) { | ||
| 83 | Ok(c) => c, | ||
| 84 | Err(e) => { | ||
| 85 | debug_log!("[Database::open] Connection::open failed: {e}"); | ||
| 86 | return Err(e); | ||
| 87 | } | ||
| 88 | }; | ||
| 63 | let db = Database { conn }; | 89 | let db = Database { conn }; |
| 64 | db.init_schema()?; | 90 | db.init_schema()?; |
| 65 | Ok(db) | 91 | Ok(db) |
| @@ -279,6 +305,261 @@ impl Database { | |||
| 279 | self.get_album_tracks_after(album_id, track_number, &path) | 305 | self.get_album_tracks_after(album_id, track_number, &path) |
| 280 | } | 306 | } |
| 281 | 307 | ||
| 308 | pub fn complete(&self, query: &str) -> SqlResult<Vec<String>> { | ||
| 309 | let mut results: Vec<String> = Vec::new(); | ||
| 310 | |||
| 311 | if query.is_empty() { | ||
| 312 | let mut stmt = self | ||
| 313 | .conn | ||
| 314 | .prepare("SELECT name FROM artists ORDER BY name LIMIT 25")?; | ||
| 315 | let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; | ||
| 316 | for name in rows.flatten() { | ||
| 317 | results.push(format!("{name}/")); | ||
| 318 | } | ||
| 319 | return Ok(results); | ||
| 320 | } | ||
| 321 | |||
| 322 | let has_trailing_slash = query.ends_with('/'); | ||
| 323 | |||
| 324 | if has_trailing_slash { | ||
| 325 | let prefix = query.trim_end_matches('/'); | ||
| 326 | let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect(); | ||
| 327 | |||
| 328 | match segments.len() { | ||
| 329 | 1 => { | ||
| 330 | let artist = segments[0]; | ||
| 331 | let mut stmt = self.conn.prepare( | ||
| 332 | "SELECT a.title FROM albums a | ||
| 333 | JOIN artists ar ON a.artist_id = ar.id | ||
| 334 | WHERE ar.name LIKE ?1 | ||
| 335 | ORDER BY a.title LIMIT 25", | ||
| 336 | )?; | ||
| 337 | let rows = stmt.query_map([artist], |row| row.get::<_, String>(0))?; | ||
| 338 | for album in rows.flatten() { | ||
| 339 | results.push(format!("{artist}/{album}/")); | ||
| 340 | } | ||
| 341 | } | ||
| 342 | 2 => { | ||
| 343 | let artist = segments[0]; | ||
| 344 | let album = segments[1]; | ||
| 345 | let mut stmt = self.conn.prepare( | ||
| 346 | "SELECT t.title FROM tracks t | ||
| 347 | JOIN albums a ON t.album_id = a.id | ||
| 348 | JOIN artists ar ON a.artist_id = ar.id | ||
| 349 | WHERE ar.name LIKE ?1 AND a.title LIKE ?2 | ||
| 350 | ORDER BY t.track_number, t.title LIMIT 25", | ||
| 351 | )?; | ||
| 352 | let rows = stmt.query_map(rusqlite::params![artist, album], |row| { | ||
| 353 | row.get::<_, String>(0) | ||
| 354 | })?; | ||
| 355 | for track in rows.flatten() { | ||
| 356 | results.push(format!("{artist}/{album}/{track}")); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | _ => {} | ||
| 360 | } | ||
| 361 | } else { | ||
| 362 | let segments: Vec<&str> = query.split('/').collect(); | ||
| 363 | |||
| 364 | match segments.len() { | ||
| 365 | 1 => { | ||
| 366 | let prefix = segments[0]; | ||
| 367 | let like_pattern = format!("%{prefix}%"); | ||
| 368 | let prefix_pattern = format!("{prefix}%"); | ||
| 369 | |||
| 370 | let mut stmt = self.conn.prepare( | ||
| 371 | "SELECT name FROM artists | ||
| 372 | WHERE name LIKE ?1 | ||
| 373 | ORDER BY | ||
| 374 | CASE WHEN name LIKE ?2 THEN 0 ELSE 1 END, | ||
| 375 | name | ||
| 376 | LIMIT 25", | ||
| 377 | )?; | ||
| 378 | let rows = stmt | ||
| 379 | .query_map(rusqlite::params![&like_pattern, &prefix_pattern], |row| { | ||
| 380 | row.get::<_, String>(0) | ||
| 381 | })?; | ||
| 382 | for name in rows.flatten() { | ||
| 383 | results.push(format!("{name}/")); | ||
| 384 | } | ||
| 385 | } | ||
| 386 | 2 => { | ||
| 387 | let artist = segments[0]; | ||
| 388 | let album_prefix = segments[1]; | ||
| 389 | if artist.is_empty() { | ||
| 390 | return Ok(results); | ||
| 391 | } | ||
| 392 | |||
| 393 | let artist_pattern = format!("%{artist}%"); | ||
| 394 | let artist_prefix = format!("{artist}%"); | ||
| 395 | let album_pattern = format!("%{album_prefix}%"); | ||
| 396 | let album_prefix_pattern = format!("{album_prefix}%"); | ||
| 397 | |||
| 398 | let mut stmt = self.conn.prepare( | ||
| 399 | "SELECT ar.name, a.title FROM albums a | ||
| 400 | JOIN artists ar ON a.artist_id = ar.id | ||
| 401 | WHERE ar.name LIKE ?1 AND a.title LIKE ?2 | ||
| 402 | ORDER BY | ||
| 403 | CASE WHEN ar.name LIKE ?3 THEN 0 ELSE 1 END, | ||
| 404 | CASE WHEN a.title LIKE ?4 THEN 0 ELSE 1 END, | ||
| 405 | ar.name, a.title | ||
| 406 | LIMIT 25", | ||
| 407 | )?; | ||
| 408 | let rows = stmt.query_map( | ||
| 409 | rusqlite::params![ | ||
| 410 | &artist_pattern, | ||
| 411 | &album_pattern, | ||
| 412 | &artist_prefix, | ||
| 413 | &album_prefix_pattern, | ||
| 414 | ], | ||
| 415 | |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), | ||
| 416 | )?; | ||
| 417 | for (name, album) in rows.flatten() { | ||
| 418 | results.push(format!("{name}/{album}/")); | ||
| 419 | } | ||
| 420 | } | ||
| 421 | 3 => { | ||
| 422 | let artist = segments[0]; | ||
| 423 | let album = segments[1]; | ||
| 424 | let track_prefix = segments[2]; | ||
| 425 | if artist.is_empty() || album.is_empty() { | ||
| 426 | return Ok(results); | ||
| 427 | } | ||
| 428 | |||
| 429 | let artist_pattern = format!("%{artist}%"); | ||
| 430 | let artist_prefix = format!("{artist}%"); | ||
| 431 | let album_pattern = format!("%{album}%"); | ||
| 432 | let album_prefix = format!("{album}%"); | ||
| 433 | let track_pattern = format!("%{track_prefix}%"); | ||
| 434 | let track_prefix_pattern = format!("{track_prefix}%"); | ||
| 435 | |||
| 436 | let mut stmt = self.conn.prepare( | ||
| 437 | "SELECT ar.name, a.title, t.title FROM tracks t | ||
| 438 | JOIN albums a ON t.album_id = a.id | ||
| 439 | JOIN artists ar ON a.artist_id = ar.id | ||
| 440 | WHERE ar.name LIKE ?1 AND a.title LIKE ?2 AND t.title LIKE ?3 | ||
| 441 | ORDER BY | ||
| 442 | CASE WHEN ar.name LIKE ?4 THEN 0 ELSE 1 END, | ||
| 443 | CASE WHEN a.title LIKE ?5 THEN 0 ELSE 1 END, | ||
| 444 | CASE WHEN t.title LIKE ?6 THEN 0 ELSE 1 END, | ||
| 445 | t.track_number, t.title | ||
| 446 | LIMIT 25", | ||
| 447 | )?; | ||
| 448 | let rows = stmt.query_map( | ||
| 449 | rusqlite::params![ | ||
| 450 | &artist_pattern, | ||
| 451 | &album_pattern, | ||
| 452 | &track_pattern, | ||
| 453 | &artist_prefix, | ||
| 454 | &album_prefix, | ||
| 455 | &track_prefix_pattern, | ||
| 456 | ], | ||
| 457 | |row| { | ||
| 458 | Ok(( | ||
| 459 | row.get::<_, String>(0)?, | ||
| 460 | row.get::<_, String>(1)?, | ||
| 461 | row.get::<_, String>(2)?, | ||
| 462 | )) | ||
| 463 | }, | ||
| 464 | )?; | ||
| 465 | for (name, album_title, track) in rows.flatten() { | ||
| 466 | results.push(format!("{name}/{album_title}/{track}")); | ||
| 467 | } | ||
| 468 | } | ||
| 469 | _ => {} | ||
| 470 | } | ||
| 471 | } | ||
| 472 | |||
| 473 | results.truncate(25); | ||
| 474 | |||
| 475 | Ok(results) | ||
| 476 | } | ||
| 477 | |||
| 478 | #[allow(dead_code)] | ||
| 479 | pub fn open_in_memory() -> SqlResult<Database> { | ||
| 480 | let conn = Connection::open_in_memory()?; | ||
| 481 | let db = Database { conn }; | ||
| 482 | db.init_schema()?; | ||
| 483 | Ok(db) | ||
| 484 | } | ||
| 485 | |||
| 486 | /// Find all tracks in an album by case-insensitive artist and album match. | ||
| 487 | pub fn find_tracks_by_album(&self, artist: &str, album: &str) -> SqlResult<Vec<Track>> { | ||
| 488 | let mut stmt = self.conn.prepare( | ||
| 489 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | ||
| 490 | FROM tracks t | ||
| 491 | JOIN albums a ON t.album_id = a.id | ||
| 492 | JOIN artists ar ON a.artist_id = ar.id | ||
| 493 | WHERE ar.name COLLATE NOCASE = ?1 AND a.title COLLATE NOCASE = ?2 | ||
| 494 | ORDER BY t.track_number", | ||
| 495 | )?; | ||
| 496 | let rows = stmt.query_map(rusqlite::params![artist, album], |row| { | ||
| 497 | Ok(Track { | ||
| 498 | id: row.get(0)?, | ||
| 499 | album_id: row.get(1)?, | ||
| 500 | title: row.get(2)?, | ||
| 501 | path: PathBuf::from(row.get::<_, String>(3)?), | ||
| 502 | duration: row.get(4)?, | ||
| 503 | track_number: row.get(5)?, | ||
| 504 | artist_name: Some(row.get(6)?), | ||
| 505 | album_title: Some(row.get(7)?), | ||
| 506 | }) | ||
| 507 | })?; | ||
| 508 | rows.collect() | ||
| 509 | } | ||
| 510 | |||
| 511 | /// Find a single track by exact artist, exact album, and track title prefix. | ||
| 512 | /// Returns None if no match found. | ||
| 513 | pub fn find_track_by_parts( | ||
| 514 | &self, | ||
| 515 | artist: &str, | ||
| 516 | album: &str, | ||
| 517 | track_prefix: &str, | ||
| 518 | ) -> SqlResult<Option<Track>> { | ||
| 519 | let pattern = format!("{}%", track_prefix); | ||
| 520 | let mut stmt = self.conn.prepare( | ||
| 521 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | ||
| 522 | FROM tracks t | ||
| 523 | JOIN albums a ON t.album_id = a.id | ||
| 524 | JOIN artists ar ON a.artist_id = ar.id | ||
| 525 | WHERE ar.name COLLATE NOCASE = ?1 AND a.title COLLATE NOCASE = ?2 AND t.title LIKE ?3 | ||
| 526 | ORDER BY t.track_number LIMIT 1", | ||
| 527 | )?; | ||
| 528 | let mut rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| { | ||
| 529 | Ok(Track { | ||
| 530 | id: row.get(0)?, | ||
| 531 | album_id: row.get(1)?, | ||
| 532 | title: row.get(2)?, | ||
| 533 | path: PathBuf::from(row.get::<_, String>(3)?), | ||
| 534 | duration: row.get(4)?, | ||
| 535 | track_number: row.get(5)?, | ||
| 536 | artist_name: Some(row.get(6)?), | ||
| 537 | album_title: Some(row.get(7)?), | ||
| 538 | }) | ||
| 539 | })?; | ||
| 540 | match rows.next() { | ||
| 541 | Some(Ok(t)) => Ok(Some(t)), | ||
| 542 | Some(Err(e)) => Err(e), | ||
| 543 | None => Ok(None), | ||
| 544 | } | ||
| 545 | } | ||
| 546 | |||
| 547 | /// Resolve a hierarchical path like "Artist/Album" or "Artist/Album/Track" | ||
| 548 | /// to DB tracks. Returns None if the path isn't hierarchical (single part | ||
| 549 | /// or 4+ parts) — the caller should fall back to search_tracks(). | ||
| 550 | pub fn resolve_path(&self, path: &str) -> SqlResult<Option<Vec<Track>>> { | ||
| 551 | let path = path.strip_suffix('/').unwrap_or(path); | ||
| 552 | let parts: Vec<&str> = path.splitn(3, '/').collect(); | ||
| 553 | match parts.len() { | ||
| 554 | 2 => Ok(Some(self.find_tracks_by_album(parts[0], parts[1])?)), | ||
| 555 | 3 => match self.find_track_by_parts(parts[0], parts[1], parts[2])? { | ||
| 556 | Some(t) => Ok(Some(vec![t])), | ||
| 557 | None => Ok(Some(vec![])), | ||
| 558 | }, | ||
| 559 | _ => Ok(None), | ||
| 560 | } | ||
| 561 | } | ||
| 562 | |||
| 282 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { | 563 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { |
| 283 | let mut stmt = self.conn.prepare( | 564 | let mut stmt = self.conn.prepare( |
| 284 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | 565 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title |
| @@ -336,11 +617,19 @@ pub fn extract_metadata(path: &Path) -> (String, String, String, Option<i32>, Op | |||
| 336 | .and_then(|s| s.to_str()) | 617 | .and_then(|s| s.to_str()) |
| 337 | .unwrap_or("Unknown"); | 618 | .unwrap_or("Unknown"); |
| 338 | 619 | ||
| 339 | ( | 620 | let fs_artist = grandparent.to_string(); |
| 340 | grandparent.to_string(), | 621 | let fs_album = parent.to_string(); |
| 341 | parent.to_string(), | 622 | let fs_title = file_stem.to_string(); |
| 342 | file_stem.to_string(), | 623 | |
| 343 | None, | 624 | if let Some(audio_meta) = crate::queue::extract_metadata(path) { |
| 344 | None, | 625 | ( |
| 345 | ) | 626 | audio_meta.artist.unwrap_or(fs_artist), |
| 627 | audio_meta.album.unwrap_or(fs_album), | ||
| 628 | audio_meta.title.unwrap_or(fs_title), | ||
| 629 | audio_meta.track_number, | ||
| 630 | audio_meta.duration_secs.map(|d| d as u64), | ||
| 631 | ) | ||
| 632 | } else { | ||
| 633 | (fs_artist, fs_album, fs_title, None, None) | ||
| 634 | } | ||
| 346 | } | 635 | } |
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 { | |||
| 21 | pub title: Option<String>, | 21 | pub title: Option<String>, |
| 22 | pub artist: Option<String>, | 22 | pub artist: Option<String>, |
| 23 | pub album: Option<String>, | 23 | pub album: Option<String>, |
| 24 | pub track_number: Option<i32>, | ||
| 24 | pub duration_secs: Option<f64>, | 25 | pub duration_secs: Option<f64>, |
| 25 | } | 26 | } |
| 26 | 27 | ||
| @@ -64,16 +65,27 @@ pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> { | |||
| 64 | Some(StandardTag::Artist(value)) => { | 65 | Some(StandardTag::Artist(value)) => { |
| 65 | metadata.artist = Some(value.to_string()); | 66 | metadata.artist = Some(value.to_string()); |
| 66 | } | 67 | } |
| 68 | Some(StandardTag::AlbumArtist(value)) => { | ||
| 69 | metadata.artist = Some(value.to_string()); | ||
| 70 | } | ||
| 67 | Some(StandardTag::Album(value)) => { | 71 | Some(StandardTag::Album(value)) => { |
| 68 | metadata.album = Some(value.to_string()); | 72 | metadata.album = Some(value.to_string()); |
| 69 | } | 73 | } |
| 74 | Some(StandardTag::TrackNumber(value)) => { | ||
| 75 | metadata.track_number = value.to_string().parse().ok(); | ||
| 76 | } | ||
| 70 | _ => {} | 77 | _ => {} |
| 71 | } | 78 | } |
| 72 | } | 79 | } |
| 73 | } | 80 | } |
| 74 | } | 81 | } |
| 75 | 82 | ||
| 76 | if metadata.title.is_some() || metadata.artist.is_some() || metadata.album.is_some() { | 83 | if metadata.title.is_some() |
| 84 | || metadata.artist.is_some() | ||
| 85 | || metadata.album.is_some() | ||
| 86 | || metadata.track_number.is_some() | ||
| 87 | || metadata.duration_secs.is_some() | ||
| 88 | { | ||
| 77 | Some(metadata) | 89 | Some(metadata) |
| 78 | } else { | 90 | } else { |
| 79 | None | 91 | 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 { | |||
| 162 | } | 162 | } |
| 163 | } | 163 | } |
| 164 | 164 | ||
| 165 | fn sign_lastfm(params: &BTreeMap<&str, &str>, secret: &str) -> String { | 165 | pub fn sign_lastfm(params: &BTreeMap<&str, &str>, secret: &str) -> String { |
| 166 | let mut concat = String::new(); | 166 | let mut concat = String::new(); |
| 167 | for (key, value) in params { | 167 | for (key, value) in params { |
| 168 | concat.push_str(key); | 168 | concat.push_str(key); |
| @@ -333,23 +333,3 @@ impl Scrobbler { | |||
| 333 | Ok(()) | 333 | Ok(()) |
| 334 | } | 334 | } |
| 335 | } | 335 | } |
| 336 | |||
| 337 | #[cfg(test)] | ||
| 338 | mod tests { | ||
| 339 | use super::*; | ||
| 340 | |||
| 341 | #[test] | ||
| 342 | fn test_sign_lastfm() { | ||
| 343 | let params = BTreeMap::from([ | ||
| 344 | ("api_key", "abc123"), | ||
| 345 | ("method", "track.scrobble"), | ||
| 346 | ("sk", "session_key"), | ||
| 347 | ("track", "Test Track"), | ||
| 348 | ("artist", "Test Artist"), | ||
| 349 | ("timestamp", "1234567890"), | ||
| 350 | ]); | ||
| 351 | let sig = Scrobbler::sign_lastfm(¶ms, "sekrit"); | ||
| 352 | assert_eq!(sig.len(), 32); | ||
| 353 | assert!(sig.chars().all(|c| c.is_ascii_hexdigit())); | ||
| 354 | } | ||
| 355 | } | ||
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 @@ | |||
| 1 | /* | ||
| 2 | * This file is a part of RSPlayer | ||
| 3 | * Licensed under the GPL-3.0 License | ||
| 4 | * Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 5 | * https://codeberg.org/salmonik/RSPlayer | ||
| 6 | */ | ||
| 7 | |||
| 8 | use std::path::PathBuf; | ||
| 9 | |||
| 10 | use rsp::daemon::ScrobbleState; | ||
| 11 | use rsp::queue::Track; | ||
| 12 | |||
| 13 | #[test] | ||
| 14 | fn test_scrobble_state_new() { | ||
| 15 | let s = ScrobbleState::new(); | ||
| 16 | assert!(s.track.is_none()); | ||
| 17 | assert!(s.start_time.is_none()); | ||
| 18 | assert!(s.timestamp.is_none()); | ||
| 19 | } | ||
| 20 | |||
| 21 | #[test] | ||
| 22 | fn test_scrobble_state_clear() { | ||
| 23 | let mut s = ScrobbleState::new(); | ||
| 24 | let t = Track { | ||
| 25 | path: PathBuf::from("/test/song.mp3"), | ||
| 26 | title: Some("Test".into()), | ||
| 27 | artist: Some("Artist".into()), | ||
| 28 | album: None, | ||
| 29 | duration_secs: Some(200.0), | ||
| 30 | is_stream: false, | ||
| 31 | }; | ||
| 32 | s.start(t); | ||
| 33 | assert!(s.track.is_some()); | ||
| 34 | s.clear(); | ||
| 35 | assert!(s.track.is_none()); | ||
| 36 | assert!(s.start_time.is_none()); | ||
| 37 | assert!(s.timestamp.is_none()); | ||
| 38 | } | ||
| 39 | |||
| 40 | #[test] | ||
| 41 | fn test_should_scrobble_without_track_returns_false() { | ||
| 42 | assert!(!ScrobbleState::new().should_scrobble()); | ||
| 43 | } | ||
| 44 | |||
| 45 | #[test] | ||
| 46 | fn test_should_scrobble_threshold_below_30_seconds() { | ||
| 47 | assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0))); | ||
| 48 | } | ||
| 49 | |||
| 50 | #[test] | ||
| 51 | fn test_should_scrobble_threshold_half_duration() { | ||
| 52 | assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0))); | ||
| 53 | } | ||
| 54 | |||
| 55 | #[test] | ||
| 56 | fn test_should_scrobble_threshold_below_half_duration() { | ||
| 57 | assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0))); | ||
| 58 | } | ||
| 59 | |||
| 60 | #[test] | ||
| 61 | fn test_should_scrobble_threshold_capped_at_4min() { | ||
| 62 | assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0))); | ||
| 63 | assert!(!ScrobbleState::should_scrobble_with_params( | ||
| 64 | 239, | ||
| 65 | Some(600.0) | ||
| 66 | )); | ||
| 67 | } | ||
| 68 | |||
| 69 | #[test] | ||
| 70 | fn test_should_scrobble_no_duration_requires_4min() { | ||
| 71 | assert!(ScrobbleState::should_scrobble_with_params(240, None)); | ||
| 72 | assert!(!ScrobbleState::should_scrobble_with_params(239, None)); | ||
| 73 | } | ||
| 74 | |||
| 75 | #[test] | ||
| 76 | fn test_should_scrobble_short_song() { | ||
| 77 | assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0))); | ||
| 78 | assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0))); | ||
| 79 | } | ||
diff --git a/tests/database.rs b/tests/database.rs index 6cd870e..84eb663 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,412 @@ 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("M").unwrap(); | ||
| 118 | assert!(results.contains(&"Metallica/".to_string())); | ||
| 119 | assert!(results.contains(&"Miles Davis/".to_string())); | ||
| 120 | } | ||
| 121 | |||
| 122 | #[test] | ||
| 123 | fn test_complete_artists_prefix_lowercase() { | ||
| 124 | let db = Database::open_in_memory().unwrap(); | ||
| 125 | seed_test_db(&db); | ||
| 126 | let results = db.complete("m").unwrap(); | ||
| 127 | assert!( | ||
| 128 | results.contains(&"Metallica/".to_string()), | ||
| 129 | "LIKE should be case-insensitive" | ||
| 130 | ); | ||
| 131 | } | ||
| 132 | |||
| 133 | #[test] | ||
| 134 | fn test_complete_no_match() { | ||
| 135 | let db = Database::open_in_memory().unwrap(); | ||
| 136 | seed_test_db(&db); | ||
| 137 | let results = db.complete("Z").unwrap(); | ||
| 138 | assert!(results.is_empty()); | ||
| 139 | } | ||
| 140 | |||
| 141 | #[test] | ||
| 142 | fn test_complete_empty_prefix() { | ||
| 143 | let db = Database::open_in_memory().unwrap(); | ||
| 144 | seed_test_db(&db); | ||
| 145 | let results = db.complete("").unwrap(); | ||
| 146 | assert_eq!(results.len(), 3); | ||
| 147 | assert!(results.contains(&"Metallica/".to_string())); | ||
| 148 | assert!(results.contains(&"Miles Davis/".to_string())); | ||
| 149 | assert!(results.contains(&"Aphex Twin/".to_string())); | ||
| 150 | } | ||
| 151 | |||
| 152 | #[test] | ||
| 153 | fn test_complete_albums_for_artist() { | ||
| 154 | let db = Database::open_in_memory().unwrap(); | ||
| 155 | seed_test_db(&db); | ||
| 156 | let results = db.complete("Metallica/").unwrap(); | ||
| 157 | assert_eq!(results.len(), 2); | ||
| 158 | assert!(results.contains(&"Metallica/Master of Puppets/".to_string())); | ||
| 159 | assert!(results.contains(&"Metallica/Ride the Lightning/".to_string())); | ||
| 160 | } | ||
| 161 | |||
| 162 | #[test] | ||
| 163 | fn test_complete_albums_artist_lowercase() { | ||
| 164 | let db = Database::open_in_memory().unwrap(); | ||
| 165 | seed_test_db(&db); | ||
| 166 | let results = db.complete("metallica/").unwrap(); | ||
| 167 | assert_eq!(results.len(), 2, "artist match should be case-insensitive"); | ||
| 168 | assert!(results.contains(&"metallica/Master of Puppets/".to_string())); | ||
| 169 | } | ||
| 170 | |||
| 171 | #[test] | ||
| 172 | fn test_complete_albums_with_album_prefix() { | ||
| 173 | let db = Database::open_in_memory().unwrap(); | ||
| 174 | seed_test_db(&db); | ||
| 175 | // "R" matches "Ride the Lightning" (prefix) and "Master of Puppets" (contains 'r') | ||
| 176 | let results = db.complete("Metallica/R").unwrap(); | ||
| 177 | assert_eq!(results.len(), 2); | ||
| 178 | assert_eq!(results[0], "Metallica/Ride the Lightning/"); | ||
| 179 | assert_eq!(results[1], "Metallica/Master of Puppets/"); | ||
| 180 | } | ||
| 181 | |||
| 182 | #[test] | ||
| 183 | fn test_complete_albums_substring_match() { | ||
| 184 | let db = Database::open_in_memory().unwrap(); | ||
| 185 | seed_test_db(&db); | ||
| 186 | // "ind" matches "Kind of Blue" (contains "ind") | ||
| 187 | let results = db.complete("Miles Davis/ind").unwrap(); | ||
| 188 | assert_eq!(results.len(), 1); | ||
| 189 | assert_eq!(results[0], "Miles Davis/Kind of Blue/"); | ||
| 190 | } | ||
| 191 | |||
| 192 | #[test] | ||
| 193 | fn test_complete_artists_substring_match() { | ||
| 194 | let db = Database::open_in_memory().unwrap(); | ||
| 195 | seed_test_db(&db); | ||
| 196 | // "x" matches "Aphex Twin" (contains 'x') | ||
| 197 | let results = db.complete("x").unwrap(); | ||
| 198 | assert!(results.contains(&"Aphex Twin/".to_string())); | ||
| 199 | } | ||
| 200 | |||
| 201 | #[test] | ||
| 202 | fn test_complete_albums_unknown_artist() { | ||
| 203 | let db = Database::open_in_memory().unwrap(); | ||
| 204 | seed_test_db(&db); | ||
| 205 | let results = db.complete("Unknown/").unwrap(); | ||
| 206 | assert!(results.is_empty()); | ||
| 207 | } | ||
| 208 | |||
| 209 | #[test] | ||
| 210 | fn test_complete_tracks_for_album() { | ||
| 211 | let db = Database::open_in_memory().unwrap(); | ||
| 212 | seed_test_db(&db); | ||
| 213 | let results = db.complete("Metallica/Master of Puppets/").unwrap(); | ||
| 214 | assert_eq!(results.len(), 2); | ||
| 215 | assert!(results.contains(&"Metallica/Master of Puppets/Battery".to_string())); | ||
| 216 | assert!(results.contains(&"Metallica/Master of Puppets/Master of Puppets".to_string())); | ||
| 217 | } | ||
| 218 | |||
| 219 | #[test] | ||
| 220 | fn test_complete_tracks_with_prefix() { | ||
| 221 | let db = Database::open_in_memory().unwrap(); | ||
| 222 | seed_test_db(&db); | ||
| 223 | let results = db.complete("Metallica/Master of Puppets/B").unwrap(); | ||
| 224 | assert_eq!(results.len(), 1); | ||
| 225 | assert_eq!(results[0], "Metallica/Master of Puppets/Battery"); | ||
| 226 | } | ||
| 227 | |||
| 228 | #[test] | ||
| 229 | fn test_complete_tracks_unknown_album() { | ||
| 230 | let db = Database::open_in_memory().unwrap(); | ||
| 231 | seed_test_db(&db); | ||
| 232 | let results = db.complete("Metallica/Nonexistent/").unwrap(); | ||
| 233 | assert!(results.is_empty()); | ||
| 234 | } | ||
| 235 | |||
| 236 | #[test] | ||
| 237 | fn test_complete_empty_db() { | ||
| 238 | let db = Database::open_in_memory().unwrap(); | ||
| 239 | assert!(db.complete("").unwrap().is_empty()); | ||
| 240 | assert!(db.complete("x/").unwrap().is_empty()); | ||
| 241 | assert!(db.complete("x/y/").unwrap().is_empty()); | ||
| 242 | } | ||
| 243 | |||
| 244 | // --- find_tracks_by_album tests --- | ||
| 245 | |||
| 246 | #[test] | ||
| 247 | fn test_find_tracks_by_album_all() { | ||
| 248 | let db = Database::open_in_memory().unwrap(); | ||
| 249 | seed_test_db(&db); | ||
| 250 | let tracks = db | ||
| 251 | .find_tracks_by_album("Metallica", "Master of Puppets") | ||
| 252 | .unwrap(); | ||
| 253 | assert_eq!(tracks.len(), 2); | ||
| 254 | assert_eq!(tracks[0].title, "Battery"); | ||
| 255 | assert_eq!(tracks[1].title, "Master of Puppets"); | ||
| 256 | assert_eq!(tracks[0].artist_name.as_deref(), Some("Metallica")); | ||
| 257 | assert_eq!(tracks[0].album_title.as_deref(), Some("Master of Puppets")); | ||
| 258 | } | ||
| 259 | |||
| 260 | #[test] | ||
| 261 | fn test_find_tracks_by_album_single_track() { | ||
| 262 | let db = Database::open_in_memory().unwrap(); | ||
| 263 | seed_test_db(&db); | ||
| 264 | let tracks = db | ||
| 265 | .find_tracks_by_album("Miles Davis", "Kind of Blue") | ||
| 266 | .unwrap(); | ||
| 267 | assert_eq!(tracks.len(), 1); | ||
| 268 | assert_eq!(tracks[0].title, "So What"); | ||
| 269 | } | ||
| 270 | |||
| 271 | #[test] | ||
| 272 | fn test_find_tracks_by_album_unknown_artist() { | ||
| 273 | let db = Database::open_in_memory().unwrap(); | ||
| 274 | seed_test_db(&db); | ||
| 275 | let tracks = db | ||
| 276 | .find_tracks_by_album("Nonexistent", "Master of Puppets") | ||
| 277 | .unwrap(); | ||
| 278 | assert!(tracks.is_empty()); | ||
| 279 | } | ||
| 280 | |||
| 281 | #[test] | ||
| 282 | fn test_find_tracks_by_album_unknown_album() { | ||
| 283 | let db = Database::open_in_memory().unwrap(); | ||
| 284 | seed_test_db(&db); | ||
| 285 | let tracks = db.find_tracks_by_album("Metallica", "Nonexistent").unwrap(); | ||
| 286 | assert!(tracks.is_empty()); | ||
| 287 | } | ||
| 288 | |||
| 289 | #[test] | ||
| 290 | fn test_find_tracks_by_album_case_insensitive_artist() { | ||
| 291 | let db = Database::open_in_memory().unwrap(); | ||
| 292 | seed_test_db(&db); | ||
| 293 | let tracks = db | ||
| 294 | .find_tracks_by_album("metallica", "Master of Puppets") | ||
| 295 | .unwrap(); | ||
| 296 | assert_eq!( | ||
| 297 | tracks.len(), | ||
| 298 | 2, | ||
| 299 | "artist name match should be case-insensitive" | ||
| 300 | ); | ||
| 301 | assert_eq!(tracks[0].title, "Battery"); | ||
| 302 | } | ||
| 303 | |||
| 304 | #[test] | ||
| 305 | fn test_find_tracks_by_album_ordered_by_track_number() { | ||
| 306 | let db = Database::open_in_memory().unwrap(); | ||
| 307 | seed_test_db(&db); | ||
| 308 | let tracks = db | ||
| 309 | .find_tracks_by_album("Metallica", "Master of Puppets") | ||
| 310 | .unwrap(); | ||
| 311 | assert_eq!(tracks[0].track_number, Some(1)); | ||
| 312 | assert_eq!(tracks[1].track_number, Some(2)); | ||
| 313 | } | ||
| 314 | |||
| 315 | // --- find_track_by_parts tests --- | ||
| 316 | |||
| 317 | #[test] | ||
| 318 | fn test_find_track_by_parts_exact() { | ||
| 319 | let db = Database::open_in_memory().unwrap(); | ||
| 320 | seed_test_db(&db); | ||
| 321 | let track = db | ||
| 322 | .find_track_by_parts("Metallica", "Master of Puppets", "Battery") | ||
| 323 | .unwrap(); | ||
| 324 | assert!(track.is_some()); | ||
| 325 | let t = track.unwrap(); | ||
| 326 | assert_eq!(t.title, "Battery"); | ||
| 327 | assert_eq!(t.artist_name.as_deref(), Some("Metallica")); | ||
| 328 | assert_eq!(t.album_title.as_deref(), Some("Master of Puppets")); | ||
| 329 | } | ||
| 330 | |||
| 331 | #[test] | ||
| 332 | fn test_find_track_by_parts_prefix() { | ||
| 333 | let db = Database::open_in_memory().unwrap(); | ||
| 334 | seed_test_db(&db); | ||
| 335 | let track = db | ||
| 336 | .find_track_by_parts("Metallica", "Master of Puppets", "B") | ||
| 337 | .unwrap(); | ||
| 338 | assert!(track.is_some()); | ||
| 339 | assert_eq!(track.unwrap().title, "Battery"); | ||
| 340 | } | ||
| 341 | |||
| 342 | #[test] | ||
| 343 | fn test_find_track_by_parts_no_match_track() { | ||
| 344 | let db = Database::open_in_memory().unwrap(); | ||
| 345 | seed_test_db(&db); | ||
| 346 | let track = db | ||
| 347 | .find_track_by_parts("Metallica", "Master of Puppets", "Z") | ||
| 348 | .unwrap(); | ||
| 349 | assert!(track.is_none()); | ||
| 350 | } | ||
| 351 | |||
| 352 | #[test] | ||
| 353 | fn test_find_track_by_parts_no_match_album() { | ||
| 354 | let db = Database::open_in_memory().unwrap(); | ||
| 355 | seed_test_db(&db); | ||
| 356 | let track = db | ||
| 357 | .find_track_by_parts("Metallica", "Nonexistent", "B") | ||
| 358 | .unwrap(); | ||
| 359 | assert!(track.is_none()); | ||
| 360 | } | ||
| 361 | |||
| 362 | #[test] | ||
| 363 | fn test_find_track_by_parts_no_match_artist() { | ||
| 364 | let db = Database::open_in_memory().unwrap(); | ||
| 365 | seed_test_db(&db); | ||
| 366 | let track = db | ||
| 367 | .find_track_by_parts("Nonexistent", "Master of Puppets", "B") | ||
| 368 | .unwrap(); | ||
| 369 | assert!(track.is_none()); | ||
| 370 | } | ||
| 371 | |||
| 372 | #[test] | ||
| 373 | fn test_find_track_by_parts_case_insensitive() { | ||
| 374 | let db = Database::open_in_memory().unwrap(); | ||
| 375 | seed_test_db(&db); | ||
| 376 | let track = db | ||
| 377 | .find_track_by_parts("metallica", "Master of Puppets", "Battery") | ||
| 378 | .unwrap(); | ||
| 379 | assert!(track.is_some(), "artist match should be case-insensitive"); | ||
| 380 | assert_eq!(track.unwrap().title, "Battery"); | ||
| 381 | } | ||
| 382 | |||
| 383 | // --- resolve_path tests --- | ||
| 384 | |||
| 385 | #[test] | ||
| 386 | fn test_resolve_path_two_parts_album() { | ||
| 387 | let db = Database::open_in_memory().unwrap(); | ||
| 388 | seed_test_db(&db); | ||
| 389 | let result = db.resolve_path("Metallica/Master of Puppets").unwrap(); | ||
| 390 | assert!(result.is_some()); | ||
| 391 | let tracks = result.unwrap(); | ||
| 392 | assert_eq!(tracks.len(), 2); | ||
| 393 | assert_eq!(tracks[0].title, "Battery"); | ||
| 394 | } | ||
| 395 | |||
| 396 | #[test] | ||
| 397 | fn test_resolve_path_two_parts_trailing_slash() { | ||
| 398 | let db = Database::open_in_memory().unwrap(); | ||
| 399 | seed_test_db(&db); | ||
| 400 | let result = db.resolve_path("Metallica/Master of Puppets/").unwrap(); | ||
| 401 | assert!(result.is_some()); | ||
| 402 | let tracks = result.unwrap(); | ||
| 403 | assert_eq!(tracks.len(), 2); | ||
| 404 | } | ||
| 405 | |||
| 406 | #[test] | ||
| 407 | fn test_resolve_path_three_parts_track() { | ||
| 408 | let db = Database::open_in_memory().unwrap(); | ||
| 409 | seed_test_db(&db); | ||
| 410 | let result = db | ||
| 411 | .resolve_path("Metallica/Master of Puppets/Battery") | ||
| 412 | .unwrap(); | ||
| 413 | assert!(result.is_some()); | ||
| 414 | let tracks = result.unwrap(); | ||
| 415 | assert_eq!(tracks.len(), 1); | ||
| 416 | assert_eq!(tracks[0].title, "Battery"); | ||
| 417 | } | ||
| 418 | |||
| 419 | #[test] | ||
| 420 | fn test_resolve_path_three_parts_track_prefix() { | ||
| 421 | let db = Database::open_in_memory().unwrap(); | ||
| 422 | seed_test_db(&db); | ||
| 423 | let result = db.resolve_path("Metallica/Master of Puppets/B").unwrap(); | ||
| 424 | assert!(result.is_some()); | ||
| 425 | let tracks = result.unwrap(); | ||
| 426 | assert_eq!(tracks.len(), 1); | ||
| 427 | assert_eq!(tracks[0].title, "Battery"); | ||
| 428 | } | ||
| 429 | |||
| 430 | #[test] | ||
| 431 | fn test_resolve_path_single_part_returns_none() { | ||
| 432 | let db = Database::open_in_memory().unwrap(); | ||
| 433 | seed_test_db(&db); | ||
| 434 | let result = db.resolve_path("Metallica").unwrap(); | ||
| 435 | assert!( | ||
| 436 | result.is_none(), | ||
| 437 | "single part should return None for fallback" | ||
| 438 | ); | ||
| 439 | } | ||
| 440 | |||
| 441 | #[test] | ||
| 442 | fn test_resolve_path_no_match_album() { | ||
| 443 | let db = Database::open_in_memory().unwrap(); | ||
| 444 | seed_test_db(&db); | ||
| 445 | let result = db.resolve_path("Metallica/Nonexistent").unwrap(); | ||
| 446 | assert!(result.is_some()); | ||
| 447 | assert!(result.unwrap().is_empty()); | ||
| 448 | } | ||
| 449 | |||
| 450 | #[test] | ||
| 451 | fn test_resolve_path_no_match_track() { | ||
| 452 | let db = Database::open_in_memory().unwrap(); | ||
| 453 | seed_test_db(&db); | ||
| 454 | let result = db.resolve_path("Metallica/Master of Puppets/Z").unwrap(); | ||
| 455 | assert!(result.is_some()); | ||
| 456 | assert!(result.unwrap().is_empty()); | ||
| 457 | } | ||
| 458 | |||
| 459 | #[test] | ||
| 460 | fn test_resolve_path_empty_db() { | ||
| 461 | let db = Database::open_in_memory().unwrap(); | ||
| 462 | assert!(db.resolve_path("Artist/Album").unwrap().unwrap().is_empty()); | ||
| 463 | assert!( | ||
| 464 | db.resolve_path("Artist/Album/Track") | ||
| 465 | .unwrap() | ||
| 466 | .unwrap() | ||
| 467 | .is_empty() | ||
| 468 | ); | ||
| 469 | assert!(db.resolve_path("Artist").unwrap().is_none()); | ||
| 470 | } | ||
| 471 | |||
| 472 | // --- Track conversion helper tests --- | ||
| 473 | |||
| 474 | #[test] | ||
| 475 | fn test_find_tracks_by_album_returns_full_metadata() { | ||
| 476 | let db = Database::open_in_memory().unwrap(); | ||
| 477 | seed_test_db(&db); | ||
| 478 | let tracks = db | ||
| 479 | .find_tracks_by_album("Metallica", "Ride the Lightning") | ||
| 480 | .unwrap(); | ||
| 481 | assert_eq!(tracks.len(), 1); | ||
| 482 | let t = &tracks[0]; | ||
| 483 | assert_eq!(t.artist_name.as_deref(), Some("Metallica")); | ||
| 484 | assert_eq!(t.album_title.as_deref(), Some("Ride the Lightning")); | ||
| 485 | assert_eq!(t.title, "Fight Fire with Fire"); | ||
| 486 | assert!(t.path.to_string_lossy().contains("Ride the Lightning")); | ||
| 487 | } | ||
| 488 | |||
| 489 | #[test] | ||
| 490 | fn test_find_track_by_parts_returns_full_metadata() { | ||
| 491 | let db = Database::open_in_memory().unwrap(); | ||
| 492 | seed_test_db(&db); | ||
| 493 | let t = db | ||
| 494 | .find_track_by_parts("Aphex Twin", "Selected Ambient Works", "Xtal") | ||
| 495 | .unwrap() | ||
| 496 | .expect("track should be found"); | ||
| 497 | assert_eq!(t.artist_name.as_deref(), Some("Aphex Twin")); | ||
| 498 | assert_eq!(t.album_title.as_deref(), Some("Selected Ambient Works")); | ||
| 499 | assert_eq!(t.title, "Xtal"); | ||
| 500 | assert_eq!(t.track_number, Some(1)); | ||
| 501 | } | ||
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 @@ | |||
| 5 | * https://codeberg.org/salmonik/RSPlayer | 5 | * https://codeberg.org/salmonik/RSPlayer |
| 6 | */ | 6 | */ |
| 7 | 7 | ||
| 8 | use std::collections::BTreeMap; | ||
| 9 | |||
| 8 | use rsp::scrobbler::Scrobbler; | 10 | use rsp::scrobbler::Scrobbler; |
| 9 | 11 | ||
| 10 | #[test] | 12 | #[test] |
| @@ -80,3 +82,18 @@ fn test_scrobbler_skip_empty_credentials() { | |||
| 80 | scrobbler.now_playing("Artist", "Track", Some("Album")); | 82 | scrobbler.now_playing("Artist", "Track", Some("Album")); |
| 81 | scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); | 83 | scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); |
| 82 | } | 84 | } |
| 85 | |||
| 86 | #[test] | ||
| 87 | fn test_sign_lastfm() { | ||
| 88 | let params = BTreeMap::from([ | ||
| 89 | ("api_key", "abc123"), | ||
| 90 | ("method", "track.scrobble"), | ||
| 91 | ("sk", "session_key"), | ||
| 92 | ("track", "Test Track"), | ||
| 93 | ("artist", "Test Artist"), | ||
| 94 | ("timestamp", "1234567890"), | ||
| 95 | ]); | ||
| 96 | let sig = Scrobbler::sign_lastfm(¶ms, "sekrit"); | ||
| 97 | assert_eq!(sig.len(), 32); | ||
| 98 | assert!(sig.chars().all(|c| c.is_ascii_hexdigit())); | ||
| 99 | } | ||
