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/// What the library is made of, by file type.
130///
131/// `stats` could say how many files there were and how big they were in total,
132/// but not what they *are* - and "70,601 files, 480GB" answers a different
133/// question from "12,000 of those are HEIC and they are 22GB of it".
134///
135/// Grouped by extension rather than mime, with the mime shown alongside,
136/// because extension is what a user recognises and types into `--ext`.
137pub struct TypeBreakdown {
138    pub ext: String,
139    pub mime: String,
140    pub files: i64,
141    pub bytes: i64,
142}
143
144pub fn by_type(conn: &rusqlite::Connection, limit: usize) -> rusqlite::Result<Vec<TypeBreakdown>> {
145    let mut stmt = conn.prepare(
146        "SELECT LOWER(COALESCE(NULLIF(ext,''),'(none)')),
147                COALESCE(NULLIF(mime,''),'(unknown)'),
148                COUNT(*), COALESCE(SUM(size_bytes),0)
149           FROM file_hashes
150          GROUP BY 1, 2
151          ORDER BY 4 DESC",
152    )?;
153    let rows = stmt.query_map([], |r| {
154        Ok(TypeBreakdown {
155            ext: r.get(0)?,
156            mime: r.get(1)?,
157            files: r.get(2)?,
158            bytes: r.get(3)?,
159        })
160    })?;
161    let mut out: Vec<TypeBreakdown> = rows.collect::<rusqlite::Result<_>>()?;
162    out.truncate(limit);
163    Ok(out)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn test_db() -> Connection {
171        let conn = Connection::open_in_memory().unwrap();
172        conn.execute_batch(
173            "CREATE TABLE file_hashes (
174                path        TEXT PRIMARY KEY,
175                hash        TEXT NOT NULL,
176                size_bytes  INTEGER,
177                ext         TEXT
178            );",
179        )
180        .unwrap();
181        crate::db::ensure_file_hashes_columns(&conn);
182        conn
183    }
184
185    fn insert_file(conn: &Connection, path: &str, hash: &str, size_bytes: i64, ext: &str) {
186        conn.execute(
187            "INSERT INTO file_hashes (path, hash, size_bytes, ext) VALUES (?1, ?2, ?3, ?4)",
188            rusqlite::params![path, hash, size_bytes, ext],
189        )
190        .unwrap();
191    }
192
193    #[test]
194    fn compute_counts_total_files_and_size() {
195        let conn = test_db();
196        insert_file(&conn, "/a/1.jpg", "h1", 1000, "jpg");
197        insert_file(&conn, "/a/2.png", "h2", 2500, "png");
198
199        let stats = compute(&conn).unwrap();
200        assert_eq!(stats.total_files, 2);
201        assert_eq!(stats.total_size_bytes, 3500);
202    }
203
204    #[test]
205    fn compute_on_empty_db_returns_zeros() {
206        let conn = test_db();
207        let stats = compute(&conn).unwrap();
208        assert_eq!(stats.total_files, 0);
209        assert_eq!(stats.total_size_bytes, 0);
210    }
211
212    #[test]
213    fn compute_splits_photos_and_videos_by_extension() {
214        let conn = test_db();
215        insert_file(&conn, "/a/1.jpg", "h1", 100, "jpg");
216        insert_file(&conn, "/a/2.heic", "h2", 100, "heic");
217        insert_file(&conn, "/a/3.mov", "h3", 100, "mov");
218        insert_file(&conn, "/a/4.mp4", "h4", 100, "mp4");
219        insert_file(&conn, "/a/5.unknown", "h5", 100, "xyz");
220
221        let stats = compute(&conn).unwrap();
222        assert_eq!(stats.total_photos, 2);
223        assert_eq!(stats.total_videos, 2);
224        assert_eq!(stats.total_files, 5); // unrecognized ext still counts toward total_files
225    }
226
227    #[test]
228    fn compute_counts_video_exts_case_insensitively() {
229        let conn = test_db();
230        insert_file(&conn, "/a/1.MOV", "h1", 100, "MOV");
231        insert_file(&conn, "/a/2.Mp4", "h2", 100, "Mp4");
232        insert_file(&conn, "/a/3.mov", "h3", 100, "mov");
233
234        let stats = compute(&conn).unwrap();
235        assert_eq!(stats.total_videos, 3); // uppercase/mixed-case exts still count as video
236    }
237
238    #[test]
239    fn compute_counts_duplicate_groups_and_wasted_bytes() {
240        let conn = test_db();
241        insert_file(&conn, "/a/1.jpg", "dup-hash", 1000, "jpg");
242        insert_file(&conn, "/b/1-copy.jpg", "dup-hash", 1000, "jpg");
243        insert_file(&conn, "/a/2.jpg", "dup-hash", 1000, "jpg");
244        insert_file(&conn, "/a/3.jpg", "unique-hash", 500, "jpg");
245
246        let stats = compute(&conn).unwrap();
247        assert_eq!(stats.duplicate_group_count, 1);
248        assert_eq!(stats.duplicate_file_count, 3); // all 3 members of the dup group
249        assert_eq!(stats.wasted_bytes, 2000); // (3 - 1) * 1000
250    }
251
252    #[test]
253    fn compute_with_no_duplicates_reports_zero() {
254        let conn = test_db();
255        insert_file(&conn, "/a/1.jpg", "h1", 500, "jpg");
256        insert_file(&conn, "/a/2.jpg", "h2", 500, "jpg");
257
258        let stats = compute(&conn).unwrap();
259        assert_eq!(stats.duplicate_group_count, 0);
260        assert_eq!(stats.duplicate_file_count, 0);
261        assert_eq!(stats.wasted_bytes, 0);
262    }
263
264    #[test]
265    fn compute_counts_faces_and_named_people() {
266        let conn = test_db();
267        conn.execute_batch(
268            "CREATE TABLE faces (
269                id            INTEGER PRIMARY KEY,
270                hash          TEXT NOT NULL,
271                bbox          TEXT NOT NULL,
272                landmark      TEXT,
273                embedding     BLOB NOT NULL,
274                cluster_id    INTEGER,
275                person_label  TEXT,
276                confirmed     INTEGER DEFAULT 0,
277                is_primary    INTEGER DEFAULT 0
278            );",
279        )
280        .unwrap();
281        conn.execute(
282            "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
283             VALUES (1, 'h1', '[]', X'00', 'Alice', 1)",
284            [],
285        )
286        .unwrap();
287        conn.execute(
288            "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
289             VALUES (2, 'h1', '[]', X'00', 'Alice', 1)",
290            [],
291        )
292        .unwrap();
293        conn.execute(
294            "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
295             VALUES (3, 'h2', '[]', X'00', NULL, 0)",
296            [],
297        )
298        .unwrap();
299
300        let stats = compute(&conn).unwrap();
301        assert_eq!(stats.faces_detected, 3);
302        assert_eq!(stats.people_named, 1); // distinct confirmed person_label
303    }
304
305    #[test]
306    fn compute_without_faces_table_returns_zero_not_error() {
307        let conn = test_db(); // no faces table created
308        let stats = compute(&conn).unwrap();
309        assert_eq!(stats.faces_detected, 0);
310        assert_eq!(stats.people_named, 0);
311    }
312
313    #[test]
314    fn compute_full_in_reads_models_without_creating_missing_stores() {
315        let temp = tempfile::tempdir().unwrap();
316        let root = temp.path().join("library");
317        let cache = temp.path().join("cache");
318        std::fs::create_dir(&root).unwrap();
319        let ctx = crate::library::LibraryContext::new(&root, &cache).unwrap();
320        let conn = crate::library_db::initialize(&ctx).unwrap();
321        let stats = compute_full_in(&conn, &ctx).unwrap();
322        assert!(stats.embeddings.is_empty());
323        assert!(!ctx.paths.embeddings.exists());
324    }
325}