Add GET /people browse/discover endpoint
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s

Extends the browse/discover pattern already built for movies/series
to the fourth major entity type. No genre/year/status equivalent for
people -- known_for_department is the one categorical filter that
actually means something for a cast/crew list, and sort_by is just
popularity (default) or name. No invalid-filter case either: unlike
genre/status, known_for_department is a plain string compared
directly, nothing to parse or validate.

build_person_summary refactored to share row-to-summary logic with
the new list path, same pattern used for movies/series/companies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-05 10:32:16 -05:00
co-authored by Claude Sonnet 5
parent 74adcf77bc
commit 6aa9f0b840
6 changed files with 257 additions and 10 deletions
+25 -2
View File
@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends from typing import Literal
from fastapi import APIRouter, Depends, Query
from redis.asyncio import Redis from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,13 +12,34 @@ from app.api.deps import cached_response, not_found
from app.core.database import get_db from app.core.database import get_db
from app.core.redis import get_redis from app.core.redis import get_redis
from app.core.security import enforce_rate_limit from app.core.security import enforce_rate_limit
from app.schemas.person import PersonCreditsOut, PersonDetailOut from app.schemas.person import PersonCreditsOut, PersonDetailOut, PersonListOut
from app.services import person_service from app.services import person_service
from app.services.cache import build_key from app.services.cache import build_key
router = APIRouter(prefix="/people", tags=["people"], dependencies=[Depends(enforce_rate_limit)]) router = APIRouter(prefix="/people", tags=["people"], dependencies=[Depends(enforce_rate_limit)])
@router.get("", response_model=PersonListOut)
async def list_people(
known_for_department: str | None = Query(
None, description="Exact match, e.g. Acting, Directing, Writing"
),
sort_by: Literal["popularity", "name"] = Query("popularity"),
sort_dir: Literal["asc", "desc"] = Query("desc"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
session: AsyncSession = Depends(get_db),
) -> PersonListOut:
return await person_service.list_people(
session,
known_for_department=known_for_department,
sort_by=sort_by,
sort_dir=sort_dir,
page=page,
page_size=page_size,
)
@router.get("/{aurora_id}", response_model=PersonDetailOut) @router.get("/{aurora_id}", response_model=PersonDetailOut)
async def get_person( async def get_person(
aurora_id: str, aurora_id: str,
+36 -1
View File
@@ -4,12 +4,17 @@ from __future__ import annotations
import uuid import uuid
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.credit import Credit from app.models.credit import Credit
from app.models.person import Person from app.models.person import Person
_PERSON_SORT_COLUMNS = {
"popularity": Person.popularity,
"name": Person.name,
}
async def get_person_by_id(session: AsyncSession, person_id: uuid.UUID) -> Person | None: async def get_person_by_id(session: AsyncSession, person_id: uuid.UUID) -> Person | None:
result = await session.execute(select(Person).where(Person.id == person_id)) result = await session.execute(select(Person).where(Person.id == person_id))
@@ -24,3 +29,33 @@ async def get_person_credits(session: AsyncSession, person_id: uuid.UUID) -> lis
) )
result = await session.execute(stmt) result = await session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
async def list_people(
session: AsyncSession,
*,
known_for_department: str | None,
sort_by: str,
sort_dir: str,
limit: int,
offset: int,
) -> tuple[list[Person], int]:
"""Browse people with an optional known_for_department filter -- see
movie_repository.list_movies for the window-function total-count
approach this mirrors. No genre/year/status equivalent for people:
department is the one categorical grouping that actually means
something when browsing a cast/crew list."""
column = _PERSON_SORT_COLUMNS[sort_by]
order = column.desc() if sort_dir == "desc" else column.asc()
stmt = select(Person, func.count().over().label("total_count"))
if known_for_department is not None:
stmt = stmt.where(Person.known_for_department == known_for_department)
stmt = stmt.order_by(order.nulls_last(), Person.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
+8
View File
@@ -70,3 +70,11 @@ class PersonCreditsOut(BaseModel):
id: str id: str
cast: list[FilmographyCreditOut] = [] cast: list[FilmographyCreditOut] = []
crew: list[FilmographyCreditOut] = [] crew: list[FilmographyCreditOut] = []
class PersonListOut(BaseModel):
page: int
page_size: int
total_results: int
total_pages: int
results: list[PersonSummaryOut] = []
+38 -7
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id from app.core.ids import aurora_id, parse_aurora_id
from app.models.credit import Credit from app.models.credit import Credit
from app.models.enums import ArtworkType from app.models.enums import ArtworkType
from app.models.person import Person
from app.models.tv import Series from app.models.tv import Series
from app.repositories import movie_repository, person_repository, polymorphic, tv_repository from app.repositories import movie_repository, person_repository, polymorphic, tv_repository
from app.schemas.person import ( from app.schemas.person import (
@@ -17,6 +18,7 @@ from app.schemas.person import (
GuestEpisodeOut, GuestEpisodeOut,
PersonCreditsOut, PersonCreditsOut,
PersonDetailOut, PersonDetailOut,
PersonListOut,
PersonSummaryOut, PersonSummaryOut,
) )
from app.services.common import primary_artwork_path, to_artwork_out from app.services.common import primary_artwork_path, to_artwork_out
@@ -189,13 +191,8 @@ async def get_person_credits(session: AsyncSession, person_aurora_id: str) -> Pe
return PersonCreditsOut(id=aurora_id("person", str(person.id)), cast=cast, crew=crew) return PersonCreditsOut(id=aurora_id("person", str(person.id)), cast=cast, crew=crew)
async def build_person_summary( async def _person_summary_from_row(session: AsyncSession, person: Person) -> PersonSummaryOut:
session: AsyncSession, person_id: uuid.UUID profiles = await polymorphic.get_artwork(session, "person", person.id, ArtworkType.PROFILE)
) -> PersonSummaryOut | None:
person = await person_repository.get_person_by_id(session, person_id)
if person is None:
return None
profiles = await polymorphic.get_artwork(session, "person", person_id, ArtworkType.PROFILE)
return PersonSummaryOut( return PersonSummaryOut(
id=aurora_id("person", str(person.id)), id=aurora_id("person", str(person.id)),
name=person.name, name=person.name,
@@ -205,6 +202,15 @@ async def build_person_summary(
) )
async def build_person_summary(
session: AsyncSession, person_id: uuid.UUID
) -> PersonSummaryOut | None:
person = await person_repository.get_person_by_id(session, person_id)
if person is None:
return None
return await _person_summary_from_row(session, person)
async def build_person_summaries( async def build_person_summaries(
session: AsyncSession, person_ids: list[uuid.UUID] session: AsyncSession, person_ids: list[uuid.UUID]
) -> list[PersonSummaryOut]: ) -> list[PersonSummaryOut]:
@@ -214,3 +220,28 @@ async def build_person_summaries(
if summary is not None: if summary is not None:
out.append(summary) out.append(summary)
return out return out
async def list_people(
session: AsyncSession,
*,
known_for_department: str | None,
sort_by: str,
sort_dir: str,
page: int,
page_size: int,
) -> PersonListOut:
people, total = await person_repository.list_people(
session,
known_for_department=known_for_department,
sort_by=sort_by,
sort_dir=sort_dir,
limit=page_size,
offset=(page - 1) * page_size,
)
results = [await _person_summary_from_row(session, p) for p in people]
total_pages = (total + page_size - 1) // page_size if total else 0
return PersonListOut(
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
)
+8
View File
@@ -150,9 +150,17 @@ carries the same `cast`/`crew`/`creators` three-way split as series detail.
| Method | Path | | Method | Path |
|---|---| |---|---|
| GET | `/api/v1/people` |
| GET | `/api/v1/people/{aurora_id}` | | GET | `/api/v1/people/{aurora_id}` |
| GET | `/api/v1/people/{aurora_id}/credits` (movie and series filmography; guest-star episode credits are aggregated to one row per series rather than one per episode, but carry both `episode_count` and the individual `episodes` breakdown, sorted by season/episode number) | | GET | `/api/v1/people/{aurora_id}/credits` (movie and series filmography; guest-star episode credits are aggregated to one row per series rather than one per episode, but carry both `episode_count` and the individual `episodes` breakdown, sorted by season/episode number) |
`GET /api/v1/people` is the browse/discover equivalent for people. No
genre/year/status here — `known_for_department` (exact match, e.g.
`Acting`, `Directing`, `Writing`) is the one categorical filter that
actually means something for a cast/crew list, and `sort_by` is just
`popularity` (default) or `name`. Otherwise the same `page`/`page_size`
pagination and response shape as `/movies` and `/series`.
## Collections / Companies ## Collections / Companies
| Method | Path | | Method | Path |
+142
View File
@@ -0,0 +1,142 @@
"""Integration tests for GET /api/v1/people (browse/discover, section 17 --
additive beyond the spec's minimum endpoint list): sorting, filtering, and
pagination against real PostgreSQL, exercised through
person_service.list_people directly rather than over HTTP. Mirrors
test_movie_browse.py; people have no genre/year/status equivalent, just
known_for_department, so there's no invalid-filter case to test here (no
parsing/enum lookup happens, unlike movies/series filters)."""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.provider_repository import get_or_create_provider
from app.repositories.reference_repository import get_or_create_person
from app.services import person_service
async def _seed_people(db_session: AsyncSession) -> None:
provider = await get_or_create_provider(db_session, "tmdb", "TMDB")
people = [
("1001", "Alice Actor", 10.0, "Acting"),
("1002", "Bob Actor", 90.0, "Acting"),
("1003", "Cara Director", 50.0, "Directing"),
("1004", "Dana Writer", 20.0, "Writing"),
]
for external_id, name, popularity, department in people:
await get_or_create_person(
db_session,
provider_id=provider.id,
external_id=external_id,
name=name,
popularity=popularity,
known_for_department=department,
)
await db_session.commit()
@pytest.mark.asyncio
async def test_list_people_defaults_to_popularity_descending(db_session: AsyncSession):
await _seed_people(db_session)
out = await person_service.list_people(
db_session,
known_for_department=None,
sort_by="popularity",
sort_dir="desc",
page=1,
page_size=20,
)
assert out.total_results == 4
assert out.total_pages == 1
assert [p.name for p in out.results] == [
"Bob Actor",
"Cara Director",
"Dana Writer",
"Alice Actor",
]
@pytest.mark.asyncio
async def test_list_people_filters_by_known_for_department(db_session: AsyncSession):
await _seed_people(db_session)
out = await person_service.list_people(
db_session,
known_for_department="Acting",
sort_by="popularity",
sort_dir="desc",
page=1,
page_size=20,
)
assert out.total_results == 2
assert [p.name for p in out.results] == ["Bob Actor", "Alice Actor"]
@pytest.mark.asyncio
async def test_list_people_sorts_by_name_ascending(db_session: AsyncSession):
await _seed_people(db_session)
out = await person_service.list_people(
db_session,
known_for_department=None,
sort_by="name",
sort_dir="asc",
page=1,
page_size=20,
)
assert [p.name for p in out.results] == [
"Alice Actor",
"Bob Actor",
"Cara Director",
"Dana Writer",
]
@pytest.mark.asyncio
async def test_list_people_paginates(db_session: AsyncSession):
await _seed_people(db_session)
page1 = await person_service.list_people(
db_session,
known_for_department=None,
sort_by="popularity",
sort_dir="desc",
page=1,
page_size=2,
)
page2 = await person_service.list_people(
db_session,
known_for_department=None,
sort_by="popularity",
sort_dir="desc",
page=2,
page_size=2,
)
assert page1.total_results == 4
assert page1.total_pages == 2
assert [p.name for p in page1.results] == ["Bob Actor", "Cara Director"]
assert [p.name for p in page2.results] == ["Dana Writer", "Alice Actor"]
@pytest.mark.asyncio
async def test_list_people_returns_empty_page_when_nothing_matches(db_session: AsyncSession):
await _seed_people(db_session)
out = await person_service.list_people(
db_session,
known_for_department="Editing",
sort_by="popularity",
sort_dir="desc",
page=1,
page_size=20,
)
assert out.results == []
assert out.total_results == 0
assert out.total_pages == 0