From cb9b286c531f65f87931800618c60d35ba25ade5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 6 Jun 2026 14:51:22 -0400 Subject: [PATCH] =?UTF-8?q?fix(maintenance):=20stage=20re-extract=20under?= =?UTF-8?q?=20the=20artist=20dir=20so=20members=20link=20=E2=80=94=20#713?= =?UTF-8?q?=20part=202=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration test caught it: _import_media re-derives the artist by path-walk from the attribution path (ignoring the explicit artist) AND _copy_to_library lands members relative to that path. Staging the archive in /tmp meant the artist didn't resolve (provenance skipped) and members would land in the temp dir. Stage under images_root//// instead so the artist resolves and members land in the real library; remove only the staged archive + sidecar afterward (members stay). Require a real artist+slug (skip + count otherwise). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/services/cleanup_service.py | 51 ++++++++++++++++++------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index f897e8b..605c215 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -675,23 +675,29 @@ def cancel_audit_run(session: Session, *, audit_id: int) -> None: _ARCHIVE_EXT_FOR_FORMAT = {"zip": ".zip", "rar": ".rar", "7z": ".7z"} -def _reextract_archive_to_post(importer, archive_path: Path, post, source_row, artist) -> list[int]: +def _reextract_archive_to_post( + importer, archive_path: Path, post, source_row, artist, images_root: Path, +) -> list[int]: """Extract one stored archive and link its members to `post`. The stored attachment has no adjacent sidecar (it lives in the sha-addressed - attachment store), so reconstruct the post's sidecar from the DB and re-run - the normal import path: `attach_in_place` extracts the members and - `_apply_sidecar` → `find_or_create_post` re-attaches them to the SAME Post - (matched by source_id + external_post_id). Returns the new member image ids. + attachment store). Stage a copy + a reconstructed sidecar UNDER the artist's + library dir (`images_root////`) — 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. """ import json import shutil - import tempfile 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"), @@ -701,12 +707,20 @@ def _reextract_archive_to_post(importer, archive_path: Path, post, source_row, a "published_at": post.post_date.isoformat() if post.post_date else None, "url": post.post_url, } - with tempfile.TemporaryDirectory(prefix="fc_reextract_") as td: - tmp = Path(td) / f"archive{ext}" # clean ext → is_archive + find_sidecar - shutil.copy2(archive_path, tmp) - tmp.with_suffix(".json").write_text(json.dumps(sidecar)) - res = importer.attach_in_place(tmp, artist=artist, source=source_row) + work = images_root / artist.slug / platform / str(post.external_post_id) + 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)) + res = importer.attach_in_place(staged, artist=artist, source=source_row) return list(res.member_image_ids or []) + finally: + # Drop only the staged archive + sidecar; the extracted member files + # were copied into the library alongside them and must stay. + staged.unlink(missing_ok=True) + sidecar_path.unlink(missing_ok=True) def reextract_archive_attachments(session: Session, *, images_root: Path) -> dict: @@ -727,7 +741,8 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic summary = { "scanned": 0, "archives": 0, "members_imported": 0, - "posts_touched": 0, "skipped_no_post": 0, "errors": 0, + "posts_touched": 0, "skipped_no_post": 0, "skipped_no_artist": 0, + "errors": 0, } settings = ImportSettings.load_sync(session) importer = Importer( @@ -756,9 +771,19 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic summary["skipped_no_post"] += 1 continue artist = session.get(Artist, att.artist_id) if att.artist_id else None + if artist is 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 + # a real artist+slug to anchor under. (Shouldn't happen for + # 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 try: - ids = _reextract_archive_to_post(importer, stored, post, source_row, artist) + ids = _reextract_archive_to_post( + importer, stored, post, source_row, artist, images_root, + ) session.commit() except Exception as exc: # one bad archive must not strand the rest session.rollback()