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