1use std::path::{Path, PathBuf};
4
5use rusqlite::{Connection, Result as SqliteResult};
6use thiserror::Error;
7
8#[derive(Error, Debug)]
9pub enum DatabaseError {
10 #[error("Failed to open database: {0}")]
11 OpenError(#[from] rusqlite::Error),
12
13 #[error("Database path does not exist: {0}")]
14 PathNotFound(PathBuf),
15
16 #[error("Failed to create .photovault directory: {0}")]
17 DirectoryCreationError(std::io::Error),
18}
19
20pub struct Database {
22 pub conn: Connection,
23}
24
25impl Drop for Database {
26 fn drop(&mut self) {
27 let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE);");
32 }
33}
34
35pub fn library_metadata_dir(drive_root: &Path) -> PathBuf {
40 drive_root.join(super::LIBRARY_METADATA_DIR)
41}
42
43pub fn db_path_for(drive_root: &Path) -> PathBuf {
51 library_metadata_dir(drive_root).join("photovault.db")
52}
53
54pub fn tile_cache_dir(drive_root: &Path) -> PathBuf {
56 library_metadata_dir(drive_root).join("tile_cache")
57}
58
59pub fn open_secondary(db_path: &Path) -> Result<Connection, DatabaseError> {
65 let conn = Connection::open(db_path)?;
66 Database::configure_connection(&conn)?;
67 Ok(conn)
68}
69
70impl Database {
71 pub fn open_for_drive<P: AsRef<Path>>(drive_root: P) -> Result<Self, DatabaseError> {
79 let drive_root = drive_root.as_ref().to_path_buf();
80
81 if !drive_root.exists() {
82 return Err(DatabaseError::PathNotFound(drive_root));
83 }
84
85 let metadata_dir = library_metadata_dir(&drive_root);
86 if !metadata_dir.exists() {
87 std::fs::create_dir_all(&metadata_dir)
88 .map_err(DatabaseError::DirectoryCreationError)?;
89 }
90
91 let db_path = metadata_dir.join("photovault.db");
92 let conn = Connection::open(&db_path)?;
93
94 Self::configure_connection(&conn)?;
96 let _ = Self::create_indexes(&conn);
97
98 Ok(Self { conn })
99 }
100
101 pub(crate) fn configure_connection(conn: &Connection) -> SqliteResult<()> {
103 conn.pragma_update(None, "journal_mode", "WAL")?;
105
106 conn.pragma_update(None, "synchronous", "NORMAL")?;
108
109 conn.pragma_update(None, "cache_size", -64000)?;
111
112 conn.pragma_update(None, "temp_store", "MEMORY")?;
114
115 conn.pragma_update(None, "mmap_size", 268435456)?;
117
118 conn.pragma_update(None, "foreign_keys", "ON")?;
120
121 conn.pragma_update(None, "busy_timeout", 5000)?;
126
127 Ok(())
128 }
129
130 fn create_indexes(conn: &Connection) -> SqliteResult<()> {
132 conn.execute_batch(
133 r#"
134 CREATE INDEX IF NOT EXISTS idx_photos_date ON photos(date_taken);
135 CREATE INDEX IF NOT EXISTS idx_photos_hash ON photos(file_hash);
136 CREATE INDEX IF NOT EXISTS idx_photos_location ON photos(location_country, location_city);
137 CREATE INDEX IF NOT EXISTS idx_photos_trashed ON photos(is_trashed);
138 CREATE INDEX IF NOT EXISTS idx_photos_path ON photos(file_path);
139 CREATE INDEX IF NOT EXISTS idx_photos_faces_trashed ON photos(faces_processed, is_trashed);
140 CREATE INDEX IF NOT EXISTS idx_photos_hash_trashed ON photos(file_hash, is_trashed);
141 CREATE INDEX IF NOT EXISTS idx_faces_cluster ON faces(cluster_id);
142 CREATE INDEX IF NOT EXISTS idx_faces_photo ON faces(photo_id);
143 CREATE INDEX IF NOT EXISTS idx_faces_photo_cluster ON faces(photo_id, cluster_id);
144 CREATE INDEX IF NOT EXISTS idx_clusters_name ON face_clusters(name);
145 "#,
146 )
147 }
148
149 pub fn needs_schema(&self) -> SqliteResult<bool> {
151 let count: i32 = self.conn.query_row(
152 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='photos'",
153 [],
154 |row| row.get(0),
155 )?;
156 Ok(count == 0)
157 }
158
159 pub fn run_maintenance(&self) -> SqliteResult<()> {
163 self.conn.execute_batch("PRAGMA optimize;")?;
165 self.conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE);")?;
167 Ok(())
168 }
169
170 pub fn flush_and_close(self) {
176 if let Err(e) = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") {
177 tracing::debug!("WAL truncate-checkpoint failed: {}", e);
178 }
179 }
181
182 pub fn check_integrity(&self) -> SqliteResult<bool> {
186 let result: String = self
187 .conn
188 .query_row("PRAGMA quick_check;", [], |row| row.get(0))?;
189 Ok(result == "ok")
190 }
191
192 pub fn backup(drive_root: &Path, max_backups: usize) -> std::io::Result<PathBuf> {
196 let db_path = library_metadata_dir(drive_root).join("photovault.db");
197 if !db_path.exists() {
198 return Err(std::io::Error::new(
199 std::io::ErrorKind::NotFound,
200 "Database file not found",
201 ));
202 }
203
204 let backup_dir = library_metadata_dir(drive_root).join("backups");
205 std::fs::create_dir_all(&backup_dir)?;
206
207 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
209 let backup_path = backup_dir.join(format!("photovault_{}.db", timestamp));
210
211 std::fs::copy(&db_path, &backup_path)?;
212
213 let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)?
215 .filter_map(|e| e.ok())
216 .filter(|e| {
217 e.path()
218 .file_name()
219 .and_then(|n| n.to_str())
220 .map(|n| n.starts_with("photovault_") && n.ends_with(".db"))
221 .unwrap_or(false)
222 })
223 .collect();
224
225 backups.sort_by_key(|e| std::cmp::Reverse(e.path()));
226
227 for old in backups.into_iter().skip(max_backups) {
228 let _ = std::fs::remove_file(old.path());
229 }
230
231 tracing::info!("Database backed up to {}", backup_path.display());
232 Ok(backup_path)
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use tempfile::tempdir;
240
241 #[test]
242 fn test_open_for_drive() {
243 let temp = tempdir().unwrap();
244 let db = Database::open_for_drive(temp.path()).unwrap();
245
246 let table_count: i64 = db
247 .conn
248 .query_row(
249 "SELECT COUNT(*) FROM sqlite_master WHERE type='table'",
250 [],
251 |row| row.get(0),
252 )
253 .unwrap();
254 assert!(table_count >= 0);
255 assert!(temp.path().join(".photovault").exists());
256 }
257}