diff options
Diffstat (limited to 'src/database.rs')
| -rw-r--r-- | src/database.rs | 259 |
1 files changed, 259 insertions, 0 deletions
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 @@ | |||
| 1 | use directories::ProjectDirs; | ||
| 2 | use rusqlite::{Connection, Result as SqlResult}; | ||
| 3 | use std::fs; | ||
| 4 | use std::path::{Path, PathBuf}; | ||
| 5 | use std::time::SystemTime; | ||
| 6 | |||
| 7 | #[derive(Debug, Clone)] | ||
| 8 | #[allow(dead_code)] | ||
| 9 | pub struct Artist { | ||
| 10 | pub id: i64, | ||
| 11 | pub name: String, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Debug, Clone)] | ||
| 15 | #[allow(dead_code)] | ||
| 16 | pub struct Album { | ||
| 17 | pub id: i64, | ||
| 18 | pub artist_id: i64, | ||
| 19 | pub title: String, | ||
| 20 | } | ||
| 21 | |||
| 22 | #[derive(Debug, Clone)] | ||
| 23 | #[allow(dead_code)] | ||
| 24 | pub struct Track { | ||
| 25 | pub id: i64, | ||
| 26 | pub album_id: i64, | ||
| 27 | pub title: String, | ||
| 28 | pub path: PathBuf, | ||
| 29 | pub duration: Option<i64>, | ||
| 30 | pub track_number: Option<i32>, | ||
| 31 | pub artist_name: Option<String>, | ||
| 32 | pub album_title: Option<String>, | ||
| 33 | } | ||
| 34 | |||
| 35 | pub struct Database { | ||
| 36 | conn: Connection, | ||
| 37 | } | ||
| 38 | |||
| 39 | #[allow(dead_code)] | ||
| 40 | impl Database { | ||
| 41 | pub fn open(_config: &crate::config::Config) -> SqlResult<Self> { | ||
| 42 | let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { | ||
| 43 | rusqlite::Error::InvalidPath("Failed to get project directories".into()) | ||
| 44 | })?; | ||
| 45 | |||
| 46 | let data_dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { | ||
| 47 | PathBuf::from(xdg).join("rsplayer") | ||
| 48 | } else { | ||
| 49 | proj_dirs.data_dir().to_path_buf() | ||
| 50 | }; | ||
| 51 | |||
| 52 | fs::create_dir_all(&data_dir).ok(); | ||
| 53 | let db_path = data_dir.join("library.db"); | ||
| 54 | |||
| 55 | let conn = Connection::open(&db_path)?; | ||
| 56 | let db = Database { conn }; | ||
| 57 | db.init_schema()?; | ||
| 58 | Ok(db) | ||
| 59 | } | ||
| 60 | |||
| 61 | fn init_schema(&self) -> SqlResult<()> { | ||
| 62 | self.conn.execute( | ||
| 63 | "CREATE TABLE IF NOT EXISTS artists ( | ||
| 64 | id INTEGER PRIMARY KEY, | ||
| 65 | name TEXT UNIQUE NOT NULL | ||
| 66 | )", | ||
| 67 | [], | ||
| 68 | )?; | ||
| 69 | |||
| 70 | self.conn.execute( | ||
| 71 | "CREATE TABLE IF NOT EXISTS albums ( | ||
| 72 | id INTEGER PRIMARY KEY, | ||
| 73 | artist_id INTEGER NOT NULL, | ||
| 74 | title TEXT NOT NULL, | ||
| 75 | FOREIGN KEY (artist_id) REFERENCES artists(id), | ||
| 76 | UNIQUE(artist_id, title) | ||
| 77 | )", | ||
| 78 | [], | ||
| 79 | )?; | ||
| 80 | |||
| 81 | self.conn.execute( | ||
| 82 | "CREATE TABLE IF NOT EXISTS tracks ( | ||
| 83 | id INTEGER PRIMARY KEY, | ||
| 84 | album_id INTEGER NOT NULL, | ||
| 85 | title TEXT NOT NULL, | ||
| 86 | path TEXT UNIQUE NOT NULL, | ||
| 87 | duration INTEGER, | ||
| 88 | track_number INTEGER, | ||
| 89 | last_scanned INTEGER, | ||
| 90 | FOREIGN KEY (album_id) REFERENCES albums(id) | ||
| 91 | )", | ||
| 92 | [], | ||
| 93 | )?; | ||
| 94 | |||
| 95 | Ok(()) | ||
| 96 | } | ||
| 97 | |||
| 98 | pub fn scan_paths(&self, paths: &[PathBuf], force: bool) -> SqlResult<()> { | ||
| 99 | for path in paths { | ||
| 100 | self.scan_directory(path, force)?; | ||
| 101 | } | ||
| 102 | Ok(()) | ||
| 103 | } | ||
| 104 | |||
| 105 | fn scan_directory(&self, dir: &Path, force: bool) -> SqlResult<()> { | ||
| 106 | if !dir.exists() { | ||
| 107 | return Ok(()); | ||
| 108 | } | ||
| 109 | |||
| 110 | let now = SystemTime::now() | ||
| 111 | .duration_since(SystemTime::UNIX_EPOCH) | ||
| 112 | .unwrap() | ||
| 113 | .as_secs() as i64; | ||
| 114 | |||
| 115 | self.scan_recursive(dir, now, force)?; | ||
| 116 | self.cleanup_old_tracks(now)?; | ||
| 117 | Ok(()) | ||
| 118 | } | ||
| 119 | |||
| 120 | fn scan_recursive(&self, dir: &Path, scan_time: i64, force: bool) -> SqlResult<()> { | ||
| 121 | let entries = fs::read_dir(dir).ok(); | ||
| 122 | if let Some(entries) = entries { | ||
| 123 | for entry in entries.flatten() { | ||
| 124 | let path = entry.path(); | ||
| 125 | if path.is_dir() { | ||
| 126 | self.scan_recursive(&path, scan_time, force)?; | ||
| 127 | } else if is_audio_file(&path) { | ||
| 128 | self.process_audio_file(&path, scan_time, force)?; | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | Ok(()) | ||
| 133 | } | ||
| 134 | |||
| 135 | fn process_audio_file(&self, path: &Path, scan_time: i64, force: bool) -> SqlResult<()> { | ||
| 136 | let path_str = path.to_string_lossy().to_string(); | ||
| 137 | |||
| 138 | if !force { | ||
| 139 | let count: i64 = self | ||
| 140 | .conn | ||
| 141 | .query_row( | ||
| 142 | "SELECT COUNT(*) FROM tracks WHERE path = ? AND last_scanned = ?", | ||
| 143 | rusqlite::params![path_str, scan_time], | ||
| 144 | |row| row.get(0), | ||
| 145 | ) | ||
| 146 | .unwrap_or(0); | ||
| 147 | if count > 0 { | ||
| 148 | return Ok(()); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | let metadata = extract_metadata(path); | ||
| 153 | let (artist_name, album_title, track_title, track_number, duration) = metadata; | ||
| 154 | let duration_i64 = duration.map(|d| d as i64); | ||
| 155 | |||
| 156 | let artist_id = self.get_or_create_artist(&artist_name)?; | ||
| 157 | let album_id = self.get_or_create_album(artist_id, &album_title)?; | ||
| 158 | |||
| 159 | self.conn.execute( | ||
| 160 | "INSERT OR REPLACE INTO tracks (album_id, title, path, duration, track_number, last_scanned) | ||
| 161 | VALUES (?, ?, ?, ?, ?, ?)", | ||
| 162 | rusqlite::params![album_id, track_title, path_str, duration_i64, track_number, scan_time], | ||
| 163 | )?; | ||
| 164 | |||
| 165 | Ok(()) | ||
| 166 | } | ||
| 167 | |||
| 168 | fn get_or_create_artist(&self, name: &str) -> SqlResult<i64> { | ||
| 169 | self.conn | ||
| 170 | .execute("INSERT OR IGNORE INTO artists (name) VALUES (?)", [name])?; | ||
| 171 | self.conn | ||
| 172 | .query_row("SELECT id FROM artists WHERE name = ?", [name], |row| { | ||
| 173 | row.get(0) | ||
| 174 | }) | ||
| 175 | } | ||
| 176 | |||
| 177 | fn get_or_create_album(&self, artist_id: i64, title: &str) -> SqlResult<i64> { | ||
| 178 | self.conn.execute( | ||
| 179 | "INSERT OR IGNORE INTO albums (artist_id, title) VALUES (?, ?)", | ||
| 180 | rusqlite::params![artist_id, title], | ||
| 181 | )?; | ||
| 182 | self.conn.query_row( | ||
| 183 | "SELECT id FROM albums WHERE artist_id = ? AND title = ?", | ||
| 184 | rusqlite::params![artist_id, title], | ||
| 185 | |row| row.get(0), | ||
| 186 | ) | ||
| 187 | } | ||
| 188 | |||
| 189 | fn cleanup_old_tracks(&self, scan_time: i64) -> SqlResult<()> { | ||
| 190 | self.conn | ||
| 191 | .execute("DELETE FROM tracks WHERE last_scanned < ?", [scan_time])?; | ||
| 192 | Ok(()) | ||
| 193 | } | ||
| 194 | |||
| 195 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { | ||
| 196 | let mut stmt = self.conn.prepare( | ||
| 197 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | ||
| 198 | FROM tracks t | ||
| 199 | JOIN albums a ON t.album_id = a.id | ||
| 200 | JOIN artists ar ON a.artist_id = ar.id | ||
| 201 | WHERE t.title LIKE ? OR a.title LIKE ? OR ar.name LIKE ? | ||
| 202 | ORDER BY ar.name, a.title, t.track_number", | ||
| 203 | )?; | ||
| 204 | |||
| 205 | let search = format!("%{}%", query); | ||
| 206 | let rows = stmt.query_map([&search, &search, &search], |row| { | ||
| 207 | Ok(Track { | ||
| 208 | id: row.get(0)?, | ||
| 209 | album_id: row.get(1)?, | ||
| 210 | title: row.get(2)?, | ||
| 211 | path: PathBuf::from(row.get::<_, String>(3)?), | ||
| 212 | duration: row.get(4)?, | ||
| 213 | track_number: row.get(5)?, | ||
| 214 | artist_name: Some(row.get(6)?), | ||
| 215 | album_title: Some(row.get(7)?), | ||
| 216 | }) | ||
| 217 | })?; | ||
| 218 | |||
| 219 | rows.collect() | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | fn is_audio_file(path: &Path) -> bool { | ||
| 224 | path.extension() | ||
| 225 | .and_then(|ext| ext.to_str()) | ||
| 226 | .map(|ext| { | ||
| 227 | matches!( | ||
| 228 | ext.to_lowercase().as_str(), | ||
| 229 | "mp3" | "flac" | "ogg" | "m4a" | "wav" | "opus" | ||
| 230 | ) | ||
| 231 | }) | ||
| 232 | .unwrap_or(false) | ||
| 233 | } | ||
| 234 | |||
| 235 | fn extract_metadata(path: &Path) -> (String, String, String, Option<i32>, Option<u64>) { | ||
| 236 | let file_stem = path | ||
| 237 | .file_stem() | ||
| 238 | .and_then(|s| s.to_str()) | ||
| 239 | .unwrap_or("Unknown"); | ||
| 240 | let parent = path | ||
| 241 | .parent() | ||
| 242 | .and_then(|p| p.file_name()) | ||
| 243 | .and_then(|s| s.to_str()) | ||
| 244 | .unwrap_or("Unknown"); | ||
| 245 | let grandparent = path | ||
| 246 | .parent() | ||
| 247 | .and_then(|p| p.parent()) | ||
| 248 | .and_then(|p| p.file_name()) | ||
| 249 | .and_then(|s| s.to_str()) | ||
| 250 | .unwrap_or("Unknown"); | ||
| 251 | |||
| 252 | ( | ||
| 253 | grandparent.to_string(), | ||
| 254 | parent.to_string(), | ||
| 255 | file_stem.to_string(), | ||
| 256 | None, | ||
| 257 | None, | ||
| 258 | ) | ||
| 259 | } | ||
