feat: archive images reach the library: game builds stay whole, and re-extract covers every archive
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 21s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / integration (push) Successful in 2m31s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 4s
CI and images / build-web (push) Successful in 2m1s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped

Most archive attachments never had their images imported. The re-extract
action skipped archives without a post, and nothing kept game builds out
of an extraction.

- An archive holding executables, libraries, or game-engine data
  (Ren'Py, Unity, Godot, RPG Maker, Python builds) is kept as an
  attachment and not extracted. The same holds for one nested inside an
  art pack.
- Re-extract now reaches archives imported from a folder without a post.
  Their images go to the artist, in a folder named after the archive.
- Re-extract passes over archives whose members are already recorded.
- Re-extract stages each archive by symlink instead of copying it.
- The card says what the action does now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 23:27:36 -05:00
co-authored by Claude Opus 5.5
parent f22d284b74
commit c2af70e6af
6 changed files with 259 additions and 48 deletions
+44
View File
@@ -57,6 +57,50 @@ def is_archive(path: Path) -> bool:
return detect_archive_format(path) is not None
# Files that only a program ships: executables and libraries, and the packed
# data and compiled scripts of the common game engines (Ren'Py, Unity, Godot,
# RPG Maker, Python builds). An archive holding any of them is a game or app
# build. Its images are sprites, UI and CGs bundled for the program, not art
# for the library, so the archive is kept whole as an attachment.
SOFTWARE_EXTS = frozenset({
".exe", ".msi", ".dll", ".so", ".dylib", ".apk",
".rpa", ".rpyc", ".rpy", ".pyc", ".pyo",
".pak", ".pck", ".unity3d", ".assets", ".ress",
".rgssad", ".rgss2a", ".rgss3a", ".rxdata", ".rvdata", ".rvdata2",
})
def member_names(path: Path) -> list[str]:
"""Every member name in the archive, read from its directory without
decompressing anything. Empty when the archive can't be listed."""
try:
fmt = detect_archive_format(path)
if fmt == "zip":
with zipfile.ZipFile(path) as zf:
return zf.namelist()
if fmt == "rar":
import rarfile
with rarfile.RarFile(path) as rf:
return rf.namelist()
if fmt == "7z":
import py7zr
with py7zr.SevenZipFile(path, "r") as zf:
return zf.getnames()
except Exception as exc:
log.warning("archive listing failed for %s: %s", path, exc)
return []
def software_marker(path: Path) -> str | None:
"""The first member that marks the archive as a software build, or None."""
for name in member_names(path):
if Path(name).suffix.lower() in SOFTWARE_EXTS:
return name
return None
@contextmanager
def extract_archive(path: Path):
"""Context manager yielding a list of (member_name, extracted_path)
+74 -34
View File
@@ -1044,45 +1044,75 @@ def cancel_audit_run(session: Session, *, audit_id: int) -> None:
_ARCHIVE_EXT_FOR_FORMAT = {"zip": ".zip", "rar": ".rar", "7z": ".7z"}
def _stage_archive(archive_path: Path, staged: Path) -> None:
"""Put the stored archive at `staged` for the importer to read: a symlink,
so a multi-gigabyte archive isn't copied, or a copy where links aren't
possible."""
import shutil
staged.unlink(missing_ok=True)
try:
staged.symlink_to(archive_path)
except OSError:
shutil.copy2(archive_path, staged)
def _archive_folder_name(original_filename: str | None, attachment_id: int) -> str:
"""The library folder a post-less archive's members land in: the archive's
own name without its extension, made safe for a path."""
stem = Path(original_filename or "").stem.strip().strip(".")
stem = "".join(c if c not in '/\\:*?"<>|' else "_" for c in stem)[:120].strip()
return stem or f"archive-{attachment_id}"
def _reextract_archive_to_post(
importer, archive_path: Path, post, source_row, artist, images_root: Path,
*, folder: str = "",
) -> list[int]:
"""Extract one stored archive and link its members to `post`.
"""Extract one stored archive and import its members.
The stored attachment has no adjacent sidecar (it lives in the sha-addressed
attachment store). Stage a copy + a reconstructed sidecar UNDER the artist's
library dir (`images_root/<slug>/<platform>/<post>/`) — the importer
re-derives the artist from the path AND copies members relative to it, so the
members land in the real library and resolve to the right artist — then re-run
`attach_in_place`: the archive extracts and `find_or_create_post` re-attaches
the members to the SAME Post (source_id + external_post_id). Removes only the
staged archive + sidecar afterward; the imported member files stay. Returns
the new member image ids.
attachment store), so the archive is staged under the artist's library dir
and `attach_in_place` re-run on it: the importer derives the artist from the
path and copies members relative to it, so they land in the real library.
- With a `post`, the stage is `images_root/<slug>/<platform>/<post>/` beside
a reconstructed sidecar, so `find_or_create_post` re-attaches the members
to that same Post (source_id + external_post_id).
- Without one (a filesystem import that had no sidecar), the stage is
`images_root/<slug>/<folder>/` with no sidecar, and the members belong to
the artist alone, as a filesystem import of the archive would have left
them.
Only the staged archive and sidecar are removed afterward; the imported
member files stay. Returns the new member image ids.
"""
import json
import shutil
from .archive_extractor import detect_archive_format
fmt = detect_archive_format(archive_path)
ext = _ARCHIVE_EXT_FOR_FORMAT.get(fmt or "", ".zip")
platform = source_row.platform if source_row is not None else "imported"
sidecar = {
"category": source_row.platform if source_row is not None
else (post.raw_metadata or {}).get("category"),
"id": post.external_post_id,
"title": post.post_title or "",
"content": post.description or "",
"published_at": post.post_date.isoformat() if post.post_date else None,
"url": post.post_url,
}
work = images_root / artist.slug / platform / str(post.external_post_id)
if post is not None:
platform = source_row.platform if source_row is not None else "imported"
work = images_root / artist.slug / platform / str(post.external_post_id)
else:
work = images_root / artist.slug / folder
work.mkdir(parents=True, exist_ok=True)
staged = work / f"archive{ext}" # clean ext → is_archive + find_sidecar
sidecar_path = staged.with_suffix(".json")
try:
shutil.copy2(archive_path, staged)
sidecar_path.write_text(json.dumps(sidecar))
_stage_archive(archive_path, staged)
if post is not None:
sidecar_path.write_text(json.dumps({
"category": source_row.platform if source_row is not None
else (post.raw_metadata or {}).get("category"),
"id": post.external_post_id,
"title": post.post_title or "",
"content": post.description or "",
"published_at": post.post_date.isoformat() if post.post_date else None,
"url": post.post_url,
}))
res = importer.attach_in_place(staged, artist=artist, source=source_row)
return list(res.member_image_ids or [])
finally:
@@ -1124,8 +1154,9 @@ def reextract_archive_attachments(
summary = {
"scanned": 0, "archives": 0, "members_imported": 0,
"posts_touched": 0, "skipped_no_post": 0, "skipped_no_artist": 0,
"errors": 0, "partial": False, "resume_after_id": after_id,
"posts_touched": 0, "without_post": 0, "already_extracted": 0,
"skipped_no_artist": 0, "errors": 0, "partial": False,
"resume_after_id": after_id,
}
settings = ImportSettings.load_sync(session)
importer = Importer(
@@ -1150,15 +1181,17 @@ def reextract_archive_attachments(
except OSError:
continue
summary["archives"] += 1
if att.post_id is None:
summary["skipped_no_post"] += 1
continue
post = session.get(Post, att.post_id)
if post is None:
summary["skipped_no_post"] += 1
# Members already recorded as coming from this archive: done before.
if session.execute(
select(ImageProvenance.id)
.where(ImageProvenance.from_attachment_id == att.id)
.limit(1)
).first() is not None:
summary["already_extracted"] += 1
continue
post = session.get(Post, att.post_id) if att.post_id else None
artist = session.get(Artist, att.artist_id) if att.artist_id else None
if artist is None and post.artist_id:
if artist is None and post is not None and post.artist_id:
artist = session.get(Artist, post.artist_id)
if artist is None or not artist.slug:
# The importer re-derives the artist from the staged path, so we need
@@ -1166,10 +1199,16 @@ def reextract_archive_attachments(
# subscription posts; skip rather than orphan the members.)
summary["skipped_no_artist"] += 1
continue
source_row = session.get(Source, post.source_id) if post.source_id else None
source_row = (
session.get(Source, post.source_id)
if post is not None and post.source_id else None
)
if post is None:
summary["without_post"] += 1
try:
ids = _reextract_archive_to_post(
importer, stored, post, source_row, artist, images_root,
folder=_archive_folder_name(att.original_filename, att.id),
)
session.commit()
except Exception as exc: # one bad archive must not strand the rest
@@ -1179,7 +1218,8 @@ def reextract_archive_attachments(
continue
if ids:
summary["members_imported"] += len(ids)
summary["posts_touched"] += 1
if post is not None:
summary["posts_touched"] += 1
enqueue_ids.extend(ids)
# Time-box the chunk. resume_after_id already points at this attachment,
+23 -7
View File
@@ -45,7 +45,7 @@ from ..utils.phash import compute_phash, find_similar, fingerprint_path, fingerp
from ..utils.sidecar import find_sidecar, parse_sidecar
from ..utils.slug import slugify
from ..utils.staging import is_temp_staging_file
from .archive_extractor import extract_archive, is_archive
from .archive_extractor import extract_archive, is_archive, software_marker
from .attachment_store import AttachmentStore
from .audits import single_color
from .link_extract import extract_external_links
@@ -616,6 +616,14 @@ class Importer:
artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use, source_row=source_row)
if (marker := software_marker(source)) is not None:
# A game or app build: kept whole, its bundled images left alone.
self._capture_attachment(
source, post=post, artist=artist_use, resolved=True,
)
log.info("%s: a software build (holds %s), kept unextracted",
source.name, marker)
return ImportResult(status="attached")
# Committed before extraction, so a member that fails (and is rolled
# back on its own) can never take the archive's post with it.
self.session.commit()
@@ -631,7 +639,7 @@ class Importer:
# or failed/corrupt members — instead of one catch-all string.
counts = {
"media": 0, "deduped": 0, "unsupported": 0,
"failed": 0, "nested": 0, "nested_rejected": 0,
"failed": 0, "nested": 0, "nested_rejected": 0, "nested_software": 0,
}
self._collect_archive_members(
source, attribution=source, source_row=source_row,
@@ -663,20 +671,21 @@ class Importer:
if counts["media"] == 0 and counts["nested"] == 0:
reason = ("archive yielded no members (unsupported/corrupt, or the "
"extractor backend failed)")
elif counts["deduped"] and not (
elif (counts["deduped"] or counts["nested_software"]) and not (
counts["unsupported"] or counts["failed"] or counts["nested_rejected"]
):
log.info(
"%s: all %d image member(s) already in library (deduped + linked "
"to this post) — archive preserved, no new import",
source.name, counts["deduped"],
"%s: no new images — %d already in the library (linked to this "
"post), %d nested software build(s) skipped; archive preserved",
source.name, counts["deduped"], counts["nested_software"],
)
return ImportResult(status="attached")
else:
reason = (
f"no new images — media={counts['media']} deduped={counts['deduped']} "
f"unsupported/non-media={counts['unsupported']} failed={counts['failed']} "
f"nested={counts['nested']} nested-rejected={counts['nested_rejected']}"
f"nested={counts['nested']} nested-rejected={counts['nested_rejected']} "
f"nested-software={counts['nested_software']}"
)
log.warning("%s: %s", source.name, reason)
return ImportResult(status="attached", error=reason)
@@ -717,6 +726,13 @@ class Importer:
probe.reason, member_path.name,
)
continue
if (marker := software_marker(member_path)) is not None:
counts["nested_software"] += 1
log.info(
"nested archive is a software build (holds %s), "
"skipped: %s", marker, member_path.name,
)
continue
self._collect_archive_members(
member_path, attribution=attribution,
source_row=source_row, depth=depth + 1,
@@ -1,17 +1,15 @@
<template>
<!-- #713: re-extract PostAttachments that are really archives but were filed
opaquely before the magic-byte gate, and attach their images to the post. -->
<MaintenanceTile
icon="mdi-folder-zip-outline"
title="Re-extract archives"
blurb="Extract images from zip attachments filed opaquely."
blurb="Import the images inside archive attachments that were never extracted."
:open="busy"
>
<p class="text-body-2 mb-3">
Some posts attached an archive (zip) whose images weren't extracted
because the downloaded file had no usable extension. This scans existing
attachments, extracts any that are really archives, and attaches their
images to the post. Idempotent — safe to run more than once.
Goes through every stored zip, rar and 7z, including ones imported from
a folder without a post, and imports the images inside. Archives already
extracted are passed over, and game or app builds stay whole. Safe to
run again.
</p>
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
<v-icon start>mdi-folder-zip-outline</v-icon> Re-extract archives now
+36
View File
@@ -269,3 +269,39 @@ def test_reimport_archive_is_idempotent(importer, import_layout):
assert importer.session.execute(
select(func.count()).select_from(PostAttachment)
).scalar_one() == 1
def test_a_game_build_is_kept_whole_and_its_images_left_alone(importer, import_layout):
import_root, _ = import_layout
importer.settings.phash_threshold = 0
arc = import_root / "Bob" / "MyGame-1.0-pc.zip"
arc.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("MyGame/lib/py3-windows-x86_64/python.exe", b"MZ")
zf.writestr("MyGame/game/images/cg01.jpg", _split_bytes("v"))
zf.writestr("MyGame/game/script.rpyc", b"\x00")
r = importer.import_one(arc)
assert r.status == "attached"
assert importer.session.execute(select(func.count()).select_from(ImageRecord)).scalar_one() == 0
att = importer.session.execute(select(PostAttachment)).scalar_one()
assert att.original_filename == "MyGame-1.0-pc.zip"
def test_a_game_build_inside_an_art_pack_is_skipped_and_the_art_imported(importer, import_layout):
import_root, _ = import_layout
importer.settings.phash_threshold = 0
game = io.BytesIO()
with zipfile.ZipFile(game, "w") as zf:
zf.writestr("Game_Data/sharedassets0.assets", b"\x00")
zf.writestr("Game_Data/sprite.jpg", _split_bytes("h"))
arc = import_root / "Bob" / "pack.zip"
arc.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("art.jpg", _split_bytes("v"))
zf.writestr("bonus/game.zip", game.getvalue())
r = importer.import_one(arc)
assert r.status == "imported"
assert len(r.member_image_ids) == 1
assert importer.session.execute(select(func.count()).select_from(ImageRecord)).scalar_one() == 1
+77
View File
@@ -171,3 +171,80 @@ def test_reextract_timebox_resumes_from_cursor(db_sync, tmp_path, monkeypatch):
assert second["scanned"] == 1
assert second["members_imported"] == 1
assert len(db_sync.execute(select(ImageRecord)).scalars().all()) == 2
def test_reextract_reaches_archives_without_a_post_and_passes_over_extracted_ones(
db_sync, tmp_path, monkeypatch,
):
from backend.app.tasks import ml as ml_mod
from backend.app.tasks import thumbnail as thumb_mod
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda *a, **k: None)
monkeypatch.setattr(ml_mod.embed_image, "delay", lambda *a, **k: None)
images_root = tmp_path / "images"
store_dir = images_root / "attachments" / "def"
store_dir.mkdir(parents=True)
artist = Artist(name="Cleo", slug="cleo")
db_sync.add(artist)
db_sync.flush()
# A filesystem-imported archive: an artist, no post, no sidecar.
arc = store_dir / "20210630_Demons.zip"
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("1.jpg", _patterned_jpeg(0))
zf.writestr("2.jpg", _patterned_jpeg(1))
att = PostAttachment(
post_id=None, artist_id=artist.id,
sha256=hashlib.sha256(arc.read_bytes()).hexdigest(), path=str(arc),
original_filename=arc.name, ext=".zip", size_bytes=arc.stat().st_size,
)
db_sync.add(att)
db_sync.commit()
summary = cleanup_service.reextract_archive_attachments(db_sync, images_root=images_root)
assert summary["without_post"] == 1
assert summary["members_imported"] == 2
images = db_sync.execute(select(ImageRecord)).scalars().all()
assert {i.artist_id for i in images} == {artist.id}
# The members sit in a folder named after the archive, and the stored
# archive itself is untouched.
assert all("/cleo/20210630_Demons/" in i.path for i in images)
assert arc.is_file()
assert not list((images_root / "cleo" / "20210630_Demons").glob("archive.*"))
def test_reextract_passes_over_an_archive_whose_members_are_recorded(
db_sync, tmp_path, monkeypatch,
):
images_root = tmp_path / "images"
store_dir = images_root / "attachments" / "fed"
store_dir.mkdir(parents=True)
artist = Artist(name="Dee", slug="dee")
db_sync.add(artist)
db_sync.flush()
post = Post(artist_id=artist.id, external_post_id="1")
db_sync.add(post)
db_sync.flush()
arc = store_dir / "done.zip"
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("1.jpg", _patterned_jpeg(0))
att = PostAttachment(
post_id=post.id, artist_id=artist.id,
sha256=hashlib.sha256(arc.read_bytes()).hexdigest(), path=str(arc),
original_filename=arc.name, ext=".zip", size_bytes=arc.stat().st_size,
)
img = ImageRecord(path=str(images_root / "dee" / "x.jpg"), sha256="0" * 64,
size_bytes=1, mime="image/jpeg", origin="downloaded")
db_sync.add_all([att, img])
db_sync.flush()
db_sync.add(ImageProvenance(image_record_id=img.id, post_id=post.id,
from_attachment_id=att.id))
db_sync.commit()
called = []
monkeypatch.setattr(cleanup_service, "_reextract_archive_to_post",
lambda *a, **k: called.append(a) or [])
summary = cleanup_service.reextract_archive_attachments(db_sync, images_root=images_root)
assert summary["already_extracted"] == 1
assert called == []