feat(import): surface WHY an archive was captured without extracting images
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s

The recurring "post shows a zip but no images" report had no diagnostic: when
_import_archive captured an archive as a bare PostAttachment — because the
bomb-guard probe rejected it, or extraction yielded zero members (corrupt /
unsupported / missing extractor backend), or it held only non-media files — it
returned status="attached" silently.

Now those paths set ImportResult.error with the specific reason and log a
warning, and download_service records each as {file, reason} under the event's
metadata.unextracted_archives (None when every archive extracted cleanly). So
the next run names exactly which archives failed and why, instead of leaving the
operator to guess. No behaviour change to the happy path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 19:01:22 -04:00
parent a559fabdd5
commit 19eb4e9388
2 changed files with 38 additions and 2 deletions
+20
View File
@@ -269,6 +269,11 @@ class DownloadService:
source_row = self.sync_session.get(Source, ctx["source_id"]) source_row = self.sync_session.get(Source, ctx["source_id"])
import_summary = {"attached": 0, "skipped": 0, "errors": 0} import_summary = {"attached": 0, "skipped": 0, "errors": 0}
# Archives detected but captured WITHOUT extracting any image (probe
# rejected / corrupt / missing extractor backend). Surfaced on the event
# so a post showing "no images" beside a zip is diagnosable (plan
# follow-up 2026-06-06 — the recurring archive-association report).
unextracted_archives: list[dict] = []
bytes_downloaded = 0 bytes_downloaded = 0
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -322,6 +327,17 @@ class DownloadService:
# mirror duplicate_hash cleanup so we don't keep two copies. # mirror duplicate_hash cleanup so we don't keep two copies.
# Operator-flagged 2026-06-02 (Lustria OST zip). # Operator-flagged 2026-06-02 (Lustria OST zip).
import_summary["attached"] += 1 import_summary["attached"] += 1
# An archive captured WITHOUT extracting any image carries a
# reason on result.error — record it so the event explains the
# "no images beside a zip" symptom instead of staying silent.
if result.error:
unextracted_archives.append(
{"file": path.name, "reason": result.error}
)
log.warning(
"archive captured unextracted (%s): %s",
path.name, result.error,
)
try: try:
bytes_downloaded += path.stat().st_size # noqa: ASYNC240 bytes_downloaded += path.stat().st_size # noqa: ASYNC240
except OSError: except OSError:
@@ -403,6 +419,10 @@ class DownloadService:
"duration_seconds": dl_result.duration_seconds, "duration_seconds": dl_result.duration_seconds,
"quarantined_paths": dl_result.quarantined_paths or None, "quarantined_paths": dl_result.quarantined_paths or None,
"import_summary": import_summary, "import_summary": import_summary,
# Archives detected but captured without extracting an image — the
# recurring "post shows a zip but no images" report. Each entry is
# {file, reason}; None when every archive extracted cleanly.
"unextracted_archives": unextracted_archives or None,
} }
await self._update_source_health( await self._update_source_health(
source_id=ctx["source_id"], status=status, error_message=ev.error, source_id=ctx["source_id"], status=status, error_message=ev.error,
+18 -2
View File
@@ -430,13 +430,17 @@ class Importer:
self._capture_attachment( self._capture_attachment(
source, post=post, artist=artist_use, resolved=True, source, post=post, artist=artist_use, resolved=True,
) )
return ImportResult(status="attached") reason = f"archive probe rejected, captured unextracted: {probe.reason}"
log.warning("%s: %s", source.name, reason)
return ImportResult(status="attached", error=reason)
artist_use = artist if artist is not None else self._resolve_artist(source) artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use) post = self._post_for_sidecar(source, artist_use)
member_ids: list[int] = [] member_ids: list[int] = []
member_total = 0
with extract_archive(source) as members: with extract_archive(source) as members:
for _name, member_path in members: for _name, member_path in members:
member_total += 1
if not is_supported(member_path): if not is_supported(member_path):
continue # non-media preserved via the stored archive continue # non-media preserved via the stored archive
res = self._import_media( res = self._import_media(
@@ -453,7 +457,19 @@ class Importer:
status="imported", image_id=member_ids[0], status="imported", image_id=member_ids[0],
member_image_ids=member_ids, member_image_ids=member_ids,
) )
return ImportResult(status="attached") # No images landed — surface WHY so a post showing "no images" beside an
# archive is diagnosable instead of silent. Zero members usually means
# the extractor backend is missing/failed (unar for rar, py7zr for 7z)
# or the file is corrupt; non-zero-but-no-images means it held only
# non-media files.
reason = (
"archive extracted but held no supported image/video members"
if member_total
else "archive yielded no members (unsupported/corrupt, or the "
"extractor backend failed)"
)
log.warning("%s: %s", source.name, reason)
return ImportResult(status="attached", error=reason)
def _import_media( def _import_media(
self, source: Path, attribution_path: Path, self, source: Path, attribution_path: Path,