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