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

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:
NixReaper
2026-09-06 04:45:09 -05:00
co-authored by Claude Sonnet 5
parent 567b4ef068
commit 74ebad379a
5 changed files with 301 additions and 1 deletions
+20
View File
@@ -54,6 +54,26 @@ async def list_movies(
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)
async def get_movie(
aurora_id: str,
+86 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import uuid
from datetime import date, timedelta
from sqlalchemy import func, select
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.genre import Genre, MovieGenre
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 = {
"popularity": Movie.popularity,
@@ -98,6 +105,84 @@ async def get_movie_recommendation_ids(
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(
session: AsyncSession,
*,
+24
View File
@@ -192,6 +192,30 @@ async def build_movie_summaries(
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(
session: AsyncSession,
*,
+13
View File
@@ -49,6 +49,8 @@ GET /api/v1/search?q=the%20matrix
| 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/now-playing` |
| GET | `/api/v1/movies/upcoming` |
`GET /api/v1/movies` browses/discovers without a search query — the
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
| 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