1use rusqlite::{Connection, Result, params};
7
8pub fn ensure_classifications_table(conn: &Connection) -> Result<()> {
16 if crate::db::table_exists(conn, "classifications")? {
17 let has_model_id = conn
18 .prepare("SELECT model_id FROM classifications LIMIT 0")
19 .is_ok();
20 if !has_model_id {
21 eprintln!(
22 "note: the classifications table predates multi-model support and has been \
23 reset. Re-run 'videre classify' to rebuild it (minutes, no image decoding)."
24 );
25 conn.execute_batch("DROP TABLE classifications;")?;
26 }
27 }
28 conn.execute_batch(
29 "CREATE TABLE IF NOT EXISTS classifications (
30 model_id TEXT NOT NULL,
31 hash TEXT NOT NULL,
32 category TEXT NOT NULL,
33 confidence REAL NOT NULL,
34 classified_at TEXT NOT NULL,
35 PRIMARY KEY (model_id, hash)
36 );",
37 )
38}
39
40pub fn pending_hashes(conn: &Connection, model_id: &str) -> Result<Vec<String>> {
45 let mut stmt = conn.prepare(
46 "SELECT hash FROM emb.embeddings
47 WHERE model_id = ?1
48 AND NOT EXISTS (
49 SELECT 1 FROM classifications c
50 WHERE c.hash = emb.embeddings.hash AND c.model_id = ?1
51 )
52 AND NOT EXISTS (
53 SELECT 1 FROM file_hashes fh
54 WHERE fh.hash = emb.embeddings.hash
55 AND (fh.mime IN ('video/quicktime', 'video/mp4')
56 OR (fh.mime IS NULL AND lower(fh.ext) IN ('mov', 'mp4')))
57 )
58 ORDER BY hash",
59 )?;
60 let rows = stmt.query_map(params![model_id], |row| row.get(0))?;
61 rows.collect()
62}
63
64pub fn exclude_video_hashes(conn: &Connection, hashes: &[String]) -> Result<Vec<String>> {
75 let mut stmt =
76 conn.prepare("SELECT hash, lower(COALESCE(ext, '')), mime FROM file_hashes")?;
77 let rows: Vec<(String, String, Option<String>)> = stmt
79 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
80 .collect::<rusqlite::Result<_>>()?;
81 let ext_by_hash: std::collections::HashMap<String, (String, Option<String>)> =
82 rows.into_iter().map(|(h, e, m)| (h, (e, m))).collect();
83
84 Ok(hashes
85 .iter()
86 .filter(|hash| {
87 !ext_by_hash.get(*hash).is_some_and(|(ext, mime)| {
88 crate::mime_probe::effective_mime(mime.as_deref(), ext)
89 .is_some_and(crate::mime_probe::is_video_mime)
90 })
91 })
92 .cloned()
93 .collect())
94}
95
96pub fn insert_classifications(
98 conn: &Connection,
99 model_id: &str,
100 items: &[(String, &str, f32)],
101) -> Result<()> {
102 let tx = conn.unchecked_transaction()?;
103 {
104 let mut stmt = tx.prepare(
105 "INSERT OR REPLACE INTO classifications
106 (model_id, hash, category, confidence, classified_at)
107 VALUES (?1, ?2, ?3, ?4, datetime('now'))",
108 )?;
109 for (hash, category, confidence) in items {
110 stmt.execute(params![model_id, hash, category, confidence])?;
111 }
112 }
113 tx.commit()
114}
115
116pub fn paths_for_category(
120 conn: &Connection,
121 model_id: &str,
122 category: &str,
123) -> Result<Vec<(String, String)>> {
124 let mut stmt = conn.prepare(
125 "SELECT file_hashes.path, file_hashes.hash FROM file_hashes
126 JOIN classifications ON file_hashes.hash = classifications.hash
127 WHERE classifications.model_id = ?1 AND classifications.category = ?2
128 ORDER BY file_hashes.path",
129 )?;
130 let rows = stmt.query_map(params![model_id, category], |row| {
131 Ok((row.get(0)?, row.get(1)?))
132 })?;
133 rows.collect()
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn test_db_attached(tag: &str) -> Connection {
144 let lib = crate::embeddings_db::test_library(tag);
145 let conn = Connection::open_in_memory().unwrap();
146 conn.execute_batch(
147 "CREATE TABLE file_hashes (
148 path TEXT PRIMARY KEY,
149 hash TEXT NOT NULL,
150 ext TEXT
151 );",
152 )
153 .unwrap();
154 crate::db::ensure_file_hashes_columns(&conn);
155 ensure_classifications_table(&conn).unwrap();
156 crate::embeddings_db::attach(&conn, &lib, "test-model", true).unwrap();
157 conn
158 }
159
160 fn insert_file(conn: &Connection, path: &str, hash: &str) {
161 conn.execute(
162 "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, ?2, 'jpg')",
163 rusqlite::params![path, hash],
164 )
165 .unwrap();
166 }
167
168 fn insert_file_with_ext(conn: &Connection, path: &str, hash: &str, ext: &str) {
169 conn.execute(
170 "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, ?2, ?3)",
171 rusqlite::params![path, hash, ext],
172 )
173 .unwrap();
174 }
175
176 fn insert_embedding(conn: &Connection, hash: &str, model_id: &str) {
177 conn.execute(
178 "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
179 VALUES (?1, ?2, X'00', datetime('now'))",
180 rusqlite::params![hash, model_id],
181 )
182 .unwrap();
183 }
184
185 #[test]
186 fn pending_hashes_excludes_video_extensions() {
187 let conn = test_db_attached("cls_video");
188 insert_file_with_ext(&conn, "/a/1.jpg", "h1", "jpg");
189 insert_file_with_ext(&conn, "/a/clip.mp4", "h2", "mp4");
190 insert_file_with_ext(&conn, "/a/clip.mov", "h3", "mov");
191 insert_embedding(&conn, "h1", "test-model");
192 insert_embedding(&conn, "h2", "test-model");
193 insert_embedding(&conn, "h3", "test-model");
194
195 let pending = pending_hashes(&conn, "test-model").unwrap();
196 assert_eq!(pending, vec!["h1".to_string()]);
197 }
198
199 #[test]
200 fn exclude_video_hashes_filters_out_mov_and_mp4() {
201 let conn = test_db_attached("cls_filter");
202 insert_file_with_ext(&conn, "/a/1.jpg", "h1", "jpg");
203 insert_file_with_ext(&conn, "/a/clip.mp4", "h2", "mp4");
204 insert_file_with_ext(&conn, "/a/clip.mov", "h3", "mov");
205
206 let all = vec!["h1".to_string(), "h2".to_string(), "h3".to_string()];
207 let filtered = exclude_video_hashes(&conn, &all).unwrap();
208 assert_eq!(filtered, vec!["h1".to_string()]);
209 }
210
211 #[test]
212 fn exclude_video_hashes_keeps_hashes_with_no_file_hashes_row() {
213 let conn = test_db_attached("cls_orphan");
217 let all = vec!["orphan-hash".to_string()];
218 let filtered = exclude_video_hashes(&conn, &all).unwrap();
219 assert_eq!(filtered, vec!["orphan-hash".to_string()]);
220 }
221
222 #[test]
223 fn pending_hashes_returns_work_for_a_second_model() {
224 let lib = crate::embeddings_db::test_library("cls_secondmodel");
233 let conn = Connection::open_in_memory().unwrap();
234 conn.execute_batch(
235 "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL, ext TEXT);",
236 )
237 .unwrap();
238 crate::db::ensure_file_hashes_columns(&conn);
239 ensure_classifications_table(&conn).unwrap();
240
241 crate::embeddings_db::attach(&conn, &lib, "model-a", true).unwrap();
242 insert_embedding(&conn, "h1", "model-a");
243 insert_classifications(&conn, "model-a", &[("h1".to_string(), "photo", 0.9)]).unwrap();
244 assert!(pending_hashes(&conn, "model-a").unwrap().is_empty());
245 crate::embeddings_db::detach(&conn).unwrap();
246
247 crate::embeddings_db::attach(&conn, &lib, "model-b", true).unwrap();
248 insert_embedding(&conn, "h1", "model-b");
249 assert_eq!(
250 pending_hashes(&conn, "model-b").unwrap(),
251 vec!["h1".to_string()],
252 "model-b must still have work to do"
253 );
254 }
255
256 #[test]
257 fn the_same_hash_can_hold_one_row_per_model() {
258 let conn = test_db_attached("cls_perlmodel");
259 insert_classifications(&conn, "model-a", &[("h1".to_string(), "photo", 0.9)]).unwrap();
260 insert_classifications(&conn, "model-b", &[("h1".to_string(), "meme", 0.7)]).unwrap();
261
262 let n: i64 = conn
263 .query_row("SELECT COUNT(*) FROM classifications", [], |r| r.get(0))
264 .unwrap();
265 assert_eq!(n, 2);
266 }
267
268 #[test]
269 fn paths_for_category_is_scoped_to_one_model() {
270 let conn = test_db_attached("cls_catmodel");
271 insert_file(&conn, "/a/1.jpg", "h1");
272 insert_classifications(&conn, "model-a", &[("h1".to_string(), "screenshot", 0.8)]).unwrap();
273 insert_classifications(&conn, "model-b", &[("h1".to_string(), "photo", 0.8)]).unwrap();
274
275 assert_eq!(
276 paths_for_category(&conn, "model-a", "screenshot").unwrap().len(),
277 1
278 );
279 assert!(paths_for_category(&conn, "model-b", "screenshot")
280 .unwrap()
281 .is_empty());
282 }
283
284 #[test]
285 fn a_legacy_table_without_model_id_is_dropped_and_recreated() {
286 let conn = test_db_attached("cls_legacy");
290 conn.execute_batch("DROP TABLE classifications;").unwrap();
291 conn.execute_batch(
292 "CREATE TABLE classifications (
293 hash TEXT PRIMARY KEY NOT NULL, category TEXT NOT NULL,
294 confidence REAL NOT NULL, classified_at TEXT NOT NULL
295 );
296 INSERT INTO classifications VALUES ('h1', 'photo', 0.9, 'now');",
297 )
298 .unwrap();
299
300 ensure_classifications_table(&conn).unwrap();
301
302 let n: i64 = conn
303 .query_row("SELECT COUNT(*) FROM classifications", [], |r| r.get(0))
304 .unwrap();
305 assert_eq!(n, 0, "legacy rows are dropped, not silently mislabeled");
306 conn.execute(
307 "INSERT INTO classifications VALUES ('m', 'h1', 'photo', 0.9, 'now')",
308 [],
309 )
310 .expect("recreated table must have the 5-column shape");
311 }
312
313 #[test]
314 fn ensure_classifications_table_is_idempotent() {
315 let conn = test_db_attached("cls_idem");
316 ensure_classifications_table(&conn).unwrap();
317 ensure_classifications_table(&conn).unwrap();
318 }
319
320 #[test]
321 fn pending_hashes_returns_embedded_but_unclassified() {
322 let conn = test_db_attached("cls_pending");
323 insert_embedding(&conn, "h1", "test-model");
324 insert_embedding(&conn, "h2", "test-model");
325 insert_classifications(&conn, "test-model", &[("h1".to_string(), "photo", 0.9)]).unwrap();
326
327 let pending = pending_hashes(&conn, "test-model").unwrap();
328 assert_eq!(pending, vec!["h2".to_string()]);
329 }
330
331 #[test]
332 fn pending_hashes_is_model_aware() {
333 let conn = test_db_attached("cls_modelaware");
334 insert_embedding(&conn, "h1", "model-a");
335
336 assert_eq!(pending_hashes(&conn, "model-a").unwrap(), vec!["h1".to_string()]);
337 assert!(pending_hashes(&conn, "model-b").unwrap().is_empty());
338 }
339
340 #[test]
341 fn insert_classifications_upserts_on_conflict() {
342 let conn = test_db_attached("cls_upsert");
343 insert_classifications(&conn, "test-model", &[("h1".to_string(), "screenshot", 0.4)]).unwrap();
344 insert_classifications(&conn, "test-model", &[("h1".to_string(), "photo", 0.9)]).unwrap();
345
346 let category: String = conn
347 .query_row("SELECT category FROM classifications WHERE hash = 'h1'", [], |r| r.get(0))
348 .unwrap();
349 assert_eq!(category, "photo");
350 let count: i64 = conn
351 .query_row("SELECT COUNT(*) FROM classifications", [], |r| r.get(0))
352 .unwrap();
353 assert_eq!(count, 1); }
355
356 #[test]
357 fn paths_for_category_returns_matching_paths_with_hash() {
358 let conn = test_db_attached("cls_paths");
359 insert_file(&conn, "/a/1.jpg", "h1");
360 insert_file(&conn, "/b/1-copy.jpg", "h1");
361 insert_file(&conn, "/a/2.png", "h2");
362 insert_classifications(
363 &conn,
364 "test-model",
365 &[
366 ("h1".to_string(), "screenshot", 0.8),
367 ("h2".to_string(), "photo", 0.9),
368 ],
369 )
370 .unwrap();
371
372 let hits = paths_for_category(&conn, "test-model", "screenshot").unwrap();
373 assert_eq!(hits.len(), 2);
374 assert!(hits.iter().all(|(_, hash)| hash == "h1"));
375 }
376
377 #[test]
378 fn paths_for_category_returns_empty_for_unmatched_category() {
379 let conn = test_db_attached("cls_empty");
380 insert_file(&conn, "/a/1.jpg", "h1");
381 insert_classifications(&conn, "test-model", &[("h1".to_string(), "photo", 0.9)]).unwrap();
382
383 assert!(paths_for_category(&conn, "test-model", "meme").unwrap().is_empty());
384 }
385}