fc3f: ArtistDirectoryService — cursor-paginated artists directory mirroring TagDirectoryService
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
"<name>|<id>". 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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user