mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
a2f723ca2d
Phase 1 of the functional design plan. - Move the old site into legacy/, unchanged, for reference. It still builds from there until cutover. - Add packages/schema: the one definition of all 20 note types, with field titles and descriptions, note and vault checks, and JSON Schema export so later editing tools and the MCP server share it. - Add vault-kit: Obsidian templates generated from the schema, and the Site guide for 24.01_PersonalWebsite. - Add tools/migrate: turns the legacy JSON, collections, store data and page text into 545 Site folder notes and 84 images, and checks the result with the schema. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
197 lines
7.1 KiB
JavaScript
197 lines
7.1 KiB
JavaScript
#!/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);
|
||
}
|