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
+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,