"""Assembles Movie API responses from repository data.""" from __future__ import annotations import uuid 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, 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, MovieDetailOut, MovieImagesOut, MovieListOut, MovieRecommendationsOut, MovieSummaryOut, MovieVideosOut, MovieWatchProvidersOut, RatedMovieListOut, RatedMovieOut, ) from app.services.common import ( build_watch_providers_out, pick_certification, primary_artwork_path, split_credits, to_alternate_title_out, to_images_out, to_release_date_out, to_video_out, ) 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")) except ValueError as exc: raise MovieNotFoundError(str(exc)) from exc async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> MovieDetailOut: movie_id = await _resolve_movie_id(movie_aurora_id) movie = await movie_repository.get_movie_by_id(session, movie_id) if movie is None: raise MovieNotFoundError(movie_aurora_id) genres = await movie_repository.get_movie_genres(session, movie_id) keywords = await movie_repository.get_movie_keywords(session, movie_id) alternate_titles = await movie_repository.get_movie_alternate_titles(session, movie_id) companies = await movie_repository.get_movie_companies(session, movie_id) credits_ = await polymorphic.get_credits(session, "movie", movie_id) artwork = await polymorphic.get_artwork(session, "movie", movie_id) videos = await polymorphic.get_videos(session, "movie", movie_id) release_dates = await polymorphic.get_release_dates(session, "movie", movie_id) watch_provider_rows = await polymorphic.get_watch_providers(session, "movie", movie_id) external_ids = await polymorphic.get_external_ids(session, "movie", movie_id) cast, crew = split_credits(credits_) collection_out: CollectionBriefOut | None = None if movie.collection is not None: collection_posters = await polymorphic.get_artwork( session, "collection", movie.collection.id, ArtworkType.POSTER ) collection_out = CollectionBriefOut( id=aurora_id("collection", str(movie.collection.id)), name=movie.collection.name, poster_path=primary_artwork_path(collection_posters, ArtworkType.POSTER), ) company_out: list[CompanyBriefOut] = [] for company, _role in companies: logos = await polymorphic.get_artwork(session, "company", company.id, ArtworkType.LOGO) company_out.append( CompanyBriefOut( id=aurora_id("company", str(company.id)), name=company.name, company_type=company.company_type.value, logo_path=primary_artwork_path(logos, ArtworkType.LOGO), ) ) return MovieDetailOut( id=aurora_id("movie", str(movie.id)), title=movie.title, original_title=movie.original_title, overview=movie.overview, tagline=movie.tagline, status=movie.status.value, original_language=movie.original_language_code, release_date=movie.primary_release_date, runtime=movie.runtime_minutes, budget=movie.budget, revenue=movie.revenue, popularity=movie.popularity, vote_average=movie.vote_average, vote_count=movie.vote_count, adult=movie.adult, certification=pick_certification(release_dates), genres=[GenreOut(id=aurora_id("genre", str(g.id)), name=g.name) for g in genres], keywords=[KeywordOut(id=aurora_id("keyword", str(k.id)), name=k.name) for k in keywords], alternate_titles=[to_alternate_title_out(t) for t in alternate_titles], cast=cast, crew=crew, companies=company_out, collection=collection_out, images=to_images_out(artwork), videos=[to_video_out(v) for v in videos], release_dates=[to_release_date_out(rd) for rd in release_dates], watch_providers=await build_watch_providers_out(session, watch_provider_rows), external_ids=external_ids, created_at=movie.created_at, updated_at=movie.updated_at, ) async def get_movie_credits(session: AsyncSession, movie_aurora_id: str) -> MovieCreditsOut: movie_id = await _resolve_movie_id(movie_aurora_id) movie = await movie_repository.get_movie_by_id(session, movie_id) if movie is None: raise MovieNotFoundError(movie_aurora_id) credits_ = await polymorphic.get_credits(session, "movie", movie_id) cast, crew = split_credits(credits_) return MovieCreditsOut(id=aurora_id("movie", str(movie.id)), cast=cast, crew=crew) async def get_movie_images(session: AsyncSession, movie_aurora_id: str) -> MovieImagesOut: movie_id = await _resolve_movie_id(movie_aurora_id) movie = await movie_repository.get_movie_by_id(session, movie_id) if movie is None: raise MovieNotFoundError(movie_aurora_id) artwork = await polymorphic.get_artwork(session, "movie", movie_id) return MovieImagesOut(id=aurora_id("movie", str(movie.id)), images=to_images_out(artwork)) async def get_movie_videos(session: AsyncSession, movie_aurora_id: str) -> MovieVideosOut: movie_id = await _resolve_movie_id(movie_aurora_id) movie = await movie_repository.get_movie_by_id(session, movie_id) if movie is None: raise MovieNotFoundError(movie_aurora_id) videos = await polymorphic.get_videos(session, "movie", movie_id) return MovieVideosOut(id=aurora_id("movie", str(movie.id)), videos=[to_video_out(v) for v in videos]) async def get_movie_watch_providers( session: AsyncSession, movie_aurora_id: str ) -> MovieWatchProvidersOut: movie_id = await _resolve_movie_id(movie_aurora_id) movie = await movie_repository.get_movie_by_id(session, movie_id) if movie is None: raise MovieNotFoundError(movie_aurora_id) rows = await polymorphic.get_watch_providers(session, "movie", movie_id) return MovieWatchProvidersOut( id=aurora_id("movie", str(movie.id)), results=await build_watch_providers_out(session, rows) ) async def get_movie_recommendations( session: AsyncSession, movie_aurora_id: str ) -> MovieRecommendationsOut: movie_id = await _resolve_movie_id(movie_aurora_id) movie = await movie_repository.get_movie_by_id(session, movie_id) if movie is None: raise MovieNotFoundError(movie_aurora_id) recommended_ids = await movie_repository.get_movie_recommendation_ids(session, movie_id) summaries = await build_movie_summaries(session, recommended_ids) return MovieRecommendationsOut(id=aurora_id("movie", str(movie.id)), recommendations=summaries) 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, original_title=movie.original_title, overview=movie.overview, release_date=movie.primary_release_date, poster_path=primary_artwork_path(posters, ArtworkType.POSTER), popularity=movie.popularity, vote_average=movie.vote_average, ) 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]: out: list[MovieSummaryOut] = [] for movie_id in movie_ids: summary = await build_movie_summary(session, movie_id) if summary is not None: out.append(summary) 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: movies, total = await movie_repository.list_now_playing( session, region=region.upper(), 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 ) async def list_upcoming(session: AsyncSession, *, region: str, page: int, page_size: int) -> MovieListOut: movies, total = await movie_repository.list_upcoming( session, region=region.upper(), 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 ) _TRENDING_WINDOWS = ("day", "week") async def list_trending( session: AsyncSession, *, time_window: str, page: int, page_size: int ) -> MovieListOut: if time_window not in _TRENDING_WINDOWS: raise InvalidMovieFilterError(f"unknown time_window: {time_window!r}") all_ids = await trending_repository.get_trending_entity_ids( session, entity_type="movie", time_window=time_window ) total = len(all_ids) start = (page - 1) * page_size page_ids = all_ids[start : start + page_size] movies = await movie_repository.get_movies_by_ids(session, page_ids) 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 ) async def list_movies( session: AsyncSession, *, genre_aurora_id: str | None, keyword_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 keyword_id: uuid.UUID | None = None if keyword_aurora_id is not None: try: keyword_id = uuid.UUID(parse_aurora_id(keyword_aurora_id, expected_type="keyword")) except ValueError as exc: raise InvalidMovieFilterError(f"invalid keyword id: {keyword_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, keyword_id=keyword_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 )