From bd29978b2a0d0d6def486fcdaa63bc8934d68045 Mon Sep 17 00:00:00 2001 From: salmonik Date: Tue, 5 May 2026 21:12:32 +0200 Subject: fix(daemon): robust auto-advance; ensure next track starts even if playback fails --- src/database.rs | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 src/database.rs (limited to 'src/database.rs') diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..9d1d5f2 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,259 @@ +use directories::ProjectDirs; +use rusqlite::{Connection, Result as SqlResult}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Artist { + pub id: i64, + pub name: String, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Album { + pub id: i64, + pub artist_id: i64, + pub title: String, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Track { + pub id: i64, + pub album_id: i64, + pub title: String, + pub path: PathBuf, + pub duration: Option, + pub track_number: Option, + pub artist_name: Option, + pub album_title: Option, +} + +pub struct Database { + conn: Connection, +} + +#[allow(dead_code)] +impl Database { + pub fn open(_config: &crate::config::Config) -> SqlResult { + let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { + rusqlite::Error::InvalidPath("Failed to get project directories".into()) + })?; + + let data_dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg).join("rsplayer") + } else { + proj_dirs.data_dir().to_path_buf() + }; + + fs::create_dir_all(&data_dir).ok(); + let db_path = data_dir.join("library.db"); + + let conn = Connection::open(&db_path)?; + let db = Database { conn }; + db.init_schema()?; + Ok(db) + } + + fn init_schema(&self) -> SqlResult<()> { + self.conn.execute( + "CREATE TABLE IF NOT EXISTS artists ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL + )", + [], + )?; + + self.conn.execute( + "CREATE TABLE IF NOT EXISTS albums ( + id INTEGER PRIMARY KEY, + artist_id INTEGER NOT NULL, + title TEXT NOT NULL, + FOREIGN KEY (artist_id) REFERENCES artists(id), + UNIQUE(artist_id, title) + )", + [], + )?; + + self.conn.execute( + "CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY, + album_id INTEGER NOT NULL, + title TEXT NOT NULL, + path TEXT UNIQUE NOT NULL, + duration INTEGER, + track_number INTEGER, + last_scanned INTEGER, + FOREIGN KEY (album_id) REFERENCES albums(id) + )", + [], + )?; + + Ok(()) + } + + pub fn scan_paths(&self, paths: &[PathBuf], force: bool) -> SqlResult<()> { + for path in paths { + self.scan_directory(path, force)?; + } + Ok(()) + } + + fn scan_directory(&self, dir: &Path, force: bool) -> SqlResult<()> { + if !dir.exists() { + return Ok(()); + } + + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + self.scan_recursive(dir, now, force)?; + self.cleanup_old_tracks(now)?; + Ok(()) + } + + fn scan_recursive(&self, dir: &Path, scan_time: i64, force: bool) -> SqlResult<()> { + let entries = fs::read_dir(dir).ok(); + if let Some(entries) = entries { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + self.scan_recursive(&path, scan_time, force)?; + } else if is_audio_file(&path) { + self.process_audio_file(&path, scan_time, force)?; + } + } + } + Ok(()) + } + + fn process_audio_file(&self, path: &Path, scan_time: i64, force: bool) -> SqlResult<()> { + let path_str = path.to_string_lossy().to_string(); + + if !force { + let count: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM tracks WHERE path = ? AND last_scanned = ?", + rusqlite::params![path_str, scan_time], + |row| row.get(0), + ) + .unwrap_or(0); + if count > 0 { + return Ok(()); + } + } + + let metadata = extract_metadata(path); + let (artist_name, album_title, track_title, track_number, duration) = metadata; + let duration_i64 = duration.map(|d| d as i64); + + let artist_id = self.get_or_create_artist(&artist_name)?; + let album_id = self.get_or_create_album(artist_id, &album_title)?; + + self.conn.execute( + "INSERT OR REPLACE INTO tracks (album_id, title, path, duration, track_number, last_scanned) + VALUES (?, ?, ?, ?, ?, ?)", + rusqlite::params![album_id, track_title, path_str, duration_i64, track_number, scan_time], + )?; + + Ok(()) + } + + fn get_or_create_artist(&self, name: &str) -> SqlResult { + self.conn + .execute("INSERT OR IGNORE INTO artists (name) VALUES (?)", [name])?; + self.conn + .query_row("SELECT id FROM artists WHERE name = ?", [name], |row| { + row.get(0) + }) + } + + fn get_or_create_album(&self, artist_id: i64, title: &str) -> SqlResult { + self.conn.execute( + "INSERT OR IGNORE INTO albums (artist_id, title) VALUES (?, ?)", + rusqlite::params![artist_id, title], + )?; + self.conn.query_row( + "SELECT id FROM albums WHERE artist_id = ? AND title = ?", + rusqlite::params![artist_id, title], + |row| row.get(0), + ) + } + + fn cleanup_old_tracks(&self, scan_time: i64) -> SqlResult<()> { + self.conn + .execute("DELETE FROM tracks WHERE last_scanned < ?", [scan_time])?; + Ok(()) + } + + pub fn search_tracks(&self, query: &str) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title + FROM tracks t + JOIN albums a ON t.album_id = a.id + JOIN artists ar ON a.artist_id = ar.id + WHERE t.title LIKE ? OR a.title LIKE ? OR ar.name LIKE ? + ORDER BY ar.name, a.title, t.track_number", + )?; + + let search = format!("%{}%", query); + let rows = stmt.query_map([&search, &search, &search], |row| { + Ok(Track { + id: row.get(0)?, + album_id: row.get(1)?, + title: row.get(2)?, + path: PathBuf::from(row.get::<_, String>(3)?), + duration: row.get(4)?, + track_number: row.get(5)?, + artist_name: Some(row.get(6)?), + album_title: Some(row.get(7)?), + }) + })?; + + rows.collect() + } +} + +fn is_audio_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + matches!( + ext.to_lowercase().as_str(), + "mp3" | "flac" | "ogg" | "m4a" | "wav" | "opus" + ) + }) + .unwrap_or(false) +} + +fn extract_metadata(path: &Path) -> (String, String, String, Option, Option) { + let file_stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown"); + let parent = path + .parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + .unwrap_or("Unknown"); + let grandparent = path + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + .unwrap_or("Unknown"); + + ( + grandparent.to_string(), + parent.to_string(), + file_stem.to_string(), + None, + None, + ) +} -- cgit v1.2.3