feat(provenance): importer sets image_record.artist_id, stops minting artist tags
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,10 +27,7 @@ from ..models import (
|
|||||||
ImportSettings,
|
ImportSettings,
|
||||||
Post,
|
Post,
|
||||||
Source,
|
Source,
|
||||||
Tag,
|
|
||||||
TagKind,
|
|
||||||
)
|
)
|
||||||
from ..models.tag import image_tag
|
|
||||||
from ..utils.paths import derive_subdir, derive_top_level_artist, hash_suffixed_name
|
from ..utils.paths import derive_subdir, derive_top_level_artist, hash_suffixed_name
|
||||||
from ..utils.phash import compute_phash, find_similar
|
from ..utils.phash import compute_phash, find_similar
|
||||||
from ..utils.sidecar import find_sidecar, parse_sidecar
|
from ..utils.sidecar import find_sidecar, parse_sidecar
|
||||||
@@ -244,7 +241,7 @@ class Importer:
|
|||||||
artist = None
|
artist = None
|
||||||
artist_name = derive_top_level_artist(source, self.import_root)
|
artist_name = derive_top_level_artist(source, self.import_root)
|
||||||
if artist_name:
|
if artist_name:
|
||||||
artist = self._attach_artist_tag(record.id, artist_name)
|
artist = self._attach_artist(record, artist_name)
|
||||||
|
|
||||||
# Sidecar provenance (best-effort; never fails the import).
|
# Sidecar provenance (best-effort; never fails the import).
|
||||||
self._apply_sidecar(record, source, artist)
|
self._apply_sidecar(record, source, artist)
|
||||||
@@ -266,33 +263,15 @@ class Importer:
|
|||||||
self.session.flush()
|
self.session.flush()
|
||||||
return artist
|
return artist
|
||||||
|
|
||||||
def _attach_artist_tag(self, image_id: int, artist_name: str) -> Artist:
|
def _attach_artist(self, record: ImageRecord, artist_name: str) -> Artist:
|
||||||
"""Idempotently creates Artist + artist tag, attaches to image.
|
"""Upsert the Artist and set it as the image's canonical artist.
|
||||||
Returns the Artist (sidecar provenance attaches its Source to it)."""
|
Artist-kind tags were retired in FC-2d-vii-c — the Artist row is
|
||||||
|
the single source of truth. Returns the Artist (sidecar
|
||||||
|
provenance attaches its Source to it)."""
|
||||||
artist = self._upsert_artist(artist_name)
|
artist = self._upsert_artist(artist_name)
|
||||||
|
if record.artist_id is None:
|
||||||
tag = self.session.execute(
|
record.artist_id = artist.id
|
||||||
select(Tag).where(Tag.name == artist_name).where(Tag.kind == TagKind.artist)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if tag is None:
|
|
||||||
tag = Tag(name=artist_name, kind=TagKind.artist)
|
|
||||||
self.session.add(tag)
|
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
|
|
||||||
# Idempotent association; using a raw insert + on-conflict-do-nothing
|
|
||||||
# is cleaner here than fetching first, but for a sync session we just
|
|
||||||
# check existence.
|
|
||||||
already = self.session.execute(
|
|
||||||
select(image_tag.c.tag_id)
|
|
||||||
.where(image_tag.c.image_record_id == image_id)
|
|
||||||
.where(image_tag.c.tag_id == tag.id)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if already is None:
|
|
||||||
self.session.execute(
|
|
||||||
image_tag.insert().values(
|
|
||||||
image_record_id=image_id, tag_id=tag.id, source="auto",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return artist
|
return artist
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -339,6 +318,9 @@ class Importer:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if record.artist_id is None:
|
||||||
|
record.artist_id = artist.id
|
||||||
|
|
||||||
platform = sd.platform or "unknown"
|
platform = sd.platform or "unknown"
|
||||||
url = sd.post_url or f"sidecar:{platform}"
|
url = sd.post_url or f"sidecar:{platform}"
|
||||||
src = self.session.execute(
|
src = self.session.execute(
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""FC-2d-vii-c: importer sets artist_id, creates no artist tags."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from backend.app.models import Artist, ImageRecord, ImportSettings, Tag, TagKind
|
||||||
|
from backend.app.services.importer import Importer
|
||||||
|
from backend.app.services.thumbnailer import Thumbnailer
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def import_layout(tmp_path):
|
||||||
|
import_root = tmp_path / "import"
|
||||||
|
images_root = tmp_path / "images"
|
||||||
|
import_root.mkdir()
|
||||||
|
images_root.mkdir()
|
||||||
|
return import_root, images_root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def importer(db_sync, import_layout):
|
||||||
|
import_root, images_root = import_layout
|
||||||
|
settings = db_sync.execute(
|
||||||
|
select(ImportSettings).where(ImportSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
return Importer(
|
||||||
|
session=db_sync, images_root=images_root, import_root=import_root,
|
||||||
|
thumbnailer=Thumbnailer(images_root=images_root), settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _img(path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGB", (64, 64), (123, 200, 50)).save(path, "JPEG")
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_import_sets_artist_id_no_tag(importer, import_layout):
|
||||||
|
import_root, _ = import_layout
|
||||||
|
m = import_root / "Alice" / "pic.jpg"
|
||||||
|
_img(m)
|
||||||
|
r = importer.import_one(m)
|
||||||
|
assert r.status == "imported"
|
||||||
|
rec = importer.session.get(ImageRecord, r.image_id)
|
||||||
|
artist = importer.session.execute(
|
||||||
|
select(Artist).where(Artist.slug == "alice")
|
||||||
|
).scalar_one()
|
||||||
|
assert rec.artist_id == artist.id
|
||||||
|
n_artist_tags = importer.session.execute(
|
||||||
|
select(func.count()).select_from(Tag).where(Tag.kind == TagKind.artist)
|
||||||
|
).scalar_one()
|
||||||
|
assert n_artist_tags == 0
|
||||||
Reference in New Issue
Block a user