aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-06-06 19:48:22 +0300
committersalmonik <raisersoft@proton.me>2026-06-06 19:48:22 +0300
commitd347d89dfeef54d648ffdea054897617c0016509 (patch)
tree0ea0e25e2a8e9bcdd8c06ffadbe29721a7f07f77
parentdirty, broken commit (diff)
downloadrsplayer-d347d89dfeef54d648ffdea054897617c0016509.tar.gz
rsplayer-d347d89dfeef54d648ffdea054897617c0016509.zip
correctly working auto completion for db
-rw-r--r--Cargo.lock2
-rw-r--r--Cargo.toml2
-rw-r--r--completions/_rsp67
-rwxr-xr-xinstall.sh20
-rw-r--r--src/cli.rs43
-rw-r--r--src/daemon.rs113
-rw-r--r--src/database.rs259
-rw-r--r--src/queue.rs14
-rw-r--r--src/scrobbler.rs22
-rw-r--r--tests/daemon.rs79
-rw-r--r--tests/database.rs112
-rw-r--r--tests/scrobbler.rs17
12 files changed, 488 insertions, 262 deletions
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/completions/_rsp b/completions/_rsp
index 1ddc1fe..9bf0579 100644
--- a/completions/_rsp
+++ b/completions/_rsp
@@ -1,55 +1,62 @@
1# Dynamic completion overrides for rsp play/queue arguments.
2
3# Save clap-generated function so we can delegate non-dynamic cases. 1# Save clap-generated function so we can delegate non-dynamic cases.
4functions[_rsp_clap]=$functions[_rsp] 2functions[_rsp_clap]=$functions[_rsp]
5 3
6_rsp() { 4_rsp() {
7 case $words[2] in 5 case $words[2] in
8 play|queue) 6 play|queue)
9 _rsp_complete_args 7 if (( CURRENT > 2 )); then
10 ;; 8 _rsp_complete_args
11 *) 9 return 0
12 _rsp_clap "$@" 10 fi
13 ;; 11 ;;
14 esac 12 esac
13 _rsp_clap "$@"
15} 14}
16 15
17_rsp_complete_args() { 16_rsp_complete_args() {
18 setopt localoptions 17 setopt localoptions
19 setopt nobanghist 18 setopt nobanghist
20 # Reconstruct full query from all argument words after subcommand.
21 # This handles spaces in names (e.g. "Master of Puppets") that get
22 # split across multiple $words entries when backslash escaping breaks.
23 local -a args=("${(@)words[3,$CURRENT]}") 19 local -a args=("${(@)words[3,$CURRENT]}")
24 local cur="${(j: :)args}" 20 local cur="${(j: :)args}"
21 local cur_clean="${cur//\\/}"
25 local -a suggestions 22 local -a suggestions
26 local n_slashes=${#cur//[^\/]/} 23 suggestions=("${(@f)"$(rsp db-complete "$cur" 2>/dev/null)"}")
27 case $n_slashes in 24 # Filter out empty entries (e.g. from empty command output)
28 0) suggestions=(${(f)"$(rsp db-complete artists "$cur" 2>/dev/null)"}) ;; 25 local -a filtered=()
29 1) suggestions=(${(f)"$(rsp db-complete albums "$cur" 2>/dev/null)"}) ;;
30 *) suggestions=(${(f)"$(rsp db-complete tracks "$cur" 2>/dev/null)"}) ;;
31 esac
32 # display: what the user sees in the completion menu (raw values)
33 local -a display=("${suggestions[@]}")
34 # vals: what gets inserted (with ZSH backslash escaping + ! escape)
35 local -a vals=()
36 local slash_idx=$cur[(I)/]
37 local s
38 for s in "${suggestions[@]}"; do 26 for s in "${suggestions[@]}"; do
39 local esc="${(q)s:gs/\!/\\\!/}" 27 if [[ -n "$s" ]]; then
40 if (( slash_idx )); then 28 filtered+=("$s")
41 local dir_part=$cur[1,$slash_idx]
42 local edir="${(q)dir_part:gs/\!/\\\!/}"
43 vals+=("${edir}${esc}")
44 else
45 vals+=("${esc}")
46 fi 29 fi
47 done 30 done
31 suggestions=("${filtered[@]}")
48 if (( ${#suggestions} )); then 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
49 if [[ ${vals[1]} == */ ]]; then 54 if [[ ${vals[1]} == */ ]]; then
50 compadd -U -S '' -d display -- "${vals[@]}" 55 compadd -U -S '' -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}"
51 else 56 else
52 compadd -U -d display -- "${vals[@]}" 57 compadd -U -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}"
53 fi 58 fi
59 else
60 compadd -U -S ''
54 fi 61 fi
55} 62}
diff --git a/install.sh b/install.sh
index 280be8a..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; echo; cat completions/_rsp) | 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 5a53eeb..e4bb484 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -95,12 +95,9 @@ pub enum Commands {
95 #[clap(hide = true)] 95 #[clap(hide = true)]
96 /// Complete artists, albums, or tracks from database 96 /// Complete artists, albums, or tracks from database
97 DbComplete { 97 DbComplete {
98 /// Entity type: artists, albums, or tracks 98 /// Query string (e.g. \"J\", \"Artist/\", \"Artist/Album/\")
99 #[arg(value_name = "ENTITY")] 99 #[arg(value_name = "QUERY")]
100 entity: String, 100 query: String,
101 /// Optional prefix to match
102 #[arg(value_name = "PREFIX")]
103 prefix: Option<String>,
104 }, 101 },
105} 102}
106 103
@@ -245,7 +242,23 @@ impl Cli {
245 } 242 }
246 } 243 }
247 } 244 }
248 Commands::DbComplete { entity, prefix } => { 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
249 let config = match Config::load() { 262 let config = match Config::load() {
250 Ok(c) => c, 263 Ok(c) => c,
251 Err(_) => return Ok(()), 264 Err(_) => return Ok(()),
@@ -254,19 +267,11 @@ impl Cli {
254 Ok(d) => d, 267 Ok(d) => d,
255 Err(_) => return Ok(()), 268 Err(_) => return Ok(()),
256 }; 269 };
257 let prefix = prefix.as_deref().unwrap_or(""); 270
258 let results: Vec<String> = match entity.as_str() { 271 let results = db.complete(&query).unwrap_or_default();
259 "artists" => db.complete_artists(prefix).unwrap_or_default(), 272
260 "albums" => db.complete_albums(prefix).unwrap_or_default(),
261 "tracks" => db.complete_tracks(prefix).unwrap_or_default(),
262 _ => Vec::new(),
263 };
264 let suffix = match entity.as_str() {
265 "artists" | "albums" => "/",
266 _ => "",
267 };
268 for r in &results { 273 for r in &results {
269 println!("{r}{suffix}"); 274 println!("{r}");
270 } 275 }
271 } 276 }
272 Commands::Completions { shell } => { 277 Commands::Completions { shell } => {
diff --git a/src/daemon.rs b/src/daemon.rs
index d34305a..5ad6654 100644
--- a/src/daemon.rs
+++ b/src/daemon.rs
@@ -18,14 +18,20 @@ use std::sync::{Arc, Mutex, RwLock};
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
@@ -875,6 +808,22 @@ fn process_command(
875 // Strip trailing slash so autocompleted Artist/Album/ resolves cleanly 808 // Strip trailing slash so autocompleted Artist/Album/ resolves cleanly
876 let path_str = path_str.trim_end_matches('/').to_string(); 809 let path_str = path_str.trim_end_matches('/').to_string();
877 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 };
826
878 if path_str.is_empty() { 827 if path_str.is_empty() {
879 return "Usage: play [now|next|last] <path>\n".to_string(); 828 return "Usage: play [now|next|last] <path>\n".to_string();
880 } 829 }
diff --git a/src/database.rs b/src/database.rs
index 00d970a..01bee92 100644
--- a/src/database.rs
+++ b/src/database.rs
@@ -63,7 +63,29 @@ impl Database {
63 fs::create_dir_all(&data_dir).ok(); 63 fs::create_dir_all(&data_dir).ok();
64 let db_path = data_dir.join("library.db"); 64 let db_path = data_dir.join("library.db");
65 65
66 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 };
67 let db = Database { conn }; 89 let db = Database { conn };
68 db.init_schema()?; 90 db.init_schema()?;
69 Ok(db) 91 Ok(db)
@@ -273,51 +295,174 @@ impl Database {
273 self.get_album_tracks_after(album_id, track_number, &path) 295 self.get_album_tracks_after(album_id, track_number, &path)
274 } 296 }
275 297
276 pub fn complete_artists(&self, prefix: &str) -> SqlResult<Vec<String>> { 298 pub fn complete(&self, query: &str) -> SqlResult<Vec<String>> {
277 let pattern = format!("%{}%", prefix); 299 let mut results: Vec<String> = Vec::new();
278 let mut stmt = self
279 .conn
280 .prepare("SELECT name FROM artists WHERE name LIKE ?1 ORDER BY name LIMIT 25")?;
281 let rows = stmt.query_map([&pattern], |row| row.get::<_, String>(0))?;
282 rows.collect()
283 }
284 300
285 pub fn complete_albums(&self, prefix: &str) -> SqlResult<Vec<String>> { 301 if query.is_empty() {
286 let (artist, album_prefix) = match prefix.split_once('/') { 302 let mut stmt = self
287 Some((a, ap)) => (a, ap), 303 .conn
288 None => return Ok(Vec::new()), 304 .prepare("SELECT name FROM artists ORDER BY name LIMIT 25")?;
289 }; 305 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
290 let pattern = format!("{}%", album_prefix); 306 for name in rows.flatten() {
291 let mut stmt = self.conn.prepare( 307 results.push(format!("{name}/"));
292 "SELECT a.title FROM albums a 308 }
293 JOIN artists ar ON a.artist_id = ar.id 309 return Ok(results);
294 WHERE ar.name = ?1 AND a.title LIKE ?2 310 }
295 ORDER BY a.title LIMIT 25",
296 )?;
297 let rows = stmt.query_map(rusqlite::params![artist, pattern], |row| {
298 row.get::<_, String>(0)
299 })?;
300 rows.collect()
301 }
302 311
303 pub fn complete_tracks(&self, prefix: &str) -> SqlResult<Vec<String>> { 312 let has_trailing_slash = query.ends_with('/');
304 let parts: Vec<&str> = prefix.splitn(3, '/').collect(); 313
305 if parts.len() < 3 { 314 if has_trailing_slash {
306 return Ok(Vec::new()); 315 let prefix = query.trim_end_matches('/');
316 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
317
318 match segments.len() {
319 1 => {
320 let artist = segments[0];
321 let mut stmt = self.conn.prepare(
322 "SELECT a.title FROM albums a
323 JOIN artists ar ON a.artist_id = ar.id
324 WHERE ar.name LIKE ?1
325 ORDER BY a.title LIMIT 25",
326 )?;
327 let rows = stmt.query_map([artist], |row| row.get::<_, String>(0))?;
328 for album in rows.flatten() {
329 results.push(format!("{artist}/{album}/"));
330 }
331 }
332 2 => {
333 let artist = segments[0];
334 let album = segments[1];
335 let mut stmt = self.conn.prepare(
336 "SELECT t.title FROM tracks t
337 JOIN albums a ON t.album_id = a.id
338 JOIN artists ar ON a.artist_id = ar.id
339 WHERE ar.name LIKE ?1 AND a.title LIKE ?2
340 ORDER BY t.track_number, t.title LIMIT 25",
341 )?;
342 let rows = stmt.query_map(rusqlite::params![artist, album], |row| {
343 row.get::<_, String>(0)
344 })?;
345 for track in rows.flatten() {
346 results.push(format!("{artist}/{album}/{track}"));
347 }
348 }
349 _ => {}
350 }
351 } else {
352 let segments: Vec<&str> = query.split('/').collect();
353
354 match segments.len() {
355 1 => {
356 let prefix = segments[0];
357 let like_pattern = format!("%{prefix}%");
358 let prefix_pattern = format!("{prefix}%");
359
360 let mut stmt = self.conn.prepare(
361 "SELECT name FROM artists
362 WHERE name LIKE ?1
363 ORDER BY
364 CASE WHEN name LIKE ?2 THEN 0 ELSE 1 END,
365 name
366 LIMIT 25",
367 )?;
368 let rows = stmt
369 .query_map(rusqlite::params![&like_pattern, &prefix_pattern], |row| {
370 row.get::<_, String>(0)
371 })?;
372 for name in rows.flatten() {
373 results.push(format!("{name}/"));
374 }
375 }
376 2 => {
377 let artist = segments[0];
378 let album_prefix = segments[1];
379 if artist.is_empty() {
380 return Ok(results);
381 }
382
383 let artist_pattern = format!("%{artist}%");
384 let artist_prefix = format!("{artist}%");
385 let album_pattern = format!("%{album_prefix}%");
386 let album_prefix_pattern = format!("{album_prefix}%");
387
388 let mut stmt = self.conn.prepare(
389 "SELECT ar.name, a.title FROM albums a
390 JOIN artists ar ON a.artist_id = ar.id
391 WHERE ar.name LIKE ?1 AND a.title LIKE ?2
392 ORDER BY
393 CASE WHEN ar.name LIKE ?3 THEN 0 ELSE 1 END,
394 CASE WHEN a.title LIKE ?4 THEN 0 ELSE 1 END,
395 ar.name, a.title
396 LIMIT 25",
397 )?;
398 let rows = stmt.query_map(
399 rusqlite::params![
400 &artist_pattern,
401 &album_pattern,
402 &artist_prefix,
403 &album_prefix_pattern,
404 ],
405 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
406 )?;
407 for (name, album) in rows.flatten() {
408 results.push(format!("{name}/{album}/"));
409 }
410 }
411 3 => {
412 let artist = segments[0];
413 let album = segments[1];
414 let track_prefix = segments[2];
415 if artist.is_empty() || album.is_empty() {
416 return Ok(results);
417 }
418
419 let artist_pattern = format!("%{artist}%");
420 let artist_prefix = format!("{artist}%");
421 let album_pattern = format!("%{album}%");
422 let album_prefix = format!("{album}%");
423 let track_pattern = format!("%{track_prefix}%");
424 let track_prefix_pattern = format!("{track_prefix}%");
425
426 let mut stmt = self.conn.prepare(
427 "SELECT ar.name, a.title, t.title FROM tracks t
428 JOIN albums a ON t.album_id = a.id
429 JOIN artists ar ON a.artist_id = ar.id
430 WHERE ar.name LIKE ?1 AND a.title LIKE ?2 AND t.title LIKE ?3
431 ORDER BY
432 CASE WHEN ar.name LIKE ?4 THEN 0 ELSE 1 END,
433 CASE WHEN a.title LIKE ?5 THEN 0 ELSE 1 END,
434 CASE WHEN t.title LIKE ?6 THEN 0 ELSE 1 END,
435 t.track_number, t.title
436 LIMIT 25",
437 )?;
438 let rows = stmt.query_map(
439 rusqlite::params![
440 &artist_pattern,
441 &album_pattern,
442 &track_pattern,
443 &artist_prefix,
444 &album_prefix,
445 &track_prefix_pattern,
446 ],
447 |row| {
448 Ok((
449 row.get::<_, String>(0)?,
450 row.get::<_, String>(1)?,
451 row.get::<_, String>(2)?,
452 ))
453 },
454 )?;
455 for (name, album_title, track) in rows.flatten() {
456 results.push(format!("{name}/{album_title}/{track}"));
457 }
458 }
459 _ => {}
460 }
307 } 461 }
308 let (artist, album, track_prefix) = (parts[0], parts[1], parts[2]); 462
309 let pattern = format!("{}%", track_prefix); 463 results.truncate(25);
310 let mut stmt = self.conn.prepare( 464
311 "SELECT t.title FROM tracks t 465 Ok(results)
312 JOIN albums a ON t.album_id = a.id
313 JOIN artists ar ON a.artist_id = ar.id
314 WHERE ar.name = ?1 AND a.title = ?2 AND t.title LIKE ?3
315 ORDER BY t.track_number, t.title LIMIT 25",
316 )?;
317 let rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| {
318 row.get::<_, String>(0)
319 })?;
320 rows.collect()
321 } 466 }
322 467
323 #[allow(dead_code)] 468 #[allow(dead_code)]
@@ -328,14 +473,14 @@ impl Database {
328 Ok(db) 473 Ok(db)
329 } 474 }
330 475
331 /// Find all tracks in an album by exact artist name and album title. 476 /// Find all tracks in an album by case-insensitive artist and album match.
332 pub fn find_tracks_by_album(&self, artist: &str, album: &str) -> SqlResult<Vec<Track>> { 477 pub fn find_tracks_by_album(&self, artist: &str, album: &str) -> SqlResult<Vec<Track>> {
333 let mut stmt = self.conn.prepare( 478 let mut stmt = self.conn.prepare(
334 "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title 479 "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title
335 FROM tracks t 480 FROM tracks t
336 JOIN albums a ON t.album_id = a.id 481 JOIN albums a ON t.album_id = a.id
337 JOIN artists ar ON a.artist_id = ar.id 482 JOIN artists ar ON a.artist_id = ar.id
338 WHERE ar.name = ?1 AND a.title = ?2 483 WHERE ar.name COLLATE NOCASE = ?1 AND a.title COLLATE NOCASE = ?2
339 ORDER BY t.track_number", 484 ORDER BY t.track_number",
340 )?; 485 )?;
341 let rows = stmt.query_map(rusqlite::params![artist, album], |row| { 486 let rows = stmt.query_map(rusqlite::params![artist, album], |row| {
@@ -367,7 +512,7 @@ impl Database {
367 FROM tracks t 512 FROM tracks t
368 JOIN albums a ON t.album_id = a.id 513 JOIN albums a ON t.album_id = a.id
369 JOIN artists ar ON a.artist_id = ar.id 514 JOIN artists ar ON a.artist_id = ar.id
370 WHERE ar.name = ?1 AND a.title = ?2 AND t.title LIKE ?3 515 WHERE ar.name COLLATE NOCASE = ?1 AND a.title COLLATE NOCASE = ?2 AND t.title LIKE ?3
371 ORDER BY t.track_number LIMIT 1", 516 ORDER BY t.track_number LIMIT 1",
372 )?; 517 )?;
373 let mut rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| { 518 let mut rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| {
@@ -462,11 +607,19 @@ pub fn extract_metadata(path: &Path) -> (String, String, String, Option<i32>, Op
462 .and_then(|s| s.to_str()) 607 .and_then(|s| s.to_str())
463 .unwrap_or("Unknown"); 608 .unwrap_or("Unknown");
464 609
465 ( 610 let fs_artist = grandparent.to_string();
466 grandparent.to_string(), 611 let fs_album = parent.to_string();
467 parent.to_string(), 612 let fs_title = file_stem.to_string();
468 file_stem.to_string(), 613
469 None, 614 if let Some(audio_meta) = crate::queue::extract_metadata(path) {
470 None, 615 (
471 ) 616 audio_meta.artist.unwrap_or(fs_artist),
617 audio_meta.album.unwrap_or(fs_album),
618 audio_meta.title.unwrap_or(fs_title),
619 audio_meta.track_number,
620 audio_meta.duration_secs.map(|d| d as u64),
621 )
622 } else {
623 (fs_artist, fs_album, fs_title, None, None)
624 }
472} 625}
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 adbf535..84eb663 100644
--- a/tests/database.rs
+++ b/tests/database.rs
@@ -114,115 +114,131 @@ fn seed_test_db(db: &Database) {
114fn test_complete_artists_prefix() { 114fn test_complete_artists_prefix() {
115 let db = Database::open_in_memory().unwrap(); 115 let db = Database::open_in_memory().unwrap();
116 seed_test_db(&db); 116 seed_test_db(&db);
117 let results = db.complete_artists("M").unwrap(); 117 let results = db.complete("M").unwrap();
118 assert_eq!(results.len(), 2); 118 assert!(results.contains(&"Metallica/".to_string()));
119 assert!(results.contains(&"Metallica".to_string())); 119 assert!(results.contains(&"Miles Davis/".to_string()));
120 assert!(results.contains(&"Miles Davis".to_string()));
121} 120}
122 121
123#[test] 122#[test]
124fn test_complete_artists_prefix_lowercase() { 123fn test_complete_artists_prefix_lowercase() {
125 let db = Database::open_in_memory().unwrap(); 124 let db = Database::open_in_memory().unwrap();
126 seed_test_db(&db); 125 seed_test_db(&db);
127 let results = db.complete_artists("m").unwrap(); 126 let results = db.complete("m").unwrap();
128 assert!( 127 assert!(
129 results.contains(&"Metallica".to_string()), 128 results.contains(&"Metallica/".to_string()),
130 "LIKE should be case-insensitive" 129 "LIKE should be case-insensitive"
131 ); 130 );
132} 131}
133 132
134#[test] 133#[test]
135fn test_complete_artists_no_match() { 134fn test_complete_no_match() {
136 let db = Database::open_in_memory().unwrap(); 135 let db = Database::open_in_memory().unwrap();
137 seed_test_db(&db); 136 seed_test_db(&db);
138 let results = db.complete_artists("Z").unwrap(); 137 let results = db.complete("Z").unwrap();
139 assert!(results.is_empty()); 138 assert!(results.is_empty());
140} 139}
141 140
142#[test] 141#[test]
143fn test_complete_artists_empty_prefix() { 142fn test_complete_empty_prefix() {
144 let db = Database::open_in_memory().unwrap(); 143 let db = Database::open_in_memory().unwrap();
145 seed_test_db(&db); 144 seed_test_db(&db);
146 let results = db.complete_artists("").unwrap(); 145 let results = db.complete("").unwrap();
147 assert_eq!(results.len(), 3); 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()));
148} 150}
149 151
150#[test] 152#[test]
151fn test_complete_albums_with_artist_prefix() { 153fn test_complete_albums_for_artist() {
152 let db = Database::open_in_memory().unwrap(); 154 let db = Database::open_in_memory().unwrap();
153 seed_test_db(&db); 155 seed_test_db(&db);
154 let results = db.complete_albums("Metallica/").unwrap(); 156 let results = db.complete("Metallica/").unwrap();
155 assert_eq!(results.len(), 2); 157 assert_eq!(results.len(), 2);
156 assert!(results.contains(&"Master of Puppets".to_string())); 158 assert!(results.contains(&"Metallica/Master of Puppets/".to_string()));
157 assert!(results.contains(&"Ride the Lightning".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()));
158} 169}
159 170
160#[test] 171#[test]
161fn test_complete_albums_with_album_prefix() { 172fn test_complete_albums_with_album_prefix() {
162 let db = Database::open_in_memory().unwrap(); 173 let db = Database::open_in_memory().unwrap();
163 seed_test_db(&db); 174 seed_test_db(&db);
164 let results = db.complete_albums("Metallica/R").unwrap(); 175 // "R" matches "Ride the Lightning" (prefix) and "Master of Puppets" (contains 'r')
165 assert_eq!(results.len(), 1); 176 let results = db.complete("Metallica/R").unwrap();
166 assert_eq!(results[0], "Ride the Lightning"); 177 assert_eq!(results.len(), 2);
178 assert_eq!(results[0], "Metallica/Ride the Lightning/");
179 assert_eq!(results[1], "Metallica/Master of Puppets/");
167} 180}
168 181
169#[test] 182#[test]
170fn test_complete_albums_unknown_artist() { 183fn test_complete_albums_substring_match() {
171 let db = Database::open_in_memory().unwrap(); 184 let db = Database::open_in_memory().unwrap();
172 seed_test_db(&db); 185 seed_test_db(&db);
173 let results = db.complete_albums("Unknown/").unwrap(); 186 // "ind" matches "Kind of Blue" (contains "ind")
174 assert!(results.is_empty()); 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/");
175} 190}
176 191
177#[test] 192#[test]
178fn test_complete_albums_no_slash() { 193fn test_complete_artists_substring_match() {
179 let db = Database::open_in_memory().unwrap(); 194 let db = Database::open_in_memory().unwrap();
180 seed_test_db(&db); 195 seed_test_db(&db);
181 let results = db.complete_albums("Metallica").unwrap(); 196 // "x" matches "Aphex Twin" (contains 'x')
182 assert!(results.is_empty(), "needs trailing slash"); 197 let results = db.complete("x").unwrap();
198 assert!(results.contains(&"Aphex Twin/".to_string()));
183} 199}
184 200
185#[test] 201#[test]
186fn test_complete_tracks_full_path() { 202fn test_complete_albums_unknown_artist() {
187 let db = Database::open_in_memory().unwrap(); 203 let db = Database::open_in_memory().unwrap();
188 seed_test_db(&db); 204 seed_test_db(&db);
189 let results = db.complete_tracks("Metallica/Master of Puppets/").unwrap(); 205 let results = db.complete("Unknown/").unwrap();
190 assert_eq!(results.len(), 2); 206 assert!(results.is_empty());
191 assert!(results.contains(&"Battery".to_string()));
192 assert!(results.contains(&"Master of Puppets".to_string()));
193} 207}
194 208
195#[test] 209#[test]
196fn test_complete_tracks_with_prefix() { 210fn test_complete_tracks_for_album() {
197 let db = Database::open_in_memory().unwrap(); 211 let db = Database::open_in_memory().unwrap();
198 seed_test_db(&db); 212 seed_test_db(&db);
199 let results = db.complete_tracks("Metallica/Master of Puppets/B").unwrap(); 213 let results = db.complete("Metallica/Master of Puppets/").unwrap();
200 assert_eq!(results.len(), 1); 214 assert_eq!(results.len(), 2);
201 assert_eq!(results[0], "Battery"); 215 assert!(results.contains(&"Metallica/Master of Puppets/Battery".to_string()));
216 assert!(results.contains(&"Metallica/Master of Puppets/Master of Puppets".to_string()));
202} 217}
203 218
204#[test] 219#[test]
205fn test_complete_tracks_unknown_album() { 220fn test_complete_tracks_with_prefix() {
206 let db = Database::open_in_memory().unwrap(); 221 let db = Database::open_in_memory().unwrap();
207 seed_test_db(&db); 222 seed_test_db(&db);
208 let results = db.complete_tracks("Metallica/Nonexistent/").unwrap(); 223 let results = db.complete("Metallica/Master of Puppets/B").unwrap();
209 assert!(results.is_empty()); 224 assert_eq!(results.len(), 1);
225 assert_eq!(results[0], "Metallica/Master of Puppets/Battery");
210} 226}
211 227
212#[test] 228#[test]
213fn test_complete_tracks_too_few_parts() { 229fn test_complete_tracks_unknown_album() {
214 let db = Database::open_in_memory().unwrap(); 230 let db = Database::open_in_memory().unwrap();
215 seed_test_db(&db); 231 seed_test_db(&db);
216 let results = db.complete_tracks("Metallica").unwrap(); 232 let results = db.complete("Metallica/Nonexistent/").unwrap();
217 assert!(results.is_empty()); 233 assert!(results.is_empty());
218} 234}
219 235
220#[test] 236#[test]
221fn test_complete_empty_db() { 237fn test_complete_empty_db() {
222 let db = Database::open_in_memory().unwrap(); 238 let db = Database::open_in_memory().unwrap();
223 assert!(db.complete_artists("").unwrap().is_empty()); 239 assert!(db.complete("").unwrap().is_empty());
224 assert!(db.complete_albums("x/").unwrap().is_empty()); 240 assert!(db.complete("x/").unwrap().is_empty());
225 assert!(db.complete_tracks("x/y/").unwrap().is_empty()); 241 assert!(db.complete("x/y/").unwrap().is_empty());
226} 242}
227 243
228// --- find_tracks_by_album tests --- 244// --- find_tracks_by_album tests ---
@@ -271,17 +287,18 @@ fn test_find_tracks_by_album_unknown_album() {
271} 287}
272 288
273#[test] 289#[test]
274fn test_find_tracks_by_album_case_sensitive_artist() { 290fn test_find_tracks_by_album_case_insensitive_artist() {
275 let db = Database::open_in_memory().unwrap(); 291 let db = Database::open_in_memory().unwrap();
276 seed_test_db(&db); 292 seed_test_db(&db);
277 // SQLite = is case-sensitive for TEXT (unless NOCASE collation)
278 let tracks = db 293 let tracks = db
279 .find_tracks_by_album("metallica", "Master of Puppets") 294 .find_tracks_by_album("metallica", "Master of Puppets")
280 .unwrap(); 295 .unwrap();
281 assert!( 296 assert_eq!(
282 tracks.is_empty(), 297 tracks.len(),
283 "artist name match should be case-sensitive" 298 2,
299 "artist name match should be case-insensitive"
284 ); 300 );
301 assert_eq!(tracks[0].title, "Battery");
285} 302}
286 303
287#[test] 304#[test]
@@ -353,13 +370,14 @@ fn test_find_track_by_parts_no_match_artist() {
353} 370}
354 371
355#[test] 372#[test]
356fn test_find_track_by_parts_case_sensitive() { 373fn test_find_track_by_parts_case_insensitive() {
357 let db = Database::open_in_memory().unwrap(); 374 let db = Database::open_in_memory().unwrap();
358 seed_test_db(&db); 375 seed_test_db(&db);
359 let track = db 376 let track = db
360 .find_track_by_parts("metallica", "Master of Puppets", "Battery") 377 .find_track_by_parts("metallica", "Master of Puppets", "Battery")
361 .unwrap(); 378 .unwrap();
362 assert!(track.is_none(), "artist match should be case-sensitive"); 379 assert!(track.is_some(), "artist match should be case-insensitive");
380 assert_eq!(track.unwrap().title, "Battery");
363} 381}
364 382
365// --- resolve_path tests --- 383// --- resolve_path tests ---
diff --git a/tests/scrobbler.rs b/tests/scrobbler.rs
index a4b3fda..237a024 100644
--- a/tests/scrobbler.rs
+++ b/tests/scrobbler.rs
@@ -5,6 +5,8 @@
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}