"""Assembles Person API responses from repository data.""" from __future__ import annotations import uuid from collections import defaultdict from sqlalchemy.ext.asyncio import AsyncSession from app.core.ids import aurora_id, parse_aurora_id from app.models.credit import Credit from app.models.enums import ArtworkType from app.models.tv import Series from app.repositories import movie_repository, person_repository, polymorphic, tv_repository from app.schemas.person import ( FilmographyCreditOut, GuestEpisodeOut, PersonCreditsOut, PersonDetailOut, PersonSummaryOut, ) from app.services.common import primary_artwork_path, to_artwork_out class PersonNotFoundError(Exception): pass def _resolve_person_id(aid: str) -> uuid.UUID: try: return uuid.UUID(parse_aurora_id(aid, expected_type="person")) except ValueError as exc: raise PersonNotFoundError(str(exc)) from exc async def get_person_detail(session: AsyncSession, person_aurora_id: str) -> PersonDetailOut: person_id = _resolve_person_id(person_aurora_id) person = await person_repository.get_person_by_id(session, person_id) if person is None: raise PersonNotFoundError(person_aurora_id) profiles = await polymorphic.get_artwork(session, "person", person_id, ArtworkType.PROFILE) external_ids = await polymorphic.get_external_ids(session, "person", person_id) return PersonDetailOut( id=aurora_id("person", str(person.id)), name=person.name, aliases=list(person.aliases or []), biography=person.biography, birthday=person.birthday, deathday=person.deathday, birthplace=person.birthplace, gender=person.gender, known_for_department=person.known_for_department, popularity=person.popularity, profile_images=[to_artwork_out(a) for a in profiles], external_ids=external_ids, created_at=person.created_at, updated_at=person.updated_at, ) async def _series_poster_path(session: AsyncSession, series_id: uuid.UUID) -> str | None: posters = await polymorphic.get_artwork(session, "series", series_id, ArtworkType.POSTER) return primary_artwork_path(posters, ArtworkType.POSTER) async def get_person_credits(session: AsyncSession, person_aurora_id: str) -> PersonCreditsOut: person_id = _resolve_person_id(person_aurora_id) person = await person_repository.get_person_by_id(session, person_id) if person is None: raise PersonNotFoundError(person_aurora_id) credits_ = await person_repository.get_person_credits(session, person_id) movie_credits = [c for c in credits_ if c.entity_type == "movie"] series_credits = [c for c in credits_ if c.entity_type == "series"] episode_credits = [c for c in credits_ if c.entity_type == "episode"] movies = { m.id: m for m in await movie_repository.get_movies_by_ids( session, [c.entity_id for c in movie_credits] ) } episodes = { e.id: e for e in await tv_repository.get_episodes_by_ids( session, [c.entity_id for c in episode_credits] ) } # Guest-star episode credits are aggregated to one filmography row per # (series, department/job/character) rather than one row per episode — # a prolific guest actor would otherwise flood the list with dozens of # near-identical entries, one per episode they appeared in. episode_groups: dict[tuple, list[Credit]] = defaultdict(list) for credit in episode_credits: episode = episodes.get(credit.entity_id) if episode is None: continue key = (episode.series_id, credit.department, credit.job, credit.character) episode_groups[key].append(credit) series_ids = {c.entity_id for c in series_credits} | { episode.series_id for episode in episodes.values() } series_by_id: dict[uuid.UUID, Series] = { s.id: s for s in await tv_repository.get_series_by_ids(session, list(series_ids)) } cast: list[FilmographyCreditOut] = [] crew: list[FilmographyCreditOut] = [] def _bucket(department: str) -> list[FilmographyCreditOut]: return cast if department == "Acting" else crew for credit in movie_credits: movie = movies.get(credit.entity_id) if movie is None: continue posters = await polymorphic.get_artwork(session, "movie", movie.id, ArtworkType.POSTER) _bucket(credit.department).append( FilmographyCreditOut( id=aurora_id("credit", str(credit.id)), media_id=aurora_id("movie", str(movie.id)), media_type="movie", title=movie.title, department=credit.department, job=credit.job, character=credit.character, release_date=movie.primary_release_date, poster_path=primary_artwork_path(posters, ArtworkType.POSTER), ) ) for credit in series_credits: series = series_by_id.get(credit.entity_id) if series is None: continue _bucket(credit.department).append( FilmographyCreditOut( id=aurora_id("credit", str(credit.id)), media_id=aurora_id("series", str(series.id)), media_type="series", title=series.title, department=credit.department, job=credit.job, character=credit.character, release_date=series.first_air_date, poster_path=await _series_poster_path(session, series.id), ) ) for (series_id, department, job, character), group in episode_groups.items(): series = series_by_id.get(series_id) if series is None: continue representative = group[0] guest_episodes = sorted( (episodes[c.entity_id] for c in group if c.entity_id in episodes), key=lambda ep: (ep.season_number, ep.episode_number), ) _bucket(department).append( FilmographyCreditOut( id=aurora_id("credit", str(representative.id)), media_id=aurora_id("series", str(series.id)), media_type="series", title=series.title, department=department, job=job, character=character, release_date=series.first_air_date, poster_path=await _series_poster_path(session, series.id), episode_count=len(group), episodes=[ GuestEpisodeOut( id=aurora_id("episode", str(ep.id)), season_number=ep.season_number, episode_number=ep.episode_number, title=ep.title, air_date=ep.air_date, ) for ep in guest_episodes ], ) ) return PersonCreditsOut(id=aurora_id("person", str(person.id)), cast=cast, crew=crew) async def build_person_summary( session: AsyncSession, person_id: uuid.UUID ) -> PersonSummaryOut | None: person = await person_repository.get_person_by_id(session, person_id) if person is None: return None profiles = await polymorphic.get_artwork(session, "person", person_id, ArtworkType.PROFILE) return PersonSummaryOut( id=aurora_id("person", str(person.id)), name=person.name, known_for_department=person.known_for_department, profile_path=primary_artwork_path(profiles, ArtworkType.PROFILE), popularity=person.popularity, ) async def build_person_summaries( session: AsyncSession, person_ids: list[uuid.UUID] ) -> list[PersonSummaryOut]: out: list[PersonSummaryOut] = [] for person_id in person_ids: summary = await build_person_summary(session, person_id) if summary is not None: out.append(summary) return out