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
+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