From 23e11d33ef733df00706458a9d7668ddcaf25622 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 21 May 2026 22:14:15 -0400 Subject: [PATCH] =?UTF-8?q?fc3f:=20ArtistDirectoryService=20=E2=80=94=20cu?= =?UTF-8?q?rsor-paginated=20artists=20directory=20mirroring=20TagDirectory?= =?UTF-8?q?Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/services/artist_directory_service.py | 143 ++++++++++ tests/test_artist_directory_service.py | 259 ++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 backend/app/services/artist_directory_service.py create mode 100644 tests/test_artist_directory_service.py diff --git a/backend/app/services/artist_directory_service.py b/backend/app/services/artist_directory_service.py new file mode 100644 index 0000000..a16c1aa --- /dev/null +++ b/backend/app/services/artist_directory_service.py @@ -0,0 +1,143 @@ +"""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_, exists, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Artist, 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") + stmt = ( + select(Artist, count_col) + .outerjoin(ImageRecord, ImageRecord.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), + "preview_thumbnails": previews.get(artist.id, []), + } + for artist, image_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"), + rn, + ) + .where(ImageRecord.artist_id.in_(artist_ids)) + .subquery() + ) + stmt = ( + select(sub.c.artist_id, sub.c.sha256, sub.c.mime) + .where(sub.c.rn <= _PREVIEW_COUNT) + .order_by(sub.c.artist_id, sub.c.rn) + ) + out: dict[int, list[str]] = {} + for aid, sha, mime in (await self.session.execute(stmt)).all(): + out.setdefault(aid, []).append(thumbnail_url(sha, mime)) + return out diff --git a/tests/test_artist_directory_service.py b/tests/test_artist_directory_service.py new file mode 100644 index 0000000..fbfbf2f --- /dev/null +++ b/tests/test_artist_directory_service.py @@ -0,0 +1,259 @@ +"""FC-3f: ArtistDirectoryService unit tests. + +Mirrors TagDirectoryService's test shape — covers cursor encode/decode, +ordering by (name ASC, id ASC), substring q filter, platform EXISTS +filter (with the row-duplication trap), pagination, image_count via +LEFT JOIN, and the window-function preview helper. +""" +import pytest + +from backend.app.models import Artist, ImageRecord, Source +from backend.app.services.artist_directory_service import ( + ArtistDirectoryService, + _decode, + _encode, +) + +pytestmark = pytest.mark.integration + + +# --- Cursor round-trip ---------------------------------------------------- + + +def test_cursor_round_trip(): + c = _encode("alice", 5) + assert _decode(c) == ("alice", 5) + + +def test_decode_cursor_rejects_garbage(): + with pytest.raises(ValueError): + _decode("not-base64!!!") + + +def test_cursor_round_trip_handles_pipes_in_name(): + # Names can theoretically contain '|'; rsplit on the LAST '|' must + # still recover (name, id) correctly. + c = _encode("a|b|c", 7) + assert _decode(c) == ("a|b|c", 7) + + +# --- Fixture builders ----------------------------------------------------- + + +async def _seed_artist(db, name: str, *, is_subscription: bool = False): + a = Artist( + name=name, slug=name.lower().replace(" ", "-"), + is_subscription=is_subscription, + ) + db.add(a) + await db.flush() + return a + + +async def _seed_source(db, artist_id: int, platform: str, url: str): + s = Source(artist_id=artist_id, platform=platform, url=url, enabled=True) + db.add(s) + await db.flush() + return s + + +async def _seed_image(db, artist_id: int, suffix: str): + db.add(ImageRecord( + path=f"/images/{suffix}.jpg", + sha256=("0" * 60) + f"{abs(hash(suffix)) % 10000:04d}", + size_bytes=10, mime="image/jpeg", width=10, height=10, + origin="downloaded", artist_id=artist_id, + )) + await db.flush() + + +# --- Ordering + pagination ----------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_orders_by_name_asc(db): + await _seed_artist(db, "zara-order") + await _seed_artist(db, "alice-order") + await _seed_artist(db, "miles-order") + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="-order", platform=None, cursor=None, limit=60, + ) + names = [c["name"] for c in page.cards] + assert names == ["alice-order", "miles-order", "zara-order"] + + +@pytest.mark.asyncio +async def test_list_pagination_across_two_pages(db): + artists = [] + for letter in "abcde": + a = await _seed_artist(db, f"{letter}-page") + artists.append(a) + await db.commit() + + page1 = await ArtistDirectoryService(db).list_artists( + q="-page", platform=None, cursor=None, limit=2, + ) + assert [c["name"] for c in page1.cards] == ["a-page", "b-page"] + assert page1.next_cursor is not None + + page2 = await ArtistDirectoryService(db).list_artists( + q="-page", platform=None, cursor=page1.next_cursor, limit=2, + ) + assert [c["name"] for c in page2.cards] == ["c-page", "d-page"] + assert page2.next_cursor is not None + + page3 = await ArtistDirectoryService(db).list_artists( + q="-page", platform=None, cursor=page2.next_cursor, limit=2, + ) + assert [c["name"] for c in page3.cards] == ["e-page"] + assert page3.next_cursor is None + + +# --- q filter ------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_list_q_filter_case_insensitive_substring(db): + await _seed_artist(db, "Alice-Q") + await _seed_artist(db, "Bob-Q") + await _seed_artist(db, "Charlie-Q") + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="ALICE", platform=None, cursor=None, limit=60, + ) + names = [c["name"] for c in page.cards] + assert names == ["Alice-Q"] + + +# --- platform filter (correlated EXISTS, not JOIN) ----------------------- + + +@pytest.mark.asyncio +async def test_list_platform_filter_excludes_no_source(db): + a = await _seed_artist(db, "alice-noplat") + await _seed_source(db, a.id, "patreon", "https://p/alice-np") + await _seed_artist(db, "bob-noplat") # no sources + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="-noplat", platform="patreon", cursor=None, limit=60, + ) + names = [c["name"] for c in page.cards] + assert names == ["alice-noplat"] + + +@pytest.mark.asyncio +async def test_list_platform_filter_excludes_wrong_platform(db): + a = await _seed_artist(db, "alice-wplat") + await _seed_source(db, a.id, "deviantart", "https://d/alice-wp") + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="-wplat", platform="patreon", cursor=None, limit=60, + ) + assert page.cards == [] + + +@pytest.mark.asyncio +async def test_list_platform_filter_does_not_duplicate_artist_with_multiple_sources(db): + """The EXISTS subquery prevents JOIN-style row duplication when an + artist has multiple sources on the requested platform.""" + a = await _seed_artist(db, "alice-multi") + await _seed_source(db, a.id, "patreon", "https://p/alice-m1") + await _seed_source(db, a.id, "patreon", "https://p/alice-m2") + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="-multi", platform="patreon", cursor=None, limit=60, + ) + assert len(page.cards) == 1 + assert page.cards[0]["name"] == "alice-multi" + + +# --- image_count via LEFT JOIN ------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_image_count_reflects_artist_id_links(db): + a = await _seed_artist(db, "alice-count") + for i in range(4): + await _seed_image(db, a.id, f"count-{i}") + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="-count", platform=None, cursor=None, limit=60, + ) + target = next(c for c in page.cards if c["name"] == "alice-count") + assert target["image_count"] == 4 + + +@pytest.mark.asyncio +async def test_list_zero_image_artist_preserved_by_left_join(db): + """LEFT JOIN ImageRecord keeps the artist row even with no images.""" + await _seed_artist(db, "alice-zero") + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="-zero", platform=None, cursor=None, limit=60, + ) + target = next(c for c in page.cards if c["name"] == "alice-zero") + assert target["image_count"] == 0 + assert target["preview_thumbnails"] == [] + + +# --- Preview thumbnails (window function) -------------------------------- + + +@pytest.mark.asyncio +async def test_list_previews_returns_top_three_by_image_id_desc(db): + a = await _seed_artist(db, "alice-prev") + for i in range(5): + await _seed_image(db, a.id, f"prev-{i}") + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="-prev", platform=None, cursor=None, limit=60, + ) + target = next(c for c in page.cards if c["name"] == "alice-prev") + assert len(target["preview_thumbnails"]) == 3 + for url in target["preview_thumbnails"]: + assert url.startswith("/images/thumbs/") + + +# --- is_subscription field plumbed through ------------------------------- + + +@pytest.mark.asyncio +async def test_list_is_subscription_field_present(db): + await _seed_artist(db, "alice-sub", is_subscription=True) + await _seed_artist(db, "bob-sub", is_subscription=False) + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="-sub", platform=None, cursor=None, limit=60, + ) + alice = next(c for c in page.cards if c["name"] == "alice-sub") + bob = next(c for c in page.cards if c["name"] == "bob-sub") + assert alice["is_subscription"] is True + assert bob["is_subscription"] is False + + +# --- Combined q + platform ----------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_combined_q_and_platform(db): + alice = await _seed_artist(db, "alice-combo-f") + await _seed_source(db, alice.id, "patreon", "https://p/alice-combo") + bob = await _seed_artist(db, "bob-combo-f") + await _seed_source(db, bob.id, "patreon", "https://p/bob-combo") + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="alice", platform="patreon", cursor=None, limit=60, + ) + names = [c["name"] for c in page.cards] + assert names == ["alice-combo-f"]