fix(maintenance): stage re-extract under the artist dir so members link — #713 part 2 fix
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m2s

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/<slug>/<platform>/<post>/ 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 14:51:22 -04:00
parent a497104661
commit cb9b286c53
+38 -13
View File
@@ -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/<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.
"""
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()