1use crate::error::{Error, Result};
6use crate::types::*;
7use rusqlite::Connection;
8use std::collections::HashMap;
9
10fn faces_table_exists(conn: &Connection) -> bool {
12 conn.query_row(
13 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='faces'",
14 [],
15 |r| r.get::<_, i64>(0),
16 )
17 .map(|n| n > 0)
18 .unwrap_or(false)
19}
20
21pub fn faces_list(conn: &Connection) -> Result<FacesData> {
33 if !faces_table_exists(conn) {
34 return Ok(FacesData::default());
35 }
36 let mut people: HashMap<String, PersonData> = HashMap::new();
37 {
38 let mut stmt = conn.prepare(
39 "SELECT f.id, f.hash, f.person_label, COALESCE(p.full_name, f.person_label) \
43 FROM faces f LEFT JOIN people p ON p.name = f.person_label \
44 WHERE f.confirmed = 1 AND f.person_label IS NOT NULL \
45 ORDER BY f.person_label, f.is_primary DESC, f.id ASC",
46 )?;
47 let rows = stmt.query_map([], |r| {
48 Ok((
49 r.get::<_, i64>(0)?,
50 r.get::<_, String>(1)?,
51 r.get::<_, String>(2)?,
52 r.get::<_, String>(3)?,
53 ))
54 })?;
55 for row in rows {
56 let (id, hash, label, full_name) = row?;
57 let person = people.entry(label.clone()).or_insert(PersonData {
58 label: label.clone(),
59 full_name,
60 face_ids: vec![],
61 representative_id: id,
62 hashes: vec![],
63 });
64 person.face_ids.push(id);
65 if !person.hashes.contains(&hash) {
66 person.hashes.push(hash);
67 }
68 }
69 }
70
71 let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
72 {
73 let mut stmt = conn.prepare(
74 "SELECT id, hash, cluster_id FROM faces \
75 WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
76 ORDER BY cluster_id, id",
77 )?;
78 let rows = stmt.query_map([], |r| {
79 Ok((
80 r.get::<_, i64>(0)?,
81 r.get::<_, String>(1)?,
82 r.get::<_, i64>(2)?,
83 ))
84 })?;
85 for row in rows {
86 let (id, hash, cid) = row?;
87 let cluster = cluster_map.entry(cid).or_insert(ClusterData {
88 cluster_id: cid,
89 face_ids: vec![],
90 hashes: vec![],
91 });
92 cluster.face_ids.push(id);
93 if !cluster.hashes.contains(&hash) {
94 cluster.hashes.push(hash);
95 }
96 }
97 }
98
99 let mut singletons: Vec<SingletonData> = vec![];
100 {
101 let mut stmt = conn.prepare(
102 "SELECT id, hash FROM faces \
103 WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
104 ORDER BY id",
105 )?;
106 let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
107 for row in rows {
108 let (id, hash) = row?;
109 singletons.push(SingletonData { face_id: id, hash });
110 }
111 }
112
113 let mut people: Vec<PersonData> = people.into_values().collect();
125 people.sort_by(|a, b| a.full_name.to_lowercase().cmp(&b.full_name.to_lowercase()));
126 let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
127 clusters.sort_by(|a, b| {
128 b.face_ids
129 .len()
130 .cmp(&a.face_ids.len())
131 .then(a.cluster_id.cmp(&b.cluster_id))
132 });
133
134 Ok(FacesData {
135 people,
136 clusters,
137 singletons,
138 })
139}
140
141pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
143 let mut stmt = conn.prepare(
144 "SELECT f.id, f.hash, fh.path FROM faces f \
145 JOIN file_hashes fh ON f.hash = fh.hash \
146 WHERE f.cluster_id = ?1 ORDER BY f.id",
147 )?;
148 let faces = stmt
149 .query_map([cluster_id], |r| {
150 Ok(ClusterFaceData {
151 face_id: r.get(0)?,
152 hash: r.get(1)?,
153 path: r.get(2)?,
154 })
155 })?
156 .collect::<rusqlite::Result<Vec<_>>>()?;
157 Ok(ClusterDetail { cluster_id, faces })
158}
159
160pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
162 let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
166 let name = name.as_str();
167 let mut stmt = conn.prepare(
168 "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
169 JOIN file_hashes fh ON f.hash = fh.hash \
170 WHERE f.person_label = ?1 AND f.confirmed = 1 \
171 ORDER BY f.is_primary DESC, f.id",
172 )?;
173 let faces = stmt
174 .query_map([name], |r| {
175 Ok(PersonFaceData {
176 face_id: r.get(0)?,
177 hash: r.get(1)?,
178 path: r.get(2)?,
179 is_primary: r.get::<_, i64>(3)? != 0,
180 })
181 })?
182 .collect::<rusqlite::Result<Vec<_>>>()?;
183 let full_name: String = conn
186 .query_row(
187 "SELECT full_name FROM people WHERE name = ?1",
188 rusqlite::params![name],
189 |r| r.get(0),
190 )
191 .unwrap_or_else(|_| name.to_string());
192 Ok(PersonDetail {
193 label: name.to_string(),
194 full_name,
195 faces,
196 })
197}
198
199pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
202 Ok(videre_core::person_search::search_by_person(
203 conn, name, None,
204 )?)
205}
206
207pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
210 let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
214 let label = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
215 conn.execute(
216 "INSERT INTO people (name, full_name) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING",
217 rusqlite::params![&label, &display],
218 )?;
219 for id in face_ids {
220 conn.execute(
221 "UPDATE faces SET person_label = ?1, confirmed = 1 WHERE id = ?2",
222 rusqlite::params![label, id],
223 )?;
224 }
225 Ok(())
226}
227
228pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
232 assign(conn, face_ids, label)
233}
234
235pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
237 conn.execute(
238 "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
239 [face_id],
240 )?;
241 Ok(())
242}
243
244pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
246 conn.execute(
247 "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
248 [cluster_id],
249 )?;
250 Ok(())
251}
252
253pub fn set_full_name(conn: &Connection, name: &str, full_name: &str) -> Result<()> {
264 let display = crate::label::sanitize_person_label(full_name).ok_or(Error::Invalid)?;
265 let name = videre_core::person::normalize(name).ok_or(Error::Invalid)?;
266 let n = conn.execute(
267 "UPDATE people SET full_name = ?1 WHERE name = ?2",
268 rusqlite::params![display, name],
269 )?;
270 if n == 0 {
271 return Err(Error::NotFound);
272 }
273 Ok(())
274}
275
276pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
277 let label = videre_core::person::normalize(label).unwrap_or_else(|| label.to_string());
278 conn.execute(
279 "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0 WHERE person_label = ?1",
280 rusqlite::params![label],
281 )?;
282 Ok(())
283}
284
285pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
290 let person_label =
291 videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
292 conn.execute_batch("BEGIN")?;
293 let result = (|| -> rusqlite::Result<()> {
294 conn.execute(
295 "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
296 rusqlite::params![person_label],
297 )?;
298 conn.execute(
299 "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
300 rusqlite::params![person_label, face_id],
301 )?;
302 Ok(())
303 })();
304 match result {
305 Ok(()) => {
306 conn.execute_batch("COMMIT")?;
307 Ok(())
308 }
309 Err(e) => {
310 let _ = conn.execute_batch("ROLLBACK");
311 Err(Error::Db(e))
312 }
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 pub(super) fn seed() -> Connection {
327 let conn = Connection::open_in_memory().unwrap();
328 videre_core::face_db::create_faces_table(&conn).unwrap();
329 conn.execute_batch(
330 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
331 INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
332 ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
333 -- Labels are stored in identity form, as `assign` writes them and
334 -- as the migration leaves them; `people` carries what a reader
335 -- sees. Seeding raw 'Alice' would test a state the application no
336 -- longer produces.
337 INSERT INTO people (name, full_name) VALUES ('alice','Alice');
338 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
339 (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
340 (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
341 (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
342 (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
343 (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
344 )
345 .unwrap();
346 videre_core::db::ensure_file_hashes_columns(&conn);
347 conn
348 }
349
350 #[test]
351 fn the_list_comes_back_in_the_same_order_every_time() {
352 let conn = seed();
359 conn.execute_batch(
363 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
366 ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
367 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
368 (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
369 (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
370 (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
371 (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
372 (10,'h10','0,0,9,9',X'0000',NULL,'Bob',1,0);",
373 )
374 .unwrap();
375
376 let a = faces_list(&conn).unwrap();
379 let b = faces_list(&conn).unwrap();
380
381 let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
382 let names =
383 |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
384 assert!(ids(&a).len() >= 3, "fixture must have several clusters");
385 assert_eq!(
386 ids(&a),
387 ids(&b),
388 "cluster order must not change between calls"
389 );
390 assert_eq!(
391 names(&a),
392 names(&b),
393 "people order must not change between calls"
394 );
395
396 let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
399 let mut want = sizes.clone();
400 want.sort_unstable_by(|x, y| y.cmp(x));
401 assert_eq!(
402 sizes, want,
403 "clusters must be ordered largest first, got {sizes:?}"
404 );
405 }
406
407 #[test]
408 fn faces_list_splits_people_clusters_singletons() {
409 let conn = seed();
410 let d = faces_list(&conn).unwrap();
411 assert_eq!(d.people.len(), 1);
412 assert_eq!(d.people[0].label, "alice");
414 assert_eq!(d.people[0].full_name, "Alice");
415 assert_eq!(
416 d.people[0].representative_id, 1,
417 "primary face is representative"
418 );
419 assert_eq!(d.clusters.len(), 1);
420 assert_eq!(d.clusters[0].cluster_id, 7);
421 assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
422 assert_eq!(d.singletons.len(), 1);
423 assert_eq!(d.singletons[0].face_id, 5);
424 }
425
426 #[test]
427 fn person_detail_marks_primary() {
428 let conn = seed();
429 let p = person_detail(&conn, "Alice").unwrap();
430 assert_eq!(p.faces.len(), 2);
431 assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
432 assert!(!p.faces[1].is_primary);
433 }
434
435 #[test]
436 fn cluster_detail_lists_faces() {
437 let conn = seed();
438 let c = cluster_detail(&conn, 7).unwrap();
439 assert_eq!(c.cluster_id, 7);
440 assert_eq!(
441 c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
442 vec![3, 4]
443 );
444 }
445
446 #[test]
447 fn assign_labels_and_confirms() {
448 let conn = seed();
449 assign(&conn, &[3, 4], "Bob").unwrap();
450 let p = person_detail(&conn, "Bob").unwrap();
451 assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
452 }
453
454 #[test]
455 fn assign_rejects_empty_label() {
456 let conn = seed();
457 assert!(matches!(assign(&conn, &[3], " "), Err(Error::Invalid)));
458 }
459
460 #[test]
461 fn remove_face_unassigns_everything() {
462 let conn = seed();
463 remove_face(&conn, 1).unwrap();
464 let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
465 .query_row(
466 "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
467 [],
468 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
469 )
470 .unwrap();
471 assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
472 }
473
474 #[test]
475 fn dissolve_cluster_nulls_cluster_id() {
476 let conn = seed();
477 dissolve_cluster(&conn, 7).unwrap();
478 assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
479 assert_eq!(
480 faces_list(&conn).unwrap().singletons.len(),
481 3,
482 "3,4 join 5 as singletons"
483 );
484 }
485
486 #[test]
487 fn delete_person_unassigns_without_touching_cluster() {
488 let conn = seed();
489 conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
493 .unwrap();
494 delete_person(&conn, "Alice").unwrap();
495 assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
496 let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
497 .query_row(
498 "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
499 [],
500 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
501 )
502 .unwrap();
503 assert_eq!(cid, Some(42), "cluster_id must be preserved");
504 assert_eq!(label, None, "person_label cleared");
505 assert_eq!(confirmed, 0, "confirmed cleared");
506 }
507
508 #[test]
509 fn set_primary_is_exclusive_per_person() {
510 let conn = seed();
511 set_primary(&conn, 2, "Alice").unwrap();
512 let primaries: Vec<i64> = {
513 let mut s = conn
514 .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
515 .unwrap();
516 s.query_map([], |r| r.get(0))
517 .unwrap()
518 .collect::<rusqlite::Result<_>>()
519 .unwrap()
520 };
521 assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
522 }
523
524 #[test]
525 fn renaming_only_the_spelling_keeps_the_identity() {
526 let conn = seed();
529 set_full_name(&conn, "alice", "Alice Smith").unwrap();
530 let (name, full): (String, String) = conn
531 .query_row("SELECT name, full_name FROM people", [], |r| {
532 Ok((r.get(0)?, r.get(1)?))
533 })
534 .unwrap();
535 assert_eq!(name, "alice", "identity is unchanged");
536 assert_eq!(full, "Alice Smith", "only the display name moved");
537 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
538 }
539}
540
541#[cfg(test)]
542mod identity_tests {
543 use super::tests::seed;
544 use super::*;
545
546 fn people(conn: &Connection) -> Vec<(String, String)> {
547 conn.prepare("SELECT name, full_name FROM people ORDER BY name")
548 .unwrap()
549 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
550 .unwrap()
551 .collect::<rusqlite::Result<_>>()
552 .unwrap()
553 }
554
555 #[test]
556 fn assign_stores_the_identity_and_records_the_display_name() {
557 let conn = seed();
558 assign(&conn, &[3], "Işıl Özyeğin").unwrap();
559
560 let label: String = conn
561 .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
562 r.get(0)
563 })
564 .unwrap();
565 assert_eq!(label, "isil_ozyegin", "faces hold the identity");
566 assert!(
567 people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
568 "and the spelling is kept for display"
569 );
570 }
571
572 #[test]
573 fn assigning_an_existing_name_in_another_case_joins_that_person() {
574 let conn = seed();
577 assign(&conn, &[3], "ALICE").unwrap();
578 assert_eq!(people(&conn).len(), 1, "still one person, not two");
579 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
580 assert_eq!(
581 people(&conn)[0].1,
582 "Alice",
583 "the existing spelling is not overwritten by the new casing"
584 );
585 }
586
587 #[test]
588 fn assign_rejects_a_name_with_no_usable_identity() {
589 let conn = seed();
592 assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
593 }
594
595 #[test]
596 fn person_detail_resolves_every_form_of_the_name() {
597 let conn = seed();
598 for form in ["alice", "Alice", "ALICE", " alice "] {
599 assert_eq!(
600 person_detail(&conn, form).unwrap().faces.len(),
601 2,
602 "form {form:?}"
603 );
604 }
605 }
606
607 #[test]
608 fn person_detail_reports_the_display_name() {
609 let d = person_detail(&seed(), "alice").unwrap();
610 assert_eq!(d.label, "alice");
611 assert_eq!(d.full_name, "Alice");
612 }
613
614 #[test]
615 fn person_detail_falls_back_when_there_is_no_people_row() {
616 let conn = seed();
618 conn.execute(
619 "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
620 VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
621 [],
622 )
623 .unwrap();
624 let d = person_detail(&conn, "orphan").unwrap();
625 assert_eq!(d.full_name, "orphan", "falls back to the identity");
626 }
627
628 #[test]
629 fn set_full_name_changes_only_the_display_name() {
630 let conn = seed();
631 set_full_name(&conn, "alice", "Alice Smith").unwrap();
632 assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
633 assert_eq!(
634 person_detail(&conn, "alice").unwrap().faces.len(),
635 2,
636 "no face was touched"
637 );
638 }
639
640 #[test]
641 fn set_full_name_accepts_any_form_of_the_identity() {
642 let conn = seed();
643 set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
644 assert_eq!(people(&conn)[0].1, "Alice Smith");
645 }
646
647 #[test]
648 fn set_full_name_on_a_missing_person_is_not_found() {
649 assert!(matches!(
650 set_full_name(&seed(), "nobody", "Someone"),
651 Err(Error::NotFound)
652 ));
653 }
654
655 #[test]
656 fn set_full_name_rejects_an_empty_display_name() {
657 assert!(matches!(
659 set_full_name(&seed(), "alice", " "),
660 Err(Error::Invalid)
661 ));
662 }
663
664 #[test]
665 fn delete_person_accepts_any_form_of_the_name() {
666 let conn = seed();
667 delete_person(&conn, "Alice").unwrap();
668 let left: i64 = conn
669 .query_row(
670 "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
671 [],
672 |r| r.get(0),
673 )
674 .unwrap();
675 assert_eq!(left, 0, "faces are unassigned whichever form was passed");
676 }
677
678 #[test]
679 fn set_primary_accepts_any_form_of_the_name() {
680 let conn = seed();
681 set_primary(&conn, 2, "ALICE").unwrap();
682 let primary: i64 = conn
683 .query_row(
684 "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
685 [],
686 |r| r.get(0),
687 )
688 .unwrap();
689 assert_eq!(primary, 2);
690 }
691}
692
693#[cfg(test)]
694mod never_run_tests {
695 use super::*;
696
697 #[test]
709 fn a_library_that_never_ran_detection_is_empty_not_an_error() {
710 let conn = Connection::open_in_memory().unwrap();
711 conn.execute_batch(
712 "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);
713 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT);",
714 )
715 .unwrap();
716
717 let data = faces_list(&conn).expect("a library with no faces table is not an error");
718 assert!(data.people.is_empty());
719 assert!(data.clusters.is_empty());
720 assert!(data.singletons.is_empty());
721 }
722}