1use rusqlite::{Connection, Result};
8use serde::Serialize;
9
10#[derive(Debug, Clone, PartialEq, Default, Serialize)]
11pub struct LibraryStats {
12 pub total_files: i64,
13 pub total_size_bytes: i64,
14 pub total_photos: i64,
15 pub total_videos: i64,
16 pub duplicate_group_count: i64,
17 pub duplicate_file_count: i64,
18 pub wasted_bytes: i64,
19 pub faces_detected: i64,
20 pub people_named: i64,
21 #[serde(default)]
24 pub embeddings: Vec<crate::embeddings_db::ModelEmbeddingCount>,
25}
26
27use crate::db::table_exists;
28
29const PHOTO_MIME_LIST: &str =
30 "'image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/heic'";
31const VIDEO_MIME_LIST: &str = "'video/quicktime','video/mp4'";
32
33const PHOTO_EXTS: &str = "'jpg','jpeg','png','gif','webp','bmp','tiff','heic','dng'";
34const VIDEO_EXTS: &str = "'mov','mp4'";
35
36pub fn compute(conn: &Connection) -> Result<LibraryStats> {
42 let total_files: i64 = conn.query_row("SELECT COUNT(*) FROM file_hashes", [], |r| r.get(0))?;
43 let total_size_bytes: i64 =
44 conn.query_row("SELECT COALESCE(SUM(size_bytes), 0) FROM file_hashes", [], |r| r.get(0))?;
45 let total_photos: i64 = conn.query_row(
46 &format!(
47 "SELECT COUNT(*) FROM file_hashes
48 WHERE mime IN ({PHOTO_MIME_LIST})
49 OR (mime IS NULL AND lower(ext) IN ({PHOTO_EXTS}))"
50 ),
51 [],
52 |r| r.get(0),
53 )?;
54 let total_videos: i64 = conn.query_row(
55 &format!(
56 "SELECT COUNT(*) FROM file_hashes
57 WHERE mime IN ({VIDEO_MIME_LIST})
58 OR (mime IS NULL AND lower(ext) IN ({VIDEO_EXTS}))"
59 ),
60 [],
61 |r| r.get(0),
62 )?;
63
64 let duplicate_group_count: i64 = conn.query_row(
65 "SELECT COUNT(*) FROM \
66 (SELECT hash FROM file_hashes GROUP BY hash HAVING COUNT(*) > 1)",
67 [],
68 |r| r.get(0),
69 )?;
70 let duplicate_file_count: i64 = conn.query_row(
71 "SELECT COUNT(*) FROM file_hashes \
72 WHERE hash IN (SELECT hash FROM file_hashes GROUP BY hash HAVING COUNT(*) > 1)",
73 [],
74 |r| r.get(0),
75 )?;
76 let wasted_bytes: i64 = conn.query_row(
77 "SELECT COALESCE(SUM(size_bytes * (cnt - 1)), 0) FROM \
78 (SELECT hash, size_bytes, COUNT(*) as cnt \
79 FROM file_hashes GROUP BY hash HAVING cnt > 1)",
80 [],
81 |r| r.get(0),
82 )?;
83
84 let (faces_detected, people_named) = if table_exists(conn, "faces")? {
85 let faces_detected: i64 = conn.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))?;
86 let people_named: i64 = conn.query_row(
87 "SELECT COUNT(DISTINCT person_label) FROM faces \
88 WHERE confirmed = 1 AND person_label IS NOT NULL",
89 [],
90 |r| r.get(0),
91 )?;
92 (faces_detected, people_named)
93 } else {
94 (0, 0)
95 };
96
97 Ok(LibraryStats {
98 total_files,
99 total_size_bytes,
100 total_photos,
101 total_videos,
102 duplicate_group_count,
103 duplicate_file_count,
104 wasted_bytes,
105 faces_detected,
106 people_named,
107 embeddings: Vec::new(),
108 })
109}
110
111pub fn compute_full(conn: &Connection, db_path: &std::path::Path) -> anyhow::Result<LibraryStats> {
117 let mut stats = compute(conn)?;
118 stats.embeddings = crate::embeddings_db::counts_by_model(db_path)?;
119 Ok(stats)
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 fn test_db() -> Connection {
127 let conn = Connection::open_in_memory().unwrap();
128 conn.execute_batch(
129 "CREATE TABLE file_hashes (
130 path TEXT PRIMARY KEY,
131 hash TEXT NOT NULL,
132 size_bytes INTEGER,
133 ext TEXT
134 );",
135 )
136 .unwrap();
137 crate::db::ensure_file_hashes_columns(&conn);
138 conn
139 }
140
141 fn insert_file(conn: &Connection, path: &str, hash: &str, size_bytes: i64, ext: &str) {
142 conn.execute(
143 "INSERT INTO file_hashes (path, hash, size_bytes, ext) VALUES (?1, ?2, ?3, ?4)",
144 rusqlite::params![path, hash, size_bytes, ext],
145 )
146 .unwrap();
147 }
148
149 #[test]
150 fn compute_counts_total_files_and_size() {
151 let conn = test_db();
152 insert_file(&conn, "/a/1.jpg", "h1", 1000, "jpg");
153 insert_file(&conn, "/a/2.png", "h2", 2500, "png");
154
155 let stats = compute(&conn).unwrap();
156 assert_eq!(stats.total_files, 2);
157 assert_eq!(stats.total_size_bytes, 3500);
158 }
159
160 #[test]
161 fn compute_on_empty_db_returns_zeros() {
162 let conn = test_db();
163 let stats = compute(&conn).unwrap();
164 assert_eq!(stats.total_files, 0);
165 assert_eq!(stats.total_size_bytes, 0);
166 }
167
168 #[test]
169 fn compute_splits_photos_and_videos_by_extension() {
170 let conn = test_db();
171 insert_file(&conn, "/a/1.jpg", "h1", 100, "jpg");
172 insert_file(&conn, "/a/2.heic", "h2", 100, "heic");
173 insert_file(&conn, "/a/3.mov", "h3", 100, "mov");
174 insert_file(&conn, "/a/4.mp4", "h4", 100, "mp4");
175 insert_file(&conn, "/a/5.unknown", "h5", 100, "xyz");
176
177 let stats = compute(&conn).unwrap();
178 assert_eq!(stats.total_photos, 2);
179 assert_eq!(stats.total_videos, 2);
180 assert_eq!(stats.total_files, 5); }
182
183 #[test]
184 fn compute_counts_video_exts_case_insensitively() {
185 let conn = test_db();
186 insert_file(&conn, "/a/1.MOV", "h1", 100, "MOV");
187 insert_file(&conn, "/a/2.Mp4", "h2", 100, "Mp4");
188 insert_file(&conn, "/a/3.mov", "h3", 100, "mov");
189
190 let stats = compute(&conn).unwrap();
191 assert_eq!(stats.total_videos, 3); }
193
194 #[test]
195 fn compute_counts_duplicate_groups_and_wasted_bytes() {
196 let conn = test_db();
197 insert_file(&conn, "/a/1.jpg", "dup-hash", 1000, "jpg");
198 insert_file(&conn, "/b/1-copy.jpg", "dup-hash", 1000, "jpg");
199 insert_file(&conn, "/a/2.jpg", "dup-hash", 1000, "jpg");
200 insert_file(&conn, "/a/3.jpg", "unique-hash", 500, "jpg");
201
202 let stats = compute(&conn).unwrap();
203 assert_eq!(stats.duplicate_group_count, 1);
204 assert_eq!(stats.duplicate_file_count, 3); assert_eq!(stats.wasted_bytes, 2000); }
207
208 #[test]
209 fn compute_with_no_duplicates_reports_zero() {
210 let conn = test_db();
211 insert_file(&conn, "/a/1.jpg", "h1", 500, "jpg");
212 insert_file(&conn, "/a/2.jpg", "h2", 500, "jpg");
213
214 let stats = compute(&conn).unwrap();
215 assert_eq!(stats.duplicate_group_count, 0);
216 assert_eq!(stats.duplicate_file_count, 0);
217 assert_eq!(stats.wasted_bytes, 0);
218 }
219
220 #[test]
221 fn compute_counts_faces_and_named_people() {
222 let conn = test_db();
223 conn.execute_batch(
224 "CREATE TABLE faces (
225 id INTEGER PRIMARY KEY,
226 hash TEXT NOT NULL,
227 bbox TEXT NOT NULL,
228 landmark TEXT,
229 embedding BLOB NOT NULL,
230 cluster_id INTEGER,
231 person_label TEXT,
232 confirmed INTEGER DEFAULT 0,
233 is_primary INTEGER DEFAULT 0
234 );",
235 )
236 .unwrap();
237 conn.execute(
238 "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
239 VALUES (1, 'h1', '[]', X'00', 'Alice', 1)",
240 [],
241 )
242 .unwrap();
243 conn.execute(
244 "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
245 VALUES (2, 'h1', '[]', X'00', 'Alice', 1)",
246 [],
247 )
248 .unwrap();
249 conn.execute(
250 "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
251 VALUES (3, 'h2', '[]', X'00', NULL, 0)",
252 [],
253 )
254 .unwrap();
255
256 let stats = compute(&conn).unwrap();
257 assert_eq!(stats.faces_detected, 3);
258 assert_eq!(stats.people_named, 1); }
260
261 #[test]
262 fn compute_without_faces_table_returns_zero_not_error() {
263 let conn = test_db(); let stats = compute(&conn).unwrap();
265 assert_eq!(stats.faces_detected, 0);
266 assert_eq!(stats.people_named, 0);
267 }
268}