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`, plus the per-model embedding inventory.
119///
120/// Separate from `compute` because embeddings live outside the connection, in
121/// files addressed by the library's own path. The gallery's stats tile
122/// keeps calling `compute` and is unaffected.
123pub fn compute_full(conn: &Connection, db_path: &std::path::Path) -> anyhow::Result<LibraryStats> {
124    let mut stats = compute(conn)?;
125    stats.embeddings = crate::embeddings_db::counts_by_model(db_path)?;
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
277/// What the library is made of, by file type.
278///
279/// `stats` could say how many files there were and how big they were in total,
280/// but not what they *are* - and "70,601 files, 480GB" answers a different
281/// question from "12,000 of those are HEIC and they are 22GB of it".
282///
283/// Grouped by extension rather than mime, with the mime shown alongside,
284/// because extension is what a user recognises and types into `--ext`.
285pub struct TypeBreakdown {
286    pub ext: String,
287    pub mime: String,
288    pub files: i64,
289    pub bytes: i64,
290}
291
292pub fn by_type(conn: &rusqlite::Connection, limit: usize) -> rusqlite::Result<Vec<TypeBreakdown>> {
293    let mut stmt = conn.prepare(
294        "SELECT LOWER(COALESCE(NULLIF(ext,''),'(none)')),
295                COALESCE(NULLIF(mime,''),'(unknown)'),
296                COUNT(*), COALESCE(SUM(size_bytes),0)
297           FROM file_hashes
298          GROUP BY 1, 2
299          ORDER BY 4 DESC",
300    )?;
301    let rows = stmt.query_map([], |r| {
302        Ok(TypeBreakdown {
303            ext: r.get(0)?,
304            mime: r.get(1)?,
305            files: r.get(2)?,
306            bytes: r.get(3)?,
307        })
308    })?;
309    let mut out: Vec<TypeBreakdown> = rows.collect::<rusqlite::Result<_>>()?;
310    out.truncate(limit);
311    Ok(out)
312}