Files
com.gregkilmer.www/scripts/fetch-tmdb-art.mjs
T
2026-07-11 20:31:13 -04:00

197 lines
7.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* fetch-tmdb-art.mjs
* ---------------------------------------------------------------------------
* Support tool for the Movies and TV pages. For each favorite item (those with
* `favorite: true`) in src/data/media/movies.json and src/data/media/tv.json
* that is missing an `image`, it looks the title up on the TMDB API and
* downloads the poster to public/images/<kind>/<slug>.jpg, then writes the
* local public path back into the JSON.
*
* Idempotent: items that already have an `image` are skipped. Be gentle on TMDB
* — there's a small delay between requests.
*
* PREREQUISITES
* 1. Get a free TMDB API key: https://www.themoviedb.org/settings/api
* 2. Put it in a `.env` file at the project root:
* TMDB_API_KEY=your_key_here
* (`.env` is gitignored; `.env.example` is the template.)
*
* USAGE
* node scripts/fetch-tmdb-art.mjs # fetch both movies + tv
* node scripts/fetch-tmdb-art.mjs --kind movies # movies only
* node scripts/fetch-tmdb-art.mjs --kind tv # tv only
* node scripts/fetch-tmdb-art.mjs --force # re-fetch even if image set
* node scripts/fetch-tmdb-art.mjs --sleep 250 # ms between TMDB calls
* node scripts/fetch-tmdb-art.mjs --help
*
* Run with `--env-file=.env` to auto-load the key (Node 20.6+):
* node --env-file=.env scripts/fetch-tmdb-art.mjs
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..');
const DATA_DIR = join(PROJECT_ROOT, 'src', 'data', 'media');
const IMAGES_ROOT = join(PROJECT_ROOT, 'public', 'images');
const TMDB_BASE = 'https://api.themoviedb.org/3';
const TMDB_IMG = 'https://image.tmdb.org/t/p/w500';
// --- args -------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
console.log(`Usage: node scripts/fetch-tmdb-art.mjs [options]
Reads movies.json + tv.json favorites and fills in the "image" field for any
favorite missing one, using the TMDB API (free key required).
Options:
--kind <k> only fetch one kind: "movies" or "tv" (default: both)
--force re-fetch even for items that already have an image
--sleep <ms> delay between TMDB API calls (default 250)
-h, --help show this help
Tip: run with \`node --env-file=.env scripts/fetch-tmdb-art.mjs ...\` to auto-load
the TMDB_API_KEY from your .env file.`);
process.exit(0);
}
const force = args.includes('--force');
const sleepMsIdx = args.indexOf('--sleep');
const sleepMs = sleepMsIdx !== -1 ? Number(args[sleepMsIdx + 1]) || 250 : 250;
const kindIdx = args.indexOf('--kind');
const kindFilter = kindIdx !== -1 ? args[kindIdx + 1] : null;
if (kindFilter && !['movies', 'tv'].includes(kindFilter)) {
console.error(`✖ Unknown --kind "${kindFilter}". Use "movies" or "tv".`);
process.exit(1);
}
const API_KEY = process.env.TMDB_API_KEY;
if (!API_KEY) {
console.error(
'✖ TMDB_API_KEY is not set. Add it to .env (see .env.example) and run with\n' +
' node --env-file=.env scripts/fetch-tmdb-art.mjs',
);
process.exit(1);
}
// --- helpers ----------------------------------------------------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const slugify = (s) =>
s
.toLowerCase()
.replace(/['’.:()]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
/** Search TMDB for a title, returning the best result's poster path. */
async function fetchPoster(title, kind, year) {
const mediaType = kind === 'movies' ? 'movie' : 'tv';
const params = new URLSearchParams({
api_key: API_KEY,
query: title,
include_adult: 'false',
});
if (year) {
// TMDB uses different year param names per type.
params.set(mediaType === 'movie' ? 'year' : 'first_air_date_year', String(year));
}
const url = `${TMDB_BASE}/search/${mediaType}?${params}`;
const res = await fetch(url);
if (res.status === 429) {
console.warn(' ! rate-limited by TMDB (429). Wait and re-run; skipping rest.');
return { rateLimited: true };
}
if (!res.ok) throw new Error(`search ${res.status} ${res.statusText}`);
const data = await res.json();
const result = data.results?.find((r) => r.poster_path) ?? data.results?.[0];
if (!result?.poster_path) return null;
return { posterPath: result.poster_path, name: result.title ?? result.name };
}
// --- per-kind processing ----------------------------------------------------
async function processKind(kind) {
const DATA_FILE = join(DATA_DIR, `${kind}.json`);
const OUT_DIR = join(IMAGES_ROOT, kind);
const PUBLIC_PREFIX = `/images/${kind}`;
let items;
try {
items = JSON.parse(await readFile(DATA_FILE, 'utf8'));
} catch (err) {
if (err?.code === 'ENOENT') {
console.warn(` ! ${DATA_FILE} not found; skipping ${kind}.`);
return;
}
throw err;
}
const favorites = items.filter((i) => i.favorite);
console.log(
`\n→ ${kind}: ${favorites.length} favorite(s) in ${kind}.json` +
(force ? ' (force re-fetch)' : ''),
);
await mkdir(OUT_DIR, { recursive: true });
let changed = false;
for (let i = 0; i < favorites.length; i++) {
const item = favorites[i];
if (item.image && !force) {
console.log(` ✓ ${item.title} — already has image, skipping.`);
continue;
}
if (i > 0) await sleep(sleepMs);
console.log(`→ [${i + 1}/${favorites.length}] ${item.title}`);
let result;
try {
result = await fetchPoster(item.title, kind, item.year);
} catch (err) {
console.warn(` ! network error: ${err?.message ?? err}; skipping.`);
continue;
}
if (result?.rateLimited) break;
if (!result?.posterPath) {
console.warn(` ! no poster found for "${item.title}"; skipping.`);
continue;
}
const imageUrl = `${TMDB_IMG}${result.posterPath}`;
const fileName = `${slugify(item.title)}.jpg`;
let buf;
try {
const imgRes = await fetch(imageUrl);
if (!imgRes.ok) throw new Error(`${imgRes.status} ${imgRes.statusText}`);
buf = Buffer.from(await imgRes.arrayBuffer());
} catch (err) {
console.warn(` ! image download failed (${err?.message ?? err}); skipping.`);
continue;
}
await writeFile(join(OUT_DIR, fileName), buf);
item.image = `${PUBLIC_PREFIX}/${fileName}`;
// Write the path back onto the original array entry (same object reference).
const orig = items.find((o) => o.title === item.title && o.favorite);
if (orig) orig.image = item.image;
changed = true;
console.log(` ✔ ${item.title} → ${item.image}`);
}
if (changed) {
await writeFile(DATA_FILE, JSON.stringify(items, null, 2) + '\n', 'utf8');
console.log(`✔ Updated ${DATA_FILE}`);
} else {
console.log(`— Nothing to update for ${kind} (all favorites already had images).`);
}
}
// --- main -------------------------------------------------------------------
const kinds = kindFilter ? [kindFilter] : ['movies', 'tv'];
for (const k of kinds) {
await processKind(k);
}