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
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:
co-authored by
Claude Sonnet 5
parent
e1a36ce227
commit
567b4ef068
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user