From 4af4ba40ef280a064967681cb589dc065a385815 Mon Sep 17 00:00:00 2001 From: NixReaper Date: Sat, 5 Sep 2026 08:16:18 -0500 Subject: [PATCH] Add GET /movies/{id}/recommendations Ingestion already stored movie_recommendations edges from TMDb's recommendations list (linking only to movies already ingested into Aurora, per the never-fabricate-a-stub rule), but nothing exposed them. Adds the repository lookup, service assembly, and route to read them back in ranked order. Co-Authored-By: Claude Sonnet 5 --- app/api/v1/movies.py | 18 +++++++++++- app/repositories/movie_repository.py | 19 +++++++++++- app/schemas/movie.py | 5 ++++ app/services/movie_service.py | 13 ++++++++ docs/api.md | 17 +++++++++++ tests/integration/test_movie_ingestion.py | 36 +++++++++++++++++++++++ 6 files changed, 106 insertions(+), 2 deletions(-) diff --git a/app/api/v1/movies.py b/app/api/v1/movies.py index ef4cf34..981f489 100644 --- a/app/api/v1/movies.py +++ b/app/api/v1/movies.py @@ -10,7 +10,13 @@ from app.api.deps import cached_response, not_found from app.core.database import get_db from app.core.redis import get_redis from app.core.security import enforce_rate_limit -from app.schemas.movie import MovieCreditsOut, MovieDetailOut, MovieImagesOut, MovieVideosOut +from app.schemas.movie import ( + MovieCreditsOut, + MovieDetailOut, + MovieImagesOut, + MovieRecommendationsOut, + MovieVideosOut, +) from app.services import movie_service from app.services.cache import build_key @@ -62,3 +68,13 @@ async def get_movie_videos( return await movie_service.get_movie_videos(session, aurora_id) except movie_service.MovieNotFoundError as exc: raise not_found("movie", aurora_id) from exc + + +@router.get("/{aurora_id}/recommendations", response_model=MovieRecommendationsOut) +async def get_movie_recommendations( + aurora_id: str, session: AsyncSession = Depends(get_db) +) -> MovieRecommendationsOut: + try: + return await movie_service.get_movie_recommendations(session, aurora_id) + except movie_service.MovieNotFoundError as exc: + raise not_found("movie", aurora_id) from exc diff --git a/app/repositories/movie_repository.py b/app/repositories/movie_repository.py index 4a2779f..772d810 100644 --- a/app/repositories/movie_repository.py +++ b/app/repositories/movie_repository.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload from app.models.company import Company, MovieCompany from app.models.genre import Genre, MovieGenre from app.models.keyword import Keyword, MovieKeyword -from app.models.movie import Movie +from app.models.movie import Movie, MovieRecommendation async def get_movie_by_id(session: AsyncSession, movie_id: uuid.UUID) -> Movie | None: @@ -59,3 +59,20 @@ async def get_movie_companies( ) result = await session.execute(stmt) return [(company, role) for company, role in result.all()] + + +async def get_movie_recommendation_ids( + session: AsyncSession, movie_id: uuid.UUID +) -> list[uuid.UUID]: + """Aurora ids of movies TMDb recommends alongside this one, ordered by + rank (section 15) -- only ever movies already ingested into Aurora, since + `ingest_movie` (movie_ingestion.py) links these edges purely by + external-id lookup and never creates a stub for an unseen movie.""" + + stmt = ( + select(MovieRecommendation.recommended_movie_id) + .where(MovieRecommendation.movie_id == movie_id) + .order_by(MovieRecommendation.rank.asc().nulls_last()) + ) + result = await session.execute(stmt) + return list(result.scalars().all()) diff --git a/app/schemas/movie.py b/app/schemas/movie.py index bac78b5..957c173 100644 --- a/app/schemas/movie.py +++ b/app/schemas/movie.py @@ -78,3 +78,8 @@ class MovieImagesOut(BaseModel): class MovieVideosOut(BaseModel): id: str videos: list[VideoOut] = [] + + +class MovieRecommendationsOut(BaseModel): + id: str + recommendations: list[MovieSummaryOut] = [] diff --git a/app/services/movie_service.py b/app/services/movie_service.py index 879c0aa..3ddd973 100644 --- a/app/services/movie_service.py +++ b/app/services/movie_service.py @@ -14,6 +14,7 @@ from app.schemas.movie import ( MovieCreditsOut, MovieDetailOut, MovieImagesOut, + MovieRecommendationsOut, MovieSummaryOut, MovieVideosOut, ) @@ -123,6 +124,18 @@ async def get_movie_videos(session: AsyncSession, movie_aurora_id: str) -> Movie return MovieVideosOut(id=aurora_id("movie", str(movie.id)), videos=[to_video_out(v) for v in videos]) +async def get_movie_recommendations( + session: AsyncSession, movie_aurora_id: str +) -> MovieRecommendationsOut: + movie_id = await _resolve_movie_id(movie_aurora_id) + movie = await movie_repository.get_movie_by_id(session, movie_id) + if movie is None: + raise MovieNotFoundError(movie_aurora_id) + recommended_ids = await movie_repository.get_movie_recommendation_ids(session, movie_id) + summaries = await build_movie_summaries(session, recommended_ids) + return MovieRecommendationsOut(id=aurora_id("movie", str(movie.id)), recommendations=summaries) + + async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> MovieSummaryOut | None: movie = await movie_repository.get_movie_by_id(session, movie_id) if movie is None: diff --git a/docs/api.md b/docs/api.md index dce8fc2..58c0730 100644 --- a/docs/api.md +++ b/docs/api.md @@ -47,6 +47,7 @@ GET /api/v1/search?q=the%20matrix | GET | `/api/v1/movies/{aurora_id}/credits` | | GET | `/api/v1/movies/{aurora_id}/images` | | GET | `/api/v1/movies/{aurora_id}/videos` | +| GET | `/api/v1/movies/{aurora_id}/recommendations` | `GET /api/v1/movies/{aurora_id}` (cached, see `docs/architecture.md#caching`): @@ -82,6 +83,22 @@ GET /api/v1/search?q=the%20matrix } ``` +`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 +dropped rather than fabricated as a stub (section 15), so this list can be +sparse (or empty) for a young dataset and fills in as more movies get +ingested: + +```json +{ + "id": "aur_movie_01m1q7p1c...", + "recommendations": [ + { "id": "aur_movie_...", "type": "movie", "title": "The Matrix Reloaded", "release_date": "2003-05-15", "poster_path": "https://..." } + ] +} +``` + ## Series / Seasons / Episodes | Method | Path | diff --git a/tests/integration/test_movie_ingestion.py b/tests/integration/test_movie_ingestion.py index 1b0e916..61c8059 100644 --- a/tests/integration/test_movie_ingestion.py +++ b/tests/integration/test_movie_ingestion.py @@ -5,6 +5,7 @@ against PostgreSQL.""" from __future__ import annotations +import uuid from typing import Any import pytest @@ -148,3 +149,38 @@ async def test_movie_detail_surfaces_company_logo_path(db_session: AsyncSession) roadshow = next(c for c in detail.companies if c.name == "Village Roadshow Pictures") assert roadshow.logo_path is not None assert roadshow.logo_path.endswith("/village-roadshow-logo.png") + + +@pytest.mark.asyncio +async def test_movie_recommendations_only_include_already_ingested_movies( + db_session: AsyncSession, +): + """TMDb's recommendations list can reference movies Aurora hasn't + ingested yet; ingest_movie must link only the ones it can resolve by + external id (section 15: never fabricate a stub for an unseen entity), + and GET .../recommendations must return them in TMDb's ranked order.""" + + sequel_payload = {**FAKE_MOVIE, "id": 604, "title": "The Matrix Reloaded"} + await ingest_movie(db_session, FakeTMDbProvider(sequel_payload), "604") + await db_session.flush() + + movie_with_recs = { + **FAKE_MOVIE, + "recommendations": {"results": [{"id": 604}, {"id": 999999}]}, + } + movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_with_recs), "603") + await db_session.commit() + + recs = await movie_service.get_movie_recommendations( + db_session, aurora_id("movie", str(movie_id)) + ) + + assert [r.title for r in recs.recommendations] == ["The Matrix Reloaded"] + + +@pytest.mark.asyncio +async def test_movie_recommendations_not_found_for_unknown_movie(db_session: AsyncSession): + with pytest.raises(movie_service.MovieNotFoundError): + await movie_service.get_movie_recommendations( + db_session, aurora_id("movie", str(uuid.uuid4())) + )