Add GET /movies/{id}/recommendations
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

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 <[email protected]>
This commit is contained in:
NixReaper
2026-09-05 08:16:18 -05:00
co-authored by Claude Sonnet 5
parent 73488a0d80
commit 4af4ba40ef
6 changed files with 106 additions and 2 deletions
+17 -1
View File
@@ -10,7 +10,13 @@ from app.api.deps import cached_response, not_found
from app.core.database import get_db from app.core.database import get_db
from app.core.redis import get_redis from app.core.redis import get_redis
from app.core.security import enforce_rate_limit 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 import movie_service
from app.services.cache import build_key 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) return await movie_service.get_movie_videos(session, aurora_id)
except movie_service.MovieNotFoundError as exc: except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from 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
+18 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
from app.models.company import Company, MovieCompany from app.models.company import Company, MovieCompany
from app.models.genre import Genre, MovieGenre from app.models.genre import Genre, MovieGenre
from app.models.keyword import Keyword, MovieKeyword 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: 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) result = await session.execute(stmt)
return [(company, role) for company, role in result.all()] 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())
+5
View File
@@ -78,3 +78,8 @@ class MovieImagesOut(BaseModel):
class MovieVideosOut(BaseModel): class MovieVideosOut(BaseModel):
id: str id: str
videos: list[VideoOut] = [] videos: list[VideoOut] = []
class MovieRecommendationsOut(BaseModel):
id: str
recommendations: list[MovieSummaryOut] = []
+13
View File
@@ -14,6 +14,7 @@ from app.schemas.movie import (
MovieCreditsOut, MovieCreditsOut,
MovieDetailOut, MovieDetailOut,
MovieImagesOut, MovieImagesOut,
MovieRecommendationsOut,
MovieSummaryOut, MovieSummaryOut,
MovieVideosOut, 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]) 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: async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> MovieSummaryOut | None:
movie = await movie_repository.get_movie_by_id(session, movie_id) movie = await movie_repository.get_movie_by_id(session, movie_id)
if movie is None: if movie is None:
+17
View File
@@ -47,6 +47,7 @@ GET /api/v1/search?q=the%20matrix
| GET | `/api/v1/movies/{aurora_id}/credits` | | GET | `/api/v1/movies/{aurora_id}/credits` |
| GET | `/api/v1/movies/{aurora_id}/images` | | GET | `/api/v1/movies/{aurora_id}/images` |
| GET | `/api/v1/movies/{aurora_id}/videos` | | 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`): `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 ## Series / Seasons / Episodes
| Method | Path | | Method | Path |
+36
View File
@@ -5,6 +5,7 @@ against PostgreSQL."""
from __future__ import annotations from __future__ import annotations
import uuid
from typing import Any from typing import Any
import pytest 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") 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 is not None
assert roadshow.logo_path.endswith("/village-roadshow-logo.png") 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()))
)