← back to Nineoh Guide
auto-save: 2026-07-27T12:20:30 (1 files) — scripts/
2aba195bdda110b8c92cbf17aaae08e831f8edb2 · 2026-07-27 12:20:36 -0700 · Steve Abrams
Files touched
A scripts/seed-episode-cast.mjs
Diff
commit 2aba195bdda110b8c92cbf17aaae08e831f8edb2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 27 12:20:36 2026 -0700
auto-save: 2026-07-27T12:20:30 (1 files) — scripts/
---
scripts/seed-episode-cast.mjs | 78 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 78 insertions(+)
diff --git a/scripts/seed-episode-cast.mjs b/scripts/seed-episode-cast.mjs
new file mode 100644
index 0000000..f2005a0
--- /dev/null
+++ b/scripts/seed-episode-cast.mjs
@@ -0,0 +1,78 @@
+// Seed episode_guest_cast from TVmaze (factual, no API key) for every episode
+// that carries a tvmaze external id. Idempotent: clears + reinserts per episode.
+// Also prints an aggregate of recurring guest people (episode counts) so we can
+// pick the real recurring cast to add to the Cast tab.
+//
+// node scripts/seed-episode-cast.mjs
+//
+import { readFileSync } from "node:fs";
+import { Pool } from "pg";
+
+// --- load DATABASE_URL from apps/web/.env.local ---
+const env = readFileSync(new URL("../apps/web/.env.local", import.meta.url), "utf8");
+const DB = env.match(/^DATABASE_URL=(.+)$/m)?.[1]?.replace(/^["']|["']$/g, "");
+if (!DB) throw new Error("DATABASE_URL not found in apps/web/.env.local");
+const pool = new Pool({ connectionString: DB, max: 4 });
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function tvmaze(url, tries = 3) {
+ for (let i = 0; i < tries; i++) {
+ const res = await fetch(url);
+ if (res.status === 429) { await sleep(1500); continue; }
+ if (!res.ok) throw new Error(`${res.status} ${url}`);
+ return res.json();
+ }
+ throw new Error(`retries exhausted ${url}`);
+}
+
+const { rows: eps } = await pool.query(
+ `select id, season_number, episode_number, external_ids
+ from episodes order by season_number, episode_number`
+);
+console.log(`episodes in DB: ${eps.length}`);
+
+const agg = new Map(); // person -> { count, character }
+let seeded = 0;
+
+for (const ep of eps) {
+ const tv = ep.external_ids?.tvmaze;
+ if (!tv) { console.log(` S${ep.season_number}E${ep.episode_number} — no tvmaze id, skip`); continue; }
+ let data;
+ try {
+ data = await tvmaze(`https://api.tvmaze.com/episodes/${tv}?embed=guestcast`);
+ } catch (e) {
+ console.log(` S${ep.season_number}E${ep.episode_number} — fetch failed: ${e.message}`);
+ continue;
+ }
+ const guests = (data._embedded?.guestcast ?? []).map((c, i) => ({
+ name: c.person?.name ?? null,
+ character: c.character?.name ?? null,
+ ord: i,
+ })).filter((g) => g.name);
+
+ await pool.query(`delete from episode_guest_cast where episode_id = $1`, [ep.id]);
+ for (const g of guests) {
+ await pool.query(
+ `insert into episode_guest_cast (episode_id, person_name, character_name, ord)
+ values ($1,$2,$3,$4)`,
+ [ep.id, g.name, g.character, g.ord]
+ );
+ const k = g.name;
+ const cur = agg.get(k) ?? { count: 0, character: g.character };
+ cur.count++;
+ agg.set(k, cur);
+ }
+ seeded += guests.length;
+ process.stdout.write(`\r seeded S${ep.season_number}E${ep.episode_number} (+${guests.length}) `);
+ await sleep(140); // be polite to TVmaze
+}
+
+console.log(`\n\ntotal guest-cast rows: ${seeded}`);
+console.log(`\n=== recurring guests (>=3 episodes) — candidates for the Cast tab ===`);
+[...agg.entries()]
+ .filter(([, v]) => v.count >= 3)
+ .sort((a, b) => b[1].count - a[1].count)
+ .forEach(([name, v]) => console.log(`${String(v.count).padStart(3)} ${name} — ${v.character ?? "?"}`));
+
+await pool.end();
← 70d1038 feat(mobile): add Watch tab — scrollable YouTube / podcasts
·
back to Nineoh Guide
·
feat: per-episode cast on every episode + recurring/guest st e26dca5 →