"""Artist overview + paged images. Images are linked to an artist through the provenance chain Source(artist_id) -> ImageProvenance(source_id) -> ImageRecord. DISTINCT guards against an image having several provenance rows for one artist. Dates come from Post.post_date via ImageProvenance.post_id. """ from dataclasses import dataclass from sqlalchemy import and_, case, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( Artist, ImageProvenance, ImageRecord, Post, Source, Tag, ) from ..models.tag import image_tag from ..utils.slug import slugify from .gallery_service import decode_cursor, encode_cursor, thumbnail_url @dataclass(frozen=True) class ArtistImagesPage: images: list[dict] next_cursor: str | None class ArtistService: def __init__(self, session: AsyncSession): self.session = session async def _artist_by_slug(self, slug: str) -> Artist | None: return ( await self.session.execute( select(Artist).where(Artist.slug == slug) ) ).scalar_one_or_none() async def overview(self, slug: str) -> dict | None: artist = await self._artist_by_slug(slug) if artist is None: return None aid = artist.id img_join = ( select(ImageRecord.id).where(ImageRecord.artist_id == aid) ) image_count = ( await self.session.execute( select(func.count(ImageRecord.id)) .where(ImageRecord.artist_id == aid) ) ).scalar_one() date_row = ( await self.session.execute( select(func.min(Post.post_date), func.max(Post.post_date)) .select_from(Post) .join(ImageProvenance, ImageProvenance.post_id == Post.id) .join(Source, Source.id == ImageProvenance.source_id) .where(Source.artist_id == aid) ) ).first() dmin, dmax = date_row if date_row else (None, None) cooccurring = ( await self.session.execute( select( Tag.id, Tag.name, Tag.kind, func.count(image_tag.c.image_record_id).label("cnt"), ) .select_from(Tag) .join(image_tag, image_tag.c.tag_id == Tag.id) .where(image_tag.c.image_record_id.in_(img_join)) .group_by(Tag.id, Tag.name, Tag.kind) .order_by(func.count(image_tag.c.image_record_id).desc()) .limit(20) ) ).all() sources = ( await self.session.execute( select( Source.id, Source.platform, Source.url, func.count(func.distinct(ImageProvenance.image_record_id)).label("cnt"), ) .select_from(Source) .outerjoin(ImageProvenance, ImageProvenance.source_id == Source.id) .where(Source.artist_id == aid) .group_by(Source.id, Source.platform, Source.url) .order_by(Source.id) ) ).all() month = func.date_trunc("month", Post.post_date).label("m") activity = ( await self.session.execute( select(month, func.count(func.distinct(ImageProvenance.image_record_id))) .select_from(Post) .join(ImageProvenance, ImageProvenance.post_id == Post.id) .join(Source, Source.id == ImageProvenance.source_id) .where(and_(Source.artist_id == aid, Post.post_date.isnot(None))) .group_by(month) .order_by(month) ) ).all() return { "id": artist.id, "name": artist.name, "slug": artist.slug, "is_subscription": bool(artist.is_subscription), "image_count": int(image_count), "date_range": { "min": dmin.isoformat() if dmin else None, "max": dmax.isoformat() if dmax else None, }, "cooccurring_tags": [ { "id": tid, "name": name, "kind": kind.value if hasattr(kind, "value") else kind, "count": int(cnt), } for tid, name, kind, cnt in cooccurring ], "sources": [ { "id": sid, "platform": platform, "url": url, "image_count": int(cnt), } for sid, platform, url, cnt in sources ], "activity": [ {"month": m.isoformat(), "count": int(cnt)} for m, cnt in activity ], } async def images( self, slug: str, cursor: str | None, limit: int = 60 ) -> ArtistImagesPage | None: if limit < 1 or limit > 200: raise ValueError("limit must be between 1 and 200") artist = await self._artist_by_slug(slug) if artist is None: return None # Dedupe via IN-subquery rather than JOIN + DISTINCT: an image can # have several provenance rows for one artist, and SELECT DISTINCT # over ImageRecord fails in Postgres because its json columns have # no equality operator. stmt = select(ImageRecord).where( ImageRecord.artist_id == artist.id ) if cursor: cur_ts, cur_id = decode_cursor(cursor) stmt = stmt.where( or_( ImageRecord.created_at < cur_ts, and_(ImageRecord.created_at == cur_ts, ImageRecord.id < cur_id), ) ) stmt = stmt.order_by( ImageRecord.created_at.desc(), ImageRecord.id.desc() ).limit(limit + 1) rows = (await self.session.execute(stmt)).scalars().all() next_cursor = None if len(rows) > limit: last = rows[limit - 1] next_cursor = encode_cursor(last.created_at, last.id) rows = rows[:limit] return ArtistImagesPage( images=[ { "id": r.id, "sha256": r.sha256, "mime": r.mime, "width": r.width, "height": r.height, "thumbnail_url": thumbnail_url(r.sha256, r.mime), } for r in rows ], next_cursor=next_cursor, ) async def find_or_create(self, name: str) -> tuple[Artist, bool]: """Return (artist, created). Slug-keyed; idempotent under races.""" cleaned = (name or "").strip() if not cleaned: raise ValueError("artist name must not be empty") slug = slugify(cleaned) existing = (await self.session.execute( select(Artist).where(Artist.slug == slug) )).scalar_one_or_none() if existing is not None: return existing, False artist = Artist(name=cleaned, slug=slug) self.session.add(artist) try: await self.session.flush() except IntegrityError: await self.session.rollback() existing = (await self.session.execute( select(Artist).where(Artist.slug == slug) )).scalar_one() return existing, False await self.session.commit() return artist, True async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]: cleaned = (prefix or "").strip() if not cleaned: return [] like = f"%{cleaned.lower()}%" prefix_like = f"{cleaned.lower()}%" # Rank: exact (0) < prefix (1) < substring (2). rank = case( (func.lower(Artist.name) == cleaned.lower(), 0), (func.lower(Artist.name).like(prefix_like), 1), else_=2, ).label("rank") rows = (await self.session.execute( select(Artist, rank) .where(func.lower(Artist.name).like(like)) .order_by(rank, Artist.name.asc()) .limit(limit) )).all() return [a for a, _ in rows]