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