diff --git a/app/api/v1/genres.py b/app/api/v1/genres.py new file mode 100644 index 0000000..82c63db --- /dev/null +++ b/app/api/v1/genres.py @@ -0,0 +1,28 @@ +"""Genre endpoints (section 17: additive) -- a directory for the `genre` +filter GET /movies and GET /series already accept, which takes an Aurora +genre id with no prior way to discover what those ids are.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import enforce_rate_limit +from app.schemas.common import GenreListOut +from app.services import genre_service + +router = APIRouter(prefix="/genres", tags=["genres"], dependencies=[Depends(enforce_rate_limit)]) + + +@router.get("", response_model=GenreListOut) +async def list_genres( + page: int = Query(1, ge=1), + # Genres are a small, fixed reference set (TMDb has ~19-20 total) -- + # default/max page_size sized so a client can fetch the whole + # directory in one call for a filter dropdown, unlike the huge + # catalogs GET /movies etc. paginate through. + page_size: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db), +) -> GenreListOut: + return await genre_service.list_genres(session, page=page, page_size=page_size) diff --git a/app/main.py b/app/main.py index b3f17af..877ba94 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,8 @@ """Aurora Core FastAPI application factory. Stateless by design (section 1): nothing here holds request-scoped state in -process memory, so any number of API replicas can run behind Traefik. +process memory, so any number of API replicas can run behind a reverse +proxy without sticky sessions. """ from __future__ import annotations @@ -25,6 +26,7 @@ from app.api.v1 import ( collections, companies, episodes, + genres, health, movies, people, @@ -174,4 +176,5 @@ app.include_router(episodes.router, prefix=API_V1_PREFIX) app.include_router(people.router, prefix=API_V1_PREFIX) app.include_router(collections.router, prefix=API_V1_PREFIX) app.include_router(companies.router, prefix=API_V1_PREFIX) +app.include_router(genres.router, prefix=API_V1_PREFIX) app.include_router(admin.router, prefix=API_V1_PREFIX) diff --git a/app/repositories/genre_repository.py b/app/repositories/genre_repository.py new file mode 100644 index 0000000..bb47d7c --- /dev/null +++ b/app/repositories/genre_repository.py @@ -0,0 +1,34 @@ +"""Data access for Genre.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.genre import Genre + + +async def get_genre_by_id(session: AsyncSession, genre_id: uuid.UUID) -> Genre | None: + result = await session.execute(select(Genre).where(Genre.id == genre_id)) + return result.scalar_one_or_none() + + +async def list_genres(session: AsyncSession, *, limit: int, offset: int) -> tuple[list[Genre], int]: + """Browse genres -- see movie_repository.list_movies for the + window-function total-count approach this mirrors. Genres have no + categorical field to filter by and no popularity/date to sort by, so + this always orders alphabetically by name (with an id tiebreak for + stable pagination).""" + + stmt = ( + select(Genre, func.count().over().label("total_count")) + .order_by(Genre.name.asc(), Genre.id.asc()) + .limit(limit) + .offset(offset) + ) + rows = (await session.execute(stmt)).all() + if not rows: + return [], 0 + return [row[0] for row in rows], rows[0].total_count diff --git a/app/schemas/common.py b/app/schemas/common.py index ace5833..51adcdd 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -22,6 +22,14 @@ class GenreOut(ORMModel): name: str +class GenreListOut(BaseModel): + page: int + page_size: int + total_results: int + total_pages: int + results: list[GenreOut] = [] + + class KeywordOut(ORMModel): id: str name: str diff --git a/app/services/genre_service.py b/app/services/genre_service.py new file mode 100644 index 0000000..714a399 --- /dev/null +++ b/app/services/genre_service.py @@ -0,0 +1,27 @@ +"""Assembles Genre API responses from repository data. + +Genres have no detail page of their own -- they only ever appear embedded +in movie/series responses (see app/services/common.py) and as entries in +this directory listing, which exists so a client can resolve the Aurora +genre ids that GET /movies and GET /series accept as a filter without +having to have already seen a title in that genre first.""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.ids import aurora_id +from app.repositories import genre_repository +from app.schemas.common import GenreListOut, GenreOut + + +async def list_genres(session: AsyncSession, *, page: int, page_size: int) -> GenreListOut: + genres, total = await genre_repository.list_genres( + session, limit=page_size, offset=(page - 1) * page_size + ) + results = [GenreOut(id=aurora_id("genre", str(g.id)), name=g.name) for g in genres] + total_pages = (total + page_size - 1) // page_size if total else 0 + + return GenreListOut( + page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results + ) diff --git a/docs/api.md b/docs/api.md index ae91d82..cb5c3fd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -178,9 +178,23 @@ date field to sort by, so results are always alphabetical by name. `GET /api/v1/collections` is the same idea for collections/franchises, but with no filter at all (no categorical field like `company_type` exists for -a collection) — just `page`/`page_size`, always alphabetical by name. This -is the last entity type to get a browse endpoint: movies, series, people, -companies, and collections are all browsable now. +a collection) — just `page`/`page_size`, always alphabetical by name. + +## Genres + +| Method | Path | +|---|---| +| GET | `/api/v1/genres` | + +A directory for the `genre` filter `GET /movies` and `GET /series` already +accept, which takes an Aurora genre id — this is how a client discovers +what those ids actually are, since nothing else exposes them on their own +(they otherwise only ever appear embedded in a movie/series response's own +`genres` list). No filter, no `sort_by` — always alphabetical by name, same +as collections. `page_size` defaults to 50 (max 200) rather than the 20/50 +used elsewhere, since genres are a small, fixed reference set (TMDb has +~19-20 total) that a client typically wants in full for a filter dropdown, +not paginated the way a catalog of millions of movies needs to be. Collection detail's `movies` list only includes movies already ingested into Aurora — `POST /api/v1/admin/ingest/collection` links a franchise's diff --git a/tests/integration/test_genre_browse.py b/tests/integration/test_genre_browse.py new file mode 100644 index 0000000..cae5f67 --- /dev/null +++ b/tests/integration/test_genre_browse.py @@ -0,0 +1,61 @@ +"""Integration tests for GET /api/v1/genres (section 17, additive): a +directory for the `genre` filter GET /movies and GET /series already +accept, which takes an Aurora genre id with no prior way to discover what +those ids are. Mirrors test_collection_browse.py; genres have no +categorical field to filter by and no popularity/date field to sort by, +so there's no filter case and always alphabetical-by-name ordering to +test.""" + +from __future__ import annotations + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.repositories.reference_repository import get_or_create_genre +from app.services import genre_service + +_GENRES = ["Zeta Fiction", "Alpha Comedy", "Mid Drama", "Beta Horror"] + + +async def _seed_genres(db_session: AsyncSession) -> None: + for name in _GENRES: + await get_or_create_genre(db_session, name) + await db_session.commit() + + +@pytest.mark.asyncio +async def test_list_genres_defaults_to_alphabetical_by_name(db_session: AsyncSession): + await _seed_genres(db_session) + + out = await genre_service.list_genres(db_session, page=1, page_size=50) + + assert out.total_results == 4 + assert out.total_pages == 1 + assert [g.name for g in out.results] == [ + "Alpha Comedy", + "Beta Horror", + "Mid Drama", + "Zeta Fiction", + ] + + +@pytest.mark.asyncio +async def test_list_genres_paginates(db_session: AsyncSession): + await _seed_genres(db_session) + + page1 = await genre_service.list_genres(db_session, page=1, page_size=2) + page2 = await genre_service.list_genres(db_session, page=2, page_size=2) + + assert page1.total_results == 4 + assert page1.total_pages == 2 + assert [g.name for g in page1.results] == ["Alpha Comedy", "Beta Horror"] + assert [g.name for g in page2.results] == ["Mid Drama", "Zeta Fiction"] + + +@pytest.mark.asyncio +async def test_list_genres_returns_empty_page_when_none_exist(db_session: AsyncSession): + out = await genre_service.list_genres(db_session, page=1, page_size=50) + + assert out.results == [] + assert out.total_results == 0 + assert out.total_pages == 0