Files
FabledCurator/backend/app/services/artist_directory_service.py
T
bvandeusen 44bb12a93d fix(thumbnails): derive URL from stored thumbnail_path, not (sha256, mime)
The showcase/gallery/artist/series/post-feed APIs were constructing
thumbnail URLs from (sha256, mime). The MIME-based extension predicate
("png if image/png or image/gif else jpg") DISAGREED with the
thumbnailer's actual on-disk extension predicate ("png if alpha else
jpg"). Result: every PNG source without transparency 404'd (URL asked
.png, disk had .jpg); every WebP/AVIF source with transparency 404'd
(URL asked .jpg, disk had .png) — despite the thumbnail file existing
on disk.

The backfill task couldn't catch these because backfill checks the
ACTUAL thumbnail_path stored on the record (correct), not the URL the
browser fetches (broken derivation). So records with valid on-disk
thumbnails kept showing as broken in the UI no matter how many times
backfill ran.

Operator-flagged 2026-05-30: "the generate thumbnails function appears
to not catch all of the failed thumbnail cases" — turned out to not be
a backfill bug at all.

Fix: thumbnail_url now takes (thumbnail_path, sha256, mime) and returns
the stored path verbatim — Quart serves /images/* 1:1 from the volume
(frontend.py:20-36), so the URL IS the disk path. Falls back to the old
sha256+mime derivation only when thumbnail_path is NULL (thumbnailer
hasn't run yet); that URL will 404 in the browser until backfill catches
it, same as before the path was tracked.

All 8 callers updated: showcase_service, gallery_service (2 sites),
artist_service, series_service, post_feed_service, tag_directory_service,
artist_directory_service. The four sites whose query was raw-tuple now
also SELECT ImageRecord.thumbnail_path.

Net effect: every record that has a valid on-disk thumbnail will now
render correctly, regardless of which extension the thumbnailer chose,
without any DB migration or backfill rerun needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 16:55:38 -04:00

147 lines
4.8 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_, 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"),
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