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
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:
co-authored by
Claude Sonnet 5
parent
73488a0d80
commit
4af4ba40ef
+17
-1
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user