diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 941754d..8e20970 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -4,6 +4,7 @@ from quart import Blueprint, jsonify, request from ..extensions import get_session from ..models import TagKind +from ..services.tag_directory_service import TagDirectoryService from ..services.tag_service import TagService, TagValidationError tags_bp = Blueprint("tags", __name__, url_prefix="/api") @@ -46,6 +47,24 @@ async def autocomplete(): ) +@tags_bp.route("/tags/directory", methods=["GET"]) +async def directory(): + kind = request.args.get("kind") or None + q = request.args.get("q") or None + cursor = request.args.get("cursor") or None + try: + limit = int(request.args.get("limit", "60")) + except ValueError: + return jsonify({"error": "limit must be an integer"}), 400 + async with get_session() as session: + svc = TagDirectoryService(session) + try: + page = await svc.list_tags(kind=kind, q=q, cursor=cursor, limit=limit) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify({"cards": page.cards, "next_cursor": page.next_cursor}) + + @tags_bp.route("/tags", methods=["POST"]) async def create_tag(): body = await request.get_json() diff --git a/backend/app/services/tag_directory_service.py b/backend/app/services/tag_directory_service.py new file mode 100644 index 0000000..a0ffaaa --- /dev/null +++ b/backend/app/services/tag_directory_service.py @@ -0,0 +1,126 @@ +"""Paged tag-directory cards with window-function preview thumbnails. + +Cursor orders by (Tag.name ASC, Tag.id ASC). Preview thumbnails for the +page's tags are fetched in ONE windowed query (ROW_NUMBER <= 3) to avoid +an N+1. fandom_name is resolved via a self-join on Tag (fandom_id -> Tag). +""" + +import base64 +from dataclasses import dataclass + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from ..models import ImageRecord, Tag +from ..models.tag import image_tag +from .gallery_service import thumbnail_url + +_SEP = "|" + + +def _encode(name: str, tag_id: int) -> str: + return base64.urlsafe_b64encode(f"{name}{_SEP}{tag_id}".encode()).decode() + + +def _decode(cursor: str) -> tuple[str, int]: + try: + raw = base64.urlsafe_b64decode(cursor.encode()).decode() + name, tid = raw.rsplit(_SEP, 1) + return name, int(tid) + 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 TagDirectoryService: + def __init__(self, session: AsyncSession): + self.session = session + + async def list_tags( + self, + kind: str | None, + q: str | None, + cursor: str | None, + limit: int = 60, + ) -> DirectoryPage: + if limit < 1 or limit > 200: + raise ValueError("limit must be between 1 and 200") + + fandom = aliased(Tag) + count_col = func.count(image_tag.c.image_record_id).label("image_count") + stmt = ( + select(Tag, fandom.name.label("fandom_name"), count_col) + .outerjoin(fandom, Tag.fandom_id == fandom.id) + .outerjoin(image_tag, image_tag.c.tag_id == Tag.id) + .group_by(Tag.id, fandom.name) + ) + if kind is not None: + stmt = stmt.where(Tag.kind == kind) + if q: + stmt = stmt.where(Tag.name.ilike(f"%{q}%")) + if cursor: + c_name, c_id = _decode(cursor) + stmt = stmt.where( + or_( + Tag.name > c_name, + and_(Tag.name == c_name, Tag.id > c_id), + ) + ) + stmt = stmt.order_by(Tag.name.asc(), Tag.id.asc()).limit(limit + 1) + rows = (await self.session.execute(stmt)).all() + + next_cursor = None + if len(rows) > limit: + last_tag = rows[limit - 1][0] + next_cursor = _encode(last_tag.name, last_tag.id) + rows = rows[:limit] + + tag_ids = [r[0].id for r in rows] + previews = await self._previews(tag_ids) + + cards = [ + { + "id": tag.id, + "name": tag.name, + "kind": tag.kind.value if hasattr(tag.kind, "value") else tag.kind, + "fandom_id": tag.fandom_id, + "fandom_name": fandom_name, + "image_count": int(image_count), + "preview_thumbnails": previews.get(tag.id, []), + } + for tag, fandom_name, image_count in rows + ] + return DirectoryPage(cards=cards, next_cursor=next_cursor) + + async def _previews(self, tag_ids: list[int]) -> dict[int, list[str]]: + if not tag_ids: + return {} + rn = func.row_number().over( + partition_by=image_tag.c.tag_id, + order_by=image_tag.c.image_record_id.desc(), + ).label("rn") + sub = ( + select( + image_tag.c.tag_id.label("tag_id"), + image_tag.c.image_record_id.label("image_record_id"), + rn, + ) + .where(image_tag.c.tag_id.in_(tag_ids)) + .subquery() + ) + stmt = ( + select(sub.c.tag_id, ImageRecord.sha256, ImageRecord.mime) + .join(ImageRecord, ImageRecord.id == sub.c.image_record_id) + .where(sub.c.rn <= 3) + .order_by(sub.c.tag_id, sub.c.rn) + ) + out: dict[int, list[str]] = {} + for tag_id, sha, mime in (await self.session.execute(stmt)).all(): + out.setdefault(tag_id, []).append(thumbnail_url(sha, mime)) + return out diff --git a/tests/test_api_tags_directory.py b/tests/test_api_tags_directory.py new file mode 100644 index 0000000..de9c01e --- /dev/null +++ b/tests/test_api_tags_directory.py @@ -0,0 +1,30 @@ +import pytest + +from backend.app import create_app +from backend.app.models import Tag, TagKind + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def client(): + app = create_app() + async with app.test_client() as c: + yield c + + +@pytest.mark.asyncio +async def test_directory_endpoint(client, db): + db.add(Tag(name="api_tag", kind=TagKind.general)) + await db.flush() + await db.commit() + resp = await client.get("/api/tags/directory?limit=10") + assert resp.status_code == 200 + body = await resp.get_json() + assert "cards" in body and "next_cursor" in body + + +@pytest.mark.asyncio +async def test_directory_bad_limit(client): + resp = await client.get("/api/tags/directory?limit=nan") + assert resp.status_code == 400 diff --git a/tests/test_tag_directory_service.py b/tests/test_tag_directory_service.py new file mode 100644 index 0000000..b40e6b8 --- /dev/null +++ b/tests/test_tag_directory_service.py @@ -0,0 +1,72 @@ +import pytest + +from backend.app.models import ImageRecord, Tag, TagKind +from backend.app.models.tag import image_tag +from backend.app.services.tag_directory_service import TagDirectoryService + +pytestmark = pytest.mark.integration + + +async def _img(db, i): + r = ImageRecord( + path=f"/images/td/{i}.jpg", sha256=f"d{i:063d}", + size_bytes=1, mime="image/jpeg", width=1, height=1, + origin="imported_filesystem", integrity_status="unknown", + ) + db.add(r) + await db.flush() + return r + + +@pytest.mark.asyncio +async def test_directory_lists_tags_with_counts_and_previews(db): + t = Tag(name="sunset", kind=TagKind.general) + db.add(t) + await db.flush() + for i in range(4): + img = await _img(db, i) + await db.execute(image_tag.insert().values( + image_record_id=img.id, tag_id=t.id, source="manual")) + await db.flush() + svc = TagDirectoryService(db) + page = await svc.list_tags(kind=None, q=None, cursor=None, limit=10) + card = next(c for c in page.cards if c["name"] == "sunset") + assert card["image_count"] == 4 + assert len(card["preview_thumbnails"]) == 3 + assert card["preview_thumbnails"][0].startswith("/images/thumbs/") + + +@pytest.mark.asyncio +async def test_directory_kind_filter_and_search(db): + db.add_all([ + Tag(name="artist_a", kind=TagKind.artist), + Tag(name="char_b", kind=TagKind.character), + Tag(name="char_zoom", kind=TagKind.character), + ]) + await db.flush() + svc = TagDirectoryService(db) + only_char = await svc.list_tags(kind="character", q=None, cursor=None, limit=10) + assert {c["name"] for c in only_char.cards} == {"char_b", "char_zoom"} + searched = await svc.list_tags(kind=None, q="zoom", cursor=None, limit=10) + assert {c["name"] for c in searched.cards} == {"char_zoom"} + + +@pytest.mark.asyncio +async def test_directory_cursor_pagination(db): + for i in range(5): + db.add(Tag(name=f"t{i:02d}", kind=TagKind.general)) + await db.flush() + svc = TagDirectoryService(db) + first = await svc.list_tags(kind=None, q=None, cursor=None, limit=2) + assert len(first.cards) == 2 + assert first.next_cursor is not None + second = await svc.list_tags(kind=None, q=None, cursor=first.next_cursor, limit=2) + assert len(second.cards) == 2 + assert {c["id"] for c in first.cards}.isdisjoint({c["id"] for c in second.cards}) + + +@pytest.mark.asyncio +async def test_directory_rejects_bad_limit(db): + svc = TagDirectoryService(db) + with pytest.raises(ValueError): + await svc.list_tags(kind=None, q=None, cursor=None, limit=0)