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
+101
View File
@@ -0,0 +1,101 @@
"""Integration tests for alternate/translated titles ("Also Known As"):
MovieAlternateTitle/SeriesAlternateTitle rows were ingested nowhere -- the
normalizer flattened TMDb's alternative_titles entries down to bare title
strings, discarding country_code/type, and nothing ever wrote them to the
DB or exposed them via the API. Mirrors test_release_dates.py's fake
provider pattern."""
from __future__ import annotations
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id
from app.models.movie import MovieAlternateTitle
from app.models.tv import SeriesAlternateTitle
from app.services import movie_service, tv_service
from app.services.ingestion.movie_ingestion import ingest_movie
from app.services.ingestion.series_ingestion import ingest_series
from tests.integration.test_release_dates import FakeTMDbProvider
from tests.unit.test_normalizer import FAKE_MOVIE, FAKE_SERIES
@pytest.mark.asyncio
async def test_ingest_movie_stores_alternate_titles(db_session: AsyncSession):
movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603")
await db_session.commit()
rows = (
(
await db_session.execute(
select(MovieAlternateTitle).where(MovieAlternateTitle.movie_id == movie_id)
)
)
.scalars()
.all()
)
assert len(rows) == 1
assert rows[0].title == "Matrix"
assert rows[0].country_code == "US"
@pytest.mark.asyncio
async def test_ingest_movie_reingestion_replaces_alternate_titles(db_session: AsyncSession):
provider = FakeTMDbProvider(movie_payload=FAKE_MOVIE)
movie_id = await ingest_movie(db_session, provider, "603")
await db_session.commit()
await ingest_movie(db_session, provider, "603")
await db_session.commit()
rows = (
(
await db_session.execute(
select(MovieAlternateTitle).where(MovieAlternateTitle.movie_id == movie_id)
)
)
.scalars()
.all()
)
assert len(rows) == 1
@pytest.mark.asyncio
async def test_movie_detail_surfaces_alternate_titles(db_session: AsyncSession):
movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603")
await db_session.commit()
detail = await movie_service.get_movie_detail(db_session, aurora_id("movie", str(movie_id)))
assert len(detail.alternate_titles) == 1
assert detail.alternate_titles[0].title == "Matrix"
assert detail.alternate_titles[0].country_code == "US"
@pytest.mark.asyncio
async def test_ingest_series_stores_alternate_titles(db_session: AsyncSession):
series_id = await ingest_series(db_session, FakeTMDbProvider(series_payload=FAKE_SERIES), "1396")
await db_session.commit()
rows = (
(
await db_session.execute(
select(SeriesAlternateTitle).where(SeriesAlternateTitle.series_id == series_id)
)
)
.scalars()
.all()
)
assert len(rows) == 1
assert rows[0].title == "Breaking Bad"
@pytest.mark.asyncio
async def test_series_detail_surfaces_alternate_titles(db_session: AsyncSession):
series_id = await ingest_series(db_session, FakeTMDbProvider(series_payload=FAKE_SERIES), "1396")
await db_session.commit()
detail = await tv_service.get_series_detail(db_session, aurora_id("series", str(series_id)))
assert len(detail.alternate_titles) == 1
assert detail.alternate_titles[0].title == "Breaking Bad"
+60
View File
@@ -0,0 +1,60 @@
"""Integration tests for GET /api/v1/keywords (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
test_genre_browse.py exactly -- keywords have no categorical field to
filter by and no popularity/date field to sort by, so there's no filter
case and always alphabetical-by-name ordering to test."""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.reference_repository import get_or_create_keyword
from app.services import keyword_service
_KEYWORDS = ["zombie", "alien invasion", "mind bending", "based on novel"]
async def _seed_keywords(db_session: AsyncSession) -> None:
for name in _KEYWORDS:
await get_or_create_keyword(db_session, name)
await db_session.commit()
@pytest.mark.asyncio
async def test_list_keywords_defaults_to_alphabetical_by_name(db_session: AsyncSession):
await _seed_keywords(db_session)
out = await keyword_service.list_keywords(db_session, page=1, page_size=50)
assert out.total_results == 4
assert out.total_pages == 1
assert [k.name for k in out.results] == [
"alien invasion",
"based on novel",
"mind bending",
"zombie",
]
@pytest.mark.asyncio
async def test_list_keywords_paginates(db_session: AsyncSession):
await _seed_keywords(db_session)
page1 = await keyword_service.list_keywords(db_session, page=1, page_size=2)
page2 = await keyword_service.list_keywords(db_session, page=2, page_size=2)
assert page1.total_results == 4
assert page1.total_pages == 2
assert [k.name for k in page1.results] == ["alien invasion", "based on novel"]
assert [k.name for k in page2.results] == ["mind bending", "zombie"]
@pytest.mark.asyncio
async def test_list_keywords_returns_empty_page_when_none_exist(db_session: AsyncSession):
out = await keyword_service.list_keywords(db_session, page=1, page_size=50)
assert out.results == []
assert out.total_results == 0
assert out.total_pages == 0
+55
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 movie_service
from app.services.ingestion.movie_ingestion import ingest_movie
from tests.integration.test_movie_ingestion import FakeTMDbProvider
@@ -41,6 +42,7 @@ async def _seed_movies(db_session: AsyncSession) -> None:
popularity=90.0,
vote_average=8.0,
release_date="2021-01-01",
keywords={"keywords": [{"id": 1, "name": "heist"}]},
),
_movie_payload(
id=9003,
@@ -70,6 +72,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_movies_defaults_to_popularity_descending(db_session: AsyncSession):
await _seed_movies(db_session)
@@ -77,6 +86,7 @@ async def test_list_movies_defaults_to_popularity_descending(db_session: AsyncSe
out = await movie_service.list_movies(
db_session,
genre_aurora_id=None,
keyword_aurora_id=None,
year=None,
status=None,
sort_by="popularity",
@@ -103,6 +113,7 @@ async def test_list_movies_filters_by_genre(db_session: AsyncSession):
out = await movie_service.list_movies(
db_session,
genre_aurora_id=action_id,
keyword_aurora_id=None,
year=None,
status=None,
sort_by="popularity",
@@ -119,6 +130,42 @@ async def test_list_movies_filters_by_genre(db_session: AsyncSession):
}
@pytest.mark.asyncio
async def test_list_movies_filters_by_keyword(db_session: AsyncSession):
await _seed_movies(db_session)
heist_id = await _keyword_aurora_id(db_session, "heist")
out = await movie_service.list_movies(
db_session,
genre_aurora_id=None,
keyword_aurora_id=heist_id,
year=None,
status=None,
sort_by="popularity",
sort_dir="desc",
page=1,
page_size=20,
)
assert [m.title for m in out.results] == ["B Action Movie"]
@pytest.mark.asyncio
async def test_list_movies_rejects_malformed_keyword_filter(db_session: AsyncSession):
with pytest.raises(movie_service.InvalidMovieFilterError):
await movie_service.list_movies(
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_movies_filters_by_year(db_session: AsyncSession):
await _seed_movies(db_session)
@@ -126,6 +173,7 @@ async def test_list_movies_filters_by_year(db_session: AsyncSession):
out = await movie_service.list_movies(
db_session,
genre_aurora_id=None,
keyword_aurora_id=None,
year=2020,
status=None,
sort_by="popularity",
@@ -144,6 +192,7 @@ async def test_list_movies_filters_by_status(db_session: AsyncSession):
out = await movie_service.list_movies(
db_session,
genre_aurora_id=None,
keyword_aurora_id=None,
year=None,
status="planned",
sort_by="popularity",
@@ -162,6 +211,7 @@ async def test_list_movies_sorts_by_title_ascending(db_session: AsyncSession):
out = await movie_service.list_movies(
db_session,
genre_aurora_id=None,
keyword_aurora_id=None,
year=None,
status=None,
sort_by="title",
@@ -185,6 +235,7 @@ async def test_list_movies_paginates(db_session: AsyncSession):
page1 = await movie_service.list_movies(
db_session,
genre_aurora_id=None,
keyword_aurora_id=None,
year=None,
status=None,
sort_by="popularity",
@@ -195,6 +246,7 @@ async def test_list_movies_paginates(db_session: AsyncSession):
page2 = await movie_service.list_movies(
db_session,
genre_aurora_id=None,
keyword_aurora_id=None,
year=None,
status=None,
sort_by="popularity",
@@ -215,6 +267,7 @@ async def test_list_movies_rejects_malformed_genre_filter(db_session: AsyncSessi
await movie_service.list_movies(
db_session,
genre_aurora_id="not-an-aurora-id",
keyword_aurora_id=None,
year=None,
status=None,
sort_by="popularity",
@@ -230,6 +283,7 @@ async def test_list_movies_rejects_unknown_status_filter(db_session: AsyncSessio
await movie_service.list_movies(
db_session,
genre_aurora_id=None,
keyword_aurora_id=None,
year=None,
status="not_a_real_status",
sort_by="popularity",
@@ -246,6 +300,7 @@ async def test_list_movies_returns_empty_page_when_nothing_matches(db_session: A
out = await movie_service.list_movies(
db_session,
genre_aurora_id=None,
keyword_aurora_id=None,
year=1899,
status=None,
sort_by="popularity",
+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",
+21 -2
View File
@@ -156,7 +156,9 @@ def test_normalize_movie_maps_genres_and_keywords():
movie = normalize_movie(FAKE_MOVIE)
assert movie.genres == ["Action", "Science Fiction"]
assert movie.keywords == ["dystopia"]
assert movie.alternate_titles == ["Matrix"]
assert len(movie.alternate_titles) == 1
assert movie.alternate_titles[0].title == "Matrix"
assert movie.alternate_titles[0].country_code == "US"
def test_normalize_movie_maps_cast_and_crew():
@@ -225,6 +227,21 @@ def test_normalize_movie_maps_collection_and_recommendations():
assert movie.recommendation_external_ids == ["604"]
def test_normalize_movie_alternate_titles_capture_type_and_skip_titleless_entries():
with_type = {
**FAKE_MOVIE,
"alternative_titles": {
"titles": [
{"title": "Matrix", "iso_3166_1": "US", "type": "working title"},
{"iso_3166_1": "FR"}, # no "title" key at all -- must be skipped
]
},
}
movie = normalize_movie(with_type)
assert len(movie.alternate_titles) == 1
assert movie.alternate_titles[0].type == "working title"
def test_normalize_movie_maps_release_dates_and_certification():
movie = normalize_movie(FAKE_MOVIE)
assert len(movie.release_dates) == 4
@@ -431,7 +448,9 @@ def test_normalize_series_keywords_and_alt_titles_use_tv_shaped_keys():
# for /movie — a normalizer bug here would silently return empty lists.
series = normalize_series(FAKE_SERIES)
assert series.keywords == ["drug dealer"]
assert series.alternate_titles == ["Breaking Bad"]
assert len(series.alternate_titles) == 1
assert series.alternate_titles[0].title == "Breaking Bad"
assert series.alternate_titles[0].country_code == "US"
def test_normalize_series_keeps_season_zero_specials():