#!/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//.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 only fetch one kind: "movies" or "tv" (default: both) --force re-fetch even for items that already have an image --sleep 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); }