feat(maintenance): re-extract archive attachments + link to post — #713 part 2
Existing PostAttachments that are actually archives (filed opaquely before the magic-byte gate) need extracting retroactively. cleanup_service. reextract_archive_attachments scans PostAttachments, magic-detects the archives, and for each reconstructs the post's sidecar from the DB + re-runs attach_in_place in a temp dir — so the members extract and re-link to the SAME post via find_or_create_post (source_id + external_post_id). Idempotent (members dedupe by sha256). Enqueues thumbnail+ML for new members. Wired as a maintenance-queue Celery task (tasks/admin) + POST /api/admin/maintenance/reextract-archives (202) + a "Re-extract archive attachments" card in Settings → Maintenance. Test: a zip stored under a mangled extension-less name extracts + links its member to the post via ImageProvenance, and a second run is a no-op (idempotent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -289,3 +289,14 @@ async def trigger_vacuum():
|
|||||||
|
|
||||||
vacuum_analyze.delay()
|
vacuum_analyze.delay()
|
||||||
return jsonify({"status": "queued"}), 202
|
return jsonify({"status": "queued"}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/maintenance/reextract-archives", methods=["POST"])
|
||||||
|
async def trigger_reextract_archives():
|
||||||
|
"""Operator-triggered re-extract (#713): PostAttachments that are actually
|
||||||
|
archives but were filed opaquely (pre magic-byte gate) get extracted and
|
||||||
|
their members linked to the post. Idempotent; runs on the maintenance queue."""
|
||||||
|
from ..tasks.admin import reextract_archive_attachments_task
|
||||||
|
|
||||||
|
async_result = reextract_archive_attachments_task.delay()
|
||||||
|
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ the one-and-done GS/IR migration tooling.)
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -22,6 +23,8 @@ from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
|
|||||||
from ..models.series_page import SeriesPage
|
from ..models.series_page import SeriesPage
|
||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def project_artist_cascade(session: Session, *, slug: str) -> dict:
|
def project_artist_cascade(session: Session, *, slug: str) -> dict:
|
||||||
"""Read-only projection of what delete_artist_cascade would touch.
|
"""Read-only projection of what delete_artist_cascade would touch.
|
||||||
@@ -665,3 +668,114 @@ def cancel_audit_run(session: Session, *, audit_id: int) -> None:
|
|||||||
.where(LibraryAuditRun.status == "running")
|
.where(LibraryAuditRun.status == "running")
|
||||||
.values(status="cancelled", finished_at=datetime.now(UTC))
|
.values(status="cancelled", finished_at=datetime.now(UTC))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- archive-attachment re-extraction (#713 part 2) ------------------------
|
||||||
|
|
||||||
|
_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]:
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
return list(res.member_image_ids or [])
|
||||||
|
|
||||||
|
|
||||||
|
def reextract_archive_attachments(session: Session, *, images_root: Path) -> dict:
|
||||||
|
"""Re-process existing PostAttachments that are ACTUALLY archives but were
|
||||||
|
filed opaquely before #713 part 1 (extension-only is_archive missed mangled /
|
||||||
|
extension-less Patreon attachment names). For each: extract the members,
|
||||||
|
import them, and link them to the attachment's post.
|
||||||
|
|
||||||
|
Idempotent — members dedupe by sha256, the archive dedupes by sha — so it's
|
||||||
|
safe to run repeatedly. Returns a summary dict for task_run.metadata.
|
||||||
|
"""
|
||||||
|
from ..models import ImportSettings, Post, PostAttachment, Source
|
||||||
|
from ..tasks.ml import tag_and_embed
|
||||||
|
from ..tasks.thumbnail import generate_thumbnail
|
||||||
|
from .archive_extractor import is_archive
|
||||||
|
from .importer import Importer
|
||||||
|
from .thumbnailer import Thumbnailer
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"scanned": 0, "archives": 0, "members_imported": 0,
|
||||||
|
"posts_touched": 0, "skipped_no_post": 0, "errors": 0,
|
||||||
|
}
|
||||||
|
settings = ImportSettings.load_sync(session)
|
||||||
|
importer = Importer(
|
||||||
|
session=session, images_root=images_root, import_root=images_root,
|
||||||
|
thumbnailer=Thumbnailer(images_root=images_root), settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
attachments = session.execute(
|
||||||
|
select(PostAttachment).order_by(PostAttachment.id)
|
||||||
|
).scalars().all()
|
||||||
|
enqueue_ids: list[int] = []
|
||||||
|
for att in attachments:
|
||||||
|
summary["scanned"] += 1
|
||||||
|
stored = Path(att.path)
|
||||||
|
try:
|
||||||
|
if not stored.is_file() or not is_archive(stored):
|
||||||
|
continue
|
||||||
|
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
|
||||||
|
continue
|
||||||
|
artist = session.get(Artist, att.artist_id) if att.artist_id else None
|
||||||
|
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)
|
||||||
|
session.commit()
|
||||||
|
except Exception as exc: # one bad archive must not strand the rest
|
||||||
|
session.rollback()
|
||||||
|
summary["errors"] += 1
|
||||||
|
log.warning("re-extract failed for attachment %s: %s", att.id, exc)
|
||||||
|
continue
|
||||||
|
if ids:
|
||||||
|
summary["members_imported"] += len(ids)
|
||||||
|
summary["posts_touched"] += 1
|
||||||
|
enqueue_ids.extend(ids)
|
||||||
|
|
||||||
|
# Thumbnails + ML for the newly-imported members (best-effort; off the
|
||||||
|
# critical path — a Redis hiccup must not fail the whole re-extract).
|
||||||
|
for img_id in enqueue_ids:
|
||||||
|
try:
|
||||||
|
generate_thumbnail.delay(img_id)
|
||||||
|
tag_and_embed.delay(img_id)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("re-extract enqueue failed for image %s: %s", img_id, exc)
|
||||||
|
return summary
|
||||||
|
|||||||
@@ -55,3 +55,21 @@ def bulk_delete_images_task(self, *, image_ids: list[int]) -> dict:
|
|||||||
return cleanup_service.delete_images(
|
return cleanup_service.delete_images(
|
||||||
session, image_ids=image_ids, images_root=IMAGES_ROOT,
|
session, image_ids=image_ids, images_root=IMAGES_ROOT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="backend.app.tasks.admin.reextract_archive_attachments_task",
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(OperationalError, DBAPIError),
|
||||||
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
||||||
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
||||||
|
)
|
||||||
|
def reextract_archive_attachments_task(self) -> dict:
|
||||||
|
"""Wraps cleanup_service.reextract_archive_attachments (#713 part 2):
|
||||||
|
re-extract PostAttachments that are actually archives but were filed
|
||||||
|
opaquely before the magic-byte gate, and link their members to the post."""
|
||||||
|
SessionLocal = _sync_session_factory()
|
||||||
|
with SessionLocal() as session:
|
||||||
|
return cleanup_service.reextract_archive_attachments(
|
||||||
|
session, images_root=IMAGES_ROOT,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<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. -->
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Re-extract archive attachments</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||||
|
<v-icon start>mdi-folder-zip-outline</v-icon> Re-extract archives now
|
||||||
|
</v-btn>
|
||||||
|
<span v-if="queued" class="ml-3 text-caption text-success">Queued ✓</span>
|
||||||
|
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
import { toast } from '../../utils/toast.js'
|
||||||
|
import QueueStatusBar from './QueueStatusBar.vue'
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
const busy = ref(false)
|
||||||
|
const queued = ref(false)
|
||||||
|
|
||||||
|
async function run () {
|
||||||
|
busy.value = true
|
||||||
|
queued.value = false
|
||||||
|
try {
|
||||||
|
await api.post('/api/admin/maintenance/reextract-archives')
|
||||||
|
queued.value = true
|
||||||
|
toast({ text: 'Archive re-extract queued', type: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: e?.body?.detail || e?.message || 'Failed to queue', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<AllowlistTable class="mt-4" />
|
<AllowlistTable class="mt-4" />
|
||||||
<AliasTable class="mt-4" />
|
<AliasTable class="mt-4" />
|
||||||
<DbMaintenanceCard class="mt-6" />
|
<DbMaintenanceCard class="mt-6" />
|
||||||
|
<ArchiveReextractCard class="mt-6" />
|
||||||
<BackupCard class="mt-6" />
|
<BackupCard class="mt-6" />
|
||||||
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
|
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
|
||||||
operates on the existing library which fits the Cleanup-tab
|
operates on the existing library which fits the Cleanup-tab
|
||||||
@@ -33,6 +34,7 @@ import MLThresholdSliders from './MLThresholdSliders.vue'
|
|||||||
import AllowlistTable from './AllowlistTable.vue'
|
import AllowlistTable from './AllowlistTable.vue'
|
||||||
import AliasTable from './AliasTable.vue'
|
import AliasTable from './AliasTable.vue'
|
||||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||||
|
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||||
import BackupCard from './BackupCard.vue'
|
import BackupCard from './BackupCard.vue'
|
||||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""#713 part 2: re-extract archive PostAttachments that were filed opaquely
|
||||||
|
(magic-byte gate missed them) and link their members to the post."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from backend.app.models import (
|
||||||
|
Artist,
|
||||||
|
ImageProvenance,
|
||||||
|
ImageRecord,
|
||||||
|
Post,
|
||||||
|
PostAttachment,
|
||||||
|
Source,
|
||||||
|
)
|
||||||
|
from backend.app.services import cleanup_service
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
def _jpeg(color, size=256):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (size, size), color).save(buf, "JPEG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reextract_links_archive_members_to_post(db_sync, tmp_path, monkeypatch):
|
||||||
|
from backend.app.tasks import ml as ml_mod
|
||||||
|
from backend.app.tasks import thumbnail as thumb_mod
|
||||||
|
|
||||||
|
# No broker in this path — the post-import enqueue is best-effort anyway.
|
||||||
|
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(ml_mod.tag_and_embed, "delay", lambda *a, **k: None)
|
||||||
|
|
||||||
|
images_root = tmp_path / "images"
|
||||||
|
images_root.mkdir()
|
||||||
|
|
||||||
|
artist = Artist(name="Bob", slug="bob")
|
||||||
|
db_sync.add(artist)
|
||||||
|
db_sync.flush()
|
||||||
|
source = Source(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="https://patreon.com/bob", enabled=True, config_overrides={},
|
||||||
|
)
|
||||||
|
db_sync.add(source)
|
||||||
|
db_sync.flush()
|
||||||
|
post = Post(
|
||||||
|
source_id=source.id, artist_id=artist.id, external_post_id="59102952",
|
||||||
|
post_url="https://www.patreon.com/posts/59102952",
|
||||||
|
)
|
||||||
|
db_sync.add(post)
|
||||||
|
db_sync.flush()
|
||||||
|
|
||||||
|
# A real zip stored under a mangled / extension-less name (the failure case).
|
||||||
|
store_dir = images_root / "attachments" / "abc"
|
||||||
|
store_dir.mkdir(parents=True)
|
||||||
|
arc = store_dir / "01_https___www.patreon.com_media-u_v3_59102952"
|
||||||
|
with zipfile.ZipFile(arc, "w") as zf:
|
||||||
|
zf.writestr("inside.jpg", _jpeg("red"))
|
||||||
|
sha = hashlib.sha256(arc.read_bytes()).hexdigest()
|
||||||
|
db_sync.add(PostAttachment(
|
||||||
|
post_id=post.id, artist_id=artist.id, sha256=sha, path=str(arc),
|
||||||
|
original_filename=arc.name, ext="", size_bytes=arc.stat().st_size,
|
||||||
|
))
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
summary = cleanup_service.reextract_archive_attachments(
|
||||||
|
db_sync, images_root=images_root,
|
||||||
|
)
|
||||||
|
assert summary["archives"] == 1
|
||||||
|
assert summary["members_imported"] == 1
|
||||||
|
assert summary["posts_touched"] == 1
|
||||||
|
|
||||||
|
images = db_sync.execute(select(ImageRecord)).scalars().all()
|
||||||
|
assert len(images) == 1
|
||||||
|
prov = db_sync.execute(
|
||||||
|
select(ImageProvenance).where(ImageProvenance.post_id == post.id)
|
||||||
|
).scalars().all()
|
||||||
|
assert len(prov) == 1
|
||||||
|
assert prov[0].image_record_id == images[0].id
|
||||||
|
|
||||||
|
# Idempotent — a second run imports nothing new (member dedups by sha256).
|
||||||
|
again = cleanup_service.reextract_archive_attachments(
|
||||||
|
db_sync, images_root=images_root,
|
||||||
|
)
|
||||||
|
assert again["members_imported"] == 0
|
||||||
|
assert db_sync.execute(select(ImageRecord)).scalars().all() == images
|
||||||
Reference in New Issue
Block a user