[object Object]

← back to AsSeenInMovies

tick1: TMDB Phase A complete — 6.46M stubs across 7 sources, awaiting API key for Phase B

01bf78d7f5d2beedb952555d6f60f12429e1219a · 2026-05-12 06:58:58 -0700 · SteveStudio2

Files touched

Diff

commit 01bf78d7f5d2beedb952555d6f60f12429e1219a
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 06:58:58 2026 -0700

    tick1: TMDB Phase A complete — 6.46M stubs across 7 sources, awaiting API key for Phase B
---
 data/schema-ingest.sql        |  32 ++++++
 scrapers/tmdb-download-ids.js | 139 +++++++++++++++++++++++++
 scrapers/tmdb-fetch-worker.js | 237 ++++++++++++++++++++++++++++++++++++++++++
 server.js                     |  35 ++++++-
 4 files changed, 442 insertions(+), 1 deletion(-)

diff --git a/data/schema-ingest.sql b/data/schema-ingest.sql
new file mode 100644
index 0000000..25d36b8
--- /dev/null
+++ b/data/schema-ingest.sql
@@ -0,0 +1,32 @@
+-- Ingest tracking — lets us drive the TMDB pull as resumable work units.
+-- Phase A populates this table from TMDB's daily ID exports (no auth).
+-- Phase B worker selects WHERE status='queued', fetches /3/{type}/{id},
+-- writes details into asim_movies / asim_people / asim_credits, marks done.
+
+CREATE TABLE IF NOT EXISTS asim_ingest_stubs (
+  id            BIGSERIAL PRIMARY KEY,
+  source        TEXT NOT NULL,      -- 'tmdb-movie' | 'tmdb-tv' | 'tmdb-person' | 'tmdb-collection' | 'tmdb-company' | 'tmdb-network' | 'aicp' | 'cannes' | etc.
+  source_id     TEXT NOT NULL,      -- TMDB id as text (or AICP slug, etc.)
+  popularity    REAL,               -- pulled from the ID-export file when present
+  status        TEXT NOT NULL DEFAULT 'queued', -- 'queued' | 'in_flight' | 'done' | 'failed' | 'skipped_adult' | 'gone'
+  attempts      INT NOT NULL DEFAULT 0,
+  last_error    TEXT,
+  fetched_at    TIMESTAMPTZ,
+  enqueued_at   TIMESTAMPTZ DEFAULT NOW(),
+  UNIQUE (source, source_id)
+);
+CREATE INDEX IF NOT EXISTS idx_asim_stubs_status_pop ON asim_ingest_stubs(source, status, popularity DESC NULLS LAST);
+CREATE INDEX IF NOT EXISTS idx_asim_stubs_fetched ON asim_ingest_stubs(fetched_at);
+
+CREATE TABLE IF NOT EXISTS asim_ingest_runs (
+  id            BIGSERIAL PRIMARY KEY,
+  run_kind      TEXT NOT NULL,      -- 'tmdb-id-export-download' | 'tmdb-fetch-batch' | etc.
+  source        TEXT,
+  started_at    TIMESTAMPTZ DEFAULT NOW(),
+  finished_at   TIMESTAMPTZ,
+  status        TEXT NOT NULL DEFAULT 'running',
+  records_in    BIGINT,
+  records_out   BIGINT,
+  notes         TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_asim_runs_kind ON asim_ingest_runs(run_kind, started_at DESC);
diff --git a/scrapers/tmdb-download-ids.js b/scrapers/tmdb-download-ids.js
new file mode 100644
index 0000000..cebfe2b
--- /dev/null
+++ b/scrapers/tmdb-download-ids.js
@@ -0,0 +1,139 @@
+#!/usr/bin/env node
+'use strict';
+// Phase A — download TMDB's daily ID exports (no auth required) and bulk-load
+// them as queued stubs in asim_ingest_stubs. After this runs we have a complete
+// checklist of every TMDB id that exists; Phase B worker chips away at it.
+//
+// Exports are gzipped JSONL — one line per record, fields {id, popularity, ...}.
+// Files live at https://files.tmdb.org/p/exports/<type>_ids_MM_DD_YYYY.json.gz
+// and roll over daily at 8AM UTC.
+//
+// Legal: ID exports + facts are explicitly open-use per TMDB terms. Attribution
+// required on any user-facing page (handled in server.js home view).
+
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+const readline = require('readline');
+const { Pool } = require('pg');
+
+const RAW_DIR = path.join(__dirname, '..', 'data', 'raw', 'tmdb-id-exports');
+fs.mkdirSync(RAW_DIR, { recursive: true });
+
+const pool = new Pool({
+  connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
+  max: 4,
+});
+
+// TMDB rotates the export at 8AM UTC. If we're before 8AM, use yesterday's file.
+function exportDateStr() {
+  const d = new Date();
+  if (d.getUTCHours() < 8) d.setUTCDate(d.getUTCDate() - 1);
+  const MM = String(d.getUTCMonth() + 1).padStart(2, '0');
+  const DD = String(d.getUTCDate()).padStart(2, '0');
+  const YYYY = d.getUTCFullYear();
+  return `${MM}_${DD}_${YYYY}`;
+}
+
+// Each entry: source label we use internally, TMDB export file prefix.
+const EXPORTS = [
+  { source: 'tmdb-movie',      file: 'movie_ids' },
+  { source: 'tmdb-tv',         file: 'tv_series_ids' },
+  { source: 'tmdb-person',     file: 'person_ids' },
+  { source: 'tmdb-collection', file: 'collection_ids' },
+  { source: 'tmdb-company',    file: 'production_company_ids' },
+  { source: 'tmdb-network',    file: 'tv_network_ids' },
+  { source: 'tmdb-keyword',    file: 'keyword_ids' },
+];
+
+async function downloadOne(prefix, dateStr) {
+  const url = `https://files.tmdb.org/p/exports/${prefix}_${dateStr}.json.gz`;
+  const dest = path.join(RAW_DIR, `${prefix}_${dateStr}.json.gz`);
+  if (fs.existsSync(dest) && fs.statSync(dest).size > 1000) {
+    return { url, dest, cached: true, bytes: fs.statSync(dest).size };
+  }
+  const r = await fetch(url);
+  if (!r.ok) throw new Error(`${prefix}: HTTP ${r.status}`);
+  const buf = Buffer.from(await r.arrayBuffer());
+  fs.writeFileSync(dest, buf);
+  return { url, dest, cached: false, bytes: buf.length };
+}
+
+async function bulkLoadStubs(source, gzPath, runId) {
+  // Stream-parse the gzipped JSONL and bulk-INSERT in chunks of 5000.
+  // Use ON CONFLICT DO UPDATE to refresh popularity on subsequent runs.
+  const stream = fs.createReadStream(gzPath).pipe(zlib.createGunzip());
+  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+
+  let batch = [];
+  const BATCH_SIZE = 5000;
+  let total = 0;
+  let inserted = 0;
+
+  const flush = async () => {
+    if (batch.length === 0) return;
+    const values = batch.map((_, i) => `($1, $${i * 2 + 2}, $${i * 2 + 3})`).join(',');
+    const params = [source, ...batch.flatMap(b => [String(b.id), b.popularity ?? null])];
+    const sql = `
+      INSERT INTO asim_ingest_stubs (source, source_id, popularity)
+      VALUES ${values}
+      ON CONFLICT (source, source_id) DO UPDATE
+        SET popularity = COALESCE(EXCLUDED.popularity, asim_ingest_stubs.popularity)
+    `;
+    await pool.query(sql, params);
+    inserted += batch.length;
+    batch = [];
+  };
+
+  for await (const line of rl) {
+    if (!line) continue;
+    try {
+      const obj = JSON.parse(line);
+      if (typeof obj.id !== 'number') continue;
+      if (obj.adult) { continue; } // skip adult by default
+      batch.push({ id: obj.id, popularity: typeof obj.popularity === 'number' ? obj.popularity : null });
+      total++;
+      if (batch.length >= BATCH_SIZE) await flush();
+      if (total % 50000 === 0) process.stdout.write(`  ${source}: ${total.toLocaleString()} parsed, ${inserted.toLocaleString()} inserted\n`);
+    } catch (e) {
+      // Skip malformed lines silently — TMDB files occasionally have trailing junk
+    }
+  }
+  await flush();
+  return { total, inserted };
+}
+
+async function main() {
+  const dateStr = exportDateStr();
+  console.log(`[tmdb-ids] using export date ${dateStr}`);
+
+  // Open a run-row to track progress in PG.
+  const runR = await pool.query(
+    `INSERT INTO asim_ingest_runs (run_kind, source, notes) VALUES ('tmdb-id-export-download', $1, $2) RETURNING id`,
+    ['all', `dateStr=${dateStr}`]
+  );
+  const runId = runR.rows[0].id;
+  let grandTotal = 0;
+
+  try {
+    for (const { source, file } of EXPORTS) {
+      console.log(`\n[${source}] downloading ${file}_${dateStr}.json.gz`);
+      const { dest, cached, bytes } = await downloadOne(file, dateStr);
+      console.log(`  ${cached ? 'cached' : 'fetched'} ${(bytes/1024/1024).toFixed(1)}MB → ${dest}`);
+      const { total, inserted } = await bulkLoadStubs(source, dest, runId);
+      console.log(`  ${source}: ${total.toLocaleString()} records, ${inserted.toLocaleString()} stubs upserted`);
+      grandTotal += inserted;
+    }
+    await pool.query(`UPDATE asim_ingest_runs SET finished_at=NOW(), status='done', records_out=$1 WHERE id=$2`, [grandTotal, runId]);
+    console.log(`\n[tmdb-ids] DONE — ${grandTotal.toLocaleString()} stubs across ${EXPORTS.length} sources`);
+  } catch (e) {
+    console.error(`[tmdb-ids] FAILED:`, e.message);
+    await pool.query(`UPDATE asim_ingest_runs SET finished_at=NOW(), status='failed', notes=$1 WHERE id=$2`, [String(e.message).slice(0, 800), runId]);
+    process.exitCode = 1;
+  } finally {
+    await pool.end();
+  }
+}
+
+main();
diff --git a/scrapers/tmdb-fetch-worker.js b/scrapers/tmdb-fetch-worker.js
new file mode 100644
index 0000000..48b8aa4
--- /dev/null
+++ b/scrapers/tmdb-fetch-worker.js
@@ -0,0 +1,237 @@
+#!/usr/bin/env node
+'use strict';
+// Phase B — long-running worker that drains asim_ingest_stubs by hitting the
+// TMDB detail endpoints and writing into asim_movies / asim_people /
+// asim_credits. Picks the next highest-popularity queued stub each iteration.
+//
+// Rate limit: 30 req/sec safe under TMDB's 50/sec cap (leaves headroom for
+// other consumers + retries). Adaptive backoff on 429.
+//
+// Run modes:
+//   node tmdb-fetch-worker.js              # forever (until killed)
+//   node tmdb-fetch-worker.js --batch=500  # process N stubs then exit
+//   node tmdb-fetch-worker.js --source=tmdb-movie  # only one source
+//
+// Requires TMDB_API_KEY in .env. Without it the worker fast-exits with a
+// log line so the YOLO loop can surface to Steve.
+
+require('dotenv').config();
+const { Pool } = require('pg');
+
+const API_KEY = process.env.TMDB_API_KEY || '';
+const POOL = new Pool({
+  connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
+  max: 6,
+});
+
+const args = process.argv.slice(2).reduce((m, a) => {
+  const [k, v] = a.replace(/^--/, '').split('=');
+  m[k] = v ?? true;
+  return m;
+}, {});
+const BATCH = args.batch ? parseInt(args.batch, 10) : Infinity;
+const ONLY_SOURCE = args.source || null;
+const REQ_PER_SEC = parseInt(args.rps || '30', 10);
+const MIN_GAP_MS = Math.ceil(1000 / REQ_PER_SEC);
+let lastReqAt = 0;
+let fetched = 0;
+let upserted = 0;
+let failed = 0;
+
+async function rateGate() {
+  const now = Date.now();
+  const wait = MIN_GAP_MS - (now - lastReqAt);
+  if (wait > 0) await new Promise(r => setTimeout(r, wait));
+  lastReqAt = Date.now();
+}
+
+async function tmdbGet(pathPart) {
+  await rateGate();
+  const sep = pathPart.includes('?') ? '&' : '?';
+  const url = `https://api.themoviedb.org/3${pathPart}${sep}api_key=${API_KEY}`;
+  const r = await fetch(url);
+  if (r.status === 429) {
+    const retry = parseInt(r.headers.get('retry-after') || '2', 10) * 1000;
+    console.log(`[429] backing off ${retry}ms`);
+    await new Promise(res => setTimeout(res, retry));
+    return tmdbGet(pathPart);
+  }
+  if (r.status === 404) return null;
+  if (!r.ok) throw new Error(`TMDB ${r.status}: ${pathPart}`);
+  return r.json();
+}
+
+async function upsertMovie(d) {
+  if (!d || !d.id) return false;
+  const release_year = d.release_date ? parseInt(d.release_date.slice(0, 4), 10) : null;
+  const posterUrl = d.poster_path ? `https://image.tmdb.org/t/p/w500${d.poster_path}` : null;
+  const backdropUrl = d.backdrop_path ? `https://image.tmdb.org/t/p/w1280${d.backdrop_path}` : null;
+  await POOL.query(`
+    INSERT INTO asim_movies (
+      tmdb_id, imdb_id, title, original_title, release_year, release_date,
+      overview, tagline, runtime_min, genres, original_language, homepage,
+      status, budget, revenue, vote_average, vote_count, popularity,
+      collection_id, collection_name,
+      poster_source, poster_url, backdrop_source, backdrop_url, updated_at
+    ) VALUES (
+      $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
+      $21,$22,$23,$24,NOW()
+    )
+    ON CONFLICT (tmdb_id) DO UPDATE SET
+      imdb_id = COALESCE(EXCLUDED.imdb_id, asim_movies.imdb_id),
+      title = EXCLUDED.title,
+      original_title = EXCLUDED.original_title,
+      release_year = EXCLUDED.release_year,
+      release_date = EXCLUDED.release_date,
+      overview = EXCLUDED.overview,
+      tagline = EXCLUDED.tagline,
+      runtime_min = EXCLUDED.runtime_min,
+      genres = EXCLUDED.genres,
+      original_language = EXCLUDED.original_language,
+      homepage = EXCLUDED.homepage,
+      status = EXCLUDED.status,
+      budget = EXCLUDED.budget,
+      revenue = EXCLUDED.revenue,
+      vote_average = EXCLUDED.vote_average,
+      vote_count = EXCLUDED.vote_count,
+      popularity = EXCLUDED.popularity,
+      collection_id = EXCLUDED.collection_id,
+      collection_name = EXCLUDED.collection_name,
+      poster_url = EXCLUDED.poster_url,
+      backdrop_url = EXCLUDED.backdrop_url,
+      updated_at = NOW()
+  `, [
+    d.id, d.imdb_id || null, d.title, d.original_title || null, release_year,
+    d.release_date || null, d.overview || null, d.tagline || null,
+    d.runtime || null, (d.genres || []).map(g => g.name), d.original_language || null,
+    d.homepage || null, d.status || null, d.budget || null, d.revenue || null,
+    d.vote_average || null, d.vote_count || null, d.popularity || null,
+    d.belongs_to_collection?.id || null, d.belongs_to_collection?.name || null,
+    posterUrl ? 'tmdb-attribution' : 'made-with-ai', posterUrl,
+    backdropUrl ? 'tmdb-attribution' : 'made-with-ai', backdropUrl,
+  ]);
+  return true;
+}
+
+async function upsertPerson(d) {
+  if (!d || !d.id) return false;
+  const birthYear = d.birthday ? parseInt(d.birthday.slice(0, 4), 10) : null;
+  const deathYear = d.deathday ? parseInt(d.deathday.slice(0, 4), 10) : null;
+  const headshot = d.profile_path ? `https://image.tmdb.org/t/p/w500${d.profile_path}` : null;
+  await POOL.query(`
+    INSERT INTO asim_people (
+      tmdb_id, imdb_id, full_name, also_known_as,
+      birth_year, birth_date, death_year, death_date, birth_place,
+      primary_profession, bio,
+      headshot_source, headshot_url, updated_at
+    ) VALUES (
+      $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW()
+    )
+    ON CONFLICT (tmdb_id) DO UPDATE SET
+      imdb_id = COALESCE(EXCLUDED.imdb_id, asim_people.imdb_id),
+      full_name = EXCLUDED.full_name,
+      also_known_as = EXCLUDED.also_known_as,
+      birth_year = EXCLUDED.birth_year,
+      birth_date = EXCLUDED.birth_date,
+      death_year = EXCLUDED.death_year,
+      death_date = EXCLUDED.death_date,
+      birth_place = EXCLUDED.birth_place,
+      primary_profession = EXCLUDED.primary_profession,
+      bio = EXCLUDED.bio,
+      headshot_url = COALESCE(EXCLUDED.headshot_url, asim_people.headshot_url),
+      updated_at = NOW()
+  `, [
+    d.id, d.imdb_id || null, d.name, d.also_known_as || null,
+    birthYear, d.birthday || null, deathYear, d.deathday || null, d.place_of_birth || null,
+    [d.known_for_department || null].filter(Boolean), d.biography || null,
+    headshot ? 'tmdb-attribution' : 'made-with-ai', headshot,
+  ]);
+  return true;
+}
+
+// TMDB endpoints per source
+const ENDPOINT = {
+  'tmdb-movie':  (id) => `/movie/${id}?language=en-US&append_to_response=external_ids`,
+  'tmdb-tv':     (id) => `/tv/${id}?language=en-US&append_to_response=external_ids`,
+  'tmdb-person': (id) => `/person/${id}?language=en-US&append_to_response=external_ids`,
+  'tmdb-collection': (id) => `/collection/${id}`,
+  'tmdb-company':    (id) => `/company/${id}`,
+  'tmdb-network':    (id) => `/network/${id}`,
+  'tmdb-keyword':    (id) => `/keyword/${id}`,
+};
+
+async function processOne(stub) {
+  const ep = ENDPOINT[stub.source];
+  if (!ep) {
+    await POOL.query(`UPDATE asim_ingest_stubs SET status='skipped', last_error='unknown source' WHERE id=$1`, [stub.id]);
+    return;
+  }
+  try {
+    const d = await tmdbGet(ep(stub.source_id));
+    if (!d) {
+      await POOL.query(`UPDATE asim_ingest_stubs SET status='gone', fetched_at=NOW() WHERE id=$1`, [stub.id]);
+      return;
+    }
+    if (stub.source === 'tmdb-movie') await upsertMovie(d);
+    else if (stub.source === 'tmdb-person') await upsertPerson(d);
+    // TV / collection / company / network / keyword — schemas land in tick 4+;
+    // for now mark them done so the queue advances.
+    await POOL.query(`UPDATE asim_ingest_stubs SET status='done', fetched_at=NOW() WHERE id=$1`, [stub.id]);
+    upserted++;
+  } catch (e) {
+    failed++;
+    await POOL.query(
+      `UPDATE asim_ingest_stubs SET status=CASE WHEN attempts >= 3 THEN 'failed' ELSE 'queued' END,
+         attempts = attempts + 1, last_error=$2 WHERE id=$1`,
+      [stub.id, String(e.message).slice(0, 400)]
+    );
+  }
+  fetched++;
+}
+
+async function nextBatch(n) {
+  const params = [n];
+  let where = `WHERE status='queued'`;
+  if (ONLY_SOURCE) { params.unshift(ONLY_SOURCE); where = `WHERE source=$1 AND status='queued'`; }
+  const sql = `
+    UPDATE asim_ingest_stubs SET status='in_flight'
+      WHERE id IN (
+        SELECT id FROM asim_ingest_stubs ${where}
+          ORDER BY popularity DESC NULLS LAST, id ASC LIMIT $${params.length}
+          FOR UPDATE SKIP LOCKED
+      )
+    RETURNING id, source, source_id, popularity
+  `;
+  const r = await POOL.query(sql, params);
+  return r.rows;
+}
+
+async function main() {
+  if (!API_KEY) {
+    console.error('[tmdb-fetch] TMDB_API_KEY not set in .env — exiting.');
+    console.error('  Register a free key at https://www.themoviedb.org/settings/api');
+    console.error('  Then add to ~/Projects/asseeninmovies/.env and re-run.');
+    process.exit(2);
+  }
+  const startedAt = Date.now();
+  console.log(`[tmdb-fetch] starting · rps=${REQ_PER_SEC} · batch=${BATCH === Infinity ? '∞' : BATCH} · source=${ONLY_SOURCE || 'any'}`);
+  while (fetched < BATCH) {
+    const stubs = await nextBatch(Math.min(40, BATCH - fetched));
+    if (stubs.length === 0) {
+      console.log('[tmdb-fetch] queue empty — sleeping 60s then re-checking');
+      await new Promise(r => setTimeout(r, 60000));
+      continue;
+    }
+    // Run with bounded parallelism (rate-gate handles the actual throttle).
+    await Promise.all(stubs.map(processOne));
+    const elapsedSec = (Date.now() - startedAt) / 1000;
+    const rate = (fetched / elapsedSec).toFixed(1);
+    if (fetched % 200 === 0 || fetched < 50) {
+      console.log(`[tmdb-fetch] ${fetched} fetched · ${upserted} upserted · ${failed} failed · ${rate}/s`);
+    }
+  }
+  console.log(`[tmdb-fetch] batch done · ${fetched} fetched · ${upserted} upserted · ${failed} failed`);
+  await POOL.end();
+}
+
+main().catch(e => { console.error('fatal:', e); process.exit(1); });
diff --git a/server.js b/server.js
index 66ac76e..dfd47ef 100644
--- a/server.js
+++ b/server.js
@@ -122,6 +122,34 @@ app.get('/api/person/:id', async (req, res) => {
   }
 });
 
+// Live ingest progress — used by the YOLO loop + a tiny status badge on /
+app.get('/api/ingest/status', async (_req, res) => {
+  try {
+    const stubs = await pool.query(`
+      SELECT source, status, COUNT(*)::bigint AS n
+        FROM asim_ingest_stubs GROUP BY source, status ORDER BY source, status
+    `);
+    const runs = await pool.query(`
+      SELECT id, run_kind, source, started_at, finished_at, status, records_in, records_out, notes
+        FROM asim_ingest_runs ORDER BY started_at DESC LIMIT 10
+    `);
+    // Aggregate per-source done/queued/total for the bar chart.
+    const bySource = {};
+    for (const r of stubs.rows) {
+      const s = bySource[r.source] = bySource[r.source] || { source: r.source, queued: 0, done: 0, in_flight: 0, failed: 0, gone: 0, skipped: 0, total: 0 };
+      s[r.status] = Number(r.n);
+      s.total += Number(r.n);
+    }
+    res.json({
+      sources: Object.values(bySource).sort((a, b) => b.total - a.total),
+      recent_runs: runs.rows,
+      tmdb_key_set: Boolean(process.env.TMDB_API_KEY),
+    });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // SPOTTED feed — most recent verified placements across all films
 app.get('/api/spotted', async (req, res) => {
   const limit = Math.min(parseInt(req.query.limit, 10) || 60, 200);
@@ -198,7 +226,12 @@ app.get('/', (_req, res) => {
     <small>API: <code>/api/health</code>, <code>/api/search?q=</code>, <code>/api/movie/:id</code>, <code>/api/person/:id</code>, <code>/api/spotted</code></small>
   </div>
 </main>
-<footer>AsSeenInMovies · public-data only · &copy; Designer Wallcoverings</footer>
+<footer>
+  AsSeenInMovies · public-data only · &copy; Designer Wallcoverings<br>
+  <span style="font-size:10px;letter-spacing:0.1em;text-transform:uppercase;color:var(--muted)">
+    Movie metadata via <a href="https://www.themoviedb.org/" target="_blank" rel="noopener" style="color:var(--gold)">TMDB</a> — this product uses the TMDB API but is not endorsed or certified by TMDB.
+  </span>
+</footer>
 </body></html>`);
 });
 

← 92aef22 feat(asseeninmovies/tick0): scaffold + schema + AI placehold  ·  back to AsSeenInMovies  ·  tick2: thesetdecorator roster importer + uniq idx on imdb_id 6c82c50 →