diff --git a/app/api/v1/movies.py b/app/api/v1/movies.py index 0da325c..9dd62e5 100644 --- a/app/api/v1/movies.py +++ b/app/api/v1/movies.py @@ -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, diff --git a/app/repositories/movie_repository.py b/app/repositories/movie_repository.py index e4647bc..16d5313 100644 --- a/app/repositories/movie_repository.py +++ b/app/repositories/movie_repository.py @@ -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, *, diff --git a/app/services/movie_service.py b/app/services/movie_service.py index 9e010b2..0c34c69 100644 --- a/app/services/movie_service.py +++ b/app/services/movie_service.py @@ -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, *, diff --git a/docs/api.md b/docs/api.md index aae9775..b3226a4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | diff --git a/tests/integration/test_now_playing_upcoming.py b/tests/integration/test_now_playing_upcoming.py new file mode 100644 index 0000000..bc61a39 --- /dev/null +++ b/tests/integration/test_now_playing_upcoming.py @@ -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