aboutsummaryrefslogtreecommitdiffstats
path: root/src/database.rs
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-05-05 21:12:32 +0200
committersalmonik <raisersoft@proton.me>2026-05-05 21:12:32 +0200
commitbd29978b2a0d0d6def486fcdaa63bc8934d68045 (patch)
tree5df0efb17d350ae7f095eb9c0940b1bbf568de96 /src/database.rs
downloadrsplayer-bd29978b2a0d0d6def486fcdaa63bc8934d68045.tar.gz
rsplayer-bd29978b2a0d0d6def486fcdaa63bc8934d68045.zip
fix(daemon): robust auto-advance; ensure next track starts even if playback fails
Diffstat (limited to '')
-rw-r--r--src/database.rs259
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 @@
1use directories::ProjectDirs;
2use rusqlite::{Connection, Result as SqlResult};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7#[derive(Debug, Clone)]
8#[allow(dead_code)]
9pub struct Artist {
10 pub id: i64,
11 pub name: String,
12}
13
14#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub 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)]
24pub 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
35pub struct Database {
36 conn: Connection,
37}
38
39#[allow(dead_code)]
40impl 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
223fn 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
235fn 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}