# Aurora Core — API Base path: `/api/v1`. Interactive OpenAPI docs are available at `/docs` in any environment where `AURORA_API_DOCS_ENABLED=true` (default; set to `false` to hide docs in production — section 32). This same document is also published as a browsable page at `/wiki/api/` (see `mkdocs.yml`). All responses are JSON, built from Pydantic response schemas (`app/schemas/`), never from serialized ORM objects (section 18). Errors use a consistent envelope: ```json { "error": { "code": "movie_not_found", "message": "No such movie: aur_movie_..." } } ``` ## Health | Method | Path | Notes | |---|---|---| | GET | `/health` and `/api/v1/health` | Liveness only. | | GET | `/ready` and `/api/v1/ready` | Checks PostgreSQL (always) and Redis (if `REDIS_REQUIRED=true`). | ## Accounts | Method | Path | Body | Notes | |---|---|---|---| | POST | `/api/v1/auth/register` | `{"email", "password", "display_name"?}` | Creates an account and logs it in — returns an access token immediately, same as `/login`. `password` is 8-128 characters (length only; NIST 800-63B recommends against complexity rules). 409 if the email is already registered (matched case-insensitively). | | POST | `/api/v1/auth/login` | `{"email", "password"}` | Returns `{"access_token", "token_type": "bearer"}`. A nonexistent email and a wrong password both return the identical 401 `invalid_credentials` — distinguishing them would let a client enumerate registered emails, including via response timing (see `app/core/auth.py:burn_time_like_a_password_check`). | | GET | `/api/v1/auth/me` | — | Requires `Authorization: Bearer `. Returns the caller's own `{"id", "email", "display_name", "created_at"}`. | Access tokens are JWTs signed with `AURORA_SECRET_KEY`, valid for 24 hours, with no refresh-token flow yet — a client re-authenticates (logs in again) once a token expires. ## Ratings and watchlist All of these require `Authorization: Bearer ` and are scoped to the caller — there is no way to read or modify another user's ratings or watchlist. `{aurora_id}` is a movie or series id. | Method | Path | Body | Notes | |---|---|---|---| | PUT | `/api/v1/movies/{aurora_id}/rating` | `{"value"}` | Rates a movie, 0.5-10.0. Upsert — rating the same movie again replaces the previous value rather than erroring. Returns `{"value", "created_at", "updated_at"}`. 404 if the movie doesn't exist, 422 if `value` is out of range. | | DELETE | `/api/v1/movies/{aurora_id}/rating` | — | Removes the caller's rating, if any. 204. | | PUT / DELETE | `/api/v1/series/{aurora_id}/rating` | `{"value"}` | Same as above, for a series. | | POST | `/api/v1/movies/{aurora_id}/watchlist` | — | Adds a movie to the caller's watchlist. Idempotent — adding an already-watchlisted movie is a no-op, not a 409. 204. | | DELETE | `/api/v1/movies/{aurora_id}/watchlist` | — | Removes it, if present. 204. | | POST / DELETE | `/api/v1/series/{aurora_id}/watchlist` | — | Same as above, for a series. | | GET | `/api/v1/account/ratings/movies` | — | Paginated list of the caller's rated movies. Each result is a normal movie summary plus `"rating"`. | | GET | `/api/v1/account/ratings/series` | — | Same, for series. | | GET | `/api/v1/account/watchlist/movies` | — | Paginated list of the caller's watchlisted movies (movie summaries, no rating field). | | GET | `/api/v1/account/watchlist/series` | — | Same, for series. | ## Search | Method | Path | Notes | |---|---|---| | GET | `/api/v1/search?q=...&limit=10` | Searches movies, series, people, collections, companies. `limit` (1-50) caps results **per type**. | ```json GET /api/v1/search?q=the%20matrix { "query": "the matrix", "movies": [ { "id": "aur_movie_...", "type": "movie", "title": "The Matrix", "release_date": "1999-03-30", "poster_path": "https://..." } ], "series": [], "people": [], "collections": [ { "id": "aur_collection_...", "name": "The Matrix Collection" } ], "companies": [], "total_results": 2 } ``` ## Movies | Method | Path | |---|---| | GET | `/api/v1/movies` | | GET | `/api/v1/movies/{aurora_id}` | | GET | `/api/v1/movies/{aurora_id}/credits` | | 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/{aurora_id}/watch-providers` | | GET | `/api/v1/movies/now-playing` | | GET | `/api/v1/movies/upcoming` | | GET | `/api/v1/movies/trending` | `GET /api/v1/movies` browses/discovers without a search query — the complement to `/search` (section 17, additive beyond the spec's minimum endpoint list). Query params: `genre` (an Aurora genre id), `keyword` (an Aurora keyword id), `year` (primary release year), `status` (e.g. `released`), `sort_by` (`popularity` default | `release_date` | `vote_average` | `title`), `sort_dir` (`desc` default | `asc`), `page` (default 1), `page_size` (default 20, max 50). An invalid filter value (a malformed genre/keyword id, an unknown status) is a 422, not a silently-empty result. Response: ```json { "page": 1, "page_size": 20, "total_results": 142, "total_pages": 8, "results": [ { "id": "aur_movie_...", "type": "movie", "title": "The Matrix", "release_date": "1999-03-30", "poster_path": "https://...", "popularity": 45.5, "vote_average": 8.2 } ] } ``` `GET /api/v1/movies/{aurora_id}` (cached, see `docs/architecture.md#caching`): ```json { "id": "aur_movie_01m1q7p1c...", "type": "movie", "title": "The Matrix", "original_title": "The Matrix", "overview": "...", "tagline": "Welcome to the Real World", "status": "released", "original_language": "en", "release_date": "1999-03-30", "runtime": 136, "budget": 63000000, "revenue": 463517383, "popularity": 45.5, "vote_average": 8.2, "vote_count": 24000, "adult": false, "genres": [{ "id": "aur_genre_...", "name": "Action" }], "keywords": [{ "id": "aur_keyword_...", "name": "dystopia" }], "alternate_titles": [{ "title": "Matrix", "country_code": "US", "type": null }], "cast": [{ "id": "aur_credit_...", "person": { "id": "aur_person_...", "name": "Keanu Reeves", "profile_path": null }, "character": "Neo", "billing_order": 0 }], "crew": [{ "id": "aur_credit_...", "person": { "id": "aur_person_...", "name": "Lana Wachowski" }, "department": "Directing", "job": "Director" }], "companies": [{ "id": "aur_company_...", "name": "Village Roadshow Pictures", "company_type": "production_company" }], "collection": { "id": "aur_collection_...", "name": "The Matrix Collection", "poster_path": "https://..." }, "images": { "posters": [...], "backdrops": [...], "logos": [...] }, "videos": [{ "id": "aur_vid_...", "type": "trailer", "site": "YouTube", "url": "https://www.youtube.com/watch?v=...", "is_official": true }], "certification": "R", "release_dates": [ { "country_code": "US", "release_date": "1999-03-31", "release_type": "theatrical", "certification": { "system": "MPA", "rating": "R" }, "note": null } ], "external_ids": { "tmdb": "603", "imdb": "tt0133093" }, "created_at": "...", "updated_at": "..." } ``` `certification` is a convenience pick — the US (MPA) rating, TMDb's own default badge — out of the full `release_dates` list, which carries every country/type/rating TMDb reports (`release_type` one of `premiere` | `theatrical` | `digital` | `physical` | `tv`, collapsed from TMDb's six numeric codes). A rating body name (`system`, e.g. `"MPA"`/`"BBFC"`) is only known for a handful of major markets — everywhere else falls back to the country code itself rather than fabricating a body TMDb never named. Series detail carries the same two fields, sourced from TMDb's `content_ratings` instead — since that endpoint gives no date at all, each rating is anchored to the show's `first_air_date` with `release_type: "tv"`. `alternate_titles` ("Also Known As") carries every translated/regional title TMDb tracks, each with the country it applies to (`country_code`, nullable — TMDb sometimes omits it) and an optional `type` (e.g. `"working title"`). Series detail carries the same field. `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://..." } ] } ``` `watch_providers` (embedded on movie/series detail as shown above, or its own `GET .../watch-providers`) is grouped by country then by monetization type — mirroring TMDb's own `watch/providers` response shape directly, since that's the shape a client porting from TMDb already expects: ```json { "id": "aur_movie_01m1q7p1c...", "results": { "US": { "link": "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US", "flatrate": [{ "id": "aur_watch_provider_...", "name": "Netflix", "logo_path": "https://...", "display_priority": 0 }], "rent": [], "buy": [], "ads": [], "free": [] } } } ``` **This data comes from JustWatch via TMDb, and carries its own attribution requirement separate from TMDb's general one** — TMDb's terms require sourcing watch-provider data back to JustWatch specifically if you display it, not just crediting TMDb itself. Read TMDb's current terms for the exact required wording/logo before showing this in the frontend. `GET /api/v1/movies/trending` mirrors TMDb's own `/trending/movie/{window}` ranking rather than Aurora computing trending itself from popularity deltas -- that would require re-syncing the entire catalog on a schedule, which isn't feasible at any real scale, whereas TMDb already computes this well from signals (real view/search velocity) Aurora has no access to. `window` (`day` default | `week`), `page`, `page_size` — same response shape as `GET /movies`. Refreshed by a worker cron job twice a day (00:00 and 12:00 UTC-ish — see `WorkerSettings.cron_jobs` in `app/workers/worker.py`); `POST /api/v1/admin/refresh-trending` (admin-protected) triggers the same refresh on demand, useful right after a fresh deploy so this isn't empty until the next scheduled tick. Unlike recommendations' never-fabricate- a-stub rule, a refresh actually ingests each trending item — there are only ~20 per window (TMDb's first page), cheap enough to fully ingest rather than drop anything Aurora hasn't seen yet. `GET /series/trending` is the same idea for series. `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 | |---|---| | GET | `/api/v1/series` | | GET | `/api/v1/series/{aurora_id}` | | GET | `/api/v1/series/{aurora_id}/seasons` | | GET | `/api/v1/series/{aurora_id}/credits` | | GET | `/api/v1/series/{aurora_id}/images` | | GET | `/api/v1/series/{aurora_id}/videos` | | GET | `/api/v1/series/{aurora_id}/watch-providers` | | GET | `/api/v1/series/trending` | | GET | `/api/v1/seasons/{aurora_id}` | | GET | `/api/v1/episodes/{aurora_id}` | `GET /api/v1/series` is the series equivalent of `GET /api/v1/movies` above, with `sort_by` values `popularity` (default) | `first_air_date` | `vote_average` | `title` (`release_date`/`year` become `first_air_date`/ `year` filtered against it) — otherwise the same params, response shape, and validation behavior. Series detail includes `networks` and `companies` as separate lists (a TV network and a production company are both `Company` rows distinguished by `company_type` — see `docs/database.md`), plus `creators` (series-level "Creator" credits) alongside `cast`/`crew`. `/credits`, `/images`, and `/videos` are the same sub-resources broken out on their own, mirroring the movie endpoints above (parity that was missing until now) — `/credits` carries the same `cast`/`crew`/`creators` three-way split as series detail. ## People | Method | Path | |---|---| | GET | `/api/v1/people` | | GET | `/api/v1/people/{aurora_id}` | | GET | `/api/v1/people/{aurora_id}/credits` (movie and series filmography; guest-star episode credits are aggregated to one row per series rather than one per episode, but carry both `episode_count` and the individual `episodes` breakdown, sorted by season/episode number) | `GET /api/v1/people` is the browse/discover equivalent for people. No genre/year/status here — `known_for_department` (exact match, e.g. `Acting`, `Directing`, `Writing`) is the one categorical filter that actually means something for a cast/crew list, and `sort_by` is just `popularity` (default) or `name`. Otherwise the same `page`/`page_size` pagination and response shape as `/movies` and `/series`. ## Collections / Companies | Method | Path | |---|---| | GET | `/api/v1/collections` | | GET | `/api/v1/collections/{aurora_id}` | | GET | `/api/v1/companies` | | GET | `/api/v1/companies/{aurora_id}` | `GET /api/v1/companies` is the browse/discover equivalent for companies: `company_type` (`production_company` | `network` | `studio` | `distributor`) and `country` (ISO 3166-1 alpha-2) filters, `page`/ `page_size` pagination. No `sort_by` — companies have no popularity or date field to sort by, so results are always alphabetical by name. `GET /api/v1/collections` is the same idea for collections/franchises, but with no filter at all (no categorical field like `company_type` exists for a collection) — just `page`/`page_size`, always alphabetical by name. ## Genres | Method | Path | |---|---| | GET | `/api/v1/genres` | A directory for the `genre` filter `GET /movies` and `GET /series` already accept, which takes an Aurora genre id — this is how a client discovers what those ids actually are, since nothing else exposes them on their own (they otherwise only ever appear embedded in a movie/series response's own `genres` list). No filter, no `sort_by` — always alphabetical by name, same as collections. `page_size` defaults to 50 (max 200) rather than the 20/50 used elsewhere, since genres are a small, fixed reference set (TMDb has ~19-20 total) that a client typically wants in full for a filter dropdown, not paginated the way a catalog of millions of movies needs to be. ## Keywords | Method | Path | |---|---| | GET | `/api/v1/keywords` | A directory for the `keyword` filter `GET /movies` and `GET /series` accept, same idea as `GET /genres` above. No filter, no `sort_by` — always alphabetical by name. Unlike genres, keywords are not a small fixed set (a full TMDb catalog has tens of thousands) — `page_size` still defaults to 50 (max 200) for consistency with genres, but a client browsing keywords should expect to paginate, not fetch the whole directory at once. Collection detail's `movies` list only includes movies already ingested into Aurora — `POST /api/v1/admin/ingest/collection` links a franchise's member movies purely by external-id lookup, the same never-fabricate-a-stub rule `recommendations` follows, so a franchise fills in as more of its movies get ingested rather than all at once. Company detail also carries `movies` and `series` lists — everything that company is credited on (any role: production, distribution, network), ordered by release/first-air date. No never-fabricate-a-stub caveat here: a `MovieCompany`/`SeriesCompany` row is only ever created as a side effect of actually ingesting that movie or series, so there's no separate "company's known filmography" TMDb ever needs linking after the fact. ## Administrative (section 17: "separately protected") Require header `X-API-Key: `. Never exposed to public clients — add a rule in the host webserver's vhost config for `api.amdbs.net` (e.g. an Apache ``/LiteSpeed rewrite blocking `/api/v1/admin/*` from outside a trusted IP range) in production if you want network-level isolation on top of the API key. | Method | Path | Body | Notes | |---|---|---|---| | POST | `/api/v1/admin/ingest/movie` | `{"tmdb_id": "603"}` | Enqueues an arq job; returns `{"job_id": "...", "status": "enqueued"}` immediately (section 15: ingestion never runs inline in a request). | | POST | `/api/v1/admin/ingest/person` | `{"tmdb_id": "6384"}` | Same, for a person profile. | | POST | `/api/v1/admin/ingest/series` | `{"tmdb_id": "1396", "include_seasons": false}` | Ingests the series and season *stubs* (number, title, air date, poster) — no episodes yet. With `include_seasons: true`, also enqueues an `ingest/season` job for every season once the series finishes (opt-in: some shows have dozens of seasons, each a separate TMDb call). | | POST | `/api/v1/admin/ingest/season` | `{"tmdb_id": "1396", "season_number": 1}` | Ingests one season's full episode list. Requires the series to already be ingested (`ingest/series` first) — fails with a clear error otherwise. | | POST | `/api/v1/admin/ingest/collection` | `{"tmdb_id": "2344"}` | Ingests a franchise's own metadata (overview, poster/backdrop) and links its already-ingested member movies (see "Collections / Companies" above). Movie ingestion also lazily creates a bare name-only stub the first time it sees a `belongs_to_collection` reference — this is what actually fills it in. | | GET | `/api/v1/admin/ingest/jobs/{job_id}` | — | Poll job status: `pending` → `running` → `succeeded`/`failed`/`retrying`. | | POST | `/api/v1/admin/refresh-trending` | — | Manually triggers the same refresh the twice-daily cron job runs (see "Trending" above). Not tracked via `ingest/jobs` — check `GET /movies/trending`/`GET /series/trending` directly instead of polling a job id. | ## Errors | Status | When | |---|---| | 404 | Entity not found, or the ID is well-formed but the wrong entity type (`aur_person_...` passed to `/movies/{id}`) | | 422 | Request validation failure (e.g. missing/invalid query params) | | 429 | Rate limit exceeded (`Retry-After` header set) | | 401 | Missing/invalid `X-API-Key` on an admin endpoint | | 503 | Admin API called with `AURORA_ADMIN_API_KEYS` unset, or the job queue is unavailable | ## Known limitations - `GET /movies` and `GET /series` (browse/discover) paginate with `page`/`page_size`, offset-based rather than the Aurora ID cursor scheme `docs/database.md#aurora-ids-vs-primary-keys` describes, since sorting by popularity/rating/date doesn't line up with creation-order cursors. Other list-shaped responses (recommendations, a collection's `movies`, a person's `credits`) still return everything with no pagination at all — fine at today's scale, but the natural next thing to revisit if any of them grow large.