ce560d09a1
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>
172 lines
6.2 KiB
Python
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}'
|