Skip to main content

smriti/services/
library_health.rs

1//! Library Health diagnostics.
2//!
3//! Surfaces data-quality counters the user can act on:
4//!   - photos missing thumbnails (file not on disk)
5//!   - photos whose date came from mtime / file_meta (likely wrong)
6//!   - photos with no date_taken at all
7//!   - HEIC files in the library and whether the binary can decode them
8//!   - photos that face-processing finished without finding any faces
9//!
10//! All numbers come from a single SQL pass plus filesystem stats for
11//! the thumbnail check; cheap enough to refresh on every view open
12//! without caching.
13
14use std::path::Path;
15
16use rusqlite::{Connection, Result as SqliteResult};
17
18use crate::services::path_util::safe_join_relative;
19
20#[derive(Debug, Clone, Default)]
21pub struct LibraryHealth {
22    pub total_photos: i64,
23    /// Photos whose `thumbnail_path` is set in DB but the file is
24    /// missing on disk, OR thumbnail_path is NULL.
25    pub missing_thumbnails: i64,
26    /// `date_taken_source IN ('mtime', 'file_meta')` — fallback
27    /// chain used; the year may be off.
28    pub inaccurate_dates: i64,
29    /// `date_taken IS NULL` — couldn't extract a date at all.
30    pub missing_dates: i64,
31    /// Total `.heic` / `.heif` photos in the library.
32    pub heic_count: i64,
33    /// True when this binary was compiled with the `heic` Cargo
34    /// feature (libheif). When false and `heic_count > 0`, the user
35    /// has photos Smriti can't decode.
36    pub heic_decoder_available: bool,
37    /// Photos marked `faces_processed = TRUE` but have zero rows in
38    /// `faces`. Heuristic: face pipeline finished but found nothing
39    /// (could be a bug or genuinely face-less landscape photos).
40    pub face_processed_no_faces: i64,
41}
42
43pub struct LibraryHealthSnapshot {
44    health: LibraryHealth,
45    thumbnail_paths: Vec<String>,
46}
47
48pub fn compute_snapshot(conn: &Connection) -> SqliteResult<LibraryHealthSnapshot> {
49    let total_photos: i64 = conn.query_row(
50        "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE",
51        [],
52        |r| r.get(0),
53    )?;
54
55    let inaccurate_dates: i64 = conn.query_row(
56        "SELECT COUNT(*) FROM photos
57         WHERE is_trashed = FALSE
58           AND date_taken_source IN ('mtime', 'file_meta')",
59        [],
60        |r| r.get(0),
61    )?;
62
63    let missing_dates: i64 = conn.query_row(
64        "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE AND date_taken IS NULL",
65        [],
66        |r| r.get(0),
67    )?;
68
69    let heic_count: i64 = conn.query_row(
70        "SELECT COUNT(*) FROM photos
71         WHERE is_trashed = FALSE
72           AND (LOWER(file_path) LIKE '%.heic' OR LOWER(file_path) LIKE '%.heif')",
73        [],
74        |r| r.get(0),
75    )?;
76
77    let face_processed_no_faces: i64 = conn.query_row(
78        "SELECT COUNT(*) FROM photos p
79         WHERE is_trashed = FALSE
80           AND faces_processed = TRUE
81           AND NOT EXISTS (SELECT 1 FROM faces f WHERE f.photo_id = p.id)",
82        [],
83        |r| r.get(0),
84    )?;
85
86    let null_thumbs: i64 = conn.query_row(
87        "SELECT COUNT(*) FROM photos
88         WHERE is_trashed = FALSE AND thumbnail_path IS NULL",
89        [],
90        |r| r.get(0),
91    )?;
92
93    let mut thumbnail_paths = Vec::new();
94    let mut stmt = conn.prepare(
95        "SELECT thumbnail_path FROM photos
96         WHERE is_trashed = FALSE AND thumbnail_path IS NOT NULL",
97    )?;
98    let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
99    for row in rows {
100        thumbnail_paths.push(row?);
101    }
102
103    Ok(LibraryHealthSnapshot {
104        health: LibraryHealth {
105            total_photos,
106            missing_thumbnails: null_thumbs,
107            inaccurate_dates,
108            missing_dates,
109            heic_count,
110            heic_decoder_available: cfg!(feature = "heic"),
111            face_processed_no_faces,
112        },
113        thumbnail_paths,
114    })
115}
116
117pub fn finish_compute(mut snapshot: LibraryHealthSnapshot, drive_root: &Path) -> LibraryHealth {
118    for row in snapshot.thumbnail_paths {
119        if safe_join_relative(drive_root, &row)
120            .map(|p| !p.exists())
121            .unwrap_or(true)
122        {
123            snapshot.health.missing_thumbnails += 1;
124        }
125    }
126    snapshot.health
127}
128
129/// Compute all library-health counters in one pass. Designed to run
130/// inside `spawn_blocking`.
131pub fn compute(conn: &Connection, drive_root: &Path) -> SqliteResult<LibraryHealth> {
132    let total_photos: i64 = conn.query_row(
133        "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE",
134        [],
135        |r| r.get(0),
136    )?;
137
138    let inaccurate_dates: i64 = conn.query_row(
139        "SELECT COUNT(*) FROM photos
140         WHERE is_trashed = FALSE
141           AND date_taken_source IN ('mtime', 'file_meta')",
142        [],
143        |r| r.get(0),
144    )?;
145
146    let missing_dates: i64 = conn.query_row(
147        "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE AND date_taken IS NULL",
148        [],
149        |r| r.get(0),
150    )?;
151
152    let heic_count: i64 = conn.query_row(
153        "SELECT COUNT(*) FROM photos
154         WHERE is_trashed = FALSE
155           AND (LOWER(file_path) LIKE '%.heic' OR LOWER(file_path) LIKE '%.heif')",
156        [],
157        |r| r.get(0),
158    )?;
159
160    let face_processed_no_faces: i64 = conn.query_row(
161        "SELECT COUNT(*) FROM photos p
162         WHERE is_trashed = FALSE
163           AND faces_processed = TRUE
164           AND NOT EXISTS (SELECT 1 FROM faces f WHERE f.photo_id = p.id)",
165        [],
166        |r| r.get(0),
167    )?;
168
169    // Missing-thumbnail count: NULL thumbnail_path counts directly;
170    // for non-NULL paths we have to stat each file. To bound the
171    // cost we sample only photos whose thumbnail_path is non-NULL —
172    // a thumbnail file gone missing is unusual (manual delete /
173    // disk corruption), so we don't care about strict accuracy.
174    let null_thumbs: i64 = conn.query_row(
175        "SELECT COUNT(*) FROM photos
176         WHERE is_trashed = FALSE AND thumbnail_path IS NULL",
177        [],
178        |r| r.get(0),
179    )?;
180    let mut stat_misses = 0i64;
181    {
182        let mut stmt = conn.prepare(
183            "SELECT thumbnail_path FROM photos
184             WHERE is_trashed = FALSE AND thumbnail_path IS NOT NULL",
185        )?;
186        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
187        for row in rows.flatten() {
188            if safe_join_relative(drive_root, &row)
189                .map(|p| !p.exists())
190                .unwrap_or(true)
191            {
192                stat_misses += 1;
193            }
194        }
195    }
196
197    Ok(LibraryHealth {
198        total_photos,
199        missing_thumbnails: null_thumbs + stat_misses,
200        inaccurate_dates,
201        missing_dates,
202        heic_count,
203        heic_decoder_available: cfg!(feature = "heic"),
204        face_processed_no_faces,
205    })
206}