This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/services/integrity.py
T
bvandeusen ce560d09a1 feat(integrity): structural verification + supersede-on-replace pipeline
Adds per-image integrity tracking so corrupt files are detected, excluded
from random/showcase/ML/suggestion paths, and recoverable by dropping a
fresh copy in /import — closing the gap that surfaced as the WD14
'6 bytes not processed' OSError.

Schema (migration l26042501)
- image_record.integrity_status: unknown | ok | truncated | unreadable | missing
- image_record.integrity_checked_at: timestamptz
- partial index on status <> 'ok' for cheap report/filter queries

Verifier
- app/services/integrity.py: verify_path() dispatches by extension
- PIL two-stage (verify + load with LOAD_TRUNCATED_IMAGES disabled)
- ffprobe for video, zipfile.testzip for archives
- Truncation-vs-unreadable distinction via PIL message hints

Pipeline
- verify_media_integrity Celery task: per-image, idempotent
- verify_unverified_images sweep: only_unknown by default, skips
  paths in active import tasks
- Hooked into the end of import_media_file (new + archive paths) and
  the supersede branch
- supersede_image() resets status to 'unknown' so the post-supersede
  verify writes a fresh truth
- Supersede-on-replace: a fresh /import/<artist>/<filename> matching
  a flagged-corrupt record routes through _supersede_existing,
  preserving tags/series/embeddings

Exclusions
- /, /api/random-images, tag_and_embed, ml.backfill enqueue, and
  get_suggestions all filter integrity_status IN ('ok', 'unknown') so
  flagged rows don't poison the gallery, ML, or suggestion math.
  'unknown' is treated as healthy so post-migration data stays visible
  until the sweep runs.

UI / report
- Settings -> Maintenance: 'Verify unknown' + 'Force re-verify all'
- GET /api/integrity/failed (paginated list of flagged rows)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 00:16:06 -04:00

172 lines
6.2 KiB
Python

"""Structural-integrity verification for media files.
Public surface: `verify_path(path) -> (status, detail)`.
Status values match the `image_record.integrity_status` column:
- 'ok': passed all checks
- 'truncated': structurally a valid file up to a point, then missing
trailing bytes (the WD14 '6 bytes not processed' case)
- 'unreadable': can't open at all, or container is malformed enough that
even the head doesn't parse
- 'missing': filepath doesn't exist on disk
Dispatch is by file extension. Images go through PIL (with truncated-image
loading explicitly disabled so we surface the very thing we want to detect).
Videos go through ffprobe. Archives go through zipfile.testzip / tarfile
where the stdlib supports it. Anything else returns 'ok' (we don't know
how to validate it, so we don't lie about its state).
"""
from __future__ import annotations
import logging
import os
import subprocess
import zipfile
# We deliberately import the PIL.ImageFile module here without flipping
# LOAD_TRUNCATED_IMAGES — opposite of wd14.py's runtime tolerance, since
# this module's job is to *detect* truncation, not paper over it.
from PIL import Image, UnidentifiedImageError
log = logging.getLogger(__name__)
IMAGE_EXTS = ('.jpg', '.jpeg', '.jfif', '.png', '.gif', '.bmp', '.tiff', '.webp')
VIDEO_EXTS = ('.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.wmv', '.flv')
ZIP_EXTS = ('.zip', '.cbr', '.cbz')
def verify_path(path: str) -> tuple[str, str | None]:
"""Verify a single file. Returns (status, detail-or-None).
Detail is a short human-readable string for the failure case, useful
for the report endpoint and logs. None when status='ok'.
"""
if not os.path.exists(path):
return 'missing', None
try:
size = os.path.getsize(path)
except OSError as e:
return 'missing', f'stat failed: {e}'
if size == 0:
return 'unreadable', 'zero-byte file'
ext = os.path.splitext(path)[1].lower()
if ext in IMAGE_EXTS:
return _verify_image(path)
if ext in VIDEO_EXTS:
return _verify_video(path)
if ext in ZIP_EXTS:
return _verify_zip(path)
# Unknown extension: don't fail it, but don't lie that we verified it.
# Caller writes 'ok' but the kind=unknown case is rare in this library.
return 'ok', None
def _verify_image(path: str) -> tuple[str, str | None]:
"""PIL-based two-stage check.
Stage 1: open + verify(). Validates header, structure, and (for most
formats) walks the entire stream to confirm framing — this is what
catches the 6-byte truncation. verify() invalidates the Image after,
so callers can't draw from it (we don't need to).
Stage 2: re-open + load(). Some PIL formats (notably JPEG) only flag
truncation during load(), not verify(). Re-opening with truncated
loading disabled lets a malformed scan trigger OSError here.
"""
try:
with Image.open(path) as img:
img.verify()
except (OSError, SyntaxError, UnidentifiedImageError, ValueError) as e:
msg = str(e).lower()
if _looks_truncated(msg):
return 'truncated', str(e)
return 'unreadable', str(e)
try:
# Force a full decode without the LOAD_TRUNCATED_IMAGES safety net
# that wd14/siglip enabled at module-import time.
from PIL import ImageFile
prev = ImageFile.LOAD_TRUNCATED_IMAGES
ImageFile.LOAD_TRUNCATED_IMAGES = False
try:
with Image.open(path) as img:
img.load()
finally:
ImageFile.LOAD_TRUNCATED_IMAGES = prev
except (OSError, SyntaxError, ValueError) as e:
msg = str(e).lower()
if _looks_truncated(msg):
return 'truncated', str(e)
return 'unreadable', str(e)
return 'ok', None
# Phrases PIL emits across formats when the file ends earlier than the
# decoder expected. Centralized so both verify-stages agree on the mapping.
_TRUNCATED_HINTS = (
'trunc', # 'image file is truncated', 'truncated file read'
'not processed', # 'N bytes not processed' (the original wd14 case)
'broken png', # PNG short final chunk
'premature', # JPEG/PNG premature end
'unexpected end', # GIF / generic end-of-stream
'not enough', # 'not enough data'
)
def _looks_truncated(lower_msg: str) -> bool:
return any(h in lower_msg for h in _TRUNCATED_HINTS)
def _verify_video(path: str) -> tuple[str, str | None]:
"""ffprobe-based container walk.
`-v error` keeps stdout/stderr quiet on success and emits the first
structural complaint on failure. Any non-zero exit is treated as a
failed verification. ffprobe distinguishes 'truncated' poorly across
formats, so we lean on stderr text to map it.
"""
try:
result = subprocess.run(
['ffprobe', '-v', 'error', '-show_format', '-show_streams', '-i', path],
capture_output=True,
text=True,
timeout=30,
)
except FileNotFoundError:
log.warning("ffprobe not on PATH; skipping video verify for %s", path)
return 'ok', None
except subprocess.TimeoutExpired:
return 'unreadable', 'ffprobe timed out'
if result.returncode == 0:
return 'ok', None
err = (result.stderr or '').strip().lower()
if any(s in err for s in ('truncated', 'partial', 'eof', 'end of file', 'invalid data found')):
return 'truncated', result.stderr.strip()
return 'unreadable', result.stderr.strip() or f'ffprobe exit {result.returncode}'
def _verify_zip(path: str) -> tuple[str, str | None]:
"""zipfile.testzip walks every CRC. None = clean; bad-name = corrupt.
BadZipFile / LargeZipFile are unreadable cases (header malformed or
too big to test), distinct from a CRC mismatch on a single member
which we treat as truncated/partial.
"""
try:
with zipfile.ZipFile(path) as zf:
bad = zf.testzip()
except zipfile.BadZipFile as e:
return 'unreadable', str(e)
except zipfile.LargeZipFile as e:
return 'unreadable', str(e)
except OSError as e:
return 'unreadable', str(e)
if bad is None:
return 'ok', None
return 'truncated', f'CRC failed for member: {bad}'