aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
5 files changed, 275 insertions, 176 deletions
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}