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)]
205pub struct CostEstimate {
206 pub secs: Option<u64>,
207 pub approximate: bool,
208}
209
210pub fn estimate_cost(
217 outstanding: i64,
218 last_run_ms: Option<i64>,
219 last_run_items: Option<i64>,
220 fallback_secs_per_item: f64,
221) -> CostEstimate {
222 let secs = if outstanding <= 0 {
223 None
224 } else {
225 let per_item = match (last_run_ms, last_run_items) {
226 (Some(ms), Some(items)) if ms > 0 && items > 0 => ms as f64 / items as f64 / 1000.0,
227 _ => fallback_secs_per_item,
228 };
229 Some((outstanding as f64 * per_item).ceil() as u64)
230 };
231 CostEstimate {
232 secs,
233 approximate: true,
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, serde::Serialize)]
241pub struct StatusReport {
242 pub coverage: Vec<StageCoverage>,
243 pub pipelines: Vec<crate::pipeline_runs::PipelineRunStatus>,
244 pub watch: WatchLiveness,
245 pub costs: Vec<(&'static str, CostEstimate)>,
248 pub embed_model: String,
250}
251
252impl StatusReport {
253 pub fn has_problem(&self) -> bool {
257 self.pipelines
258 .iter()
259 .any(|p| matches!(p.status.as_deref(), Some("failed") | Some("crashed")))
260 }
261}
262
263fn fallback_secs_per_item(stage: &str) -> f64 {
267 match stage {
268 "embed" => 2.0,
269 "classify" => 0.05,
270 "faces" => 1.0,
271 "locations" => 0.001,
272 "fix-dates" => 0.001,
273 _ => 1.0,
274 }
275}
276
277pub fn compute_status_in(
280 conn: &Connection,
281 ctx: &crate::library::LibraryContext,
282) -> Result<StatusReport> {
283 let embed_model = ctx.settings.default_model.clone();
284 let coverage = coverage_in(conn, &embed_model, &embed_model)?;
285 let pipelines = crate::pipeline_runs::read_all_in(conn, ctx)?;
286 let watch = watch_liveness_in(conn, ctx)?;
287 let costs = coverage
288 .iter()
289 .filter(|c| c.outstanding > 0)
290 .map(|c| {
291 let prior = pipelines.iter().find(|p| p.command == c.stage);
292 (
293 c.stage,
294 estimate_cost(
295 c.outstanding,
296 prior.and_then(|p| p.duration_ms),
297 None,
298 fallback_secs_per_item(c.stage),
299 ),
300 )
301 })
302 .collect();
303 Ok(StatusReport {
304 coverage,
305 pipelines,
306 watch,
307 costs,
308 embed_model,
309 })
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 const TEST_MODEL: &str = "test/model";
317
318 fn seed_db(tag: &str) -> Connection {
321 let ctx = crate::embeddings_db::test_context(tag);
322 let conn = Connection::open_in_memory().unwrap();
323 conn.execute_batch(
324 "CREATE TABLE file_hashes (
325 path TEXT PRIMARY KEY,
326 hash TEXT NOT NULL,
327 mime TEXT,
328 size_bytes INTEGER,
329 created_at TEXT,
330 modified_at TEXT,
331 ext TEXT,
332 phash INTEGER,
333 exif_date TEXT,
334 gps_lat REAL,
335 gps_lon REAL,
336 width INTEGER,
337 height INTEGER,
338 location_name TEXT
339 );",
340 )
341 .unwrap();
342 conn.execute_batch(
343 "CREATE TABLE IF NOT EXISTS classifications (
344 model_id TEXT NOT NULL,
345 hash TEXT NOT NULL,
346 category TEXT NOT NULL,
347 confidence REAL NOT NULL,
348 classified_at TEXT NOT NULL,
349 PRIMARY KEY (model_id, hash)
350 );",
351 )
352 .unwrap();
353 crate::embeddings_db::attach_in(&conn, &ctx, TEST_MODEL, true).unwrap();
354 crate::face_db::create_faces_table(&conn).unwrap();
355 conn
356 }
357
358 fn insert_file(conn: &Connection, path: &str, hash: &str, ext: &str) {
359 conn.execute(
360 "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, ?2, ?3)",
361 [path, hash, ext],
362 )
363 .unwrap();
364 }
365
366 #[test]
367 fn coverage_counts_outstanding_per_stage() {
368 let conn = seed_db("status_cov_main");
369 insert_file(&conn, "/a/1.jpg", "h1", "jpg");
371 insert_file(&conn, "/a/2.jpg", "h2", "jpg");
372 insert_file(&conn, "/a/3.jpg", "h3", "jpg");
373 crate::embeddings::insert_embeddings(
374 &conn,
375 TEST_MODEL,
376 &[("h1".into(), vec![0u8; 4]), ("h2".into(), vec![0u8; 4])],
377 )
378 .unwrap();
379 conn.execute(
380 "INSERT INTO classifications (model_id, hash, category, confidence, classified_at)
381 VALUES ('test/model', 'h1', 'cat', 0.9, 'now')",
382 [],
383 )
384 .unwrap();
385
386 let cov = coverage_in(&conn, TEST_MODEL, TEST_MODEL).unwrap();
387 let embed = cov.iter().find(|c| c.stage == "embed").unwrap();
388 assert_eq!(embed.outstanding, 1, "h3 is the only un-embedded image");
389 assert_eq!(embed.total, 3);
390 assert!(embed.heavy);
391 assert_eq!(embed.next_command, Some("videre embed"));
392
393 let classify = cov.iter().find(|c| c.stage == "classify").unwrap();
394 assert_eq!(classify.outstanding, 1, "h2 is embedded but unclassified");
395 assert_eq!(classify.total, 2);
396 }
397
398 #[test]
399 fn faces_coverage_counts_unscanned_not_faceless() {
400 let conn = seed_db("status_cov_faces");
401 insert_file(&conn, "/a/1.jpg", "h1", "jpg");
402 insert_file(&conn, "/a/2.jpg", "h2", "jpg");
403 insert_file(&conn, "/a/3.png", "h3", "png");
404 insert_file(&conn, "/a/v.mp4", "h4", "mp4"); conn.execute("INSERT INTO faces_scanned (hash) VALUES ('h1')", [])
407 .unwrap();
408 conn.execute(
409 "INSERT INTO faces (hash, bbox, embedding) VALUES ('h2', '0,0,10,10', X'00')",
410 [],
411 )
412 .unwrap();
413
414 let cov = coverage_in(&conn, TEST_MODEL, TEST_MODEL).unwrap();
415 let faces = cov.iter().find(|c| c.stage == "faces").unwrap();
416 assert_eq!(faces.total, 3, "videos are not faces-eligible");
417 assert_eq!(faces.outstanding, 1, "only h3 was never tried");
418 assert!(!faces.heavy);
419 }
420
421 #[test]
422 fn locations_and_fix_dates_count_only_true_gaps() {
423 let conn = seed_db("status_cov_locfix");
424 let exif = "2021-07-04T15:30:00";
430 let in_sync = crate::fix_dates_target::target_modified_at(exif).unwrap();
431 conn.execute(
433 "INSERT INTO file_hashes (path, hash, ext, gps_lat, gps_lon, location_name,
434 exif_date, modified_at)
435 VALUES ('/a/1.jpg', 'h1', 'jpg', 52.5, 13.4, 'Berlin, Germany', ?1, ?2)",
436 [exif.to_string(), in_sync.clone()],
437 )
438 .unwrap();
439 conn.execute(
440 "INSERT INTO file_hashes (path, hash, ext, gps_lat, gps_lon, exif_date, modified_at)
441 VALUES ('/a/2.jpg', 'h2', 'jpg', 48.8, 2.3, '2020-01-02T03:04:05', '1970-01-01T00:00:00+00:00')",
442 [],
443 )
444 .unwrap();
445 conn.execute(
446 "INSERT INTO file_hashes (path, hash, ext, exif_date, modified_at)
447 VALUES ('/a/3.jpg', 'h3', 'jpg', ?1, ?2)",
448 [exif.to_string(), in_sync],
449 )
450 .unwrap();
451
452 let cov = coverage_in(&conn, TEST_MODEL, TEST_MODEL).unwrap();
453 let locations = cov.iter().find(|c| c.stage == "locations").unwrap();
454 assert_eq!(locations.total, 2);
455 assert_eq!(locations.outstanding, 1, "h2 has GPS and no place name");
456
457 let fix = cov.iter().find(|c| c.stage == "fix-dates").unwrap();
458 assert_eq!(fix.total, 3);
459 assert_eq!(
460 fix.outstanding, 1,
461 "only h2's mtime disagrees with its exif_date"
462 );
463 }
464
465 #[test]
466 fn cost_uses_measured_rate_then_falls_back() {
467 let c = estimate_cost(10, Some(50_000), Some(100), 2.0);
469 assert_eq!(c.secs, Some(5));
470 let f = estimate_cost(10, None, None, 2.0);
472 assert_eq!(f.secs, Some(20));
473 assert!(f.approximate);
474 assert_eq!(estimate_cost(0, None, None, 2.0).secs, None);
476 assert_eq!(estimate_cost(10, Some(50_000), Some(0), 2.0).secs, Some(20));
478 }
479
480 #[test]
481 fn compute_status_assembles_the_whole_report() {
482 let ctx = crate::embeddings_db::test_context("status_compute");
483 std::fs::create_dir_all(&ctx.paths.locks).unwrap();
484 let conn = seed_db("status_compute");
485 insert_file(&conn, "/a/1.jpg", "h1", "jpg");
486 insert_file(&conn, "/a/2.jpg", "h2", "jpg");
487
488 let report = compute_status_in(&conn, &ctx).unwrap();
489 assert_eq!(report.embed_model, ctx.settings.default_model);
490 assert!(!report.coverage.is_empty());
491 let embed = report.coverage.iter().find(|c| c.stage == "embed").unwrap();
492 assert_eq!(embed.outstanding, 2);
493 assert!(report
494 .costs
495 .iter()
496 .any(|(stage, cost)| *stage == "embed" && cost.secs.is_some()));
497 assert!(report.pipelines.iter().all(|p| p.status.is_none()));
498 assert!(!report.watch.running);
499 assert_eq!(report.watch.last_cycle_at, None);
500 assert!(
501 !report.has_problem(),
502 "a fresh library is healthy, not failing"
503 );
504
505 crate::pipeline_runs::start_run(&conn, "faces").unwrap();
507 crate::pipeline_runs::finish_run(&conn, "faces", "failed", 5, Some("boom")).unwrap();
508 let report = compute_status_in(&conn, &ctx).unwrap();
509 assert!(report.has_problem());
510 }
511}