feat(attachments): importer dispatch — archive extract + non-media capture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 11:13:36 -04:00
parent df76fd75d8
commit f97551e2f6
4 changed files with 298 additions and 12 deletions
+132 -10
View File
@@ -12,7 +12,7 @@ import hashlib
import json
import logging
import shutil
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
@@ -26,12 +26,15 @@ from ..models import (
ImageRecord,
ImportSettings,
Post,
PostAttachment,
Source,
)
from ..utils.paths import derive_subdir, derive_top_level_artist, hash_suffixed_name
from ..utils.phash import compute_phash, find_similar
from ..utils.sidecar import find_sidecar, parse_sidecar
from ..utils.slug import slugify
from .archive_extractor import extract_archive, is_archive
from .attachment_store import AttachmentStore
from .thumbnailer import Thumbnailer
log = logging.getLogger(__name__)
@@ -48,10 +51,11 @@ class SkipReason(StrEnum):
@dataclass(frozen=True)
class ImportResult:
status: str # 'imported' | 'skipped' | 'failed' | 'superseded'
status: str # 'imported'|'skipped'|'failed'|'superseded'|'attached'
image_id: int | None = None
skip_reason: SkipReason | None = None
error: str | None = None
member_image_ids: list[int] = field(default_factory=list)
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
@@ -119,12 +123,128 @@ class Importer:
self.import_root = import_root
self.thumbnailer = thumbnailer
self.settings = settings
self.attachments = AttachmentStore(images_root)
def import_one(self, source: Path) -> ImportResult:
"""The full pipeline for a single file.
"""Dispatch by kind. Media → normal pipeline. Archive → extract
media members (one Post via the archive-adjacent sidecar) and
preserve the archive itself. Other non-media → PostAttachment.
Nothing is skipped for being non-media (FC-2d-iii)."""
if source.suffix.lower() == ".json":
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error="sidecar json is metadata, not content",
)
if is_archive(source):
return self._import_archive(source)
if not is_supported(source):
return self._capture_attachment(source)
return self._import_media(source, source)
Returns an ImportResult; callers (the Celery task) are responsible for
translating that into ImportTask.status and ImageRecord linkage.
def _resolve_artist(self, attribution_path: Path) -> Artist | None:
name = derive_top_level_artist(attribution_path, self.import_root)
return self._upsert_artist(name) if name else None
def _post_for_sidecar(
self, source: Path, artist: Artist | None
) -> Post | None:
"""If a sidecar sits next to `source`, ensure its Source+Post
exist (idempotent) and return the Post — so attachments can link
to the same Post the per-member _apply_sidecar will reuse."""
sc = find_sidecar(source)
if sc is None or artist is None:
return None
try:
data = json.loads(sc.read_text("utf-8"))
if not isinstance(data, dict):
raise ValueError("sidecar JSON is not an object")
except Exception as exc:
log.warning("sidecar parse failed for %s: %s", sc, exc)
return None
sd = parse_sidecar(data)
platform = sd.platform or "unknown"
url = sd.post_url or f"sidecar:{platform}"
src = self.session.execute(
select(Source).where(
Source.artist_id == artist.id,
Source.platform == platform,
Source.url == url,
)
).scalar_one_or_none()
if src is None:
src = Source(artist_id=artist.id, platform=platform, url=url)
self.session.add(src)
self.session.flush()
epid = sd.external_post_id or sc.stem
post = self.session.execute(
select(Post).where(
Post.source_id == src.id,
Post.external_post_id == epid,
)
).scalar_one_or_none()
if post is None:
post = Post(source_id=src.id, external_post_id=epid)
self.session.add(post)
self.session.flush()
return post
def _capture_attachment(
self, source: Path, *, post: Post | None = None,
artist: Artist | None = None, resolved: bool = False,
) -> ImportResult:
if not resolved:
artist = self._resolve_artist(source)
post = self._post_for_sidecar(source, artist)
sha = _sha256_of(source)
existing = self.session.execute(
select(PostAttachment).where(PostAttachment.sha256 == sha)
).scalar_one_or_none()
if existing is None:
stored = self.attachments.store(source, sha)
self.session.add(PostAttachment(
post_id=post.id if post else None,
artist_id=artist.id if artist else None,
sha256=sha,
path=stored,
original_filename=source.name,
ext=source.suffix.lower(),
mime=_mime_for(source),
size_bytes=source.stat().st_size,
))
self.session.flush()
self.session.commit()
return ImportResult(status="attached")
def _import_archive(self, source: Path) -> ImportResult:
artist = self._resolve_artist(source)
post = self._post_for_sidecar(source, artist)
member_ids: list[int] = []
with extract_archive(source) as members:
for _name, member_path in members:
if not is_supported(member_path):
continue # non-media preserved via the stored archive
res = self._import_media(member_path, source)
if res.status in ("imported", "superseded") and res.image_id:
member_ids.append(res.image_id)
# Preserve the archive itself (links to the same Post/Artist).
self._capture_attachment(
source, post=post, artist=artist, resolved=True
)
if member_ids:
return ImportResult(
status="imported", image_id=member_ids[0],
member_image_ids=member_ids,
)
return ImportResult(status="attached")
def _import_media(
self, source: Path, attribution_path: Path
) -> ImportResult:
"""The media import pipeline (filters, dedup, copy, provenance).
`attribution_path` anchors artist/subdir/sidecar derivation: for
a normal file it equals `source`; for an archive member it is the
archive's real path (the member lives in a tempdir).
"""
if not is_supported(source):
return ImportResult(
@@ -211,8 +331,8 @@ class Importer:
status="superseded", image_id=match_id
)
# Destination path.
subdir = derive_subdir(source, self.import_root)
# Destination path (artist/subdir anchored to attribution_path).
subdir = derive_subdir(attribution_path, self.import_root)
dest_dir = self.images_root / subdir if subdir else self.images_root
dest_dir.mkdir(parents=True, exist_ok=True)
dest_name = hash_suffixed_name(source.stem, sha, source.suffix)
@@ -237,14 +357,16 @@ class Importer:
self.session.add(record)
self.session.flush()
# Folder→artist auto-tag.
# Folder→artist (anchored to attribution_path).
artist = None
artist_name = derive_top_level_artist(source, self.import_root)
artist_name = derive_top_level_artist(
attribution_path, self.import_root
)
if artist_name:
artist = self._attach_artist(record, artist_name)
# Sidecar provenance (best-effort; never fails the import).
self._apply_sidecar(record, source, artist)
self._apply_sidecar(record, attribution_path, artist)
# Thumbnail is queued separately by the calling task; the importer
# does not generate thumbnails inline so the import queue stays moving.