Add GET /movies and GET /series browse/discover endpoints
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

The complement to /search for browsing without a query: paginated,
filterable (genre, year, status) and sortable (popularity,
release/air date, rating, title) listings. Offset-based pagination
rather than the Aurora ID cursor scheme, since sorting by
popularity/rating/date doesn't line up with creation-order cursors.

Total count is computed via a window function in the same query as
the page of results, avoiding a second round trip per request. An
invalid filter (malformed genre id, unknown status) is a 422 via a
per-service InvalidMovieFilterError/InvalidSeriesFilterError, caught
before any DB query runs.

build_movie_summary/build_series_summary refactored to share their
row-to-summary logic with the new list path, avoiding a redundant
get_by_id lookup when the row is already in hand.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-05 09:01:11 -05:00
co-authored by Claude Sonnet 5
parent 892f78016c
commit 20f5e82d1b
11 changed files with 798 additions and 24 deletions
+61 -8
View File
@@ -7,7 +7,8 @@ import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id
from app.models.enums import ArtworkType, CompanyType
from app.models.enums import ArtworkType, CompanyType, SeriesStatus
from app.models.tv import Series
from app.repositories import polymorphic, tv_repository
from app.schemas.common import CompanyBriefOut, GenreOut, KeywordOut
from app.schemas.tv import (
@@ -16,6 +17,7 @@ from app.schemas.tv import (
SeasonBriefOut,
SeasonDetailOut,
SeriesDetailOut,
SeriesListOut,
SeriesSeasonsOut,
SeriesSummaryOut,
)
@@ -34,6 +36,10 @@ class EpisodeNotFoundError(Exception):
pass
class InvalidSeriesFilterError(Exception):
pass
def _resolve_id(aid: str, expected_type: str, error_cls: type[Exception]) -> uuid.UUID:
try:
return uuid.UUID(parse_aurora_id(aid, expected_type=expected_type))
@@ -201,13 +207,8 @@ async def get_episode_detail(session: AsyncSession, episode_aurora_id: str) -> E
)
async def build_series_summary(
session: AsyncSession, series_id: uuid.UUID
) -> SeriesSummaryOut | None:
series = await tv_repository.get_series_by_id(session, series_id)
if series is None:
return None
posters = await polymorphic.get_artwork(session, "series", series_id, ArtworkType.POSTER)
async def _series_summary_from_row(session: AsyncSession, series: Series) -> SeriesSummaryOut:
posters = await polymorphic.get_artwork(session, "series", series.id, ArtworkType.POSTER)
return SeriesSummaryOut(
id=aurora_id("series", str(series.id)),
title=series.title,
@@ -220,6 +221,15 @@ async def build_series_summary(
)
async def build_series_summary(
session: AsyncSession, series_id: uuid.UUID
) -> SeriesSummaryOut | None:
series = await tv_repository.get_series_by_id(session, series_id)
if series is None:
return None
return await _series_summary_from_row(session, series)
async def build_series_summaries(
session: AsyncSession, series_ids: list[uuid.UUID]
) -> list[SeriesSummaryOut]:
@@ -229,3 +239,46 @@ async def build_series_summaries(
if summary is not None:
out.append(summary)
return out
async def list_series(
session: AsyncSession,
*,
genre_aurora_id: str | None,
year: int | None,
status: str | None,
sort_by: str,
sort_dir: str,
page: int,
page_size: int,
) -> SeriesListOut:
genre_id: uuid.UUID | None = None
if genre_aurora_id is not None:
try:
genre_id = uuid.UUID(parse_aurora_id(genre_aurora_id, expected_type="genre"))
except ValueError as exc:
raise InvalidSeriesFilterError(f"invalid genre id: {genre_aurora_id!r}") from exc
status_enum: SeriesStatus | None = None
if status is not None:
try:
status_enum = SeriesStatus(status)
except ValueError as exc:
raise InvalidSeriesFilterError(f"unknown series status: {status!r}") from exc
series_rows, total = await tv_repository.list_series(
session,
genre_id=genre_id,
year=year,
status=status_enum,
sort_by=sort_by,
sort_dir=sort_dir,
limit=page_size,
offset=(page - 1) * page_size,
)
results = [await _series_summary_from_row(session, s) for s in series_rows]
total_pages = (total + page_size - 1) // page_size if total else 0
return SeriesListOut(
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
)