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
+30 -1
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +16,7 @@ from app.schemas.movie import (
MovieCreditsOut,
MovieDetailOut,
MovieImagesOut,
MovieListOut,
MovieRecommendationsOut,
MovieVideosOut,
)
@@ -23,6 +26,32 @@ from app.services.cache import build_key
router = APIRouter(prefix="/movies", tags=["movies"], dependencies=[Depends(enforce_rate_limit)])
@router.get("", response_model=MovieListOut)
async def list_movies(
genre: str | None = Query(None, description="Aurora genre id, e.g. aur_genre_..."),
year: int | None = Query(None, description="Primary release year"),
status_: str | None = Query(None, alias="status", description="Movie status, e.g. released"),
sort_by: Literal["popularity", "release_date", "vote_average", "title"] = 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),
) -> MovieListOut:
try:
return await movie_service.list_movies(
session,
genre_aurora_id=genre,
year=year,
status=status_,
sort_by=sort_by,
sort_dir=sort_dir,
page=page,
page_size=page_size,
)
except movie_service.InvalidMovieFilterError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
@router.get("/{aurora_id}", response_model=MovieDetailOut)
async def get_movie(
aurora_id: str,
+30 -2
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,13 +12,39 @@ from app.api.deps import cached_response, not_found
from app.core.database import get_db
from app.core.redis import get_redis
from app.core.security import enforce_rate_limit
from app.schemas.tv import SeriesDetailOut, SeriesSeasonsOut
from app.schemas.tv import SeriesDetailOut, SeriesListOut, SeriesSeasonsOut
from app.services import tv_service
from app.services.cache import build_key
router = APIRouter(prefix="/series", tags=["series"], dependencies=[Depends(enforce_rate_limit)])
@router.get("", response_model=SeriesListOut)
async def list_series(
genre: str | None = Query(None, description="Aurora genre id, e.g. aur_genre_..."),
year: int | None = Query(None, description="First air date year"),
status_: str | None = Query(None, alias="status", description="Series status, e.g. ended"),
sort_by: Literal["popularity", "first_air_date", "vote_average", "title"] = 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),
) -> SeriesListOut:
try:
return await tv_service.list_series(
session,
genre_aurora_id=genre,
year=year,
status=status_,
sort_by=sort_by,
sort_dir=sort_dir,
page=page,
page_size=page_size,
)
except tv_service.InvalidSeriesFilterError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
@router.get("/{aurora_id}", response_model=SeriesDetailOut)
async def get_series(
aurora_id: str,
+50 -1
View File
@@ -4,15 +4,23 @@ from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.company import Company, MovieCompany
from app.models.enums import MovieStatus
from app.models.genre import Genre, MovieGenre
from app.models.keyword import Keyword, MovieKeyword
from app.models.movie import Movie, MovieRecommendation
_MOVIE_SORT_COLUMNS = {
"popularity": Movie.popularity,
"release_date": Movie.primary_release_date,
"vote_average": Movie.vote_average,
"title": Movie.sort_title,
}
async def get_movie_by_id(session: AsyncSession, movie_id: uuid.UUID) -> Movie | None:
stmt = (
@@ -76,3 +84,44 @@ async def get_movie_recommendation_ids(
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def list_movies(
session: AsyncSession,
*,
genre_id: uuid.UUID | None,
year: int | None,
status: MovieStatus | None,
sort_by: str,
sort_dir: str,
limit: int,
offset: int,
) -> tuple[list[Movie], int]:
"""Browse movies with optional filters (section 17: additive beyond the
spec's minimum endpoint list). Returns (page of movies, total matching
count) -- the count is computed via a window function in the same query
rather than a second round trip, since it must reflect the filtered set
before LIMIT/OFFSET are applied."""
column = _MOVIE_SORT_COLUMNS[sort_by]
order = column.desc() if sort_dir == "desc" else column.asc()
stmt = select(Movie, func.count().over().label("total_count"))
if genre_id is not None:
stmt = stmt.join(MovieGenre, MovieGenre.movie_id == Movie.id).where(
MovieGenre.genre_id == genre_id
)
if year is not None:
stmt = stmt.where(func.extract("year", Movie.primary_release_date) == year)
if status is not None:
stmt = stmt.where(Movie.status == status)
# A secondary tiebreak on id keeps ordering stable across pages when
# many rows share the same value on the primary sort column (e.g. a lot
# of untouched-by-TMDb popularity=None rows).
stmt = stmt.order_by(order.nulls_last(), Movie.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
+44 -2
View File
@@ -4,15 +4,22 @@ from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.company import Company, SeriesCompany
from app.models.enums import CompanyType
from app.models.enums import CompanyType, SeriesStatus
from app.models.genre import Genre, SeriesGenre
from app.models.keyword import Keyword, SeriesKeyword
from app.models.tv import Episode, Season, Series
_SERIES_SORT_COLUMNS = {
"popularity": Series.popularity,
"first_air_date": Series.first_air_date,
"vote_average": Series.vote_average,
"title": Series.sort_title,
}
async def get_series_by_id(session: AsyncSession, series_id: uuid.UUID) -> Series | None:
result = await session.execute(select(Series).where(Series.id == series_id))
@@ -149,3 +156,38 @@ async def get_series_companies(
stmt = stmt.where(Company.company_type == company_type)
result = await session.execute(stmt)
return [(company, role) for company, role in result.all()]
async def list_series(
session: AsyncSession,
*,
genre_id: uuid.UUID | None,
year: int | None,
status: SeriesStatus | None,
sort_by: str,
sort_dir: str,
limit: int,
offset: int,
) -> tuple[list[Series], int]:
"""Browse series with optional filters -- see movie_repository.list_movies
for the window-function total-count approach this mirrors."""
column = _SERIES_SORT_COLUMNS[sort_by]
order = column.desc() if sort_dir == "desc" else column.asc()
stmt = select(Series, func.count().over().label("total_count"))
if genre_id is not None:
stmt = stmt.join(SeriesGenre, SeriesGenre.series_id == Series.id).where(
SeriesGenre.genre_id == genre_id
)
if year is not None:
stmt = stmt.where(func.extract("year", Series.first_air_date) == year)
if status is not None:
stmt = stmt.where(Series.status == status)
stmt = stmt.order_by(order.nulls_last(), Series.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
@@ -83,3 +83,11 @@ class MovieVideosOut(BaseModel):
class MovieRecommendationsOut(BaseModel):
id: str
recommendations: list[MovieSummaryOut] = []
class MovieListOut(BaseModel):
page: int
page_size: int
total_results: int
total_pages: int
results: list[MovieSummaryOut] = []
+8
View File
@@ -76,6 +76,14 @@ class SeriesSeasonsOut(BaseModel):
seasons: list[SeasonBriefOut] = []
class SeriesListOut(BaseModel):
page: int
page_size: int
total_results: int
total_pages: int
results: list[SeriesSummaryOut] = []
class EpisodeBriefOut(BaseModel):
id: str
episode_number: int
+59 -6
View File
@@ -7,13 +7,15 @@ import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id
from app.models.enums import ArtworkType
from app.models.enums import ArtworkType, MovieStatus
from app.models.movie import Movie
from app.repositories import movie_repository, polymorphic
from app.schemas.common import CollectionBriefOut, CompanyBriefOut, GenreOut, KeywordOut
from app.schemas.movie import (
MovieCreditsOut,
MovieDetailOut,
MovieImagesOut,
MovieListOut,
MovieRecommendationsOut,
MovieSummaryOut,
MovieVideosOut,
@@ -25,6 +27,10 @@ class MovieNotFoundError(Exception):
pass
class InvalidMovieFilterError(Exception):
pass
async def _resolve_movie_id(movie_aurora_id: str) -> uuid.UUID:
try:
return uuid.UUID(parse_aurora_id(movie_aurora_id, expected_type="movie"))
@@ -136,11 +142,8 @@ async def get_movie_recommendations(
return MovieRecommendationsOut(id=aurora_id("movie", str(movie.id)), recommendations=summaries)
async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> MovieSummaryOut | None:
movie = await movie_repository.get_movie_by_id(session, movie_id)
if movie is None:
return None
posters = await polymorphic.get_artwork(session, "movie", movie_id, ArtworkType.POSTER)
async def _movie_summary_from_row(session: AsyncSession, movie: Movie) -> MovieSummaryOut:
posters = await polymorphic.get_artwork(session, "movie", movie.id, ArtworkType.POSTER)
return MovieSummaryOut(
id=aurora_id("movie", str(movie.id)),
title=movie.title,
@@ -153,6 +156,13 @@ async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> Mov
)
async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> MovieSummaryOut | None:
movie = await movie_repository.get_movie_by_id(session, movie_id)
if movie is None:
return None
return await _movie_summary_from_row(session, movie)
async def build_movie_summaries(
session: AsyncSession, movie_ids: list[uuid.UUID]
) -> list[MovieSummaryOut]:
@@ -162,3 +172,46 @@ async def build_movie_summaries(
if summary is not None:
out.append(summary)
return out
async def list_movies(
session: AsyncSession,
*,
genre_aurora_id: str | None,
year: int | None,
status: str | None,
sort_by: str,
sort_dir: str,
page: int,
page_size: int,
) -> MovieListOut:
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 InvalidMovieFilterError(f"invalid genre id: {genre_aurora_id!r}") from exc
status_enum: MovieStatus | None = None
if status is not None:
try:
status_enum = MovieStatus(status)
except ValueError as exc:
raise InvalidMovieFilterError(f"unknown movie status: {status!r}") from exc
movies, total = await movie_repository.list_movies(
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 _movie_summary_from_row(session, m) for m in movies]
total_pages = (total + page_size - 1) // page_size if total else 0
return MovieListOut(
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
)
+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
)