aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@noreply.codeberg.org>2026-06-06 19:07:09 +0200
committersalmonik <salmonik@noreply.codeberg.org>2026-06-06 19:07:09 +0200
commit385c2a38eba689154b46d515f6a3a010c34d3119 (patch)
tree8916681bafc9822ef371f949a176cf22307d2a2e
parentadd verbosity to `rsp scan` (diff)
parentdelete not needed md file (diff)
downloadrsplayer-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.json2
-rw-r--r--Cargo.lock2
-rw-r--r--Cargo.toml2
-rw-r--r--TODO.md4
-rw-r--r--completions/_rsp62
-rwxr-xr-xinstall.sh20
-rw-r--r--src/cli.rs41
-rw-r--r--src/daemon.rs296
-rw-r--r--src/database.rs305
-rw-r--r--src/queue.rs14
-rw-r--r--src/scrobbler.rs22
-rw-r--r--tests/daemon.rs79
-rw-r--r--tests/database.rs411
-rw-r--r--tests/scrobbler.rs17
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",
diff --git a/Cargo.lock b/Cargo.lock
index f58156d..846ed82 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1948,7 +1948,7 @@ dependencies = [
1948 1948
1949[[package]] 1949[[package]]
1950name = "rsp" 1950name = "rsp"
1951version = "0.8.3" 1951version = "0.8.4"
1952dependencies = [ 1952dependencies = [
1953 "clap", 1953 "clap",
1954 "clap_complete", 1954 "clap_complete",
diff --git a/Cargo.toml b/Cargo.toml
index ef61127..8710c51 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "rsp" 2name = "rsp"
3version = "0.8.3" 3version = "0.8.4"
4edition = "2024" 4edition = "2024"
5description = "Rust music player daemon with Unix socket IPC" 5description = "Rust music player daemon with Unix socket IPC"
6license = "GPL-3.0 or later" 6license = "GPL-3.0 or later"
diff --git a/TODO.md b/TODO.md
index 9bc36c2..55f7d52 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,10 +2,8 @@
2 2
3- **Persist queue state** - Save queue to disk so it survives daemon restarts. 3- **Persist queue state** - Save queue to disk so it survives daemon restarts.
4 4
5- **Dynamic autocompletion of artists, albums and tracks** - Let shell communicate with the daemon so it returns autocompletions for shell 5- [x] **Dynamic autocompletion of artists, albums and tracks** - `rsp complete <entity> [prefix]` queries DB directly; zsh completion wrapper concatenated via install.sh
6 6
7- **Cache config in daemon** - Avoid repeated Config::load() calls in daemon hot paths; pass display_format through command chain instead 7- **Cache config in daemon** - Avoid repeated Config::load() calls in daemon hot paths; pass display_format through command chain instead
8 8
9- **Test last.fm scrobbling** - Current scrobbling is only tested for listenbrainz 9- **Test last.fm scrobbling** - Current scrobbling is only tested for listenbrainz
10
11- **REPL mode** - like in bluetoothctl
diff --git a/completions/_rsp b/completions/_rsp
new file mode 100644
index 0000000..9bf0579
--- /dev/null
+++ b/completions/_rsp
@@ -0,0 +1,62 @@
1# Save clap-generated function so we can delegate non-dynamic cases.
2functions[_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}
diff --git a/install.sh b/install.sh
index 11af71d..25a1f52 100755
--- a/install.sh
+++ b/install.sh
@@ -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
7set -e 8set -e
8 9
9echo "Updating the repo..." 10if ping -W 2 -c 1 codeberg.org >&/dev/null; then
10git pull 2>/dev/null 11 echo "Updating the repo..."
12 git pull 2>/dev/null
13fi
11 14
12echo "Building rsplayer in release mode..." 15echo "Building rsplayer in release mode..."
13cargo build --release 16cargo build --release
@@ -17,8 +20,7 @@ systemctl --user stop rsplayer 2>/dev/null || true
17rsp stop-daemon 2>/dev/null || true 20rsp stop-daemon 2>/dev/null || true
18 21
19echo "Installing to /usr/bin/rsp..." 22echo "Installing to /usr/bin/rsp..."
20sudo cp target/release/rsp /usr/bin/rsp 23sudo install target/release/rsp /usr/bin/rsp
21sudo chmod +x /usr/bin/rsp
22 24
23echo "" 25echo ""
24echo "Installing systemd service..." 26echo "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)
diff --git a/src/cli.rs b/src/cli.rs
index f5fdd2c..e4bb484 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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};
18use std::thread; 18use std::thread;
19use std::time::{Duration, Instant, SystemTime}; 19use std::time::{Duration, Instant, SystemTime};
20 20
21struct ScrobbleState { 21pub 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
27impl Default for ScrobbleState {
28 fn default() -> Self {
29 Self::new()
30 }
25} 31}
26 32
27impl ScrobbleState { 33impl 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)]
100mod 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
172fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { 105fn 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)]
47impl Database { 47impl Database {
48 pub fn conn(&self) -> &Connection {
49 &self.conn
50 }
51
48 pub fn open(_config: &crate::config::Config) -> SqlResult<Self> { 52 pub fn open(_config: &crate::config::Config) -> SqlResult<Self> {
49 let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { 53 let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| {
50 rusqlite::Error::InvalidPath("Failed to get project directories".into()) 54 rusqlite::Error::InvalidPath("Failed to get project directories".into())
@@ -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)]
338mod 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(&params, "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
8use std::path::PathBuf;
9
10use rsp::daemon::ScrobbleState;
11use rsp::queue::Track;
12
13#[test]
14fn 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]
22fn 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]
41fn test_should_scrobble_without_track_returns_false() {
42 assert!(!ScrobbleState::new().should_scrobble());
43}
44
45#[test]
46fn test_should_scrobble_threshold_below_30_seconds() {
47 assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0)));
48}
49
50#[test]
51fn test_should_scrobble_threshold_half_duration() {
52 assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0)));
53}
54
55#[test]
56fn test_should_scrobble_threshold_below_half_duration() {
57 assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0)));
58}
59
60#[test]
61fn 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]
70fn 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]
76fn 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
8use rsp::database::{extract_metadata, is_audio_file}; 8use rsp::database::{Database, extract_metadata, is_audio_file};
9use std::path::PathBuf; 9use std::path::PathBuf;
10 10
11#[test] 11#[test]
@@ -90,3 +90,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
94fn seed_test_db(db: &Database) {
95 let conn: &rusqlite::Connection = db.conn();
96 conn.execute_batch(
97 "INSERT INTO artists (id, name) VALUES (1, 'Metallica'), (2, 'Miles Davis'), (3, 'Aphex Twin');
98 INSERT INTO albums (id, artist_id, title) VALUES
99 (1, 1, 'Master of Puppets'),
100 (2, 1, 'Ride the Lightning'),
101 (3, 2, 'Kind of Blue'),
102 (4, 3, 'Selected Ambient Works');
103 INSERT INTO tracks (id, album_id, title, path, duration, track_number) VALUES
104 (1, 1, 'Battery', '/m/Metallica/Master of Puppets/01-Battery.mp3', 313, 1),
105 (2, 1, 'Master of Puppets', '/m/Metallica/Master of Puppets/02-Master of Puppets.mp3', 515, 2),
106 (3, 2, 'Fight Fire with Fire', '/m/Metallica/Ride the Lightning/01-Fight Fire with Fire.mp3', 285, 1),
107 (4, 3, 'So What', '/m/Miles Davis/Kind of Blue/01-So What.mp3', 562, 1),
108 (5, 4, 'Xtal', '/m/Aphex Twin/Selected Ambient Works/01-Xtal.mp3', 437, 1);",
109 )
110 .unwrap();
111}
112
113#[test]
114fn test_complete_artists_prefix() {
115 let db = Database::open_in_memory().unwrap();
116 seed_test_db(&db);
117 let results = db.complete("M").unwrap();
118 assert!(results.contains(&"Metallica/".to_string()));
119 assert!(results.contains(&"Miles Davis/".to_string()));
120}
121
122#[test]
123fn 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]
134fn 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]
142fn 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]
153fn 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]
163fn 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]
172fn 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]
183fn 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]
193fn 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]
202fn 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]
210fn 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]
220fn 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]
229fn 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]
237fn 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]
247fn 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]
261fn 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]
272fn 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]
282fn 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]
290fn 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]
305fn 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]
318fn 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]
332fn 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]
343fn 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]
353fn 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]
363fn 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]
373fn 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]
386fn 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]
397fn 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]
407fn 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]
420fn 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]
431fn 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]
442fn 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]
451fn 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]
460fn 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]
475fn 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]
490fn 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
8use std::collections::BTreeMap;
9
8use rsp::scrobbler::Scrobbler; 10use 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]
87fn 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(&params, "sekrit");
97 assert_eq!(sig.len(), 32);
98 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
99}