diff --git a/src/infrastructure/services/thumb_derived_import_service.rs b/src/infrastructure/services/thumb_derived_import_service.rs index 4a189f7a..570305ed 100644 --- a/src/infrastructure/services/thumb_derived_import_service.rs +++ b/src/infrastructure/services/thumb_derived_import_service.rs @@ -159,11 +159,14 @@ impl RecoverableJobHandler for ThumbDerivedImport { let mut already = 0u64; let mut failed = 0u64; let mut since_checkpoint = 0usize; - // Sidecars under `.thumbnails/` are `{hash}.webp` — the filter that - // built this list requires the extension — so the imported rows are - // WebP, and the variant must say so since migration - // `20261022000000`. Writing the bare size here would produce rows the - // read path can never match. + // Two different strings, and conflating them is a real trap: the + // DIRECTORY is `{size}` on disk, while the VARIANT is + // `{size}.{ext}` since migration `20261022000000`. Using the variant + // as a path yields `.thumbnails/preview.webp/…`, which does not + // exist, so every file reads as unreadable and nothing imports. + // + // Sidecars here are always `{hash}.webp` — the name filter requires + // that extension — so the variant is unconditionally the WebP one. let variant_of = |s: ThumbnailSize| { format!( "{}.{}", @@ -173,8 +176,11 @@ impl RecoverableJobHandler for ThumbDerivedImport { }; for size in ThumbnailSize::all() { - let dir_name = variant_of(*size); + let dir_name = size.dir_name(); // on-disk directory + let variant = variant_of(*size); // content_derived_blobs.variant for name in Self::sidecar_names(&self.thumbnails_root, *size).await { + // Cursor position uses the DIRECTORY, so a run paused before + // this change resumes at the same place. let position = format!("{dir_name}/{name}"); // Resume: everything at or before the cursor is done. @@ -206,13 +212,13 @@ impl RecoverableJobHandler for ThumbDerivedImport { // reason this job is safe to trigger repeatedly. if self .dedup - .find_derived_blob(hash, "thumbnail", &dir_name) + .find_derived_blob(hash, "thumbnail", &variant) .await .is_some() { already += 1; } else { - let path = self.thumbnails_root.join(&dir_name).join(&name); + let path = self.thumbnails_root.join(dir_name).join(&name); match fs::read(&path).await { Ok(data) => { match self @@ -220,7 +226,7 @@ impl RecoverableJobHandler for ThumbDerivedImport { .store_derived_blob( hash, "thumbnail", - &dir_name, + &variant, "image/webp", Bytes::from(data), ) diff --git a/tests/api/thumb_import_check.sh b/tests/api/thumb_import_check.sh index f7978972..763bfd22 100755 --- a/tests/api/thumb_import_check.sh +++ b/tests/api/thumb_import_check.sh @@ -80,8 +80,17 @@ fail() { # psql inside the compose container — no host psql dependency, matching # how spawn-db.sh probes readiness. sql() { + # Podman's docker-compose shim prints a provider banner to stderr on every + # invocation, which buries this script's own output. Filtered rather than + # discarded (`2>/dev/null`) so genuine psql errors still surface — losing + # those would turn a broken query into a silently wrong assertion. + # + # Suppressing it at the source needs `[engine] compose_warning_logs = false` + # in containers.conf, which is per-developer config and cannot be relied on + # in CI. docker compose -f "$COMPOSE_FILE" exec -T postgres-test \ - psql -U oxicloud_test -d oxicloud_test -tAqc "$1" + psql -U oxicloud_test -d oxicloud_test -tAqc "$1" \ + 2> >(grep -v 'Executing external compose provider' >&2) } TOKEN=$(curl -sf -X POST "$base_url/api/auth/login" \