Add keyword browsing/filtering and expose alternate titles
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

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 <[email protected]>
This commit is contained in:
NixReaper
2026-09-06 04:40:32 -05:00
co-authored by Claude Sonnet 5
parent e1a36ce227
commit 567b4ef068
23 changed files with 548 additions and 17 deletions
+24
View File
@@ -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)
+2
View File
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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)
+31
View File
@@ -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
+18 -1
View File
@@ -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:
+20 -1
View File
@@ -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:
+14
View File
@@ -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
+2
View File
@@ -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] = []
+2
View File
@@ -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] = []
+7 -1
View File
@@ -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:
+20 -1
View File
@@ -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(
+20 -1
View File
@@ -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
)
+26
View File
@@ -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
)
+21 -4
View File
@@ -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,
+12
View File
@@ -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,
+12
View File
@@ -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,
+23 -6
View File
@@ -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
+101
View File
@@ -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"
+60
View File
@@ -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
+55
View File
@@ -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",
+53
View File
@@ -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",
+21 -2
View File
@@ -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():