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