diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index b68e475..ad1251f 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -16,6 +16,7 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"]) def all_blueprints() -> list[Blueprint]: from .aliases import aliases_bp from .allowlist import allowlist_bp + from .artist import artist_bp from .gallery import gallery_bp from .import_admin import import_admin_bp from .ml_admin import ml_admin_bp @@ -27,6 +28,7 @@ def all_blueprints() -> list[Blueprint]: api_bp, gallery_bp, tags_bp, + artist_bp, showcase_bp, settings_bp, import_admin_bp, diff --git a/backend/app/api/artist.py b/backend/app/api/artist.py new file mode 100644 index 0000000..4b755e6 --- /dev/null +++ b/backend/app/api/artist.py @@ -0,0 +1,36 @@ +"""Artist API: overview aggregates + paged images.""" + +from quart import Blueprint, jsonify, request + +from ..extensions import get_session +from ..services.artist_service import ArtistService + +artist_bp = Blueprint("artist", __name__, url_prefix="/api/artist") + + +@artist_bp.route("/", methods=["GET"]) +async def overview(slug: str): + async with get_session() as session: + svc = ArtistService(session) + data = await svc.overview(slug) + if data is None: + return jsonify({"error": "artist not found"}), 404 + return jsonify(data) + + +@artist_bp.route("//images", methods=["GET"]) +async def images(slug: str): + 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 = ArtistService(session) + try: + page = await svc.images(slug, cursor=cursor, limit=limit) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if page is None: + return jsonify({"error": "artist not found"}), 404 + return jsonify({"images": page.images, "next_cursor": page.next_cursor}) diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py new file mode 100644 index 0000000..9aeaa8e --- /dev/null +++ b/backend/app/services/artist_service.py @@ -0,0 +1,199 @@ +"""Artist overview + paged images. + +Images are linked to an artist through the provenance chain +Source(artist_id) -> ImageProvenance(source_id) -> ImageRecord. DISTINCT +guards against an image having several provenance rows for one artist. +Dates come from Post.post_date via ImageProvenance.post_id. +""" + +from dataclasses import dataclass + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import ( + Artist, + ImageProvenance, + ImageRecord, + Post, + Source, + Tag, +) +from ..models.tag import image_tag +from .gallery_service import decode_cursor, encode_cursor, thumbnail_url + + +@dataclass(frozen=True) +class ArtistImagesPage: + images: list[dict] + next_cursor: str | None + + +class ArtistService: + def __init__(self, session: AsyncSession): + self.session = session + + async def _artist_by_slug(self, slug: str) -> Artist | None: + return ( + await self.session.execute( + select(Artist).where(Artist.slug == slug) + ) + ).scalar_one_or_none() + + async def overview(self, slug: str) -> dict | None: + artist = await self._artist_by_slug(slug) + if artist is None: + return None + aid = artist.id + + img_join = ( + select(ImageRecord.id) + .join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id) + .join(Source, Source.id == ImageProvenance.source_id) + .where(Source.artist_id == aid) + ) + image_count = ( + await self.session.execute( + select(func.count(func.distinct(ImageRecord.id))) + .select_from(ImageRecord) + .join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id) + .join(Source, Source.id == ImageProvenance.source_id) + .where(Source.artist_id == aid) + ) + ).scalar_one() + + date_row = ( + await self.session.execute( + select(func.min(Post.post_date), func.max(Post.post_date)) + .select_from(Post) + .join(ImageProvenance, ImageProvenance.post_id == Post.id) + .join(Source, Source.id == ImageProvenance.source_id) + .where(Source.artist_id == aid) + ) + ).first() + dmin, dmax = date_row if date_row else (None, None) + + cooccurring = ( + await self.session.execute( + select( + Tag.id, Tag.name, Tag.kind, + func.count(image_tag.c.image_record_id).label("cnt"), + ) + .select_from(Tag) + .join(image_tag, image_tag.c.tag_id == Tag.id) + .where(image_tag.c.image_record_id.in_(img_join)) + .group_by(Tag.id, Tag.name, Tag.kind) + .order_by(func.count(image_tag.c.image_record_id).desc()) + .limit(20) + ) + ).all() + + sources = ( + await self.session.execute( + select( + Source.id, Source.platform, Source.url, + func.count(func.distinct(ImageProvenance.image_record_id)).label("cnt"), + ) + .select_from(Source) + .outerjoin(ImageProvenance, ImageProvenance.source_id == Source.id) + .where(Source.artist_id == aid) + .group_by(Source.id, Source.platform, Source.url) + .order_by(Source.id) + ) + ).all() + + month = func.date_trunc("month", Post.post_date).label("m") + activity = ( + await self.session.execute( + select(month, func.count(func.distinct(ImageProvenance.image_record_id))) + .select_from(Post) + .join(ImageProvenance, ImageProvenance.post_id == Post.id) + .join(Source, Source.id == ImageProvenance.source_id) + .where(and_(Source.artist_id == aid, Post.post_date.isnot(None))) + .group_by(month) + .order_by(month) + ) + ).all() + + return { + "id": artist.id, + "name": artist.name, + "slug": artist.slug, + "image_count": int(image_count), + "date_range": { + "min": dmin.isoformat() if dmin else None, + "max": dmax.isoformat() if dmax else None, + }, + "cooccurring_tags": [ + { + "id": tid, + "name": name, + "kind": kind.value if hasattr(kind, "value") else kind, + "count": int(cnt), + } + for tid, name, kind, cnt in cooccurring + ], + "sources": [ + { + "id": sid, + "platform": platform, + "url": url, + "image_count": int(cnt), + } + for sid, platform, url, cnt in sources + ], + "activity": [ + {"month": m.isoformat(), "count": int(cnt)} + for m, cnt in activity + ], + } + + async def images( + self, slug: str, cursor: str | None, limit: int = 60 + ) -> ArtistImagesPage | None: + if limit < 1 or limit > 200: + raise ValueError("limit must be between 1 and 200") + artist = await self._artist_by_slug(slug) + if artist is None: + return None + + stmt = ( + select(ImageRecord) + .join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id) + .join(Source, Source.id == ImageProvenance.source_id) + .where(Source.artist_id == artist.id) + .distinct() + ) + if cursor: + cur_ts, cur_id = decode_cursor(cursor) + stmt = stmt.where( + or_( + ImageRecord.created_at < cur_ts, + and_(ImageRecord.created_at == cur_ts, ImageRecord.id < cur_id), + ) + ) + stmt = stmt.order_by( + ImageRecord.created_at.desc(), ImageRecord.id.desc() + ).limit(limit + 1) + rows = (await self.session.execute(stmt)).scalars().all() + + next_cursor = None + if len(rows) > limit: + last = rows[limit - 1] + next_cursor = encode_cursor(last.created_at, last.id) + rows = rows[:limit] + + return ArtistImagesPage( + images=[ + { + "id": r.id, + "sha256": r.sha256, + "mime": r.mime, + "width": r.width, + "height": r.height, + "thumbnail_url": thumbnail_url(r.sha256, r.mime), + } + for r in rows + ], + next_cursor=next_cursor, + ) diff --git a/tests/test_api_artist.py b/tests/test_api_artist.py new file mode 100644 index 0000000..599093f --- /dev/null +++ b/tests/test_api_artist.py @@ -0,0 +1,37 @@ +import pytest + +from backend.app import create_app +from backend.app.models import Artist + +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_artist_404(client): + resp = await client.get("/api/artist/nope") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_artist_overview_ok(client, db): + db.add(Artist(name="Mira", slug="mira")) + await db.flush() + await db.commit() + resp = await client.get("/api/artist/mira") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["name"] == "Mira" + assert body["image_count"] == 0 + + +@pytest.mark.asyncio +async def test_artist_images_404(client): + resp = await client.get("/api/artist/nope/images") + assert resp.status_code == 404 diff --git a/tests/test_artist_service.py b/tests/test_artist_service.py new file mode 100644 index 0000000..7853ff0 --- /dev/null +++ b/tests/test_artist_service.py @@ -0,0 +1,78 @@ +from datetime import UTC, datetime + +import pytest + +from backend.app.models import ( + Artist, ImageProvenance, ImageRecord, Post, Source, Tag, TagKind, +) +from backend.app.models.tag import image_tag +from backend.app.services.artist_service import ArtistService + +pytestmark = pytest.mark.integration + + +async def _fixture(db): + artist = Artist(name="Nadia", slug="nadia") + db.add(artist) + await db.flush() + src = Source(artist_id=artist.id, platform="web", url="http://x") + db.add(src) + await db.flush() + post = Post( + source_id=src.id, external_post_id="p1", + post_date=datetime(2026, 3, 1, tzinfo=UTC), + ) + db.add(post) + await db.flush() + img = ImageRecord( + path="/images/a/1.jpg", sha256="a" + "0" * 63, + size_bytes=1, mime="image/jpeg", width=4, height=8, + origin="downloaded", integrity_status="unknown", + ) + db.add(img) + await db.flush() + db.add(ImageProvenance( + image_record_id=img.id, post_id=post.id, source_id=src.id)) + tag = Tag(name="forest", kind=TagKind.general) + db.add(tag) + await db.flush() + await db.execute(image_tag.insert().values( + image_record_id=img.id, tag_id=tag.id, source="manual")) + await db.flush() + return artist, src, img + + +@pytest.mark.asyncio +async def test_overview_aggregates(db): + artist, src, img = await _fixture(db) + svc = ArtistService(db) + ov = await svc.overview("nadia") + assert ov["name"] == "Nadia" + assert ov["image_count"] == 1 + assert ov["date_range"]["min"].startswith("2026-03-01") + assert ov["date_range"]["max"].startswith("2026-03-01") + assert any(t["name"] == "forest" for t in ov["cooccurring_tags"]) + assert ov["sources"][0]["image_count"] == 1 + assert ov["activity"][0]["count"] == 1 + + +@pytest.mark.asyncio +async def test_overview_unknown_slug_returns_none(db): + svc = ArtistService(db) + assert await svc.overview("ghost") is None + + +@pytest.mark.asyncio +async def test_paged_images(db): + artist, src, img = await _fixture(db) + svc = ArtistService(db) + page = await svc.images("nadia", cursor=None, limit=10) + assert page is not None + assert len(page.images) == 1 + assert page.images[0]["thumbnail_url"].startswith("/images/thumbs/") + + +@pytest.mark.asyncio +async def test_paged_images_unknown_slug_none(db): + svc = ArtistService(db) + assert await svc.images("ghost", cursor=None, limit=10) is None