Add movie/series ratings and watchlist

Builds on the accounts foundation: PUT/DELETE a rating (0.5-10.0) or a
watchlist entry on any movie or series, all scoped to the bearer token's
own user, plus GET /account/ratings|watchlist/{movies,series} to list
them back. Both are polymorphic entity_type/entity_id tables, same shape
as watch_provider_offerings and trending_entries, so movie and series
share one repository/router each rather than duplicating per type.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-06 10:47:41 -05:00
co-authored by Claude Sonnet 5
parent a38c2074a6
commit 0ab31a314c
20 changed files with 1043 additions and 6 deletions
+83 -1
View File
@@ -9,7 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id
from app.models.enums import ArtworkType, MovieStatus
from app.models.movie import Movie
from app.repositories import movie_repository, polymorphic, trending_repository
from app.repositories import (
movie_repository,
polymorphic,
rating_repository,
trending_repository,
watchlist_repository,
)
from app.schemas.account import RatingOut
from app.schemas.common import CollectionBriefOut, CompanyBriefOut, GenreOut, KeywordOut
from app.schemas.movie import (
MovieCreditsOut,
@@ -20,6 +27,8 @@ from app.schemas.movie import (
MovieSummaryOut,
MovieVideosOut,
MovieWatchProvidersOut,
RatedMovieListOut,
RatedMovieOut,
)
from app.services.common import (
build_watch_providers_out,
@@ -209,6 +218,79 @@ async def build_movie_summaries(
return out
async def rate_movie(
session: AsyncSession, movie_aurora_id: str, *, user_id: uuid.UUID, value: float
) -> RatingOut:
movie_id = await _resolve_movie_id(movie_aurora_id)
if await movie_repository.get_movie_by_id(session, movie_id) is None:
raise MovieNotFoundError(movie_aurora_id)
rating = await rating_repository.upsert_rating(
session, user_id=user_id, entity_type="movie", entity_id=movie_id, value=value
)
return RatingOut(value=rating.value, created_at=rating.created_at, updated_at=rating.updated_at)
async def unrate_movie(session: AsyncSession, movie_aurora_id: str, *, user_id: uuid.UUID) -> None:
movie_id = await _resolve_movie_id(movie_aurora_id)
if await movie_repository.get_movie_by_id(session, movie_id) is None:
raise MovieNotFoundError(movie_aurora_id)
await rating_repository.delete_rating(session, user_id=user_id, entity_type="movie", entity_id=movie_id)
async def list_rated_movies(
session: AsyncSession, *, user_id: uuid.UUID, page: int, page_size: int
) -> RatedMovieListOut:
ratings, total = await rating_repository.list_rated(
session, user_id=user_id, entity_type="movie", limit=page_size, offset=(page - 1) * page_size
)
results: list[RatedMovieOut] = []
for rating in ratings:
summary = await build_movie_summary(session, rating.entity_id)
if summary is not None:
results.append(RatedMovieOut(**summary.model_dump(), rating=rating.value))
total_pages = (total + page_size - 1) // page_size if total else 0
return RatedMovieListOut(
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
)
async def add_movie_to_watchlist(
session: AsyncSession, movie_aurora_id: str, *, user_id: uuid.UUID
) -> None:
movie_id = await _resolve_movie_id(movie_aurora_id)
if await movie_repository.get_movie_by_id(session, movie_id) is None:
raise MovieNotFoundError(movie_aurora_id)
await watchlist_repository.add_entry(session, user_id=user_id, entity_type="movie", entity_id=movie_id)
async def remove_movie_from_watchlist(
session: AsyncSession, movie_aurora_id: str, *, user_id: uuid.UUID
) -> None:
movie_id = await _resolve_movie_id(movie_aurora_id)
if await movie_repository.get_movie_by_id(session, movie_id) is None:
raise MovieNotFoundError(movie_aurora_id)
await watchlist_repository.remove_entry(session, user_id=user_id, entity_type="movie", entity_id=movie_id)
async def list_watchlist_movies(
session: AsyncSession, *, user_id: uuid.UUID, page: int, page_size: int
) -> MovieListOut:
entries, total = await watchlist_repository.list_entries(
session, user_id=user_id, entity_type="movie", limit=page_size, offset=(page - 1) * page_size
)
results = await build_movie_summaries(session, [entry.entity_id for entry in entries])
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
)
async def list_now_playing(
session: AsyncSession, *, region: str, page: int, page_size: int
) -> MovieListOut:
+85 -1
View File
@@ -10,11 +10,20 @@ from app.core.ids import aurora_id, parse_aurora_id
from app.models.credit import Credit
from app.models.enums import ArtworkType, CompanyType, SeriesStatus
from app.models.tv import Series
from app.repositories import polymorphic, trending_repository, tv_repository
from app.repositories import (
polymorphic,
rating_repository,
trending_repository,
tv_repository,
watchlist_repository,
)
from app.schemas.account import RatingOut
from app.schemas.common import CastCreditOut, CompanyBriefOut, CrewCreditOut, GenreOut, KeywordOut
from app.schemas.tv import (
EpisodeBriefOut,
EpisodeDetailOut,
RatedSeriesListOut,
RatedSeriesOut,
SeasonBriefOut,
SeasonDetailOut,
SeriesCreditsOut,
@@ -316,6 +325,81 @@ async def build_series_summaries(
return out
async def rate_series(
session: AsyncSession, series_aurora_id: str, *, user_id: uuid.UUID, value: float
) -> RatingOut:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
if await tv_repository.get_series_by_id(session, series_id) is None:
raise SeriesNotFoundError(series_aurora_id)
rating = await rating_repository.upsert_rating(
session, user_id=user_id, entity_type="series", entity_id=series_id, value=value
)
return RatingOut(value=rating.value, created_at=rating.created_at, updated_at=rating.updated_at)
async def unrate_series(session: AsyncSession, series_aurora_id: str, *, user_id: uuid.UUID) -> None:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
if await tv_repository.get_series_by_id(session, series_id) is None:
raise SeriesNotFoundError(series_aurora_id)
await rating_repository.delete_rating(session, user_id=user_id, entity_type="series", entity_id=series_id)
async def list_rated_series(
session: AsyncSession, *, user_id: uuid.UUID, page: int, page_size: int
) -> RatedSeriesListOut:
ratings, total = await rating_repository.list_rated(
session, user_id=user_id, entity_type="series", limit=page_size, offset=(page - 1) * page_size
)
results: list[RatedSeriesOut] = []
for rating in ratings:
summary = await build_series_summary(session, rating.entity_id)
if summary is not None:
results.append(RatedSeriesOut(**summary.model_dump(), rating=rating.value))
total_pages = (total + page_size - 1) // page_size if total else 0
return RatedSeriesListOut(
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
)
async def add_series_to_watchlist(
session: AsyncSession, series_aurora_id: str, *, user_id: uuid.UUID
) -> None:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
if await tv_repository.get_series_by_id(session, series_id) is None:
raise SeriesNotFoundError(series_aurora_id)
await watchlist_repository.add_entry(session, user_id=user_id, entity_type="series", entity_id=series_id)
async def remove_series_from_watchlist(
session: AsyncSession, series_aurora_id: str, *, user_id: uuid.UUID
) -> None:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
if await tv_repository.get_series_by_id(session, series_id) is None:
raise SeriesNotFoundError(series_aurora_id)
await watchlist_repository.remove_entry(
session, user_id=user_id, entity_type="series", entity_id=series_id
)
async def list_watchlist_series(
session: AsyncSession, *, user_id: uuid.UUID, page: int, page_size: int
) -> SeriesListOut:
entries, total = await watchlist_repository.list_entries(
session, user_id=user_id, entity_type="series", limit=page_size, offset=(page - 1) * page_size
)
results = await build_series_summaries(session, [entry.entity_id for entry in entries])
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
)
_TRENDING_WINDOWS = ("day", "week")