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
@@ -0,0 +1,24 @@
|
||||
"""Keyword endpoints (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 app/api/v1/genres.py exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import enforce_rate_limit
|
||||
from app.schemas.common import KeywordListOut
|
||||
from app.services import keyword_service
|
||||
|
||||
router = APIRouter(prefix="/keywords", tags=["keywords"], dependencies=[Depends(enforce_rate_limit)])
|
||||
|
||||
|
||||
@router.get("", response_model=KeywordListOut)
|
||||
async def list_keywords(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> KeywordListOut:
|
||||
return await keyword_service.list_keywords(session, page=page, page_size=page_size)
|
||||
@@ -29,6 +29,7 @@ router = APIRouter(prefix="/movies", tags=["movies"], dependencies=[Depends(enfo
|
||||
@router.get("", response_model=MovieListOut)
|
||||
async def list_movies(
|
||||
genre: str | None = Query(None, description="Aurora genre id, e.g. aur_genre_..."),
|
||||
keyword: str | None = Query(None, description="Aurora keyword id, e.g. aur_keyword_..."),
|
||||
year: int | None = Query(None, description="Primary release year"),
|
||||
status_: str | None = Query(None, alias="status", description="Movie status, e.g. released"),
|
||||
sort_by: Literal["popularity", "release_date", "vote_average", "title"] = Query("popularity"),
|
||||
@@ -41,6 +42,7 @@ async def list_movies(
|
||||
return await movie_service.list_movies(
|
||||
session,
|
||||
genre_aurora_id=genre,
|
||||
keyword_aurora_id=keyword,
|
||||
year=year,
|
||||
status=status_,
|
||||
sort_by=sort_by,
|
||||
|
||||
@@ -29,6 +29,7 @@ router = APIRouter(prefix="/series", tags=["series"], dependencies=[Depends(enfo
|
||||
@router.get("", response_model=SeriesListOut)
|
||||
async def list_series(
|
||||
genre: str | None = Query(None, description="Aurora genre id, e.g. aur_genre_..."),
|
||||
keyword: str | None = Query(None, description="Aurora keyword id, e.g. aur_keyword_..."),
|
||||
year: int | None = Query(None, description="First air date year"),
|
||||
status_: str | None = Query(None, alias="status", description="Series status, e.g. ended"),
|
||||
sort_by: Literal["popularity", "first_air_date", "vote_average", "title"] = Query("popularity"),
|
||||
@@ -41,6 +42,7 @@ async def list_series(
|
||||
return await tv_service.list_series(
|
||||
session,
|
||||
genre_aurora_id=genre,
|
||||
keyword_aurora_id=keyword,
|
||||
year=year,
|
||||
status=status_,
|
||||
sort_by=sort_by,
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.api.v1 import (
|
||||
episodes,
|
||||
genres,
|
||||
health,
|
||||
keywords,
|
||||
movies,
|
||||
people,
|
||||
search,
|
||||
@@ -177,4 +178,5 @@ app.include_router(people.router, prefix=API_V1_PREFIX)
|
||||
app.include_router(collections.router, prefix=API_V1_PREFIX)
|
||||
app.include_router(companies.router, prefix=API_V1_PREFIX)
|
||||
app.include_router(genres.router, prefix=API_V1_PREFIX)
|
||||
app.include_router(keywords.router, prefix=API_V1_PREFIX)
|
||||
app.include_router(admin.router, prefix=API_V1_PREFIX)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -35,6 +35,14 @@ class KeywordOut(ORMModel):
|
||||
name: str
|
||||
|
||||
|
||||
class KeywordListOut(BaseModel):
|
||||
page: int
|
||||
page_size: int
|
||||
total_results: int
|
||||
total_pages: int
|
||||
results: list[KeywordOut] = []
|
||||
|
||||
|
||||
class ArtworkOut(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
@@ -112,6 +120,12 @@ class CollectionBriefOut(BaseModel):
|
||||
poster_path: str | None = None
|
||||
|
||||
|
||||
class AlternateTitleOut(BaseModel):
|
||||
title: str
|
||||
country_code: str | None = None
|
||||
type: str | None = None
|
||||
|
||||
|
||||
class CertificationOut(BaseModel):
|
||||
system: str
|
||||
rating: str
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import date, datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.common import (
|
||||
AlternateTitleOut,
|
||||
CastCreditOut,
|
||||
CollectionBriefOut,
|
||||
CompanyBriefOut,
|
||||
@@ -57,6 +58,7 @@ class MovieDetailOut(BaseModel):
|
||||
|
||||
genres: list[GenreOut] = []
|
||||
keywords: list[KeywordOut] = []
|
||||
alternate_titles: list[AlternateTitleOut] = []
|
||||
cast: list[CastCreditOut] = []
|
||||
crew: list[CrewCreditOut] = []
|
||||
companies: list[CompanyBriefOut] = []
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import date, datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.common import (
|
||||
AlternateTitleOut,
|
||||
CastCreditOut,
|
||||
CompanyBriefOut,
|
||||
CrewCreditOut,
|
||||
@@ -61,6 +62,7 @@ class SeriesDetailOut(BaseModel):
|
||||
|
||||
genres: list[GenreOut] = []
|
||||
keywords: list[KeywordOut] = []
|
||||
alternate_titles: list[AlternateTitleOut] = []
|
||||
creators: list[CrewCreditOut] = []
|
||||
cast: list[CastCreditOut] = []
|
||||
crew: list[CrewCreditOut] = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
+23
-6
@@ -52,12 +52,13 @@ GET /api/v1/search?q=the%20matrix
|
||||
|
||||
`GET /api/v1/movies` browses/discovers without a search query — the
|
||||
complement to `/search` (section 17, additive beyond the spec's minimum
|
||||
endpoint list). Query params: `genre` (an Aurora genre id), `year`
|
||||
(primary release year), `status` (e.g. `released`), `sort_by`
|
||||
(`popularity` default | `release_date` | `vote_average` | `title`),
|
||||
`sort_dir` (`desc` default | `asc`), `page` (default 1), `page_size`
|
||||
(default 20, max 50). An invalid filter value (a malformed genre id, an
|
||||
unknown status) is a 422, not a silently-empty result. Response:
|
||||
endpoint list). Query params: `genre` (an Aurora genre id), `keyword` (an
|
||||
Aurora keyword id), `year` (primary release year), `status` (e.g.
|
||||
`released`), `sort_by` (`popularity` default | `release_date` |
|
||||
`vote_average` | `title`), `sort_dir` (`desc` default | `asc`), `page`
|
||||
(default 1), `page_size` (default 20, max 50). An invalid filter value (a
|
||||
malformed genre/keyword id, an unknown status) is a 422, not a
|
||||
silently-empty result. Response:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -91,6 +92,7 @@ unknown status) is a 422, not a silently-empty result. Response:
|
||||
"adult": false,
|
||||
"genres": [{ "id": "aur_genre_...", "name": "Action" }],
|
||||
"keywords": [{ "id": "aur_keyword_...", "name": "dystopia" }],
|
||||
"alternate_titles": [{ "title": "Matrix", "country_code": "US", "type": null }],
|
||||
"cast": [{ "id": "aur_credit_...", "person": { "id": "aur_person_...", "name": "Keanu Reeves", "profile_path": null }, "character": "Neo", "billing_order": 0 }],
|
||||
"crew": [{ "id": "aur_credit_...", "person": { "id": "aur_person_...", "name": "Lana Wachowski" }, "department": "Directing", "job": "Director" }],
|
||||
"companies": [{ "id": "aur_company_...", "name": "Village Roadshow Pictures", "company_type": "production_company" }],
|
||||
@@ -109,6 +111,8 @@ unknown status) is a 422, not a silently-empty result. Response:
|
||||
|
||||
`certification` is a convenience pick — the US (MPA) rating, TMDb's own default badge — out of the full `release_dates` list, which carries every country/type/rating TMDb reports (`release_type` one of `premiere` | `theatrical` | `digital` | `physical` | `tv`, collapsed from TMDb's six numeric codes). A rating body name (`system`, e.g. `"MPA"`/`"BBFC"`) is only known for a handful of major markets — everywhere else falls back to the country code itself rather than fabricating a body TMDb never named. Series detail carries the same two fields, sourced from TMDb's `content_ratings` instead — since that endpoint gives no date at all, each rating is anchored to the show's `first_air_date` with `release_type: "tv"`.
|
||||
|
||||
`alternate_titles` ("Also Known As") carries every translated/regional title TMDb tracks, each with the country it applies to (`country_code`, nullable — TMDb sometimes omits it) and an optional `type` (e.g. `"working title"`). Series detail carries the same field.
|
||||
|
||||
`GET /api/v1/movies/{aurora_id}/recommendations` returns TMDb's ranked
|
||||
recommendations, but only the ones already ingested into Aurora — a
|
||||
recommendation pointing at a movie Aurora hasn't seen yet is silently
|
||||
@@ -202,6 +206,19 @@ used elsewhere, since genres are a small, fixed reference set (TMDb has
|
||||
~19-20 total) that a client typically wants in full for a filter dropdown,
|
||||
not paginated the way a catalog of millions of movies needs to be.
|
||||
|
||||
## Keywords
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| GET | `/api/v1/keywords` |
|
||||
|
||||
A directory for the `keyword` filter `GET /movies` and `GET /series` accept,
|
||||
same idea as `GET /genres` above. No filter, no `sort_by` — always
|
||||
alphabetical by name. Unlike genres, keywords are not a small fixed set (a
|
||||
full TMDb catalog has tens of thousands) — `page_size` still defaults to 50
|
||||
(max 200) for consistency with genres, but a client browsing keywords
|
||||
should expect to paginate, not fetch the whole directory at once.
|
||||
|
||||
Collection detail's `movies` list only includes movies already ingested
|
||||
into Aurora — `POST /api/v1/admin/ingest/collection` links a franchise's
|
||||
member movies purely by external-id lookup, the same never-fabricate-a-stub
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user