Skip to main content

videre_core/
library_stats.rs

1//! Aggregate library statistics for dashboard-style callers.
2//! Plain queries over an open `rusqlite::Connection`, shared source of truth
3//! for the gallery's stats tile, `videre stats`, and any other embedder.
4//! See docs/superpowers/specs/2026-07-31-dashboard-stats-backend-design.md
5//! (Pass A) for what is and isn't in scope.
6
7use 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    /// Counts of rated/picked/labelled/liked photos.
22    #[serde(default)]
23    pub marks: crate::marks::MarksSummary,
24    /// One entry per model with an embedding database for this library.
25    /// Empty when nothing has been embedded, which is a normal state.
26    #[serde(default)]
27    pub embeddings: Vec<crate::embeddings_db::ModelEmbeddingCount>,
28}
29
30use crate::db::table_exists;
31
32const PHOTO_MIME_LIST: &str =
33    "'image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/heic'";
34const VIDEO_MIME_LIST: &str = "'video/quicktime','video/mp4'";
35
36const PHOTO_EXTS: &str = "'jpg','jpeg','png','gif','webp','bmp','tiff','heic','dng'";
37const VIDEO_EXTS: &str = "'mov','mp4'";
38
39// `VIDEO_EXTS`'s values must stay in sync with
40// `crate::embeddings::is_video_ext` (the shared "is this a video" check).
41// The SQL below uses `lower(ext)` so this list stays case-insensitive,
42// matching that helper.
43
44pub fn compute(conn: &Connection) -> Result<LibraryStats> {
45    let total_files: i64 = conn.query_row("SELECT COUNT(*) FROM file_hashes", [], |r| r.get(0))?;
46    let total_size_bytes: i64 = conn.query_row(
47        "SELECT COALESCE(SUM(size_bytes), 0) FROM file_hashes",
48        [],
49        |r| r.get(0),
50    )?;
51    let total_photos: i64 = conn.query_row(
52        &format!(
53            "SELECT COUNT(*) FROM file_hashes
54             WHERE mime IN ({PHOTO_MIME_LIST})
55                OR (mime IS NULL AND lower(ext) IN ({PHOTO_EXTS}))"
56        ),
57        [],
58        |r| r.get(0),
59    )?;
60    let total_videos: i64 = conn.query_row(
61        &format!(
62            "SELECT COUNT(*) FROM file_hashes
63             WHERE mime IN ({VIDEO_MIME_LIST})
64                OR (mime IS NULL AND lower(ext) IN ({VIDEO_EXTS}))"
65        ),
66        [],
67        |r| r.get(0),
68    )?;
69
70    let duplicate_group_count: i64 = conn.query_row(
71        "SELECT COUNT(*) FROM \
72         (SELECT hash FROM file_hashes GROUP BY hash HAVING COUNT(*) > 1)",
73        [],
74        |r| r.get(0),
75    )?;
76    let duplicate_file_count: i64 = conn.query_row(
77        "SELECT COUNT(*) FROM file_hashes \
78         WHERE hash IN (SELECT hash FROM file_hashes GROUP BY hash HAVING COUNT(*) > 1)",
79        [],
80        |r| r.get(0),
81    )?;
82    let wasted_bytes: i64 = conn.query_row(
83        "SELECT COALESCE(SUM(size_bytes * (cnt - 1)), 0) FROM \
84         (SELECT hash, size_bytes, COUNT(*) as cnt \
85          FROM file_hashes GROUP BY hash HAVING cnt > 1)",
86        [],
87        |r| r.get(0),
88    )?;
89
90    let (faces_detected, people_named) = if table_exists(conn, "faces")? {
91        let faces_detected: i64 = conn.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))?;
92        let people_named: i64 = conn.query_row(
93            "SELECT COUNT(DISTINCT person_label) FROM faces \
94             WHERE confirmed = 1 AND person_label IS NOT NULL",
95            [],
96            |r| r.get(0),
97        )?;
98        (faces_detected, people_named)
99    } else {
100        (0, 0)
101    };
102
103    Ok(LibraryStats {
104        total_files,
105        total_size_bytes,
106        total_photos,
107        total_videos,
108        duplicate_group_count,
109        duplicate_file_count,
110        wasted_bytes,
111        faces_detected,
112        people_named,
113        marks: crate::marks::summary(conn)?,
114        embeddings: Vec::new(),
115    })
116}
117
118/// Compute database and model-store statistics for one selected library.
119pub fn compute_full_in(
120    conn: &Connection,
121    ctx: &crate::library::LibraryContext,
122) -> anyhow::Result<LibraryStats> {
123    ctx.ensure_root_identity()?;
124    let mut stats = compute(conn)?;
125    stats.embeddings = crate::embeddings_db::counts_by_model_in(ctx)?;
126    Ok(stats)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn test_db() -> Connection {
134        let conn = Connection::open_in_memory().unwrap();
135        conn.execute_batch(
136            "CREATE TABLE file_hashes (
137                path        TEXT PRIMARY KEY,
138                hash        TEXT NOT NULL,
139                size_bytes  INTEGER,
140                ext         TEXT
141            );",
142        )
143        .unwrap();
144        crate::db::ensure_file_hashes_columns(&conn);
145        conn
146    }
147
148    fn insert_file(conn: &Connection, path: &str, hash: &str, size_bytes: i64, ext: &str) {
149        conn.execute(
150            "INSERT INTO file_hashes (path, hash, size_bytes, ext) VALUES (?1, ?2, ?3, ?4)",
151            rusqlite::params![path, hash, size_bytes, ext],
152        )
153        .unwrap();
154    }
155
156    #[test]
157    fn compute_counts_total_files_and_size() {
158        let conn = test_db();
159        insert_file(&conn, "/a/1.jpg", "h1", 1000, "jpg");
160        insert_file(&conn, "/a/2.png", "h2", 2500, "png");
161
162        let stats = compute(&conn).unwrap();
163        assert_eq!(stats.total_files, 2);
164        assert_eq!(stats.total_size_bytes, 3500);
165    }
166
167    #[test]
168    fn compute_on_empty_db_returns_zeros() {
169        let conn = test_db();
170        let stats = compute(&conn).unwrap();
171        assert_eq!(stats.total_files, 0);
172        assert_eq!(stats.total_size_bytes, 0);
173    }
174
175    #[test]
176    fn compute_splits_photos_and_videos_by_extension() {
177        let conn = test_db();
178        insert_file(&conn, "/a/1.jpg", "h1", 100, "jpg");
179        insert_file(&conn, "/a/2.heic", "h2", 100, "heic");
180        insert_file(&conn, "/a/3.mov", "h3", 100, "mov");
181        insert_file(&conn, "/a/4.mp4", "h4", 100, "mp4");
182        insert_file(&conn, "/a/5.unknown", "h5", 100, "xyz");
183
184        let stats = compute(&conn).unwrap();
185        assert_eq!(stats.total_photos, 2);
186        assert_eq!(stats.total_videos, 2);
187        assert_eq!(stats.total_files, 5); // unrecognized ext still counts toward total_files
188    }
189
190    #[test]
191    fn compute_counts_video_exts_case_insensitively() {
192        let conn = test_db();
193        insert_file(&conn, "/a/1.MOV", "h1", 100, "MOV");
194        insert_file(&conn, "/a/2.Mp4", "h2", 100, "Mp4");
195        insert_file(&conn, "/a/3.mov", "h3", 100, "mov");
196
197        let stats = compute(&conn).unwrap();
198        assert_eq!(stats.total_videos, 3); // uppercase/mixed-case exts still count as video
199    }
200
201    #[test]
202    fn compute_counts_duplicate_groups_and_wasted_bytes() {
203        let conn = test_db();
204        insert_file(&conn, "/a/1.jpg", "dup-hash", 1000, "jpg");
205        insert_file(&conn, "/b/1-copy.jpg", "dup-hash", 1000, "jpg");
206        insert_file(&conn, "/a/2.jpg", "dup-hash", 1000, "jpg");
207        insert_file(&conn, "/a/3.jpg", "unique-hash", 500, "jpg");
208
209        let stats = compute(&conn).unwrap();
210        assert_eq!(stats.duplicate_group_count, 1);
211        assert_eq!(stats.duplicate_file_count, 3); // all 3 members of the dup group
212        assert_eq!(stats.wasted_bytes, 2000); // (3 - 1) * 1000
213    }
214
215    #[test]
216    fn compute_with_no_duplicates_reports_zero() {
217        let conn = test_db();
218        insert_file(&conn, "/a/1.jpg", "h1", 500, "jpg");
219        insert_file(&conn, "/a/2.jpg", "h2", 500, "jpg");
220
221        let stats = compute(&conn).unwrap();
222        assert_eq!(stats.duplicate_group_count, 0);
223        assert_eq!(stats.duplicate_file_count, 0);
224        assert_eq!(stats.wasted_bytes, 0);
225    }
226
227    #[test]
228    fn compute_counts_faces_and_named_people() {
229        let conn = test_db();
230        conn.execute_batch(
231            "CREATE TABLE faces (
232                id            INTEGER PRIMARY KEY,
233                hash          TEXT NOT NULL,
234                bbox          TEXT NOT NULL,
235                landmark      TEXT,
236                embedding     BLOB NOT NULL,
237                cluster_id    INTEGER,
238                person_label  TEXT,
239                confirmed     INTEGER DEFAULT 0,
240                is_primary    INTEGER DEFAULT 0
241            );",
242        )
243        .unwrap();
244        conn.execute(
245            "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
246             VALUES (1, 'h1', '[]', X'00', 'Alice', 1)",
247            [],
248        )
249        .unwrap();
250        conn.execute(
251            "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
252             VALUES (2, 'h1', '[]', X'00', 'Alice', 1)",
253            [],
254        )
255        .unwrap();
256        conn.execute(
257            "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
258             VALUES (3, 'h2', '[]', X'00', NULL, 0)",
259            [],
260        )
261        .unwrap();
262
263        let stats = compute(&conn).unwrap();
264        assert_eq!(stats.faces_detected, 3);
265        assert_eq!(stats.people_named, 1); // distinct confirmed person_label
266    }
267
268    #[test]
269    fn compute_without_faces_table_returns_zero_not_error() {
270        let conn = test_db(); // no faces table created
271        let stats = compute(&conn).unwrap();
272        assert_eq!(stats.faces_detected, 0);
273        assert_eq!(stats.people_named, 0);
274    }
275
276    #[test]
277    fn compute_full_in_reads_models_without_creating_missing_stores() {
278        let temp = tempfile::tempdir().unwrap();
279        let root = temp.path().join("library");
280        let cache = temp.path().join("cache");
281        std::fs::create_dir(&root).unwrap();
282        let ctx = crate::library::LibraryContext::new(&root, &cache).unwrap();
283        let conn = crate::library_db::initialize(&ctx).unwrap();
284        let stats = compute_full_in(&conn, &ctx).unwrap();
285        assert!(stats.embeddings.is_empty());
286        assert!(!ctx.paths.embeddings.exists());
287    }
288}
289
290/// What the library is made of, by file type.
291///
292/// `stats` could say how many files there were and how big they were in total,
293/// but not what they *are* - and "70,601 files, 480GB" answers a different
294/// question from "12,000 of those are HEIC and they are 22GB of it".
295///
296/// Grouped by extension rather than mime, with the mime shown alongside,
297/// because extension is what a user recognises and types into `--ext`.
298pub struct TypeBreakdown {
299    pub ext: String,
300    pub mime: String,
301    pub files: i64,
302    pub bytes: i64,
303}
304
305pub fn by_type(conn: &rusqlite::Connection, limit: usize) -> rusqlite::Result<Vec<TypeBreakdown>> {
306    let mut stmt = conn.prepare(
307        "SELECT LOWER(COALESCE(NULLIF(ext,''),'(none)')),
308                COALESCE(NULLIF(mime,''),'(unknown)'),
309                COUNT(*), COALESCE(SUM(size_bytes),0)
310           FROM file_hashes
311          GROUP BY 1, 2
312          ORDER BY 4 DESC",
313    )?;
314    let rows = stmt.query_map([], |r| {
315        Ok(TypeBreakdown {
316            ext: r.get(0)?,
317            mime: r.get(1)?,
318            files: r.get(2)?,
319            bytes: r.get(3)?,
320        })
321    })?;
322    let mut out: Vec<TypeBreakdown> = rows.collect::<rusqlite::Result<_>>()?;
323    out.truncate(limit);
324    Ok(out)
325}