Add now-playing and upcoming movie endpoints
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
Buildable now that release_dates exists: GET /movies/now-playing and /movies/upcoming filter on release_type="theatrical" per region, windowed around today. A movie can carry more than one theatrical-type row in the same region (limited release then wide release both map to "theatrical" per normalizer._RELEASE_TYPE_MAP) -- collapsed to one row per movie via a grouped subquery rather than joining ReleaseDate directly, which would duplicate the movie in the page and inflate the total count. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
567b4ef068
commit
74ebad379a
@@ -54,6 +54,26 @@ async def list_movies(
|
|||||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/now-playing", response_model=MovieListOut)
|
||||||
|
async def now_playing_movies(
|
||||||
|
region: str = Query("US", min_length=2, max_length=2, description="ISO 3166-1 alpha-2 country"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=50),
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
) -> MovieListOut:
|
||||||
|
return await movie_service.list_now_playing(session, region=region, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/upcoming", response_model=MovieListOut)
|
||||||
|
async def upcoming_movies(
|
||||||
|
region: str = Query("US", min_length=2, max_length=2, description="ISO 3166-1 alpha-2 country"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=50),
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
) -> MovieListOut:
|
||||||
|
return await movie_service.list_upcoming(session, region=region, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{aurora_id}", response_model=MovieDetailOut)
|
@router.get("/{aurora_id}", response_model=MovieDetailOut)
|
||||||
async def get_movie(
|
async def get_movie(
|
||||||
aurora_id: str,
|
aurora_id: str,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -12,7 +13,13 @@ from app.models.company import Company, MovieCompany
|
|||||||
from app.models.enums import MovieStatus
|
from app.models.enums import MovieStatus
|
||||||
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, MovieAlternateTitle, MovieRecommendation
|
from app.models.movie import Movie, MovieAlternateTitle, MovieRecommendation, ReleaseDate
|
||||||
|
|
||||||
|
# How far back "now playing" looks for a theatrical release -- TMDb's own
|
||||||
|
# now_playing window is roughly a month; a movie ages out of "now playing"
|
||||||
|
# once its theatrical run is this old, whether or not it has any release
|
||||||
|
# still in the future (that's what "upcoming" is for).
|
||||||
|
_NOW_PLAYING_WINDOW_DAYS = 45
|
||||||
|
|
||||||
_MOVIE_SORT_COLUMNS = {
|
_MOVIE_SORT_COLUMNS = {
|
||||||
"popularity": Movie.popularity,
|
"popularity": Movie.popularity,
|
||||||
@@ -98,6 +105,84 @@ async def get_movie_recommendation_ids(
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_now_playing(
|
||||||
|
session: AsyncSession, *, region: str, limit: int, offset: int
|
||||||
|
) -> tuple[list[Movie], int]:
|
||||||
|
"""Movies with a theatrical release in `region` within the last
|
||||||
|
_NOW_PLAYING_WINDOW_DAYS, most recent first. A movie can have more than
|
||||||
|
one theatrical-type release_date row in the same region (a limited
|
||||||
|
release then a wide release both map to release_type="theatrical" --
|
||||||
|
see normalizer._RELEASE_TYPE_MAP), so this collapses to each movie's
|
||||||
|
latest one via a grouped subquery rather than joining ReleaseDate
|
||||||
|
directly, which would duplicate the movie in the page and inflate the
|
||||||
|
window-function total_count."""
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
window_start = today - timedelta(days=_NOW_PLAYING_WINDOW_DAYS)
|
||||||
|
latest_release = (
|
||||||
|
select(
|
||||||
|
ReleaseDate.entity_id.label("movie_id"),
|
||||||
|
func.max(ReleaseDate.release_date).label("release_date"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
ReleaseDate.entity_type == "movie",
|
||||||
|
ReleaseDate.country_code == region,
|
||||||
|
ReleaseDate.release_type == "theatrical",
|
||||||
|
ReleaseDate.release_date <= today,
|
||||||
|
ReleaseDate.release_date >= window_start,
|
||||||
|
)
|
||||||
|
.group_by(ReleaseDate.entity_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Movie, func.count().over().label("total_count"))
|
||||||
|
.join(latest_release, latest_release.c.movie_id == Movie.id)
|
||||||
|
.order_by(latest_release.c.release_date.desc(), Movie.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
|
||||||
|
|
||||||
|
|
||||||
|
async def list_upcoming(
|
||||||
|
session: AsyncSession, *, region: str, limit: int, offset: int
|
||||||
|
) -> tuple[list[Movie], int]:
|
||||||
|
"""Movies with a future theatrical release in `region`, soonest first
|
||||||
|
-- see list_now_playing for why this collapses to one row per movie
|
||||||
|
(here, the earliest upcoming date) rather than joining ReleaseDate
|
||||||
|
directly."""
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
earliest_release = (
|
||||||
|
select(
|
||||||
|
ReleaseDate.entity_id.label("movie_id"),
|
||||||
|
func.min(ReleaseDate.release_date).label("release_date"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
ReleaseDate.entity_type == "movie",
|
||||||
|
ReleaseDate.country_code == region,
|
||||||
|
ReleaseDate.release_type == "theatrical",
|
||||||
|
ReleaseDate.release_date > today,
|
||||||
|
)
|
||||||
|
.group_by(ReleaseDate.entity_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Movie, func.count().over().label("total_count"))
|
||||||
|
.join(earliest_release, earliest_release.c.movie_id == Movie.id)
|
||||||
|
.order_by(earliest_release.c.release_date.asc(), Movie.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
|
||||||
|
|
||||||
|
|
||||||
async def list_movies(
|
async def list_movies(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -192,6 +192,30 @@ async def build_movie_summaries(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def list_now_playing(
|
||||||
|
session: AsyncSession, *, region: str, page: int, page_size: int
|
||||||
|
) -> MovieListOut:
|
||||||
|
movies, total = await movie_repository.list_now_playing(
|
||||||
|
session, region=region.upper(), limit=page_size, offset=(page - 1) * page_size
|
||||||
|
)
|
||||||
|
results = [await _movie_summary_from_row(session, m) for m in movies]
|
||||||
|
total_pages = (total + page_size - 1) // page_size if total else 0
|
||||||
|
return MovieListOut(
|
||||||
|
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_upcoming(session: AsyncSession, *, region: str, page: int, page_size: int) -> MovieListOut:
|
||||||
|
movies, total = await movie_repository.list_upcoming(
|
||||||
|
session, region=region.upper(), limit=page_size, offset=(page - 1) * page_size
|
||||||
|
)
|
||||||
|
results = [await _movie_summary_from_row(session, m) for m in movies]
|
||||||
|
total_pages = (total + page_size - 1) // page_size if total else 0
|
||||||
|
return MovieListOut(
|
||||||
|
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def list_movies(
|
async def list_movies(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
+13
@@ -49,6 +49,8 @@ GET /api/v1/search?q=the%20matrix
|
|||||||
| 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}/recommendations` |
|
||||||
|
| GET | `/api/v1/movies/now-playing` |
|
||||||
|
| GET | `/api/v1/movies/upcoming` |
|
||||||
|
|
||||||
`GET /api/v1/movies` browses/discovers without a search query — the
|
`GET /api/v1/movies` browses/discovers without a search query — the
|
||||||
complement to `/search` (section 17, additive beyond the spec's minimum
|
complement to `/search` (section 17, additive beyond the spec's minimum
|
||||||
@@ -129,6 +131,17 @@ ingested:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`GET /api/v1/movies/now-playing` and `GET /api/v1/movies/upcoming` use the
|
||||||
|
`release_dates` data above rather than `release_date`/`year`: `region`
|
||||||
|
(ISO 3166-1 alpha-2, default `US`), `page`, `page_size` — same
|
||||||
|
`MovieListOut` response shape as `GET /movies`, no `genre`/`keyword`/`sort_by`.
|
||||||
|
"Now playing" is movies with a `release_type: "theatrical"` date in
|
||||||
|
`region` within roughly the last month; "upcoming" is the same but in the
|
||||||
|
future, soonest first. A movie can carry more than one theatrical-type row
|
||||||
|
in the same region (a limited release followed by a wide release both map
|
||||||
|
to `"theatrical"`) — these collapse to one entry per movie (latest date for
|
||||||
|
now-playing, soonest for upcoming) rather than appearing twice.
|
||||||
|
|
||||||
## Series / Seasons / Episodes
|
## Series / Seasons / Episodes
|
||||||
|
|
||||||
| Method | Path |
|
| Method | Path |
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Integration tests for GET /api/v1/movies/now-playing and .../upcoming
|
||||||
|
(section 17, additive): buildable now that release_dates exists (see
|
||||||
|
test_release_dates.py) -- theatrical release dates per region, windowed
|
||||||
|
around today. Mirrors test_movie_browse.py's fake-provider seeding
|
||||||
|
pattern."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services import movie_service
|
||||||
|
from app.services.ingestion.movie_ingestion import ingest_movie
|
||||||
|
from tests.integration.test_movie_ingestion import FakeTMDbProvider
|
||||||
|
|
||||||
|
_TODAY = date.today()
|
||||||
|
|
||||||
|
|
||||||
|
def _release_dates_block(country: str, release_date: date, release_type_code: int) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"iso_3166_1": country,
|
||||||
|
"release_dates": [
|
||||||
|
{
|
||||||
|
"certification": "",
|
||||||
|
"release_date": f"{release_date.isoformat()}T00:00:00.000Z",
|
||||||
|
"type": release_type_code,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _movie_payload(**overrides: Any) -> dict[str, Any]:
|
||||||
|
base = {"id": 9000, "title": "Untitled", "status": "Released", "popularity": 10.0}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(db_session: AsyncSession) -> None:
|
||||||
|
movies = [
|
||||||
|
# In the now-playing window (released 10 days ago, theatrical).
|
||||||
|
_movie_payload(
|
||||||
|
id=9201,
|
||||||
|
title="Currently Showing",
|
||||||
|
release_dates=_release_dates_block("US", _TODAY - timedelta(days=10), 3),
|
||||||
|
),
|
||||||
|
# Outside the now-playing window (too old).
|
||||||
|
_movie_payload(
|
||||||
|
id=9202,
|
||||||
|
title="Long Gone",
|
||||||
|
release_dates=_release_dates_block("US", _TODAY - timedelta(days=200), 3),
|
||||||
|
),
|
||||||
|
# Upcoming (future theatrical release).
|
||||||
|
_movie_payload(
|
||||||
|
id=9203,
|
||||||
|
title="Coming Soon",
|
||||||
|
release_dates=_release_dates_block("US", _TODAY + timedelta(days=14), 3),
|
||||||
|
),
|
||||||
|
# Theatrical release in a different region only -- must not leak
|
||||||
|
# into the default US-region results.
|
||||||
|
_movie_payload(
|
||||||
|
id=9204,
|
||||||
|
title="GB Exclusive",
|
||||||
|
release_dates=_release_dates_block("GB", _TODAY - timedelta(days=5), 3),
|
||||||
|
),
|
||||||
|
# A digital-only release near today -- must not count as "now
|
||||||
|
# playing" (which is theatrical-only).
|
||||||
|
_movie_payload(
|
||||||
|
id=9205,
|
||||||
|
title="Streaming Only",
|
||||||
|
release_dates=_release_dates_block("US", _TODAY - timedelta(days=2), 4),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for payload in movies:
|
||||||
|
await ingest_movie(db_session, FakeTMDbProvider(payload), str(payload["id"]))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_now_playing_returns_recent_theatrical_releases_only(db_session: AsyncSession):
|
||||||
|
await _seed(db_session)
|
||||||
|
|
||||||
|
out = await movie_service.list_now_playing(db_session, region="US", page=1, page_size=20)
|
||||||
|
|
||||||
|
assert [m.title for m in out.results] == ["Currently Showing"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upcoming_returns_future_theatrical_releases_only(db_session: AsyncSession):
|
||||||
|
await _seed(db_session)
|
||||||
|
|
||||||
|
out = await movie_service.list_upcoming(db_session, region="US", page=1, page_size=20)
|
||||||
|
|
||||||
|
assert [m.title for m in out.results] == ["Coming Soon"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_now_playing_respects_region(db_session: AsyncSession):
|
||||||
|
await _seed(db_session)
|
||||||
|
|
||||||
|
out = await movie_service.list_now_playing(db_session, region="GB", page=1, page_size=20)
|
||||||
|
|
||||||
|
assert [m.title for m in out.results] == ["GB Exclusive"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_now_playing_ignores_movies_with_only_a_theatrical_release_outside_the_window(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
await _seed(db_session)
|
||||||
|
|
||||||
|
out = await movie_service.list_now_playing(db_session, region="US", page=1, page_size=20)
|
||||||
|
|
||||||
|
assert "Long Gone" not in [m.title for m in out.results]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_now_playing_dedupes_a_movie_with_multiple_theatrical_dates(db_session: AsyncSession):
|
||||||
|
"""A limited release followed by a wide release both map to
|
||||||
|
release_type="theatrical" (see normalizer._RELEASE_TYPE_MAP) -- a movie
|
||||||
|
with two such rows in the same region must appear exactly once."""
|
||||||
|
|
||||||
|
payload = _movie_payload(
|
||||||
|
id=9301,
|
||||||
|
title="Limited Then Wide",
|
||||||
|
release_dates={
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"iso_3166_1": "US",
|
||||||
|
"release_dates": [
|
||||||
|
{
|
||||||
|
"certification": "",
|
||||||
|
"release_date": f"{(_TODAY - timedelta(days=20)).isoformat()}T00:00:00.000Z",
|
||||||
|
"type": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"certification": "",
|
||||||
|
"release_date": f"{(_TODAY - timedelta(days=5)).isoformat()}T00:00:00.000Z",
|
||||||
|
"type": 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await ingest_movie(db_session, FakeTMDbProvider(payload), "9301")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
out = await movie_service.list_now_playing(db_session, region="US", page=1, page_size=20)
|
||||||
|
|
||||||
|
assert [m.title for m in out.results] == ["Limited Then Wide"]
|
||||||
|
assert out.total_results == 1
|
||||||
Reference in New Issue
Block a user