fix(thumbnails): surface backfill results + tighten validity check
Two coupled problems, operator-flagged 2026-06-01: "missing thumbnails
but triggering backfill found nothing."
1. **Backfill UI was a black box.** `POST /api/thumbnails/backfill`
returned just `{celery_task_id}` and the admin card said
"Enqueued." with no counts. There was no way to tell whether the
scan found 0 candidates, 5000 candidates, or whether the worker
even picked up the task. "Found nothing" was indistinguishable
from a broken queue.
Fix: refactor the scan into a sync helper (`_run_backfill_scan`)
shared by the Celery task and the API endpoint. The API now runs
the scan in an executor and returns `{scanned, enqueued, ok,
regenerated}`. The actual thumbnail generation work still goes
to the thumbnail Celery queue per row via
`generate_thumbnail.delay()` — the scan itself is fast
(SELECT id+thumbnail_path + a file.stat() per row).
2. **`_thumb_is_valid` accepted header-only corrupt files.** The
magic-byte check passed for any 8-byte file starting with a JPEG
or PNG header, including empty/truncated/zero-pad files that
browsers render as broken. Backfill counted these as `ok` and
never regenerated.
Fix: also require file size ≥ MIN_THUMB_BYTES (256). Real
thumbnails are minimum ~2KB even on solid-color sources; header-
only corrupt files top out around 12 bytes. 256 is well above
the corrupt floor and well below any legitimate thumbnail.
Plus the admin card now shows the per-run counts instead of
"Enqueued.":
Scanned 5,432 · enqueued 3 (2 regenerated) · 5,429 ok
This commit is contained in:
@@ -12,13 +12,16 @@ pytestmark = pytest.mark.integration
|
||||
|
||||
def test_thumb_is_valid_jpeg(tmp_path):
|
||||
p = tmp_path / "good.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||
# Real thumbnails are at least ~2KB; size check (MIN_THUMB_BYTES=256)
|
||||
# requires the file body be plausible. 300 bytes here clears the
|
||||
# floor with margin.
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 300)
|
||||
assert _thumb_is_valid(p) is True
|
||||
|
||||
|
||||
def test_thumb_is_valid_png(tmp_path):
|
||||
p = tmp_path / "good.png"
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 300)
|
||||
assert _thumb_is_valid(p) is True
|
||||
|
||||
|
||||
@@ -38,6 +41,16 @@ def test_thumb_is_valid_missing_file(tmp_path):
|
||||
assert _thumb_is_valid(tmp_path / "nope") is False
|
||||
|
||||
|
||||
def test_thumb_is_valid_header_only_below_min_size(tmp_path):
|
||||
"""Operator-flagged 2026-06-01: header-only corrupt files were
|
||||
silently passing the magic-byte check and backfill counted them as
|
||||
`ok`, so the UI's broken-image tiles never got regenerated. Files
|
||||
smaller than MIN_THUMB_BYTES are now invalid even with valid magic."""
|
||||
p = tmp_path / "header_only.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 50) # 54 bytes total
|
||||
assert _thumb_is_valid(p) is False
|
||||
|
||||
|
||||
# --- backfill_thumbnails planner tests ------------------------------------
|
||||
|
||||
|
||||
@@ -80,13 +93,14 @@ def _rec(db_sync, path, *, sha, thumb_path=None, mime="image/jpeg"):
|
||||
|
||||
def _write_jpeg(p: Path) -> Path:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||
# ≥ MIN_THUMB_BYTES (256) so the size floor doesn't reject it.
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 300)
|
||||
return p
|
||||
|
||||
|
||||
def _write_png(p: Path) -> Path:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 300)
|
||||
return p
|
||||
|
||||
|
||||
@@ -238,5 +252,5 @@ def test_backfill_mixed_aggregate(db_sync, tmp_path, monkeypatch):
|
||||
)
|
||||
|
||||
result = m.backfill_thumbnails()
|
||||
assert result == {"enqueued": 3, "ok": 2, "regenerated": 2}
|
||||
assert result == {"scanned": 5, "enqueued": 3, "ok": 2, "regenerated": 2}
|
||||
assert sorted(delayed) == sorted([r_null.id, r_missing.id, r_bad.id])
|
||||
|
||||
Reference in New Issue
Block a user