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,