← back to AsSeenInMovies
scrapers/tmdb-fetch-worker.js
238 lines
#!/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); });