b65e956ad2
Per-artist "+N" accent pill on the artists directory and a "N new since last visit" banner inside ArtistView. Counts new IMAGES (not posts) so multi-image posts increment correctly. - alembic 0034: artist_visit (artist_id PK, last_viewed_at NOT NULL). Seeds every existing artist with last_viewed_at=NOW() so the badge starts at 0 across the board — no noisy "5000 unseen images" on first deploy. - ArtistService.find_or_create autoseeds a visit row alongside new artists, so freshly imported content doesn't read as unseen. - ArtistService.overview reads pre-visit last_viewed_at, counts images created since, then atomically UPSERTs last_viewed_at=NOW() via postgres ON CONFLICT DO UPDATE (no SELECT-then-INSERT race per reference_scalar_one_or_none_duplicates). Returns the pre-update count as `unseen_count_at_visit` so the banner has data. - ArtistDirectoryService.list_artists adds an `unseen_count` aggregate to each card via LEFT JOIN artist_visit + conditional COUNT. NULL last_viewed_at (artist created before this code shipped) defensively counts as "never visited" → all images unseen. - Frontend: ArtistCard renders an accent pill in the preview-strip corner when unseen_count > 0 (capped at 99+); ArtistView shows a closable v-alert banner on initial load when unseen_count_at_visit > 0, re-arms on slug change. Single-row-per-artist (no user_id) — rule #47 multi-user ACL is aspirational; widens to (user_id, artist_id) PK when User lands, per rule #22. Scribe plan #597.
166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
"""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_, 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
|