From 567b4ef06873428e28f7c4f1fd71404ff34cfa80 Mon Sep 17 00:00:00 2001 From: NixReaper Date: Sun, 6 Sep 2026 04:40:32 -0500 Subject: [PATCH] Add keyword browsing/filtering and expose alternate titles Keywords were ingested and shown read-only on detail pages, but had no directory (GET /keywords) or filter param -- the same gap genres had before today. Mirrors the genre browse pattern exactly: repository, service, route, plus a `keyword` filter on GET /movies and GET /series. Alternate/translated titles ("Also Known As") were normalized down to bare title strings and never written to MovieAlternateTitle/ SeriesAlternateTitle at all. Normalizer now captures country_code + type per TMDb's actual shape; ingestion writes them; movie/series detail now expose an alternate_titles list. Co-Authored-By: Claude Sonnet 5 --- app/api/v1/keywords.py | 24 +++++ app/api/v1/movies.py | 2 + app/api/v1/series.py | 2 + app/main.py | 2 + app/repositories/keyword_repository.py | 31 +++++++ app/repositories/movie_repository.py | 19 +++- app/repositories/tv_repository.py | 21 ++++- app/schemas/common.py | 14 +++ app/schemas/movie.py | 2 + app/schemas/tv.py | 2 + app/services/common.py | 8 +- app/services/ingestion/movie_ingestion.py | 21 ++++- app/services/ingestion/series_ingestion.py | 21 ++++- app/services/keyword_service.py | 26 ++++++ app/services/metadata/normalizer.py | 25 ++++- app/services/movie_service.py | 12 +++ app/services/tv_service.py | 12 +++ docs/api.md | 29 ++++-- tests/integration/test_alternate_titles.py | 101 +++++++++++++++++++++ tests/integration/test_keyword_browse.py | 60 ++++++++++++ tests/integration/test_movie_browse.py | 55 +++++++++++ tests/integration/test_series_browse.py | 53 +++++++++++ tests/unit/test_normalizer.py | 23 ++++- 23 files changed, 548 insertions(+), 17 deletions(-) create mode 100644 app/api/v1/keywords.py create mode 100644 app/repositories/keyword_repository.py create mode 100644 app/services/keyword_service.py create mode 100644 tests/integration/test_alternate_titles.py create mode 100644 tests/integration/test_keyword_browse.py diff --git a/app/api/v1/keywords.py b/app/api/v1/keywords.py new file mode 100644 index 0000000..ae20db1 --- /dev/null +++ b/app/api/v1/keywords.py @@ -0,0 +1,24 @@ +"""Keyword endpoints (section 17: additive) -- a directory for the +`keyword` filter GET /movies and GET /series accept, with no prior way to +discover what those ids are. Mirrors app/api/v1/genres.py exactly.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import enforce_rate_limit +from app.schemas.common import KeywordListOut +from app.services import keyword_service + +router = APIRouter(prefix="/keywords", tags=["keywords"], dependencies=[Depends(enforce_rate_limit)]) + + +@router.get("", response_model=KeywordListOut) +async def list_keywords( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db), +) -> KeywordListOut: + return await keyword_service.list_keywords(session, page=page, page_size=page_size) diff --git a/app/api/v1/movies.py b/app/api/v1/movies.py index 4b175bf..0da325c 100644 --- a/app/api/v1/movies.py +++ b/app/api/v1/movies.py @@ -29,6 +29,7 @@ router = APIRouter(prefix="/movies", tags=["movies"], dependencies=[Depends(enfo @router.get("", response_model=MovieListOut) async def list_movies( genre: str | None = Query(None, description="Aurora genre id, e.g. aur_genre_..."), + keyword: str | None = Query(None, description="Aurora keyword id, e.g. aur_keyword_..."), 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"), @@ -41,6 +42,7 @@ async def list_movies( return await movie_service.list_movies( session, genre_aurora_id=genre, + keyword_aurora_id=keyword, year=year, status=status_, sort_by=sort_by, diff --git a/app/api/v1/series.py b/app/api/v1/series.py index 63d8b4d..7226031 100644 --- a/app/api/v1/series.py +++ b/app/api/v1/series.py @@ -29,6 +29,7 @@ router = APIRouter(prefix="/series", tags=["series"], dependencies=[Depends(enfo @router.get("", response_model=SeriesListOut) async def list_series( genre: str | None = Query(None, description="Aurora genre id, e.g. aur_genre_..."), + keyword: str | None = Query(None, description="Aurora keyword id, e.g. aur_keyword_..."), 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"), @@ -41,6 +42,7 @@ async def list_series( return await tv_service.list_series( session, genre_aurora_id=genre, + keyword_aurora_id=keyword, year=year, status=status_, sort_by=sort_by, diff --git a/app/main.py b/app/main.py index 877ba94..e1843bb 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,7 @@ from app.api.v1 import ( episodes, genres, health, + keywords, movies, people, search, @@ -177,4 +178,5 @@ app.include_router(people.router, prefix=API_V1_PREFIX) app.include_router(collections.router, prefix=API_V1_PREFIX) app.include_router(companies.router, prefix=API_V1_PREFIX) app.include_router(genres.router, prefix=API_V1_PREFIX) +app.include_router(keywords.router, prefix=API_V1_PREFIX) app.include_router(admin.router, prefix=API_V1_PREFIX) diff --git a/app/repositories/keyword_repository.py b/app/repositories/keyword_repository.py new file mode 100644 index 0000000..99e5939 --- /dev/null +++ b/app/repositories/keyword_repository.py @@ -0,0 +1,31 @@ +"""Data access for Keyword.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.keyword import Keyword + + +async def get_keyword_by_id(session: AsyncSession, keyword_id: uuid.UUID) -> Keyword | None: + result = await session.execute(select(Keyword).where(Keyword.id == keyword_id)) + return result.scalar_one_or_none() + + +async def list_keywords(session: AsyncSession, *, limit: int, offset: int) -> tuple[list[Keyword], int]: + """Browse keywords -- see genre_repository.list_genres, which this + mirrors exactly (no filter, no sort_by, alphabetical by name).""" + + stmt = ( + select(Keyword, func.count().over().label("total_count")) + .order_by(Keyword.name.asc(), Keyword.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 diff --git a/app/repositories/movie_repository.py b/app/repositories/movie_repository.py index 86cea9d..e4647bc 100644 --- a/app/repositories/movie_repository.py +++ b/app/repositories/movie_repository.py @@ -12,7 +12,7 @@ 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 +from app.models.movie import Movie, MovieAlternateTitle, MovieRecommendation _MOVIE_SORT_COLUMNS = { "popularity": Movie.popularity, @@ -69,6 +69,18 @@ async def get_movie_companies( return [(company, role) for company, role in result.all()] +async def get_movie_alternate_titles( + session: AsyncSession, movie_id: uuid.UUID +) -> list[MovieAlternateTitle]: + stmt = ( + select(MovieAlternateTitle) + .where(MovieAlternateTitle.movie_id == movie_id) + .order_by(MovieAlternateTitle.country_code.asc().nulls_last(), MovieAlternateTitle.title.asc()) + ) + result = await session.execute(stmt) + return list(result.scalars().all()) + + async def get_movie_recommendation_ids( session: AsyncSession, movie_id: uuid.UUID ) -> list[uuid.UUID]: @@ -90,6 +102,7 @@ async def list_movies( session: AsyncSession, *, genre_id: uuid.UUID | None, + keyword_id: uuid.UUID | None, year: int | None, status: MovieStatus | None, sort_by: str, @@ -111,6 +124,10 @@ async def list_movies( stmt = stmt.join(MovieGenre, MovieGenre.movie_id == Movie.id).where( MovieGenre.genre_id == genre_id ) + if keyword_id is not None: + stmt = stmt.join(MovieKeyword, MovieKeyword.movie_id == Movie.id).where( + MovieKeyword.keyword_id == keyword_id + ) if year is not None: stmt = stmt.where(func.extract("year", Movie.primary_release_date) == year) if status is not None: diff --git a/app/repositories/tv_repository.py b/app/repositories/tv_repository.py index 08aa41f..07eb8be 100644 --- a/app/repositories/tv_repository.py +++ b/app/repositories/tv_repository.py @@ -11,7 +11,7 @@ from app.models.company import Company, SeriesCompany 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 +from app.models.tv import Episode, Season, Series, SeriesAlternateTitle _SERIES_SORT_COLUMNS = { "popularity": Series.popularity, @@ -158,10 +158,25 @@ async def get_series_companies( return [(company, role) for company, role in result.all()] +async def get_series_alternate_titles( + session: AsyncSession, series_id: uuid.UUID +) -> list[SeriesAlternateTitle]: + stmt = ( + select(SeriesAlternateTitle) + .where(SeriesAlternateTitle.series_id == series_id) + .order_by( + SeriesAlternateTitle.country_code.asc().nulls_last(), SeriesAlternateTitle.title.asc() + ) + ) + result = await session.execute(stmt) + return list(result.scalars().all()) + + async def list_series( session: AsyncSession, *, genre_id: uuid.UUID | None, + keyword_id: uuid.UUID | None, year: int | None, status: SeriesStatus | None, sort_by: str, @@ -180,6 +195,10 @@ async def list_series( stmt = stmt.join(SeriesGenre, SeriesGenre.series_id == Series.id).where( SeriesGenre.genre_id == genre_id ) + if keyword_id is not None: + stmt = stmt.join(SeriesKeyword, SeriesKeyword.series_id == Series.id).where( + SeriesKeyword.keyword_id == keyword_id + ) if year is not None: stmt = stmt.where(func.extract("year", Series.first_air_date) == year) if status is not None: diff --git a/app/schemas/common.py b/app/schemas/common.py index eacc275..846138a 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -35,6 +35,14 @@ class KeywordOut(ORMModel): name: str +class KeywordListOut(BaseModel): + page: int + page_size: int + total_results: int + total_pages: int + results: list[KeywordOut] = [] + + class ArtworkOut(BaseModel): id: str type: str @@ -112,6 +120,12 @@ class CollectionBriefOut(BaseModel): poster_path: str | None = None +class AlternateTitleOut(BaseModel): + title: str + country_code: str | None = None + type: str | None = None + + class CertificationOut(BaseModel): system: str rating: str diff --git a/app/schemas/movie.py b/app/schemas/movie.py index cb66e79..e096d91 100644 --- a/app/schemas/movie.py +++ b/app/schemas/movie.py @@ -7,6 +7,7 @@ from datetime import date, datetime from pydantic import BaseModel from app.schemas.common import ( + AlternateTitleOut, CastCreditOut, CollectionBriefOut, CompanyBriefOut, @@ -57,6 +58,7 @@ class MovieDetailOut(BaseModel): genres: list[GenreOut] = [] keywords: list[KeywordOut] = [] + alternate_titles: list[AlternateTitleOut] = [] cast: list[CastCreditOut] = [] crew: list[CrewCreditOut] = [] companies: list[CompanyBriefOut] = [] diff --git a/app/schemas/tv.py b/app/schemas/tv.py index 0e7ec37..0a498c3 100644 --- a/app/schemas/tv.py +++ b/app/schemas/tv.py @@ -7,6 +7,7 @@ from datetime import date, datetime from pydantic import BaseModel from app.schemas.common import ( + AlternateTitleOut, CastCreditOut, CompanyBriefOut, CrewCreditOut, @@ -61,6 +62,7 @@ class SeriesDetailOut(BaseModel): genres: list[GenreOut] = [] keywords: list[KeywordOut] = [] + alternate_titles: list[AlternateTitleOut] = [] creators: list[CrewCreditOut] = [] cast: list[CastCreditOut] = [] crew: list[CrewCreditOut] = [] diff --git a/app/services/common.py b/app/services/common.py index f0d0803..abca94d 100644 --- a/app/services/common.py +++ b/app/services/common.py @@ -6,9 +6,11 @@ from app.core.ids import aurora_id from app.models.artwork import Artwork from app.models.credit import Credit from app.models.enums import ArtworkType -from app.models.movie import ReleaseDate +from app.models.movie import MovieAlternateTitle, ReleaseDate +from app.models.tv import SeriesAlternateTitle from app.models.video import Video from app.schemas.common import ( + AlternateTitleOut, ArtworkOut, CastCreditOut, CertificationOut, @@ -73,6 +75,10 @@ def to_video_out(video: Video) -> VideoOut: ) +def to_alternate_title_out(alt: MovieAlternateTitle | SeriesAlternateTitle) -> AlternateTitleOut: + return AlternateTitleOut(title=alt.title, country_code=alt.country_code, type=alt.type) + + def to_release_date_out(release_date: ReleaseDate) -> ReleaseDateOut: certification = None if release_date.certification is not None: diff --git a/app/services/ingestion/movie_ingestion.py b/app/services/ingestion/movie_ingestion.py index 971f7ae..8a1e2af 100644 --- a/app/services/ingestion/movie_ingestion.py +++ b/app/services/ingestion/movie_ingestion.py @@ -25,7 +25,7 @@ from app.models.credit import Credit from app.models.enums import MatchConfidence, MovieStatus, VideoType from app.models.genre import MovieGenre from app.models.keyword import MovieKeyword -from app.models.movie import Movie, MovieRecommendation +from app.models.movie import Movie, MovieAlternateTitle, MovieRecommendation from app.models.video import Video from app.providers.base import MetadataProvider from app.repositories import polymorphic @@ -51,6 +51,24 @@ async def _replace_movie_keywords(session: AsyncSession, movie_id: uuid.UUID, na session.add(MovieKeyword(movie_id=movie_id, keyword_id=keyword.id)) +async def _replace_movie_alternate_titles( + session: AsyncSession, movie_id: uuid.UUID, alt_titles +) -> None: + await session.execute( + delete(MovieAlternateTitle).where(MovieAlternateTitle.movie_id == movie_id) + ) + for alt in alt_titles: + country_code = None + if alt.country_code: + country = await refs.get_or_create_country(session, alt.country_code, alt.country_code) + country_code = country.code + session.add( + MovieAlternateTitle( + movie_id=movie_id, title=alt.title, country_code=country_code, type=alt.type + ) + ) + + async def _replace_movie_companies( session: AsyncSession, movie_id: uuid.UUID, provider_id: uuid.UUID, companies ) -> None: @@ -239,6 +257,7 @@ async def ingest_movie( await _replace_movie_genres(session, movie.id, normalized.genres) await _replace_movie_keywords(session, movie.id, normalized.keywords) + await _replace_movie_alternate_titles(session, movie.id, normalized.alternate_titles) await _replace_movie_companies(session, movie.id, tmdb_provider.id, normalized.companies) await _replace_movie_credits(session, movie.id, tmdb_provider.id, normalized.cast, normalized.crew) await polymorphic.sync_artwork( diff --git a/app/services/ingestion/series_ingestion.py b/app/services/ingestion/series_ingestion.py index d61a2b0..bccad6e 100644 --- a/app/services/ingestion/series_ingestion.py +++ b/app/services/ingestion/series_ingestion.py @@ -25,7 +25,7 @@ from app.models.credit import Credit from app.models.enums import CompanyType, MatchConfidence, SeriesStatus, VideoType from app.models.genre import SeriesGenre from app.models.keyword import SeriesKeyword -from app.models.tv import Series +from app.models.tv import Series, SeriesAlternateTitle from app.models.video import Video from app.providers.base import MetadataProvider, ProviderNotFoundError from app.repositories import polymorphic, tv_repository @@ -70,6 +70,24 @@ async def _replace_series_keywords( session.add(SeriesKeyword(series_id=series_id, keyword_id=keyword.id)) +async def _replace_series_alternate_titles( + session: AsyncSession, series_id: uuid.UUID, alt_titles +) -> None: + await session.execute( + delete(SeriesAlternateTitle).where(SeriesAlternateTitle.series_id == series_id) + ) + for alt in alt_titles: + country_code = None + if alt.country_code: + country = await refs.get_or_create_country(session, alt.country_code, alt.country_code) + country_code = country.code + session.add( + SeriesAlternateTitle( + series_id=series_id, title=alt.title, country_code=country_code, type=alt.type + ) + ) + + async def _replace_series_companies( session: AsyncSession, series_id: uuid.UUID, @@ -230,6 +248,7 @@ async def ingest_series( await _replace_series_genres(session, series.id, normalized.genres) await _replace_series_keywords(session, series.id, normalized.keywords) + await _replace_series_alternate_titles(session, series.id, normalized.alternate_titles) await _replace_series_companies( session, series.id, tmdb_provider.id, normalized.networks, normalized.companies ) diff --git a/app/services/keyword_service.py b/app/services/keyword_service.py new file mode 100644 index 0000000..13cf190 --- /dev/null +++ b/app/services/keyword_service.py @@ -0,0 +1,26 @@ +"""Assembles Keyword API responses from repository data. + +Mirrors genre_service.py exactly: keywords have no detail page of their own +-- they only appear embedded in movie/series responses and as entries in +this directory listing, which exists so a client can resolve the Aurora +keyword ids that GET /movies and GET /series accept as a filter.""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.ids import aurora_id +from app.repositories import keyword_repository +from app.schemas.common import KeywordListOut, KeywordOut + + +async def list_keywords(session: AsyncSession, *, page: int, page_size: int) -> KeywordListOut: + keywords, total = await keyword_repository.list_keywords( + session, limit=page_size, offset=(page - 1) * page_size + ) + results = [KeywordOut(id=aurora_id("keyword", str(k.id)), name=k.name) for k in keywords] + total_pages = (total + page_size - 1) // page_size if total else 0 + + return KeywordListOut( + page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results + ) diff --git a/app/services/metadata/normalizer.py b/app/services/metadata/normalizer.py index 9f8cdc6..c4d9745 100644 --- a/app/services/metadata/normalizer.py +++ b/app/services/metadata/normalizer.py @@ -96,6 +96,23 @@ def company_logo_artwork(logo_path: str | None) -> NormalizedArtwork | None: ) +@dataclass +class NormalizedAlternateTitle: + title: str + country_code: str | None + type: str | None = None + + +def _alternate_titles(entries: list[dict]) -> list[NormalizedAlternateTitle]: + return [ + NormalizedAlternateTitle( + title=t["title"], country_code=t.get("iso_3166_1") or None, type=t.get("type") or None + ) + for t in entries + if t.get("title") + ] + + # TMDb's per-country rating body isn't returned alongside a release_dates # certification or a content_ratings rating -- only the country and the # rating string itself are. This maps the handful of markets Aurora can @@ -216,7 +233,7 @@ class NormalizedMovie: adult: bool genres: list[str] = field(default_factory=list) keywords: list[str] = field(default_factory=list) - alternate_titles: list[str] = field(default_factory=list) + alternate_titles: list[NormalizedAlternateTitle] = field(default_factory=list) companies: list[NormalizedCompanyRef] = field(default_factory=list) cast: list[NormalizedPersonRef] = field(default_factory=list) crew: list[NormalizedPersonRef] = field(default_factory=list) @@ -419,7 +436,7 @@ def normalize_movie(raw: dict) -> NormalizedMovie: adult=bool(raw.get("adult", False)), genres=[g["name"] for g in raw.get("genres", [])], keywords=[k["name"] for k in keywords], - alternate_titles=[t["title"] for t in alt_titles if t.get("title")], + alternate_titles=_alternate_titles(alt_titles), companies=companies, cast=cast, crew=crew, @@ -567,7 +584,7 @@ class NormalizedSeries: vote_count: int | None genres: list[str] = field(default_factory=list) keywords: list[str] = field(default_factory=list) - alternate_titles: list[str] = field(default_factory=list) + alternate_titles: list[NormalizedAlternateTitle] = field(default_factory=list) networks: list[NormalizedCompanyRef] = field(default_factory=list) companies: list[NormalizedCompanyRef] = field(default_factory=list) creators: list[NormalizedPersonRef] = field(default_factory=list) @@ -748,7 +765,7 @@ def normalize_series(raw: dict) -> NormalizedSeries: vote_count=raw.get("vote_count"), genres=[g["name"] for g in raw.get("genres", [])], keywords=[k["name"] for k in keywords], - alternate_titles=[t["title"] for t in alt_titles if t.get("title")], + alternate_titles=_alternate_titles(alt_titles), networks=networks, companies=companies, creators=creators, diff --git a/app/services/movie_service.py b/app/services/movie_service.py index 5eabd02..9e010b2 100644 --- a/app/services/movie_service.py +++ b/app/services/movie_service.py @@ -24,6 +24,7 @@ from app.services.common import ( pick_certification, primary_artwork_path, split_credits, + to_alternate_title_out, to_images_out, to_release_date_out, to_video_out, @@ -53,6 +54,7 @@ async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> Movie 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) @@ -104,6 +106,7 @@ async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> Movie 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, @@ -193,6 +196,7 @@ 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, @@ -207,6 +211,13 @@ async def list_movies( 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: @@ -217,6 +228,7 @@ async def list_movies( 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, diff --git a/app/services/tv_service.py b/app/services/tv_service.py index d24e428..c0d4cff 100644 --- a/app/services/tv_service.py +++ b/app/services/tv_service.py @@ -29,6 +29,7 @@ from app.services.common import ( pick_certification, primary_artwork_path, split_credits, + to_alternate_title_out, to_images_out, to_release_date_out, to_video_out, @@ -98,6 +99,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser genres = await tv_repository.get_series_genres(session, series_id) keywords = await tv_repository.get_series_keywords(session, series_id) + alternate_titles = await tv_repository.get_series_alternate_titles(session, series_id) networks = await tv_repository.get_series_companies(session, series_id, CompanyType.NETWORK) companies = await tv_repository.get_series_companies( session, series_id, CompanyType.PRODUCTION_COMPANY @@ -133,6 +135,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser 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], creators=creators, cast=cast, crew=crew, @@ -300,6 +303,7 @@ async def list_series( session: AsyncSession, *, genre_aurora_id: str | None, + keyword_aurora_id: str | None, year: int | None, status: str | None, sort_by: str, @@ -314,6 +318,13 @@ async def list_series( except ValueError as exc: raise InvalidSeriesFilterError(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 InvalidSeriesFilterError(f"invalid keyword id: {keyword_aurora_id!r}") from exc + status_enum: SeriesStatus | None = None if status is not None: try: @@ -324,6 +335,7 @@ async def list_series( series_rows, total = await tv_repository.list_series( session, genre_id=genre_id, + keyword_id=keyword_id, year=year, status=status_enum, sort_by=sort_by, diff --git a/docs/api.md b/docs/api.md index 35676a9..aae9775 100644 --- a/docs/api.md +++ b/docs/api.md @@ -52,12 +52,13 @@ GET /api/v1/search?q=the%20matrix `GET /api/v1/movies` browses/discovers without a search query — the complement to `/search` (section 17, additive beyond the spec's minimum -endpoint list). Query params: `genre` (an Aurora genre id), `year` -(primary release year), `status` (e.g. `released`), `sort_by` -(`popularity` default | `release_date` | `vote_average` | `title`), -`sort_dir` (`desc` default | `asc`), `page` (default 1), `page_size` -(default 20, max 50). An invalid filter value (a malformed genre id, an -unknown status) is a 422, not a silently-empty result. Response: +endpoint list). Query params: `genre` (an Aurora genre id), `keyword` (an +Aurora keyword id), `year` (primary release year), `status` (e.g. +`released`), `sort_by` (`popularity` default | `release_date` | +`vote_average` | `title`), `sort_dir` (`desc` default | `asc`), `page` +(default 1), `page_size` (default 20, max 50). An invalid filter value (a +malformed genre/keyword id, an unknown status) is a 422, not a +silently-empty result. Response: ```json { @@ -91,6 +92,7 @@ unknown status) is a 422, not a silently-empty result. Response: "adult": false, "genres": [{ "id": "aur_genre_...", "name": "Action" }], "keywords": [{ "id": "aur_keyword_...", "name": "dystopia" }], + "alternate_titles": [{ "title": "Matrix", "country_code": "US", "type": null }], "cast": [{ "id": "aur_credit_...", "person": { "id": "aur_person_...", "name": "Keanu Reeves", "profile_path": null }, "character": "Neo", "billing_order": 0 }], "crew": [{ "id": "aur_credit_...", "person": { "id": "aur_person_...", "name": "Lana Wachowski" }, "department": "Directing", "job": "Director" }], "companies": [{ "id": "aur_company_...", "name": "Village Roadshow Pictures", "company_type": "production_company" }], @@ -109,6 +111,8 @@ unknown status) is a 422, not a silently-empty result. Response: `certification` is a convenience pick — the US (MPA) rating, TMDb's own default badge — out of the full `release_dates` list, which carries every country/type/rating TMDb reports (`release_type` one of `premiere` | `theatrical` | `digital` | `physical` | `tv`, collapsed from TMDb's six numeric codes). A rating body name (`system`, e.g. `"MPA"`/`"BBFC"`) is only known for a handful of major markets — everywhere else falls back to the country code itself rather than fabricating a body TMDb never named. Series detail carries the same two fields, sourced from TMDb's `content_ratings` instead — since that endpoint gives no date at all, each rating is anchored to the show's `first_air_date` with `release_type: "tv"`. +`alternate_titles` ("Also Known As") carries every translated/regional title TMDb tracks, each with the country it applies to (`country_code`, nullable — TMDb sometimes omits it) and an optional `type` (e.g. `"working title"`). Series detail carries the same field. + `GET /api/v1/movies/{aurora_id}/recommendations` returns TMDb's ranked recommendations, but only the ones already ingested into Aurora — a recommendation pointing at a movie Aurora hasn't seen yet is silently @@ -202,6 +206,19 @@ used elsewhere, since genres are a small, fixed reference set (TMDb has ~19-20 total) that a client typically wants in full for a filter dropdown, not paginated the way a catalog of millions of movies needs to be. +## Keywords + +| Method | Path | +|---|---| +| GET | `/api/v1/keywords` | + +A directory for the `keyword` filter `GET /movies` and `GET /series` accept, +same idea as `GET /genres` above. No filter, no `sort_by` — always +alphabetical by name. Unlike genres, keywords are not a small fixed set (a +full TMDb catalog has tens of thousands) — `page_size` still defaults to 50 +(max 200) for consistency with genres, but a client browsing keywords +should expect to paginate, not fetch the whole directory at once. + Collection detail's `movies` list only includes movies already ingested into Aurora — `POST /api/v1/admin/ingest/collection` links a franchise's member movies purely by external-id lookup, the same never-fabricate-a-stub diff --git a/tests/integration/test_alternate_titles.py b/tests/integration/test_alternate_titles.py new file mode 100644 index 0000000..35413ae --- /dev/null +++ b/tests/integration/test_alternate_titles.py @@ -0,0 +1,101 @@ +"""Integration tests for alternate/translated titles ("Also Known As"): +MovieAlternateTitle/SeriesAlternateTitle rows were ingested nowhere -- the +normalizer flattened TMDb's alternative_titles entries down to bare title +strings, discarding country_code/type, and nothing ever wrote them to the +DB or exposed them via the API. Mirrors test_release_dates.py's fake +provider pattern.""" + +from __future__ import annotations + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.ids import aurora_id +from app.models.movie import MovieAlternateTitle +from app.models.tv import SeriesAlternateTitle +from app.services import movie_service, tv_service +from app.services.ingestion.movie_ingestion import ingest_movie +from app.services.ingestion.series_ingestion import ingest_series +from tests.integration.test_release_dates import FakeTMDbProvider +from tests.unit.test_normalizer import FAKE_MOVIE, FAKE_SERIES + + +@pytest.mark.asyncio +async def test_ingest_movie_stores_alternate_titles(db_session: AsyncSession): + movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603") + await db_session.commit() + + rows = ( + ( + await db_session.execute( + select(MovieAlternateTitle).where(MovieAlternateTitle.movie_id == movie_id) + ) + ) + .scalars() + .all() + ) + assert len(rows) == 1 + assert rows[0].title == "Matrix" + assert rows[0].country_code == "US" + + +@pytest.mark.asyncio +async def test_ingest_movie_reingestion_replaces_alternate_titles(db_session: AsyncSession): + provider = FakeTMDbProvider(movie_payload=FAKE_MOVIE) + movie_id = await ingest_movie(db_session, provider, "603") + await db_session.commit() + await ingest_movie(db_session, provider, "603") + await db_session.commit() + + rows = ( + ( + await db_session.execute( + select(MovieAlternateTitle).where(MovieAlternateTitle.movie_id == movie_id) + ) + ) + .scalars() + .all() + ) + assert len(rows) == 1 + + +@pytest.mark.asyncio +async def test_movie_detail_surfaces_alternate_titles(db_session: AsyncSession): + movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603") + await db_session.commit() + + detail = await movie_service.get_movie_detail(db_session, aurora_id("movie", str(movie_id))) + + assert len(detail.alternate_titles) == 1 + assert detail.alternate_titles[0].title == "Matrix" + assert detail.alternate_titles[0].country_code == "US" + + +@pytest.mark.asyncio +async def test_ingest_series_stores_alternate_titles(db_session: AsyncSession): + series_id = await ingest_series(db_session, FakeTMDbProvider(series_payload=FAKE_SERIES), "1396") + await db_session.commit() + + rows = ( + ( + await db_session.execute( + select(SeriesAlternateTitle).where(SeriesAlternateTitle.series_id == series_id) + ) + ) + .scalars() + .all() + ) + assert len(rows) == 1 + assert rows[0].title == "Breaking Bad" + + +@pytest.mark.asyncio +async def test_series_detail_surfaces_alternate_titles(db_session: AsyncSession): + series_id = await ingest_series(db_session, FakeTMDbProvider(series_payload=FAKE_SERIES), "1396") + await db_session.commit() + + detail = await tv_service.get_series_detail(db_session, aurora_id("series", str(series_id))) + + assert len(detail.alternate_titles) == 1 + assert detail.alternate_titles[0].title == "Breaking Bad" diff --git a/tests/integration/test_keyword_browse.py b/tests/integration/test_keyword_browse.py new file mode 100644 index 0000000..6e03674 --- /dev/null +++ b/tests/integration/test_keyword_browse.py @@ -0,0 +1,60 @@ +"""Integration tests for GET /api/v1/keywords (section 17, additive): a +directory for the `keyword` filter GET /movies and GET /series accept, +with no prior way to discover what those ids are. Mirrors +test_genre_browse.py exactly -- keywords have no categorical field to +filter by and no popularity/date field to sort by, so there's no filter +case and always alphabetical-by-name ordering to test.""" + +from __future__ import annotations + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.repositories.reference_repository import get_or_create_keyword +from app.services import keyword_service + +_KEYWORDS = ["zombie", "alien invasion", "mind bending", "based on novel"] + + +async def _seed_keywords(db_session: AsyncSession) -> None: + for name in _KEYWORDS: + await get_or_create_keyword(db_session, name) + await db_session.commit() + + +@pytest.mark.asyncio +async def test_list_keywords_defaults_to_alphabetical_by_name(db_session: AsyncSession): + await _seed_keywords(db_session) + + out = await keyword_service.list_keywords(db_session, page=1, page_size=50) + + assert out.total_results == 4 + assert out.total_pages == 1 + assert [k.name for k in out.results] == [ + "alien invasion", + "based on novel", + "mind bending", + "zombie", + ] + + +@pytest.mark.asyncio +async def test_list_keywords_paginates(db_session: AsyncSession): + await _seed_keywords(db_session) + + page1 = await keyword_service.list_keywords(db_session, page=1, page_size=2) + page2 = await keyword_service.list_keywords(db_session, page=2, page_size=2) + + assert page1.total_results == 4 + assert page1.total_pages == 2 + assert [k.name for k in page1.results] == ["alien invasion", "based on novel"] + assert [k.name for k in page2.results] == ["mind bending", "zombie"] + + +@pytest.mark.asyncio +async def test_list_keywords_returns_empty_page_when_none_exist(db_session: AsyncSession): + out = await keyword_service.list_keywords(db_session, page=1, page_size=50) + + assert out.results == [] + assert out.total_results == 0 + assert out.total_pages == 0 diff --git a/tests/integration/test_movie_browse.py b/tests/integration/test_movie_browse.py index 2e426b6..90f8987 100644 --- a/tests/integration/test_movie_browse.py +++ b/tests/integration/test_movie_browse.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.ids import aurora_id from app.models.genre import Genre +from app.models.keyword import Keyword from app.services import movie_service from app.services.ingestion.movie_ingestion import ingest_movie from tests.integration.test_movie_ingestion import FakeTMDbProvider @@ -41,6 +42,7 @@ async def _seed_movies(db_session: AsyncSession) -> None: popularity=90.0, vote_average=8.0, release_date="2021-01-01", + keywords={"keywords": [{"id": 1, "name": "heist"}]}, ), _movie_payload( id=9003, @@ -70,6 +72,13 @@ async def _genre_aurora_id(db_session: AsyncSession, name: str) -> str: return aurora_id("genre", str(genre.id)) +async def _keyword_aurora_id(db_session: AsyncSession, name: str) -> str: + keyword = ( + await db_session.execute(select(Keyword).where(Keyword.name == name)) + ).scalar_one() + return aurora_id("keyword", str(keyword.id)) + + @pytest.mark.asyncio async def test_list_movies_defaults_to_popularity_descending(db_session: AsyncSession): await _seed_movies(db_session) @@ -77,6 +86,7 @@ async def test_list_movies_defaults_to_popularity_descending(db_session: AsyncSe out = await movie_service.list_movies( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -103,6 +113,7 @@ async def test_list_movies_filters_by_genre(db_session: AsyncSession): out = await movie_service.list_movies( db_session, genre_aurora_id=action_id, + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -119,6 +130,42 @@ async def test_list_movies_filters_by_genre(db_session: AsyncSession): } +@pytest.mark.asyncio +async def test_list_movies_filters_by_keyword(db_session: AsyncSession): + await _seed_movies(db_session) + heist_id = await _keyword_aurora_id(db_session, "heist") + + out = await movie_service.list_movies( + db_session, + genre_aurora_id=None, + keyword_aurora_id=heist_id, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert [m.title for m in out.results] == ["B Action Movie"] + + +@pytest.mark.asyncio +async def test_list_movies_rejects_malformed_keyword_filter(db_session: AsyncSession): + with pytest.raises(movie_service.InvalidMovieFilterError): + await movie_service.list_movies( + db_session, + genre_aurora_id=None, + keyword_aurora_id="not-an-aurora-id", + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + @pytest.mark.asyncio async def test_list_movies_filters_by_year(db_session: AsyncSession): await _seed_movies(db_session) @@ -126,6 +173,7 @@ async def test_list_movies_filters_by_year(db_session: AsyncSession): out = await movie_service.list_movies( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=2020, status=None, sort_by="popularity", @@ -144,6 +192,7 @@ async def test_list_movies_filters_by_status(db_session: AsyncSession): out = await movie_service.list_movies( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status="planned", sort_by="popularity", @@ -162,6 +211,7 @@ async def test_list_movies_sorts_by_title_ascending(db_session: AsyncSession): out = await movie_service.list_movies( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status=None, sort_by="title", @@ -185,6 +235,7 @@ async def test_list_movies_paginates(db_session: AsyncSession): page1 = await movie_service.list_movies( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -195,6 +246,7 @@ async def test_list_movies_paginates(db_session: AsyncSession): page2 = await movie_service.list_movies( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -215,6 +267,7 @@ async def test_list_movies_rejects_malformed_genre_filter(db_session: AsyncSessi await movie_service.list_movies( db_session, genre_aurora_id="not-an-aurora-id", + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -230,6 +283,7 @@ async def test_list_movies_rejects_unknown_status_filter(db_session: AsyncSessio await movie_service.list_movies( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status="not_a_real_status", sort_by="popularity", @@ -246,6 +300,7 @@ async def test_list_movies_returns_empty_page_when_nothing_matches(db_session: A out = await movie_service.list_movies( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=1899, status=None, sort_by="popularity", diff --git a/tests/integration/test_series_browse.py b/tests/integration/test_series_browse.py index e54833e..089e05b 100644 --- a/tests/integration/test_series_browse.py +++ b/tests/integration/test_series_browse.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.ids import aurora_id from app.models.genre import Genre +from app.models.keyword import Keyword from app.services import tv_service from app.services.ingestion.series_ingestion import ingest_series from tests.integration.test_series_ingestion import FakeTMDbSeriesProvider @@ -41,6 +42,7 @@ async def _seed_series(db_session: AsyncSession) -> None: popularity=90.0, vote_average=8.0, first_air_date="2021-01-01", + keywords={"results": [{"id": 1, "name": "anthology"}]}, ), _series_payload( id=9103, @@ -72,6 +74,13 @@ async def _genre_aurora_id(db_session: AsyncSession, name: str) -> str: return aurora_id("genre", str(genre.id)) +async def _keyword_aurora_id(db_session: AsyncSession, name: str) -> str: + keyword = ( + await db_session.execute(select(Keyword).where(Keyword.name == name)) + ).scalar_one() + return aurora_id("keyword", str(keyword.id)) + + @pytest.mark.asyncio async def test_list_series_defaults_to_popularity_descending(db_session: AsyncSession): await _seed_series(db_session) @@ -79,6 +88,7 @@ async def test_list_series_defaults_to_popularity_descending(db_session: AsyncSe out = await tv_service.list_series( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -105,6 +115,7 @@ async def test_list_series_filters_by_genre(db_session: AsyncSession): out = await tv_service.list_series( db_session, genre_aurora_id=drama_id, + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -117,6 +128,42 @@ async def test_list_series_filters_by_genre(db_session: AsyncSession): assert {s.title for s in out.results} == {"B Drama Show", "D Planned Show", "A Drama Show"} +@pytest.mark.asyncio +async def test_list_series_filters_by_keyword(db_session: AsyncSession): + await _seed_series(db_session) + anthology_id = await _keyword_aurora_id(db_session, "anthology") + + out = await tv_service.list_series( + db_session, + genre_aurora_id=None, + keyword_aurora_id=anthology_id, + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + assert [s.title for s in out.results] == ["B Drama Show"] + + +@pytest.mark.asyncio +async def test_list_series_rejects_malformed_keyword_filter(db_session: AsyncSession): + with pytest.raises(tv_service.InvalidSeriesFilterError): + await tv_service.list_series( + db_session, + genre_aurora_id=None, + keyword_aurora_id="not-an-aurora-id", + year=None, + status=None, + sort_by="popularity", + sort_dir="desc", + page=1, + page_size=20, + ) + + @pytest.mark.asyncio async def test_list_series_filters_by_year(db_session: AsyncSession): await _seed_series(db_session) @@ -124,6 +171,7 @@ async def test_list_series_filters_by_year(db_session: AsyncSession): out = await tv_service.list_series( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=2020, status=None, sort_by="popularity", @@ -142,6 +190,7 @@ async def test_list_series_filters_by_status(db_session: AsyncSession): out = await tv_service.list_series( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status="planned", sort_by="popularity", @@ -160,6 +209,7 @@ async def test_list_series_paginates(db_session: AsyncSession): page1 = await tv_service.list_series( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -170,6 +220,7 @@ async def test_list_series_paginates(db_session: AsyncSession): page2 = await tv_service.list_series( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -190,6 +241,7 @@ async def test_list_series_rejects_malformed_genre_filter(db_session: AsyncSessi await tv_service.list_series( db_session, genre_aurora_id="not-an-aurora-id", + keyword_aurora_id=None, year=None, status=None, sort_by="popularity", @@ -205,6 +257,7 @@ async def test_list_series_rejects_unknown_status_filter(db_session: AsyncSessio await tv_service.list_series( db_session, genre_aurora_id=None, + keyword_aurora_id=None, year=None, status="not_a_real_status", sort_by="popularity", diff --git a/tests/unit/test_normalizer.py b/tests/unit/test_normalizer.py index c832281..52bd5a7 100644 --- a/tests/unit/test_normalizer.py +++ b/tests/unit/test_normalizer.py @@ -156,7 +156,9 @@ def test_normalize_movie_maps_genres_and_keywords(): movie = normalize_movie(FAKE_MOVIE) assert movie.genres == ["Action", "Science Fiction"] assert movie.keywords == ["dystopia"] - assert movie.alternate_titles == ["Matrix"] + assert len(movie.alternate_titles) == 1 + assert movie.alternate_titles[0].title == "Matrix" + assert movie.alternate_titles[0].country_code == "US" def test_normalize_movie_maps_cast_and_crew(): @@ -225,6 +227,21 @@ def test_normalize_movie_maps_collection_and_recommendations(): assert movie.recommendation_external_ids == ["604"] +def test_normalize_movie_alternate_titles_capture_type_and_skip_titleless_entries(): + with_type = { + **FAKE_MOVIE, + "alternative_titles": { + "titles": [ + {"title": "Matrix", "iso_3166_1": "US", "type": "working title"}, + {"iso_3166_1": "FR"}, # no "title" key at all -- must be skipped + ] + }, + } + movie = normalize_movie(with_type) + assert len(movie.alternate_titles) == 1 + assert movie.alternate_titles[0].type == "working title" + + def test_normalize_movie_maps_release_dates_and_certification(): movie = normalize_movie(FAKE_MOVIE) assert len(movie.release_dates) == 4 @@ -431,7 +448,9 @@ def test_normalize_series_keywords_and_alt_titles_use_tv_shaped_keys(): # for /movie — a normalizer bug here would silently return empty lists. series = normalize_series(FAKE_SERIES) assert series.keywords == ["drug dealer"] - assert series.alternate_titles == ["Breaking Bad"] + assert len(series.alternate_titles) == 1 + assert series.alternate_titles[0].title == "Breaking Bad" + assert series.alternate_titles[0].country_code == "US" def test_normalize_series_keeps_season_zero_specials():