1use anyhow::Result;
13use rusqlite::Connection;
14use rusqlite::OptionalExtension;
15
16#[derive(Debug, Clone, PartialEq, serde::Serialize)]
20pub struct StageCoverage {
21 pub stage: &'static str,
22 pub outstanding: i64,
23 pub total: i64,
24 pub next_command: Option<&'static str>,
26 pub heavy: bool,
29}
30
31fn faces_eligible_hashes(conn: &Connection) -> Result<Vec<String>> {
34 let mut stmt = conn.prepare(
35 "SELECT hash FROM file_hashes
36 WHERE lower(COALESCE(ext, '')) IN
37 ('jpg','jpeg','png','gif','webp','bmp','tiff','heic')
38 GROUP BY hash",
39 )?;
40 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
41 rows.collect::<std::result::Result<Vec<String>, _>>()
42 .map_err(Into::into)
43}
44
45fn embed_coverage(conn: &Connection, embed_model: &str) -> Result<StageCoverage> {
47 let total = crate::embeddings::embeddable_images(conn, embed_model)?.len() as i64;
48 let outstanding = match crate::embeddings::pending_images(conn, embed_model)? {
49 v => v.len() as i64,
50 };
51 Ok(StageCoverage {
52 stage: "embed",
53 outstanding,
54 total,
55 next_command: Some("videre embed"),
56 heavy: true,
57 })
58}
59
60fn classify_coverage(conn: &Connection, classify_model: &str) -> Result<StageCoverage> {
62 let total: i64 = conn.query_row(
63 "SELECT COUNT(*) FROM emb.embeddings WHERE model_id = ?1",
64 [classify_model],
65 |r| r.get(0),
66 )?;
67 let outstanding = crate::classify::pending_hashes(conn, classify_model)?.len() as i64;
68 Ok(StageCoverage {
69 stage: "classify",
70 outstanding,
71 total,
72 next_command: Some("videre classify"),
73 heavy: true,
74 })
75}
76
77fn faces_coverage(conn: &Connection) -> Result<StageCoverage> {
81 let eligible = faces_eligible_hashes(conn)?;
82 let scanned: std::collections::HashSet<String> =
83 crate::face_db::scanned_hashes(conn)?.into_iter().collect();
84 let with_faces: std::collections::HashSet<String> = crate::face_db::hashes_with_faces(conn)?
85 .into_iter()
86 .collect();
87 let outstanding = eligible
88 .iter()
89 .filter(|h| !scanned.contains(*h) && !with_faces.contains(*h))
90 .count() as i64;
91 Ok(StageCoverage {
92 stage: "faces",
93 outstanding,
94 total: eligible.len() as i64,
95 next_command: Some("videre faces"),
96 heavy: false,
97 })
98}
99
100fn locations_coverage(conn: &Connection) -> Result<StageCoverage> {
104 let total: i64 = conn.query_row(
105 "SELECT COUNT(*) FROM file_hashes WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL",
106 [],
107 |r| r.get(0),
108 )?;
109 let outstanding: i64 = conn.query_row(
110 "SELECT COUNT(*) FROM file_hashes
111 WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL AND location_name IS NULL",
112 [],
113 |r| r.get(0),
114 )?;
115 Ok(StageCoverage {
116 stage: "locations",
117 outstanding,
118 total,
119 next_command: Some("videre locations"),
120 heavy: false,
121 })
122}
123
124fn fix_dates_coverage(conn: &Connection) -> Result<StageCoverage> {
128 let mut stmt =
129 conn.prepare("SELECT exif_date, modified_at FROM file_hashes WHERE exif_date IS NOT NULL")?;
130 let rows = stmt.query_map([], |r| {
131 Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
132 })?;
133 let mut total = 0i64;
134 let mut outstanding = 0i64;
135 for row in rows {
136 let (exif_date, modified_at) = row?;
137 total += 1;
138 if let Some(target) = crate::fix_dates_target::target_modified_at(&exif_date) {
139 if modified_at.as_deref() != Some(target.as_str()) {
140 outstanding += 1;
141 }
142 }
143 }
144 Ok(StageCoverage {
145 stage: "fix-dates",
146 outstanding,
147 total,
148 next_command: Some("videre fix-dates"),
149 heavy: false,
150 })
151}
152
153pub fn coverage_in(
157 conn: &Connection,
158 embed_model: &str,
159 classify_model: &str,
160) -> Result<Vec<StageCoverage>> {
161 Ok(vec![
162 embed_coverage(conn, embed_model)?,
163 classify_coverage(conn, classify_model)?,
164 faces_coverage(conn)?,
165 locations_coverage(conn)?,
166 fix_dates_coverage(conn)?,
167 ])
168}
169
170#[derive(Debug, Clone, PartialEq, serde::Serialize)]
176pub struct WatchLiveness {
177 pub running: bool,
178 pub last_cycle_at: Option<String>,
179}
180
181pub fn watch_liveness_in(
184 conn: &Connection,
185 ctx: &crate::library::LibraryContext,
186) -> Result<WatchLiveness> {
187 crate::pipeline_runs::ensure_pipeline_runs_table(conn)?;
188 let last_cycle_at: Option<String> = conn
189 .query_row(
190 "SELECT started_at FROM pipeline_runs WHERE command = 'watch'",
191 [],
192 |r| r.get(0),
193 )
194 .optional()?;
195 Ok(WatchLiveness {
196 running: crate::library_locks::command_locked(ctx, "watch")?,
197 last_cycle_at,
198 })
199}
200
201#[derive(Debug, Clone, PartialEq, serde::Serialize)]
206pub struct CostEstimate {
207 pub secs: Option<u64>,
208 pub approximate: bool,
209}
210
211pub fn estimate_cost(
222 outstanding: i64,
223 last_run_ms: Option<i64>,
224 last_run_items: Option<i64>,
225) -> CostEstimate {
226 let secs = match (last_run_ms, last_run_items) {
227 (Some(ms), Some(items)) if outstanding > 0 && ms > 0 && items > 0 => {
228 let per_item = ms as f64 / items as f64 / 1000.0;
229 Some((outstanding as f64 * per_item).ceil() as u64)
230 }
231 _ => None,
232 };
233 CostEstimate {
234 secs,
235 approximate: true,
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, serde::Serialize)]
243pub struct StatusReport {
244 pub coverage: Vec<StageCoverage>,
245 pub pipelines: Vec<crate::pipeline_runs::PipelineRunStatus>,
246 pub watch: WatchLiveness,
247 pub costs: Vec<(&'static str, CostEstimate)>,
250 pub embed_model: String,
252}
253
254impl StatusReport {
255 pub fn has_problem(&self) -> bool {
259 self.pipelines
260 .iter()
261 .any(|p| matches!(p.status.as_deref(), Some("failed") | Some("crashed")))
262 }
263}
264
265pub fn compute_status_in(
268 conn: &Connection,
269 ctx: &crate::library::LibraryContext,
270) -> Result<StatusReport> {
271 let embed_model = ctx.settings.default_model.clone();
272 let coverage = coverage_in(conn, &embed_model, &embed_model)?;
273 let pipelines = crate::pipeline_runs::read_all_in(conn, ctx)?;
274 let watch = watch_liveness_in(conn, ctx)?;
275 let costs = coverage
276 .iter()
277 .filter(|c| c.outstanding > 0)
278 .map(|c| {
279 let prior = pipelines.iter().find(|p| p.command == c.stage);
280 (
281 c.stage,
282 estimate_cost(c.outstanding, prior.and_then(|p| p.duration_ms), None),
286 )
287 })
288 .collect();
289 Ok(StatusReport {
290 coverage,
291 pipelines,
292 watch,
293 costs,
294 embed_model,
295 })
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 const TEST_MODEL: &str = "test/model";
303
304 fn seed_db(tag: &str) -> Connection {
307 let ctx = crate::embeddings_db::test_context(tag);
308 let conn = Connection::open_in_memory().unwrap();
309 conn.execute_batch(
310 "CREATE TABLE file_hashes (
311 path TEXT PRIMARY KEY,
312 hash TEXT NOT NULL,
313 mime TEXT,
314 size_bytes INTEGER,
315 created_at TEXT,
316 modified_at TEXT,
317 ext TEXT,
318 phash INTEGER,
319 exif_date TEXT,
320 gps_lat REAL,
321 gps_lon REAL,
322 width INTEGER,
323 height INTEGER,
324 location_name TEXT
325 );",
326 )
327 .unwrap();
328 conn.execute_batch(
329 "CREATE TABLE IF NOT EXISTS classifications (
330 model_id TEXT NOT NULL,
331 hash TEXT NOT NULL,
332 category TEXT NOT NULL,
333 confidence REAL NOT NULL,
334 classified_at TEXT NOT NULL,
335 PRIMARY KEY (model_id, hash)
336 );",
337 )
338 .unwrap();
339 crate::embeddings_db::attach_in(&conn, &ctx, TEST_MODEL, true).unwrap();
340 crate::face_db::create_faces_table(&conn).unwrap();
341 conn
342 }
343
344 fn insert_file(conn: &Connection, path: &str, hash: &str, ext: &str) {
345 conn.execute(
346 "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, ?2, ?3)",
347 [path, hash, ext],
348 )
349 .unwrap();
350 }
351
352 #[test]
353 fn coverage_counts_outstanding_per_stage() {
354 let conn = seed_db("status_cov_main");
355 insert_file(&conn, "/a/1.jpg", "h1", "jpg");
357 insert_file(&conn, "/a/2.jpg", "h2", "jpg");
358 insert_file(&conn, "/a/3.jpg", "h3", "jpg");
359 crate::embeddings::insert_embeddings(
360 &conn,
361 TEST_MODEL,
362 &[("h1".into(), vec![0u8; 4]), ("h2".into(), vec![0u8; 4])],
363 )
364 .unwrap();
365 conn.execute(
366 "INSERT INTO classifications (model_id, hash, category, confidence, classified_at)
367 VALUES ('test/model', 'h1', 'cat', 0.9, 'now')",
368 [],
369 )
370 .unwrap();
371
372 let cov = coverage_in(&conn, TEST_MODEL, TEST_MODEL).unwrap();
373 let embed = cov.iter().find(|c| c.stage == "embed").unwrap();
374 assert_eq!(embed.outstanding, 1, "h3 is the only un-embedded image");
375 assert_eq!(embed.total, 3);
376 assert!(embed.heavy);
377 assert_eq!(embed.next_command, Some("videre embed"));
378
379 let classify = cov.iter().find(|c| c.stage == "classify").unwrap();
380 assert_eq!(classify.outstanding, 1, "h2 is embedded but unclassified");
381 assert_eq!(classify.total, 2);
382 }
383
384 #[test]
385 fn faces_coverage_counts_unscanned_not_faceless() {
386 let conn = seed_db("status_cov_faces");
387 insert_file(&conn, "/a/1.jpg", "h1", "jpg");
388 insert_file(&conn, "/a/2.jpg", "h2", "jpg");
389 insert_file(&conn, "/a/3.png", "h3", "png");
390 insert_file(&conn, "/a/v.mp4", "h4", "mp4"); conn.execute("INSERT INTO faces_scanned (hash) VALUES ('h1')", [])
393 .unwrap();
394 conn.execute(
395 "INSERT INTO faces (hash, bbox, embedding) VALUES ('h2', '0,0,10,10', X'00')",
396 [],
397 )
398 .unwrap();
399
400 let cov = coverage_in(&conn, TEST_MODEL, TEST_MODEL).unwrap();
401 let faces = cov.iter().find(|c| c.stage == "faces").unwrap();
402 assert_eq!(faces.total, 3, "videos are not faces-eligible");
403 assert_eq!(faces.outstanding, 1, "only h3 was never tried");
404 assert!(!faces.heavy);
405 }
406
407 #[test]
408 fn locations_and_fix_dates_count_only_true_gaps() {
409 let conn = seed_db("status_cov_locfix");
410 let exif = "2021-07-04T15:30:00";
416 let in_sync = crate::fix_dates_target::target_modified_at(exif).unwrap();
417 conn.execute(
419 "INSERT INTO file_hashes (path, hash, ext, gps_lat, gps_lon, location_name,
420 exif_date, modified_at)
421 VALUES ('/a/1.jpg', 'h1', 'jpg', 52.5, 13.4, 'Berlin, Germany', ?1, ?2)",
422 [exif.to_string(), in_sync.clone()],
423 )
424 .unwrap();
425 conn.execute(
426 "INSERT INTO file_hashes (path, hash, ext, gps_lat, gps_lon, exif_date, modified_at)
427 VALUES ('/a/2.jpg', 'h2', 'jpg', 48.8, 2.3, '2020-01-02T03:04:05', '1970-01-01T00:00:00+00:00')",
428 [],
429 )
430 .unwrap();
431 conn.execute(
432 "INSERT INTO file_hashes (path, hash, ext, exif_date, modified_at)
433 VALUES ('/a/3.jpg', 'h3', 'jpg', ?1, ?2)",
434 [exif.to_string(), in_sync],
435 )
436 .unwrap();
437
438 let cov = coverage_in(&conn, TEST_MODEL, TEST_MODEL).unwrap();
439 let locations = cov.iter().find(|c| c.stage == "locations").unwrap();
440 assert_eq!(locations.total, 2);
441 assert_eq!(locations.outstanding, 1, "h2 has GPS and no place name");
442
443 let fix = cov.iter().find(|c| c.stage == "fix-dates").unwrap();
444 assert_eq!(fix.total, 3);
445 assert_eq!(
446 fix.outstanding, 1,
447 "only h2's mtime disagrees with its exif_date"
448 );
449 }
450
451 #[test]
452 fn cost_is_shown_only_when_it_can_be_measured() {
453 let c = estimate_cost(10, Some(50_000), Some(100));
455 assert_eq!(c.secs, Some(5));
456 assert!(c.approximate);
457 assert_eq!(estimate_cost(10, None, None).secs, None);
459 assert_eq!(estimate_cost(10, Some(50_000), None).secs, None);
461 assert_eq!(estimate_cost(0, Some(50_000), Some(100)).secs, None);
463 assert_eq!(estimate_cost(10, Some(50_000), Some(0)).secs, None);
465 }
466
467 #[test]
468 fn compute_status_assembles_the_whole_report() {
469 let ctx = crate::embeddings_db::test_context("status_compute");
470 std::fs::create_dir_all(&ctx.paths.locks).unwrap();
471 let conn = seed_db("status_compute");
472 insert_file(&conn, "/a/1.jpg", "h1", "jpg");
473 insert_file(&conn, "/a/2.jpg", "h2", "jpg");
474
475 let report = compute_status_in(&conn, &ctx).unwrap();
476 assert_eq!(report.embed_model, ctx.settings.default_model);
477 assert!(!report.coverage.is_empty());
478 let embed = report.coverage.iter().find(|c| c.stage == "embed").unwrap();
479 assert_eq!(embed.outstanding, 2);
480 let embed_cost = report
483 .costs
484 .iter()
485 .find(|(stage, _)| *stage == "embed")
486 .expect("embed cost entry");
487 assert_eq!(embed_cost.1.secs, None);
488 assert!(report.pipelines.iter().all(|p| p.status.is_none()));
489 assert!(!report.watch.running);
490 assert_eq!(report.watch.last_cycle_at, None);
491 assert!(
492 !report.has_problem(),
493 "a fresh library is healthy, not failing"
494 );
495
496 crate::pipeline_runs::start_run(&conn, "faces").unwrap();
498 crate::pipeline_runs::finish_run(&conn, "faces", "failed", 5, Some("boom")).unwrap();
499 let report = compute_status_in(&conn, &ctx).unwrap();
500 assert!(report.has_problem());
501 }
502}