"""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_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( Artist, ImageProvenance, ImageRecord, Post, Source, Tag, ) from ..models.tag import image_tag 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) .join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id) .join(Source, Source.id == ImageProvenance.source_id) .where(Source.artist_id == aid) ) image_count = ( await self.session.execute( select(func.count(func.distinct(ImageRecord.id))) .select_from(ImageRecord) .join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id) .join(Source, Source.id == ImageProvenance.source_id) .where(Source.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, "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.id.in_( select(ImageProvenance.image_record_id) .join(Source, Source.id == ImageProvenance.source_id) .where(Source.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, )