feat(sidecar): importer creates Source/Post/ImageProvenance from sidecars (idempotent, fail-soft)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 14:46:17 -04:00
parent ef7e4d92a9
commit 9b79b66b76
2 changed files with 294 additions and 6 deletions
+130 -6
View File
@@ -9,6 +9,8 @@ by the task. The Quart side uses an async session via the same models.
"""
import hashlib
import json
import logging
import shutil
from dataclasses import dataclass
from enum import StrEnum
@@ -18,13 +20,25 @@ from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import Artist, ImageRecord, ImportSettings, Tag, TagKind
from ..models import (
Artist,
ImageProvenance,
ImageRecord,
ImportSettings,
Post,
Source,
Tag,
TagKind,
)
from ..models.tag import image_tag
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 .thumbnailer import Thumbnailer
log = logging.getLogger(__name__)
class SkipReason(StrEnum):
too_small = "too_small"
@@ -227,9 +241,13 @@ class Importer:
self.session.flush()
# Folder→artist auto-tag.
artist = None
artist_name = derive_top_level_artist(source, self.import_root)
if artist_name:
self._attach_artist_tag(record.id, artist_name)
artist = self._attach_artist_tag(record.id, artist_name)
# Sidecar provenance (best-effort; never fails the import).
self._apply_sidecar(record, source, artist)
# Thumbnail is queued separately by the calling task; the importer
# does not generate thumbnails inline so the import queue stays moving.
@@ -237,16 +255,21 @@ class Importer:
self.session.commit()
return ImportResult(status="imported", image_id=record.id)
def _attach_artist_tag(self, image_id: int, artist_name: str) -> None:
"""Idempotently creates Artist + artist tag, attaches to image."""
slug = slugify(artist_name)
def _upsert_artist(self, name: str) -> Artist:
slug = slugify(name)
artist = self.session.execute(
select(Artist).where(Artist.slug == slug)
).scalar_one_or_none()
if artist is None:
artist = Artist(name=artist_name, slug=slug, is_subscription=False)
artist = Artist(name=name, slug=slug, is_subscription=False)
self.session.add(artist)
self.session.flush()
return artist
def _attach_artist_tag(self, image_id: int, artist_name: str) -> Artist:
"""Idempotently creates Artist + artist tag, attaches to image.
Returns the Artist (sidecar provenance attaches its Source to it)."""
artist = self._upsert_artist(artist_name)
tag = self.session.execute(
select(Tag).where(Tag.name == artist_name).where(Tag.kind == TagKind.artist)
@@ -270,6 +293,107 @@ class Importer:
image_record_id=image_id, tag_id=tag.id, source="auto",
)
)
return artist
@staticmethod
def _sidecar_artist_name(data: dict) -> str | None:
for k in ("artist", "author_name"):
v = data.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
for k in ("user", "creator"):
v = data.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
if isinstance(v, dict):
for sub in ("name", "full_name", "vanity", "account"):
sv = v.get(sub)
if isinstance(sv, str) and sv.strip():
return sv.strip()
return None
def _apply_sidecar(
self, record: ImageRecord, source: Path, artist: Artist | None
) -> None:
sc = find_sidecar(source)
if sc is None:
return
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
sd = parse_sidecar(data)
if artist is None:
name = self._sidecar_artist_name(data)
if name:
artist = self._upsert_artist(name)
if artist is None:
log.warning(
"sidecar present for %s but no artist; skipping provenance",
source,
)
return
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()
if sd.post_url is not None:
post.post_url = sd.post_url
if sd.post_title is not None:
post.post_title = sd.post_title
if sd.post_date is not None:
post.post_date = sd.post_date
if sd.description is not None:
post.description = sd.description
if sd.attachment_count is not None:
post.attachment_count = sd.attachment_count
post.raw_metadata = sd.raw
exists = self.session.execute(
select(ImageProvenance.id).where(
ImageProvenance.image_record_id == record.id,
ImageProvenance.post_id == post.id,
)
).scalar_one_or_none()
if exists is None:
self.session.add(
ImageProvenance(
image_record_id=record.id,
post_id=post.id,
source_id=src.id,
captured_metadata=sd.raw,
)
)
if record.primary_post_id is None:
record.primary_post_id = post.id
self.session.flush()
def _supersede(
self, existing: ImageRecord, source: Path, sha: str,