fix: startup crash, watcher count, timeline date fallback

db.rs:
- Move idx_images_taken_at creation to after ensure_column so it never
  runs against a schema where taken_at doesn't exist yet; this was causing
  a startup panic on any DB that predates Phase 1
- COALESCE(taken_at, created_at) → COALESCE(taken_at, modified_at) since
  created_at is never populated (modified_at is always set)
- Remove dead is_tagging_job_cancelled function (superseded by
  is_tagging_job_processing) and unused thumbnail_path from ImagePathRecord

indexer.rs:
- Watcher create path now calls update_folder_count after commit_batch and
  emits folder-counts-changed so the sidebar count stays in sync; the
  notification is only emitted when the DB write actually succeeds

store.ts / Timeline.tsx:
- compareImages taken_asc/taken_desc: fall back to modified_at not
  created_at (created_at is always null)
- Timeline groupImages: same fallback fix so images group by month
  correctly for libraries not yet re-indexed for EXIF
- subscribeToProgress: listen for folder-counts-changed and call
  loadFolders() to keep the sidebar image count live
This commit is contained in:
2026-06-08 22:09:50 +01:00
parent 9ace1f6778
commit 0ab156d2d9
4 changed files with 27 additions and 21 deletions
+9 -18
View File
@@ -273,7 +273,6 @@ pub fn migrate(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_images_folder_id ON images(folder_id);
CREATE INDEX IF NOT EXISTS idx_images_modified_at ON images(modified_at);
CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);
CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status);
CREATE INDEX IF NOT EXISTS idx_thumbnail_jobs_status ON thumbnail_jobs(status);
CREATE INDEX IF NOT EXISTS idx_metadata_jobs_status ON metadata_jobs(status);
@@ -316,6 +315,12 @@ pub fn migrate(conn: &Connection) -> Result<()> {
ensure_column(conn, "images", "ai_tagged_at", "TEXT")?;
ensure_column(conn, "images", "ai_tagger_error", "TEXT")?;
ensure_column(conn, "images", "taken_at", "TEXT")?;
// Index must be created after ensure_column adds the column; it cannot live
// in the execute_batch above because that batch runs before the column exists
// on databases that predate Phase 1.
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);",
)?;
ensure_column(conn, "folders", "scan_error", "TEXT")?;
vector::migrate(conn)?;
@@ -1500,8 +1505,8 @@ pub fn get_images(
"rating_desc" => "rating DESC, modified_at DESC NULLS LAST",
"duration_asc" => "duration_ms ASC NULLS LAST",
"duration_desc" => "duration_ms DESC NULLS LAST",
"taken_asc" => "COALESCE(taken_at, created_at) ASC NULLS LAST",
"taken_desc" => "COALESCE(taken_at, created_at) DESC NULLS LAST",
"taken_asc" => "COALESCE(taken_at, modified_at) ASC NULLS LAST",
"taken_desc" => "COALESCE(taken_at, modified_at) DESC NULLS LAST",
_ => "modified_at DESC NULLS LAST",
};
@@ -1671,7 +1676,6 @@ pub fn search_tags_autocomplete(
pub struct ImagePathRecord {
pub id: i64,
pub path: String,
pub thumbnail_path: Option<String>,
}
pub fn get_all_image_paths(
@@ -1679,14 +1683,13 @@ pub fn get_all_image_paths(
folder_id: Option<i64>,
) -> Result<Vec<ImagePathRecord>> {
let mut stmt = conn.prepare(
"SELECT id, path, thumbnail_path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id",
"SELECT id, path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id",
)?;
let rows = stmt
.query_map(params![folder_id], |row| {
Ok(ImagePathRecord {
id: row.get(0)?,
path: row.get(1)?,
thumbnail_path: row.get(2)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
@@ -2057,18 +2060,6 @@ pub fn requeue_processing_tagging_jobs_for_folder(conn: &Connection, folder_id:
Ok(())
}
/// Returns `true` when the job row for `image_id` currently has status = 'cancelled'.
/// Used by the worker to discard inference results for jobs that were cancelled
/// while inference was running.
pub fn is_tagging_job_cancelled(conn: &Connection, image_id: i64) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM tagging_jobs WHERE image_id = ?1 AND status = 'cancelled'",
[image_id],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Returns `true` when the job row for `image_id` is still `processing`.
/// A `false` result means the row was reset to `pending` (pause) or `cancelled`
/// while inference was running — either way the result must be discarded.
+10
View File
@@ -1480,8 +1480,17 @@ fn process_watcher_path(
match commit_batch(pool, &[record]) {
Ok(committed) if !committed.is_empty() => {
// Always emit the images — they are committed to the DB.
emit_images(app, &IndexedImagesBatch { folder_id, images: committed });
emit_folder_job_progress(app, pool, &[folder_id], false);
// Update the sidebar count only if we successfully write the new
// count; skip the frontend notification on pool/DB failure to
// avoid showing a stale number.
if let Ok(count_conn) = pool.get() {
if db::update_folder_count(&count_conn, folder_id).is_ok() {
let _ = app.emit("folder-counts-changed", ());
}
}
}
Ok(_) => {}
Err(e) => eprintln!("Watcher: commit error for {:?}: {}", path, e),
@@ -1494,6 +1503,7 @@ fn process_watcher_path(
if db::delete_images_by_ids(&conn, &[image_id]).is_ok() {
db::update_folder_count(&conn, folder_id).ok();
let _ = app.emit("watcher-deleted", vec![image_id]);
let _ = app.emit("folder-counts-changed", ());
}
}
Ok(None) => {} // never indexed or already removed
+1 -1
View File
@@ -22,7 +22,7 @@ function buildLabel(key: string): string {
function groupImages(images: ImageRecord[]): TimelineGroup[] {
const map = new Map<string, ImageRecord[]>();
for (const img of images) {
const ds = img.taken_at ?? img.created_at;
const ds = img.taken_at ?? img.modified_at;
const key = ds ? ds.substring(0, 7) : "unknown";
let bucket = map.get(key);
if (bucket === undefined) {
+7 -2
View File
@@ -498,9 +498,9 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number
case "duration_desc":
return compareNullableNumber(b.duration_ms, a.duration_ms);
case "taken_asc":
return compareNullableDate(a.taken_at ?? a.created_at, b.taken_at ?? b.created_at);
return compareNullableDate(a.taken_at ?? a.modified_at, b.taken_at ?? b.modified_at);
case "taken_desc":
return compareNullableDate(b.taken_at ?? b.created_at, a.taken_at ?? a.created_at);
return compareNullableDate(b.taken_at ?? b.modified_at, a.taken_at ?? a.modified_at);
default:
return compareNullableDate(b.modified_at, a.modified_at);
}
@@ -1770,6 +1770,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
});
});
const unlistenFolderCounts = await listen("folder-counts-changed", () => {
void get().loadFolders();
});
return () => {
unlistenProgress();
unlistenMediaJobs();
@@ -1778,6 +1782,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
unlistenImages();
unlistenThumbnails();
unlistenWatcherDeleted();
unlistenFolderCounts();
};
},
}));