Build your own movie database from an API
amodb is an independent API that speaks the TMDB v3 protocol. Movies, people, companies, collections, keywords and genres all have numeric ids and point at each other, so you can fill SQL tables from it and keep them current. Seed the tables with discover paging, refresh them by age, and match your own identifiers with find. amodb has no changes feed, so a refresh is a plain refetch of the rows you keep.
What you get:
- Six linked entity types with stable numeric ids.
- Discover paging for the first load: up to 20 results per page, 500 pages per query.
- Release date windows on discover, to slice the first load and to list new releases.
- External id lookups for
imdb_id,tvdb_id,wikidata_idandkinopoisk_id.
What data you get
This is the data model. Each row is one table in a relational copy.
| Entity | Read with | Key | Links to |
|---|---|---|---|
| Movie | GET / | id, imdb_id | genres[].id, production_, belongs_ |
| Person | GET / | id, imdb_id | Movies through / |
| Company | GET / | id | Movies through with_companies in discover. parent_company is always null and description is empty |
| Collection | GET / | id | parts[], a list of movies |
| Keyword | GET / | id | Movies through / or with_keywords |
| Genre | GET / | id | genres[].id on movies |
Many to many links need join tables: movie_genre, movie_company, movie_keyword and movie_credit. Use credit_id from credits as the key of the last one. Field lists are on movies, people and collections.
Start in 3 steps
- Compare the three request rates on pricing. A bigger rate shortens the first load, nothing else changes.
- Create a key in your account dashboard and keep it in an environment variable.
- Call
/3/discover/moviefor a one day window of release dates, then fetch each id on the list.
Example request
The PHP script lists the movies released yesterday and today through discover and upserts each record into a SQLite table. It sleeps 3 seconds between detail reads so it stays under 20 requests per minute, retries a 429 after Retry-After, and deletes rows whose id now returns 404.
curl -s "https://api.amodb.dev/3/discover/movie?primary_release_date.gte=2026-10-17&primary_release_date.lte=2026-10-18&sort_by=primary_release_date.asc&page=1" \
-H "Authorization: Bearer $AMODB_KEY"<?php
const BASE = 'https://api.amodb.dev/3';
function amodb(string $path, array $query = []): ?array
{
$url = BASE . $path . '?' . http_build_query($query);
for ($attempt = 0; $attempt < 3; $attempt++) {
$retryAfter = 1;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('AMODB_KEY')],
CURLOPT_HEADERFUNCTION => function ($handle, $line) use (&$retryAfter) {
if (stripos($line, 'Retry-After:') === 0) {
$retryAfter = max(1, (int) trim(substr($line, 12)));
}
return strlen($line);
},
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 429) {
sleep($retryAfter);
continue;
}
if ($status === 404) {
return null;
}
if ($status !== 200) {
throw new RuntimeException("amodb returned $status for $path");
}
return json_decode($body, true);
}
throw new RuntimeException("Still rate limited on $path");
}
$db = new PDO('sqlite:movies.sqlite');
$db->exec('CREATE TABLE IF NOT EXISTS movie (id INTEGER PRIMARY KEY, imdb_id TEXT, title TEXT, release_date TEXT, raw TEXT)');
$save = $db->prepare('INSERT OR REPLACE INTO movie (id, imdb_id, title, release_date, raw) VALUES (?, ?, ?, ?, ?)');
$drop = $db->prepare('DELETE FROM movie WHERE id = ?');
$from = date('Y-m-d', strtotime('-1 day'));
$to = date('Y-m-d');
$page = 1;
do {
$list = amodb('/discover/movie', [
'primary_release_date.gte' => $from,
'primary_release_date.lte' => $to,
'sort_by' => 'primary_release_date.asc',
'page' => $page,
]);
foreach ($list['results'] as $row) {
$movie = amodb('/movie/' . $row['id'], ['language' => 'en-US']);
if ($movie === null) {
$drop->execute([$row['id']]);
continue;
}
$save->execute([$movie['id'], $movie['imdb_id'] ?? null, $movie['title'], $movie['release_date'] ?? null, json_encode($movie)]);
sleep(3);
}
$page++;
} while ($page <= min($list['total_pages'], 500));Discover returns list rows, not full records. This is 2 of the 20 results on page 1. Read /3/movie/{id} for the rest of the fields.
{
"page": 1,
"results": [
{
"adult": false,
"backdrop_path": "/siepLRGVjM8yLKppApkD3uSh8Y0.jpg",
"genre_ids": [
35
],
"id": 1800227,
"original_language": "en",
"original_title": "Sly's Pack",
"overview": "Nesmith, Gary, & Tony are smoking weed on the roof of a parking garage, but a...",
"popularity": 0.0071,
"poster_path": "/3HpIWZUI7jmpKZkfZNdchS2WsvN.jpg",
"release_date": "2026-10-17",
"title": "Sly's Pack",
"video": false,
"vote_average": 0,
"vote_count": 0
},
{
"adult": false,
"backdrop_path": null,
"genre_ids": [
99
],
"id": 1800168,
"original_language": "en",
"original_title": "The Good Life",
"overview": "The Good LIfe is based on the best-selling book by Drs. Robert Waldinger and...",
"popularity": 0.0143,
"poster_path": null,
"release_date": "2026-10-17",
"title": "The Good Life",
"video": false,
"vote_average": 0,
"vote_count": 0
}
],
"total_pages": 15,
"total_results": 299
}Fill and refresh the tables
| Job | Call | Limits |
|---|---|---|
| First load | GET /3/discover/movie with sort_by and a date window | page 1 to 500, up to 20 results per page, so 10,000 per query. Slice by primary_ and .lte to go beyond. |
| New releases | GET /3/discover/movie with primary_ set to your last run, and GET /3/movie/upcoming | Cached for 30 minutes. |
| Refresh rows | GET / for rows older than 6 hours | There is no changes feed. Refresh the titles users open, and the rest less often. |
| Join your own ids | GET / | One id per call, source chosen with external_source. |
| Lazy fill | GET / when a user opens a title | Store the reply with a timestamp. |
The paging window is the main design choice. When total_pages in a reply is above 500, the window holds more than 10,000 rows, so cut it in halves until it fits. A recent year needs months, and one day of releases is a few hundred titles. Store the slice you finished so a crashed run can resume. Matching rules for the join are on find by external id.
Sizing the sync
Each row you refresh costs one detail read. A plan of 20 requests per minute gives 1,200 calls per hour, 40 gives 2,400 and 120 gives 7,200. A discover page lists 20 ids for one call. Run the refresh at off-peak hours if your app shares the key, because the limit is per key, not per script. See rate limits for the headers that report what is left in the window.
Caching in two layers
| Layer | Holds | TTL |
|---|---|---|
| Edge cache | Details and credits | 6 hours |
| Edge cache | Discover, trending and popular lists | 30 minutes |
| Edge cache | Search | 10 minutes |
| Edge cache | Configuration | 24 hours |
| Edge cache | Images | 30 days |
| Your tables | Rows you keep | Until your age rule says to refetch, never sooner than 6 hours |
The edge cache is shared across keys, but the rate limit runs before it, so a cache hit still uses one request from your minute. Rereading a row more often than every 6 hours returns the same copy and wastes quota. Let an age rule decide when to refetch.
How it compares
| Capability | Scrape pages yourself | Static dataset files | amodb |
|---|---|---|---|
| Freshness | As fresh as your last crawl | As fresh as the last published file | Details cached 6 hours, refetch by age |
| Incremental updates | Re-crawl and compare | Download a new file | Discover with a release date window, no changes feed |
| Ids across entities | Page URLs | Defined by the publisher | Numeric ids, imdb_id and /3/find |
| What you run | Crawlers and storage | Storage and an import job | Storage and a sync script |
| Rate limit | Set by the sites you crawl | Not applicable | 20, 40 or 120 requests per minute, counted per key |
| Languages per record | Whatever each page offers | Depends on the files | 14, chosen with language |
Pricing
The first load is the expensive part, and it is bounded by your requests per minute. Choose 20, 40 or 120 per minute on pricing. Pay for 1, 3, 6, 12 or 24 months at 0, 10, 20, 30 or 40 percent off. Nothing renews automatically, there is no free tier, and every plan reads the same data.