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_id and kinopoisk_id.

What data you get

This is the data model. Each row is one table in a relational copy.

EntityRead withKeyLinks to
MovieGET /3/movie/{movie_id}id, imdb_idgenres[].id, production_companies[].id, belongs_to_collection.id
PersonGET /3/person/{person_id}id, imdb_idMovies through /3/movie/{movie_id}/credits
CompanyGET /3/company/{company_id}idMovies through with_companies in discover. parent_company is always null and description is empty
CollectionGET /3/collection/{collection_id}idparts[], a list of movies
KeywordGET /3/keyword/{keyword_id}idMovies through /3/keyword/{keyword_id}/movies or with_keywords
GenreGET /3/genre/movie/listidgenres[].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

  1. Compare the three request rates on pricing. A bigger rate shortens the first load, nothing else changes.
  2. Create a key in your account dashboard and keep it in an environment variable.
  3. Call /3/discover/movie for 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.

bashphp
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.

JSON
{
  "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

JobCallLimits
First loadGET /3/discover/movie with sort_by and a date windowpage 1 to 500, up to 20 results per page, so 10,000 per query. Slice by primary_release_date.gte and .lte to go beyond.
New releasesGET /3/discover/movie with primary_release_date.gte set to your last run, and GET /3/movie/upcomingCached for 30 minutes.
Refresh rowsGET /3/movie/{movie_id} for rows older than 6 hoursThere is no changes feed. Refresh the titles users open, and the rest less often.
Join your own idsGET /3/find/{external_id}One id per call, source chosen with external_source.
Lazy fillGET /3/movie/{movie_id} when a user opens a titleStore 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

LayerHoldsTTL
Edge cacheDetails and credits6 hours
Edge cacheDiscover, trending and popular lists30 minutes
Edge cacheSearch10 minutes
Edge cacheConfiguration24 hours
Edge cacheImages30 days
Your tablesRows you keepUntil 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

CapabilityScrape pages yourselfStatic dataset filesamodb
FreshnessAs fresh as your last crawlAs fresh as the last published fileDetails cached 6 hours, refetch by age
Incremental updatesRe-crawl and compareDownload a new fileDiscover with a release date window, no changes feed
Ids across entitiesPage URLsDefined by the publisherNumeric ids, imdb_id and /3/find
What you runCrawlers and storageStorage and an import jobStorage and a sync script
Rate limitSet by the sites you crawlNot applicable20, 40 or 120 requests per minute, counted per key
Languages per recordWhatever each page offersDepends on the files14, 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.