diff options
| author | salmonik <salmonik@noreply.codeberg.org> | 2026-06-06 19:07:09 +0200 |
|---|---|---|
| committer | salmonik <salmonik@noreply.codeberg.org> | 2026-06-06 19:07:09 +0200 |
| commit | 385c2a38eba689154b46d515f6a3a010c34d3119 (patch) | |
| tree | 8916681bafc9822ef371f949a176cf22307d2a2e /src/database.rs | |
| parent | add verbosity to `rsp scan` (diff) | |
| parent | delete not needed md file (diff) | |
| download | rsplayer-385c2a38eba689154b46d515f6a3a010c34d3119.tar.gz rsplayer-385c2a38eba689154b46d515f6a3a010c34d3119.zip | |
Merge pull request 'autocompletion' (#1) from autocompletion into main
Reviewed-on: https://codeberg.org/salmonik/RSPlayer/pulls/1
Diffstat (limited to 'src/database.rs')
| -rw-r--r-- | src/database.rs | 305 |
1 files changed, 297 insertions, 8 deletions
diff --git a/src/database.rs b/src/database.rs index 7fd1797..e6f6f6e 100644 --- a/src/database.rs +++ b/src/database.rs | |||
| @@ -45,6 +45,10 @@ pub struct Database { | |||
| 45 | 45 | ||
| 46 | #[allow(dead_code)] | 46 | #[allow(dead_code)] |
| 47 | impl Database { | 47 | impl Database { |
| 48 | pub fn conn(&self) -> &Connection { | ||
| 49 | &self.conn | ||
| 50 | } | ||
| 51 | |||
| 48 | pub fn open(_config: &crate::config::Config) -> SqlResult<Self> { | 52 | pub fn open(_config: &crate::config::Config) -> SqlResult<Self> { |
| 49 | let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { | 53 | let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { |
| 50 | rusqlite::Error::InvalidPath("Failed to get project directories".into()) | 54 | rusqlite::Error::InvalidPath("Failed to get project directories".into()) |
| @@ -59,7 +63,29 @@ impl Database { | |||
| 59 | fs::create_dir_all(&data_dir).ok(); | 63 | fs::create_dir_all(&data_dir).ok(); |
| 60 | let db_path = data_dir.join("library.db"); | 64 | let db_path = data_dir.join("library.db"); |
| 61 | 65 | ||
| 62 | let conn = Connection::open(&db_path)?; | 66 | let debug_log = std::env::temp_dir().join("rsp-completion-debug.log"); |
| 67 | macro_rules! debug_log { | ||
| 68 | ($($arg:tt)*) => { | ||
| 69 | let _ = std::fs::OpenOptions::new() | ||
| 70 | .create(true) | ||
| 71 | .append(true) | ||
| 72 | .open(&debug_log) | ||
| 73 | .and_then(|mut f| { | ||
| 74 | use std::io::Write; | ||
| 75 | writeln!(f, $($arg)*) | ||
| 76 | }); | ||
| 77 | }; | ||
| 78 | } | ||
| 79 | debug_log!("[Database::open] db_path: {:?}", db_path); | ||
| 80 | debug_log!("[Database::open] db exists: {}", db_path.exists()); | ||
| 81 | |||
| 82 | let conn = match Connection::open(&db_path) { | ||
| 83 | Ok(c) => c, | ||
| 84 | Err(e) => { | ||
| 85 | debug_log!("[Database::open] Connection::open failed: {e}"); | ||
| 86 | return Err(e); | ||
| 87 | } | ||
| 88 | }; | ||
| 63 | let db = Database { conn }; | 89 | let db = Database { conn }; |
| 64 | db.init_schema()?; | 90 | db.init_schema()?; |
| 65 | Ok(db) | 91 | Ok(db) |
| @@ -279,6 +305,261 @@ impl Database { | |||
| 279 | self.get_album_tracks_after(album_id, track_number, &path) | 305 | self.get_album_tracks_after(album_id, track_number, &path) |
| 280 | } | 306 | } |
| 281 | 307 | ||
| 308 | pub fn complete(&self, query: &str) -> SqlResult<Vec<String>> { | ||
| 309 | let mut results: Vec<String> = Vec::new(); | ||
| 310 | |||
| 311 | if query.is_empty() { | ||
| 312 | let mut stmt = self | ||
| 313 | .conn | ||
| 314 | .prepare("SELECT name FROM artists ORDER BY name LIMIT 25")?; | ||
| 315 | let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; | ||
| 316 | for name in rows.flatten() { | ||
| 317 | results.push(format!("{name}/")); | ||
| 318 | } | ||
| 319 | return Ok(results); | ||
| 320 | } | ||
| 321 | |||
| 322 | let has_trailing_slash = query.ends_with('/'); | ||
| 323 | |||
| 324 | if has_trailing_slash { | ||
| 325 | let prefix = query.trim_end_matches('/'); | ||
| 326 | let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect(); | ||
| 327 | |||
| 328 | match segments.len() { | ||
| 329 | 1 => { | ||
| 330 | let artist = segments[0]; | ||
| 331 | let mut stmt = self.conn.prepare( | ||
| 332 | "SELECT a.title FROM albums a | ||
| 333 | JOIN artists ar ON a.artist_id = ar.id | ||
| 334 | WHERE ar.name LIKE ?1 | ||
| 335 | ORDER BY a.title LIMIT 25", | ||
| 336 | )?; | ||
| 337 | let rows = stmt.query_map([artist], |row| row.get::<_, String>(0))?; | ||
| 338 | for album in rows.flatten() { | ||
| 339 | results.push(format!("{artist}/{album}/")); | ||
| 340 | } | ||
| 341 | } | ||
| 342 | 2 => { | ||
| 343 | let artist = segments[0]; | ||
| 344 | let album = segments[1]; | ||
| 345 | let mut stmt = self.conn.prepare( | ||
| 346 | "SELECT t.title FROM tracks t | ||
| 347 | JOIN albums a ON t.album_id = a.id | ||
| 348 | JOIN artists ar ON a.artist_id = ar.id | ||
| 349 | WHERE ar.name LIKE ?1 AND a.title LIKE ?2 | ||
| 350 | ORDER BY t.track_number, t.title LIMIT 25", | ||
| 351 | )?; | ||
| 352 | let rows = stmt.query_map(rusqlite::params![artist, album], |row| { | ||
| 353 | row.get::<_, String>(0) | ||
| 354 | })?; | ||
| 355 | for track in rows.flatten() { | ||
| 356 | results.push(format!("{artist}/{album}/{track}")); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | _ => {} | ||
| 360 | } | ||
| 361 | } else { | ||
| 362 | let segments: Vec<&str> = query.split('/').collect(); | ||
| 363 | |||
| 364 | match segments.len() { | ||
| 365 | 1 => { | ||
| 366 | let prefix = segments[0]; | ||
| 367 | let like_pattern = format!("%{prefix}%"); | ||
| 368 | let prefix_pattern = format!("{prefix}%"); | ||
| 369 | |||
| 370 | let mut stmt = self.conn.prepare( | ||
| 371 | "SELECT name FROM artists | ||
| 372 | WHERE name LIKE ?1 | ||
| 373 | ORDER BY | ||
| 374 | CASE WHEN name LIKE ?2 THEN 0 ELSE 1 END, | ||
| 375 | name | ||
| 376 | LIMIT 25", | ||
| 377 | )?; | ||
| 378 | let rows = stmt | ||
| 379 | .query_map(rusqlite::params![&like_pattern, &prefix_pattern], |row| { | ||
| 380 | row.get::<_, String>(0) | ||
| 381 | })?; | ||
| 382 | for name in rows.flatten() { | ||
| 383 | results.push(format!("{name}/")); | ||
| 384 | } | ||
| 385 | } | ||
| 386 | 2 => { | ||
| 387 | let artist = segments[0]; | ||
| 388 | let album_prefix = segments[1]; | ||
| 389 | if artist.is_empty() { | ||
| 390 | return Ok(results); | ||
| 391 | } | ||
| 392 | |||
| 393 | let artist_pattern = format!("%{artist}%"); | ||
| 394 | let artist_prefix = format!("{artist}%"); | ||
| 395 | let album_pattern = format!("%{album_prefix}%"); | ||
| 396 | let album_prefix_pattern = format!("{album_prefix}%"); | ||
| 397 | |||
| 398 | let mut stmt = self.conn.prepare( | ||
| 399 | "SELECT ar.name, a.title FROM albums a | ||
| 400 | JOIN artists ar ON a.artist_id = ar.id | ||
| 401 | WHERE ar.name LIKE ?1 AND a.title LIKE ?2 | ||
| 402 | ORDER BY | ||
| 403 | CASE WHEN ar.name LIKE ?3 THEN 0 ELSE 1 END, | ||
| 404 | CASE WHEN a.title LIKE ?4 THEN 0 ELSE 1 END, | ||
| 405 | ar.name, a.title | ||
| 406 | LIMIT 25", | ||
| 407 | )?; | ||
| 408 | let rows = stmt.query_map( | ||
| 409 | rusqlite::params![ | ||
| 410 | &artist_pattern, | ||
| 411 | &album_pattern, | ||
| 412 | &artist_prefix, | ||
| 413 | &album_prefix_pattern, | ||
| 414 | ], | ||
| 415 | |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), | ||
| 416 | )?; | ||
| 417 | for (name, album) in rows.flatten() { | ||
| 418 | results.push(format!("{name}/{album}/")); | ||
| 419 | } | ||
| 420 | } | ||
| 421 | 3 => { | ||
| 422 | let artist = segments[0]; | ||
| 423 | let album = segments[1]; | ||
| 424 | let track_prefix = segments[2]; | ||
| 425 | if artist.is_empty() || album.is_empty() { | ||
| 426 | return Ok(results); | ||
| 427 | } | ||
| 428 | |||
| 429 | let artist_pattern = format!("%{artist}%"); | ||
| 430 | let artist_prefix = format!("{artist}%"); | ||
| 431 | let album_pattern = format!("%{album}%"); | ||
| 432 | let album_prefix = format!("{album}%"); | ||
| 433 | let track_pattern = format!("%{track_prefix}%"); | ||
| 434 | let track_prefix_pattern = format!("{track_prefix}%"); | ||
| 435 | |||
| 436 | let mut stmt = self.conn.prepare( | ||
| 437 | "SELECT ar.name, a.title, t.title FROM tracks t | ||
| 438 | JOIN albums a ON t.album_id = a.id | ||
| 439 | JOIN artists ar ON a.artist_id = ar.id | ||
| 440 | WHERE ar.name LIKE ?1 AND a.title LIKE ?2 AND t.title LIKE ?3 | ||
| 441 | ORDER BY | ||
| 442 | CASE WHEN ar.name LIKE ?4 THEN 0 ELSE 1 END, | ||
| 443 | CASE WHEN a.title LIKE ?5 THEN 0 ELSE 1 END, | ||
| 444 | CASE WHEN t.title LIKE ?6 THEN 0 ELSE 1 END, | ||
| 445 | t.track_number, t.title | ||
| 446 | LIMIT 25", | ||
| 447 | )?; | ||
| 448 | let rows = stmt.query_map( | ||
| 449 | rusqlite::params![ | ||
| 450 | &artist_pattern, | ||
| 451 | &album_pattern, | ||
| 452 | &track_pattern, | ||
| 453 | &artist_prefix, | ||
| 454 | &album_prefix, | ||
| 455 | &track_prefix_pattern, | ||
| 456 | ], | ||
| 457 | |row| { | ||
| 458 | Ok(( | ||
| 459 | row.get::<_, String>(0)?, | ||
| 460 | row.get::<_, String>(1)?, | ||
| 461 | row.get::<_, String>(2)?, | ||
| 462 | )) | ||
| 463 | }, | ||
| 464 | )?; | ||
| 465 | for (name, album_title, track) in rows.flatten() { | ||
| 466 | results.push(format!("{name}/{album_title}/{track}")); | ||
| 467 | } | ||
| 468 | } | ||
| 469 | _ => {} | ||
| 470 | } | ||
| 471 | } | ||
| 472 | |||
| 473 | results.truncate(25); | ||
| 474 | |||
| 475 | Ok(results) | ||
| 476 | } | ||
| 477 | |||
| 478 | #[allow(dead_code)] | ||
| 479 | pub fn open_in_memory() -> SqlResult<Database> { | ||
| 480 | let conn = Connection::open_in_memory()?; | ||
| 481 | let db = Database { conn }; | ||
| 482 | db.init_schema()?; | ||
| 483 | Ok(db) | ||
| 484 | } | ||
| 485 | |||
| 486 | /// Find all tracks in an album by case-insensitive artist and album match. | ||
| 487 | pub fn find_tracks_by_album(&self, artist: &str, album: &str) -> SqlResult<Vec<Track>> { | ||
| 488 | let mut stmt = self.conn.prepare( | ||
| 489 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | ||
| 490 | FROM tracks t | ||
| 491 | JOIN albums a ON t.album_id = a.id | ||
| 492 | JOIN artists ar ON a.artist_id = ar.id | ||
| 493 | WHERE ar.name COLLATE NOCASE = ?1 AND a.title COLLATE NOCASE = ?2 | ||
| 494 | ORDER BY t.track_number", | ||
| 495 | )?; | ||
| 496 | let rows = stmt.query_map(rusqlite::params![artist, album], |row| { | ||
| 497 | Ok(Track { | ||
| 498 | id: row.get(0)?, | ||
| 499 | album_id: row.get(1)?, | ||
| 500 | title: row.get(2)?, | ||
| 501 | path: PathBuf::from(row.get::<_, String>(3)?), | ||
| 502 | duration: row.get(4)?, | ||
| 503 | track_number: row.get(5)?, | ||
| 504 | artist_name: Some(row.get(6)?), | ||
| 505 | album_title: Some(row.get(7)?), | ||
| 506 | }) | ||
| 507 | })?; | ||
| 508 | rows.collect() | ||
| 509 | } | ||
| 510 | |||
| 511 | /// Find a single track by exact artist, exact album, and track title prefix. | ||
| 512 | /// Returns None if no match found. | ||
| 513 | pub fn find_track_by_parts( | ||
| 514 | &self, | ||
| 515 | artist: &str, | ||
| 516 | album: &str, | ||
| 517 | track_prefix: &str, | ||
| 518 | ) -> SqlResult<Option<Track>> { | ||
| 519 | let pattern = format!("{}%", track_prefix); | ||
| 520 | let mut stmt = self.conn.prepare( | ||
| 521 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | ||
| 522 | FROM tracks t | ||
| 523 | JOIN albums a ON t.album_id = a.id | ||
| 524 | JOIN artists ar ON a.artist_id = ar.id | ||
| 525 | WHERE ar.name COLLATE NOCASE = ?1 AND a.title COLLATE NOCASE = ?2 AND t.title LIKE ?3 | ||
| 526 | ORDER BY t.track_number LIMIT 1", | ||
| 527 | )?; | ||
| 528 | let mut rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| { | ||
| 529 | Ok(Track { | ||
| 530 | id: row.get(0)?, | ||
| 531 | album_id: row.get(1)?, | ||
| 532 | title: row.get(2)?, | ||
| 533 | path: PathBuf::from(row.get::<_, String>(3)?), | ||
| 534 | duration: row.get(4)?, | ||
| 535 | track_number: row.get(5)?, | ||
| 536 | artist_name: Some(row.get(6)?), | ||
| 537 | album_title: Some(row.get(7)?), | ||
| 538 | }) | ||
| 539 | })?; | ||
| 540 | match rows.next() { | ||
| 541 | Some(Ok(t)) => Ok(Some(t)), | ||
| 542 | Some(Err(e)) => Err(e), | ||
| 543 | None => Ok(None), | ||
| 544 | } | ||
| 545 | } | ||
| 546 | |||
| 547 | /// Resolve a hierarchical path like "Artist/Album" or "Artist/Album/Track" | ||
| 548 | /// to DB tracks. Returns None if the path isn't hierarchical (single part | ||
| 549 | /// or 4+ parts) — the caller should fall back to search_tracks(). | ||
| 550 | pub fn resolve_path(&self, path: &str) -> SqlResult<Option<Vec<Track>>> { | ||
| 551 | let path = path.strip_suffix('/').unwrap_or(path); | ||
| 552 | let parts: Vec<&str> = path.splitn(3, '/').collect(); | ||
| 553 | match parts.len() { | ||
| 554 | 2 => Ok(Some(self.find_tracks_by_album(parts[0], parts[1])?)), | ||
| 555 | 3 => match self.find_track_by_parts(parts[0], parts[1], parts[2])? { | ||
| 556 | Some(t) => Ok(Some(vec![t])), | ||
| 557 | None => Ok(Some(vec![])), | ||
| 558 | }, | ||
| 559 | _ => Ok(None), | ||
| 560 | } | ||
| 561 | } | ||
| 562 | |||
| 282 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { | 563 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { |
| 283 | let mut stmt = self.conn.prepare( | 564 | let mut stmt = self.conn.prepare( |
| 284 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | 565 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title |
| @@ -336,11 +617,19 @@ pub fn extract_metadata(path: &Path) -> (String, String, String, Option<i32>, Op | |||
| 336 | .and_then(|s| s.to_str()) | 617 | .and_then(|s| s.to_str()) |
| 337 | .unwrap_or("Unknown"); | 618 | .unwrap_or("Unknown"); |
| 338 | 619 | ||
| 339 | ( | 620 | let fs_artist = grandparent.to_string(); |
| 340 | grandparent.to_string(), | 621 | let fs_album = parent.to_string(); |
| 341 | parent.to_string(), | 622 | let fs_title = file_stem.to_string(); |
| 342 | file_stem.to_string(), | 623 | |
| 343 | None, | 624 | if let Some(audio_meta) = crate::queue::extract_metadata(path) { |
| 344 | None, | 625 | ( |
| 345 | ) | 626 | audio_meta.artist.unwrap_or(fs_artist), |
| 627 | audio_meta.album.unwrap_or(fs_album), | ||
| 628 | audio_meta.title.unwrap_or(fs_title), | ||
| 629 | audio_meta.track_number, | ||
| 630 | audio_meta.duration_secs.map(|d| d as u64), | ||
| 631 | ) | ||
| 632 | } else { | ||
| 633 | (fs_artist, fs_album, fs_title, None, None) | ||
| 634 | } | ||
| 346 | } | 635 | } |
