diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.rs | 43 | ||||
| -rw-r--r-- | src/daemon.rs | 113 | ||||
| -rw-r--r-- | src/database.rs | 259 | ||||
| -rw-r--r-- | src/queue.rs | 14 | ||||
| -rw-r--r-- | src/scrobbler.rs | 22 |
5 files changed, 275 insertions, 176 deletions
| @@ -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}; | |||
| 18 | use std::thread; | 18 | use std::thread; |
| 19 | use std::time::{Duration, Instant, SystemTime}; | 19 | use std::time::{Duration, Instant, SystemTime}; |
| 20 | 20 | ||
| 21 | struct ScrobbleState { | 21 | pub struct ScrobbleState { |
| 22 | track: Option<Track>, | 22 | pub track: Option<Track>, |
| 23 | start_time: Option<Instant>, | 23 | pub start_time: Option<Instant>, |
| 24 | timestamp: Option<u64>, | 24 | pub timestamp: Option<u64>, |
| 25 | } | ||
| 26 | |||
| 27 | impl Default for ScrobbleState { | ||
| 28 | fn default() -> Self { | ||
| 29 | Self::new() | ||
| 30 | } | ||
| 25 | } | 31 | } |
| 26 | 32 | ||
| 27 | impl ScrobbleState { | 33 | impl ScrobbleState { |
| 28 | fn new() -> Self { | 34 | pub fn new() -> Self { |
| 29 | ScrobbleState { | 35 | ScrobbleState { |
| 30 | track: None, | 36 | track: None, |
| 31 | start_time: None, | 37 | start_time: None, |
| @@ -33,7 +39,7 @@ impl ScrobbleState { | |||
| 33 | } | 39 | } |
| 34 | } | 40 | } |
| 35 | 41 | ||
| 36 | fn start(&mut self, track: Track) { | 42 | pub fn start(&mut self, track: Track) { |
| 37 | if track.is_stream { | 43 | if track.is_stream { |
| 38 | crate::debug_eprintln(format_args!( | 44 | crate::debug_eprintln(format_args!( |
| 39 | "scrobble: not tracking stream '{}'", | 45 | "scrobble: not tracking stream '{}'", |
| @@ -57,7 +63,7 @@ impl ScrobbleState { | |||
| 57 | self.timestamp = Some(ts); | 63 | self.timestamp = Some(ts); |
| 58 | } | 64 | } |
| 59 | 65 | ||
| 60 | fn should_scrobble(&self) -> bool { | 66 | pub fn should_scrobble(&self) -> bool { |
| 61 | let (Some(track), Some(start)) = (&self.track, self.start_time) else { | 67 | let (Some(track), Some(start)) = (&self.track, self.start_time) else { |
| 62 | crate::debug_eprintln(format_args!( | 68 | crate::debug_eprintln(format_args!( |
| 63 | "scrobble: should_scrobble false — no track or start_time" | 69 | "scrobble: should_scrobble false — no track or start_time" |
| @@ -78,7 +84,7 @@ impl ScrobbleState { | |||
| 78 | result | 84 | result |
| 79 | } | 85 | } |
| 80 | 86 | ||
| 81 | fn should_scrobble_with_params(elapsed: u64, duration_secs: Option<f64>) -> bool { | 87 | pub fn should_scrobble_with_params(elapsed: u64, duration_secs: Option<f64>) -> bool { |
| 82 | if elapsed < 30 { | 88 | if elapsed < 30 { |
| 83 | return false; | 89 | return false; |
| 84 | } | 90 | } |
| @@ -89,86 +95,13 @@ impl ScrobbleState { | |||
| 89 | elapsed >= threshold | 95 | elapsed >= threshold |
| 90 | } | 96 | } |
| 91 | 97 | ||
| 92 | fn clear(&mut self) { | 98 | pub fn clear(&mut self) { |
| 93 | self.track = None; | 99 | self.track = None; |
| 94 | self.start_time = None; | 100 | self.start_time = None; |
| 95 | self.timestamp = None; | 101 | self.timestamp = None; |
| 96 | } | 102 | } |
| 97 | } | 103 | } |
| 98 | 104 | ||
| 99 | #[cfg(test)] | ||
| 100 | mod tests { | ||
| 101 | use super::*; | ||
| 102 | |||
| 103 | #[test] | ||
| 104 | fn test_scrobble_state_new() { | ||
| 105 | let s = ScrobbleState::new(); | ||
| 106 | assert!(s.track.is_none()); | ||
| 107 | assert!(s.start_time.is_none()); | ||
| 108 | assert!(s.timestamp.is_none()); | ||
| 109 | } | ||
| 110 | |||
| 111 | #[test] | ||
| 112 | fn test_scrobble_state_clear() { | ||
| 113 | let mut s = ScrobbleState::new(); | ||
| 114 | let t = Track { | ||
| 115 | path: PathBuf::from("/test/song.mp3"), | ||
| 116 | title: Some("Test".into()), | ||
| 117 | artist: Some("Artist".into()), | ||
| 118 | album: None, | ||
| 119 | duration_secs: Some(200.0), | ||
| 120 | is_stream: false, | ||
| 121 | }; | ||
| 122 | s.start(t); | ||
| 123 | assert!(s.track.is_some()); | ||
| 124 | s.clear(); | ||
| 125 | assert!(s.track.is_none()); | ||
| 126 | assert!(s.start_time.is_none()); | ||
| 127 | assert!(s.timestamp.is_none()); | ||
| 128 | } | ||
| 129 | |||
| 130 | #[test] | ||
| 131 | fn test_should_scrobble_without_track_returns_false() { | ||
| 132 | assert!(!ScrobbleState::new().should_scrobble()); | ||
| 133 | } | ||
| 134 | |||
| 135 | #[test] | ||
| 136 | fn test_should_scrobble_threshold_below_30_seconds() { | ||
| 137 | assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0))); | ||
| 138 | } | ||
| 139 | |||
| 140 | #[test] | ||
| 141 | fn test_should_scrobble_threshold_half_duration() { | ||
| 142 | assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0))); | ||
| 143 | } | ||
| 144 | |||
| 145 | #[test] | ||
| 146 | fn test_should_scrobble_threshold_below_half_duration() { | ||
| 147 | assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0))); | ||
| 148 | } | ||
| 149 | |||
| 150 | #[test] | ||
| 151 | fn test_should_scrobble_threshold_capped_at_4min() { | ||
| 152 | assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0))); | ||
| 153 | assert!(!ScrobbleState::should_scrobble_with_params( | ||
| 154 | 239, | ||
| 155 | Some(600.0) | ||
| 156 | )); | ||
| 157 | } | ||
| 158 | |||
| 159 | #[test] | ||
| 160 | fn test_should_scrobble_no_duration_requires_4min() { | ||
| 161 | assert!(ScrobbleState::should_scrobble_with_params(240, None)); | ||
| 162 | assert!(!ScrobbleState::should_scrobble_with_params(239, None)); | ||
| 163 | } | ||
| 164 | |||
| 165 | #[test] | ||
| 166 | fn test_should_scrobble_short_song() { | ||
| 167 | assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0))); | ||
| 168 | assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0))); | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { | 105 | fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { |
| 173 | if let Some(ref track) = state.track | 106 | if let Some(ref track) = state.track |
| 174 | && track.is_stream | 107 | && track.is_stream |
| @@ -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)] | ||
| 338 | mod tests { | ||
| 339 | use super::*; | ||
| 340 | |||
| 341 | #[test] | ||
| 342 | fn test_sign_lastfm() { | ||
| 343 | let params = BTreeMap::from([ | ||
| 344 | ("api_key", "abc123"), | ||
| 345 | ("method", "track.scrobble"), | ||
| 346 | ("sk", "session_key"), | ||
| 347 | ("track", "Test Track"), | ||
| 348 | ("artist", "Test Artist"), | ||
| 349 | ("timestamp", "1234567890"), | ||
| 350 | ]); | ||
| 351 | let sig = Scrobbler::sign_lastfm(¶ms, "sekrit"); | ||
| 352 | assert_eq!(sig.len(), 32); | ||
| 353 | assert!(sig.chars().all(|c| c.is_ascii_hexdigit())); | ||
| 354 | } | ||
| 355 | } | ||
