Skip to main content

videre_core/
db.rs

1use chrono::{DateTime, Utc};
2use rusqlite::Connection;
3use std::collections::HashMap;
4use std::path::Path;
5
6/// Canonical stored form of a filesystem mtime: `DateTime<Utc>` then rfc3339.
7///
8/// Identical to `videre`'s `hasher::system_time_to_iso`, so a value stored at
9/// scan time and one computed freshly here compare as equal strings. That
10/// string equality is exact rather than lucky: both sides go through the same
11/// `DateTime<Utc>` then `to_rfc3339()`, so timezone and format never diverge
12/// (the rule BUG:21 established for prune's sync).
13pub fn mtime_iso(t: std::time::SystemTime) -> String {
14    let dt: DateTime<Utc> = t.into();
15    dt.to_rfc3339()
16}
17
18/// The stored facts change-detection needs about one row, so scan and watch can
19/// decide whether to skip a file without opening it.
20#[derive(Clone, Debug)]
21pub struct RowSig {
22    pub size_bytes: u64,
23    pub modified_at: Option<String>,
24    pub has_mime: bool,
25    pub has_phash: bool,
26}
27
28/// True when the stored row is current for the requested work, so the file can
29/// be skipped without reading it: unchanged (same size and mtime) AND complete
30/// (a known mime, and a phash when `--similar` needs one). A file the walk sees
31/// but cannot stat, or a row with no stored mtime, is never current.
32pub fn is_current(
33    sig: &RowSig,
34    cur_size: u64,
35    cur_mtime: Option<&str>,
36    want_similar: bool,
37) -> bool {
38    sig.has_mime
39        && (!want_similar || sig.has_phash)
40        && sig.size_bytes == cur_size
41        && cur_mtime.is_some()
42        && sig.modified_at.as_deref() == cur_mtime
43}
44
45/// Load every row's signature in one query, keyed by path. Empty when the
46/// table does not exist, so a first scan (no table yet) skips nothing.
47pub fn stored_signatures(conn: &Connection) -> rusqlite::Result<HashMap<String, RowSig>> {
48    if !table_exists(conn, "file_hashes")? {
49        return Ok(HashMap::new());
50    }
51    let mut stmt = conn.prepare(
52        "SELECT path, size_bytes, modified_at, mime IS NOT NULL, phash IS NOT NULL \
53         FROM file_hashes",
54    )?;
55    let rows = stmt.query_map([], |r| {
56        Ok((
57            r.get::<_, String>(0)?,
58            RowSig {
59                size_bytes: r.get::<_, Option<i64>>(1)?.unwrap_or(0) as u64,
60                modified_at: r.get::<_, Option<String>>(2)?,
61                has_mime: r.get::<_, bool>(3)?,
62                has_phash: r.get::<_, bool>(4)?,
63            },
64        ))
65    })?;
66    rows.collect()
67}
68
69/// Opens a SQLite connection and switches it to WAL journal mode, allows
70/// one writer plus many concurrent readers without "database is locked"
71/// errors, which matters once videre watch (writing in the background) and a
72/// running videre gallery server (reading/writing) hold separate
73/// connections to the same file at the same time. WAL mode persists in the
74/// database file itself once set, so this is idempotent, safe to call on
75/// every connection open, not just the first.
76pub fn open_wal(path: &Path) -> rusqlite::Result<Connection> {
77    let conn = Connection::open(path)?;
78    conn.pragma_update(None, "journal_mode", "WAL")?;
79    ensure_file_hashes_columns(&conn);
80    crate::face_db::ensure_people_table(&conn);
81    let _ = crate::marks::ensure_marks_table(&conn);
82    Ok(conn)
83}
84
85/// Idempotent column migrations for `file_hashes`, run on every open.
86///
87/// It must be every open rather than only on write: readers query `mime`, and
88/// a library scanned before the column existed would otherwise fail with
89/// "no such column" on `dedupe`, `stats`, `search`, and the rest, until the
90/// user happened to re-scan. Errors (column already exists, or no table yet in
91/// a brand-new database) are ignored, the same pattern
92/// `location::ensure_location_column` and `face_db`'s `is_primary` use.
93///
94/// `open_wal` is only ever used for the main library database; per-model
95/// embedding databases go through `Connection::open` in `embeddings_db`.
96pub fn ensure_file_hashes_columns(conn: &Connection) {
97    let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN mime TEXT;");
98    // Video metadata, 0.14.0. NULL for images, and NULL for every row scanned
99    // before that release: `--retry-incomplete` keys on `mime IS NULL` and does
100    // not catch "scanned before these existed", so only a re-scan fills them.
101    let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN duration_secs REAL;");
102    let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN codec TEXT;");
103}
104
105/// Whether `name` exists as a table in `conn`, used by every reader that
106/// queries an optional table (`faces`, `embeddings`, `classifications`) added
107/// after `file_hashes` and not guaranteed present in an older db.
108pub fn table_exists(conn: &Connection, name: &str) -> rusqlite::Result<bool> {
109    let count: i64 = conn.query_row(
110        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
111        [name],
112        |r| r.get(0),
113    )?;
114    Ok(count > 0)
115}
116
117/// Paths already recorded with a known type, for `videre scan
118/// --retry-incomplete`.
119///
120/// One query rather than a lookup per path: a library has tens of thousands of
121/// rows, and 70,000 point queries would cost more than the file reads this
122/// exists to avoid. Measured for scale: a full scan of a 70,601-file library
123/// reads roughly 460 GB in 9m50s, while walking it takes 1.8s.
124///
125/// Rows carrying `mime_probe::UNKNOWN_MIME` count as known: the file was read
126/// and checked, and re-reading it would never produce a different answer.
127/// Only NULL, meaning never scanned, is left out.
128///
129/// A missing table is an empty set, not an error, so scanning into a fresh
130/// database degrades to a normal full scan.
131pub fn paths_with_known_mime(
132    conn: &Connection,
133) -> rusqlite::Result<std::collections::HashSet<String>> {
134    if !table_exists(conn, "file_hashes")? {
135        return Ok(std::collections::HashSet::new());
136    }
137    let mut stmt = conn.prepare("SELECT path FROM file_hashes WHERE mime IS NOT NULL")?;
138    let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
139    rows.collect()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use tempfile::tempdir;
146
147    #[test]
148    fn is_current_only_when_unchanged_and_complete() {
149        let base = RowSig {
150            size_bytes: 10,
151            modified_at: Some("2024-01-01T00:00:00+00:00".to_string()),
152            has_mime: true,
153            has_phash: false,
154        };
155        let m = base.modified_at.as_deref();
156        // unchanged + known mime, not asking for similar -> current (skip)
157        assert!(is_current(&base, 10, m, false));
158        // size differs -> not current
159        assert!(!is_current(&base, 11, m, false));
160        // mtime differs -> not current
161        assert!(!is_current(
162            &base,
163            10,
164            Some("2024-02-02T00:00:00+00:00"),
165            false
166        ));
167        // missing mtime on disk -> not current
168        assert!(!is_current(&base, 10, None, false));
169        // mime unknown -> not current even if unchanged (backfill mime)
170        let no_mime = RowSig {
171            has_mime: false,
172            ..base.clone()
173        };
174        assert!(!is_current(&no_mime, 10, m, false));
175        // --similar with no phash -> not current (backfill phash)
176        assert!(!is_current(&base, 10, m, true));
177        // --similar with phash present -> current
178        let with_phash = RowSig {
179            has_phash: true,
180            ..base.clone()
181        };
182        assert!(is_current(&with_phash, 10, m, true));
183    }
184
185    #[test]
186    fn stored_signatures_reads_size_mtime_and_completeness() {
187        let conn = Connection::open_in_memory().unwrap();
188        conn.execute_batch(
189            "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT, size_bytes INTEGER,
190                modified_at TEXT, mime TEXT, phash INTEGER);
191             INSERT INTO file_hashes VALUES ('a.jpg','h',10,'2024-01-01T00:00:00+00:00','image/jpeg',NULL);
192             INSERT INTO file_hashes VALUES ('b.dng','h2',20,'2024-01-02T00:00:00+00:00',NULL,NULL);",
193        )
194        .unwrap();
195        let sigs = stored_signatures(&conn).unwrap();
196        assert_eq!(sigs["a.jpg"].size_bytes, 10);
197        assert!(sigs["a.jpg"].has_mime && !sigs["a.jpg"].has_phash);
198        assert!(!sigs["b.dng"].has_mime);
199    }
200
201    #[test]
202    fn stored_signatures_empty_without_the_table() {
203        let conn = Connection::open_in_memory().unwrap();
204        assert!(stored_signatures(&conn).unwrap().is_empty());
205    }
206
207    #[test]
208    fn table_exists_true_for_existing_table() {
209        let conn = Connection::open_in_memory().unwrap();
210        conn.execute_batch("CREATE TABLE widgets (id INTEGER);")
211            .unwrap();
212        assert!(table_exists(&conn, "widgets").unwrap());
213    }
214
215    #[test]
216    fn table_exists_false_for_missing_table() {
217        let conn = Connection::open_in_memory().unwrap();
218        assert!(!table_exists(&conn, "widgets").unwrap());
219    }
220
221    #[test]
222    fn open_wal_sets_journal_mode() {
223        let dir = tempdir().unwrap();
224        let db_path = dir.path().join("test.db");
225        let conn = open_wal(&db_path).unwrap();
226        let mode: String = conn
227            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
228            .unwrap();
229        assert_eq!(mode.to_lowercase(), "wal");
230    }
231
232    #[test]
233    fn open_wal_is_idempotent_across_repeated_opens() {
234        let dir = tempdir().unwrap();
235        let db_path = dir.path().join("test.db");
236        open_wal(&db_path).unwrap();
237        // Second open on the same file must not error, WAL mode already
238        // persisted from the first open.
239        let conn = open_wal(&db_path).unwrap();
240        let mode: String = conn
241            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
242            .unwrap();
243        assert_eq!(mode.to_lowercase(), "wal");
244    }
245
246    fn db_with_rows(rows: &[(&str, Option<&str>)]) -> Connection {
247        let conn = Connection::open_in_memory().unwrap();
248        conn.execute_batch(
249            "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT, ext TEXT, mime TEXT);",
250        )
251        .unwrap();
252        for (path, mime) in rows {
253            conn.execute(
254                "INSERT INTO file_hashes (path, hash, ext, mime) VALUES (?1, 'h', 'jpg', ?2)",
255                rusqlite::params![path, mime],
256            )
257            .unwrap();
258        }
259        conn
260    }
261
262    #[test]
263    fn paths_with_known_mime_excludes_null_rows() {
264        let conn = db_with_rows(&[("/done.jpg", Some("image/jpeg")), ("/todo.jpg", None)]);
265        let set = paths_with_known_mime(&conn).unwrap();
266        assert!(set.contains("/done.jpg"));
267        assert!(
268            !set.contains("/todo.jpg"),
269            "NULL means never scanned, so it must be retried"
270        );
271    }
272
273    #[test]
274    fn paths_with_known_mime_includes_the_sentinel() {
275        // The whole point: a file checked and found unidentifiable is done,
276        // not pending, so the retry set shrinks to empty instead of looping.
277        let conn = db_with_rows(&[("/weird.jpg", Some(crate::mime_probe::UNKNOWN_MIME))]);
278        assert!(paths_with_known_mime(&conn).unwrap().contains("/weird.jpg"));
279    }
280
281    #[test]
282    fn paths_with_known_mime_on_a_table_that_does_not_exist_is_empty_not_an_error() {
283        // Scanning into a fresh database: everything is incomplete.
284        let conn = Connection::open_in_memory().unwrap();
285        assert!(paths_with_known_mime(&conn).unwrap().is_empty());
286    }
287}