Big content update

This commit is contained in:
Greg Kilmer
2026-07-11 20:31:13 -04:00
parent 67f3fd311f
commit e0f41c98a7
76 changed files with 3659 additions and 165 deletions
+196
View File
@@ -0,0 +1,196 @@
#!/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);
}