"""FC-3f: cursor-paginated artists directory. Mirrors TagDirectoryService surface-for-surface, but backed by Artist + Source + ImageRecord.artist_id (FC-2d-vii-c authoritative attribution). Pure read-surface; no writes. Cursor wire format is identical to tag_directory_service: base64 of "|". The _encode/_decode helpers are copied (not imported) to keep the two services decoupled. """ from __future__ import annotations import base64 from dataclasses import dataclass from sqlalchemy import and_, case, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, ArtistVisit, ImageRecord, Source from .gallery_service import thumbnail_url _SEP = "|" _PREVIEW_COUNT = 3 def _encode(name: str, artist_id: int) -> str: return base64.urlsafe_b64encode(f"{name}{_SEP}{artist_id}".encode()).decode() def _decode(cursor: str) -> tuple[str, int]: try: raw = base64.urlsafe_b64decode(cursor.encode()).decode() name, aid = raw.rsplit(_SEP, 1) return name, int(aid) except Exception as exc: raise ValueError(f"invalid cursor: {cursor!r}") from exc @dataclass(frozen=True) class DirectoryPage: cards: list[dict] next_cursor: str | None class ArtistDirectoryService: def __init__(self, session: AsyncSession): self.session = session async def list_artists( self, *, q: str | None, platform: str | None, cursor: str | None, limit: int = 60, ) -> DirectoryPage: if limit < 1 or limit > 200: raise ValueError("limit must be between 1 and 200") count_col = func.count(ImageRecord.id).label("image_count") # Unseen = images imported since the artist's last_viewed_at. # NULL last_viewed_at (artist created before alembic 0034 seed # or before find_or_create autoseed) defensively counts as # "never visited" → all images unseen. Single grouped query, no # N+1. unseen_col = func.count( case( ( or_( ArtistVisit.last_viewed_at.is_(None), ImageRecord.created_at > ArtistVisit.last_viewed_at, ), ImageRecord.id, ), else_=None, ) ).label("unseen_count") stmt = ( select(Artist, count_col, unseen_col) .outerjoin(ImageRecord, ImageRecord.artist_id == Artist.id) .outerjoin(ArtistVisit, ArtistVisit.artist_id == Artist.id) .group_by(Artist.id) ) if q: stmt = stmt.where(Artist.name.ilike(f"%{q}%")) if platform is not None: # Correlated EXISTS — NOT a JOIN. A JOIN to Source duplicates # the artist row when the artist has multiple sources on this # platform, breaking the keyset cursor and image_count. stmt = stmt.where( exists().where( and_( Source.artist_id == Artist.id, Source.platform == platform, ) ) ) if cursor: c_name, c_id = _decode(cursor) stmt = stmt.where( or_( Artist.name > c_name, and_(Artist.name == c_name, Artist.id > c_id), ) ) stmt = stmt.order_by(Artist.name.asc(), Artist.id.asc()).limit(limit + 1) rows = (await self.session.execute(stmt)).all() next_cursor: str | None = None if len(rows) > limit: last_artist = rows[limit - 1][0] next_cursor = _encode(last_artist.name, last_artist.id) rows = rows[:limit] artist_ids = [a.id for a, _, _ in rows] previews = await self._previews(artist_ids) cards = [ { "id": artist.id, "name": artist.name, "slug": artist.slug, "is_subscription": bool(artist.is_subscription), "image_count": int(image_count), "unseen_count": int(unseen_count), "preview_thumbnails": previews.get(artist.id, []), } for artist, image_count, unseen_count in rows ] return DirectoryPage(cards=cards, next_cursor=next_cursor) async def _previews(self, artist_ids: list[int]) -> dict[int, list[str]]: """artist_id -> [thumbnail_url, ...up to _PREVIEW_COUNT]. Window function ranks ImageRecord rows per artist by id DESC (newest first) and slices to the top _PREVIEW_COUNT in one query. """ if not artist_ids: return {} rn = func.row_number().over( partition_by=ImageRecord.artist_id, order_by=ImageRecord.id.desc(), ).label("rn") sub = ( select( ImageRecord.artist_id.label("artist_id"), ImageRecord.sha256.label("sha256"), ImageRecord.mime.label("mime"), ImageRecord.thumbnail_path.label("thumbnail_path"), rn, ) .where(ImageRecord.artist_id.in_(artist_ids)) .subquery() ) stmt = ( select( sub.c.artist_id, sub.c.sha256, sub.c.mime, sub.c.thumbnail_path, ) .where(sub.c.rn <= _PREVIEW_COUNT) .order_by(sub.c.artist_id, sub.c.rn) ) out: dict[int, list[str]] = {} for aid, sha, mime, tp in (await self.session.execute(stmt)).all(): out.setdefault(aid, []).append(thumbnail_url(tp, sha, mime)) return out