← back to Nineoh Guide
feat: per-episode cast on every episode + recurring/guest stars in Cast tab
e26dca57166ea39667cb1ab3d373351294caa333 · 2026-07-27 12:25:15 -0700 · Steve Abrams
- seed episode_guest_cast from TVmaze (1160 rows, all 114 eps) + 78 recurring cast_people
- /api/episodes returns each episode's cast (regulars + that episode's guests)
- /api/cast includes recurring members with a kind flag; EpisodeSchema gains cast[]
- mobile: star line on each episode card + Main/Recurring grouping in Cast tab
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M apps/mobile/App.tsxM apps/web/app/api/cast/route.tsM apps/web/app/api/episodes/route.tsM packages/core/src/schemas.tsA scripts/seed-recurring-cast.mjs
Diff
commit e26dca57166ea39667cb1ab3d373351294caa333
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 27 12:25:15 2026 -0700
feat: per-episode cast on every episode + recurring/guest stars in Cast tab
- seed episode_guest_cast from TVmaze (1160 rows, all 114 eps) + 78 recurring cast_people
- /api/episodes returns each episode's cast (regulars + that episode's guests)
- /api/cast includes recurring members with a kind flag; EpisodeSchema gains cast[]
- mobile: star line on each episode card + Main/Recurring grouping in Cast tab
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
apps/mobile/App.tsx | 107 ++++++++++++++++++++++++-------------
apps/web/app/api/cast/route.ts | 44 +++++++++------
apps/web/app/api/episodes/route.ts | 40 ++++++++++++--
packages/core/src/schemas.ts | 5 ++
scripts/seed-recurring-cast.mjs | 70 ++++++++++++++++++++++++
5 files changed, 210 insertions(+), 56 deletions(-)
diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index a41bbda..efc85d7 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -129,6 +129,7 @@ type CastMember = {
bio: string | null;
photoUrl: string | null;
attribution: string | null;
+ kind?: string; // "main-cast" | "recurring"
};
type NewsItem = {
id: string;
@@ -289,6 +290,16 @@ export default function App() {
) : (
<Text style={styles.pending}>Recap coming soon</Text>
)}
+ {ep.cast && ep.cast.length > 0 ? (
+ <Text style={styles.epCast}>
+ <Text style={styles.epCastLabel}>★ </Text>
+ {ep.cast
+ .slice(0, 8)
+ .map((c) => c.name)
+ .join(", ")}
+ {ep.cast.length > 8 ? ` +${ep.cast.length - 8} more` : ""}
+ </Text>
+ ) : null}
<View style={styles.epActions}>
<Pressable
onPress={() => markWatched(ep.seasonNumber, ep.episodeNumber)}
@@ -313,6 +324,37 @@ export default function App() {
);
};
+ const renderCastCard = (p: CastMember) => (
+ <BlurView key={p.id} intensity={28} tint="light" style={styles.card}>
+ <View style={styles.castRow}>
+ {p.photoUrl ? (
+ <Image source={{ uri: p.photoUrl }} style={styles.headshot} />
+ ) : (
+ <View style={[styles.headshot, styles.headshotBlank]}>
+ <Text style={styles.headshotInitial}>{p.name.slice(0, 1)}</Text>
+ </View>
+ )}
+ <View style={{ flex: 1 }}>
+ <Text style={styles.cardTitle}>{p.name}</Text>
+ {p.characterName ? (
+ <Text style={styles.castChar}>as {p.characterName}</Text>
+ ) : null}
+ </View>
+ </View>
+ {p.bio ? (
+ <Text style={styles.cardBody}>{p.bio}</Text>
+ ) : (
+ <Text style={styles.pending}>Bio coming soon</Text>
+ )}
+ {p.attribution ? (
+ <Text style={styles.attribution}>{p.attribution}</Text>
+ ) : null}
+ </BlurView>
+ );
+
+ const mainCast = cast.filter((p) => p.kind === "main-cast");
+ const recurringCast = cast.filter((p) => p.kind !== "main-cast");
+
return (
<View style={styles.root}>
<LinearGradient
@@ -409,43 +451,18 @@ export default function App() {
</Text>
)
) : (
- cast.map((p) => (
- <BlurView
- key={p.id}
- intensity={28}
- tint="light"
- style={styles.card}
- >
- <View style={styles.castRow}>
- {p.photoUrl ? (
- <Image
- source={{ uri: p.photoUrl }}
- style={styles.headshot}
- />
- ) : (
- <View style={[styles.headshot, styles.headshotBlank]}>
- <Text style={styles.headshotInitial}>
- {p.name.slice(0, 1)}
- </Text>
- </View>
- )}
- <View style={{ flex: 1 }}>
- <Text style={styles.cardTitle}>{p.name}</Text>
- {p.characterName ? (
- <Text style={styles.castChar}>as {p.characterName}</Text>
- ) : null}
- </View>
- </View>
- {p.bio ? (
- <Text style={styles.cardBody}>{p.bio}</Text>
- ) : (
- <Text style={styles.pending}>Bio coming soon</Text>
- )}
- {p.attribution ? (
- <Text style={styles.attribution}>{p.attribution}</Text>
- ) : null}
- </BlurView>
- ))
+ <>
+ {mainCast.length > 0 ? (
+ <Text style={styles.castGroup}>Main cast</Text>
+ ) : null}
+ {mainCast.map(renderCastCard)}
+ {recurringCast.length > 0 ? (
+ <Text style={styles.castGroup}>
+ Recurring & guest stars ({recurringCast.length})
+ </Text>
+ ) : null}
+ {recurringCast.map(renderCastCard)}
+ </>
)}
</>
) : null}
@@ -616,6 +633,22 @@ const styles = StyleSheet.create({
},
markWatched: { fontSize: 11, color: "#888" },
epLink: { fontSize: 12, color: "#7b3f6e", fontWeight: "700" },
+ epCast: {
+ fontSize: 12,
+ color: "#5a4a56",
+ marginTop: 8,
+ lineHeight: 17,
+ },
+ epCastLabel: { color: "#b8860b", fontWeight: "700" },
+ castGroup: {
+ fontSize: 13,
+ fontWeight: "700",
+ color: "#7b3f6e",
+ marginTop: 10,
+ marginBottom: 8,
+ textTransform: "uppercase",
+ letterSpacing: 0.5,
+ },
chipGuard: { backgroundColor: "#f3e9e9", borderColor: "#d8b4b4" },
chipGuardText: { fontSize: 13, color: "#8a1c1c" },
// cast
diff --git a/apps/web/app/api/cast/route.ts b/apps/web/app/api/cast/route.ts
index 2170916..b041ebc 100644
--- a/apps/web/app/api/cast/route.ts
+++ b/apps/web/app/api/cast/route.ts
@@ -9,27 +9,41 @@ export const dynamic = "force-dynamic";
*/
export async function GET() {
try {
+ // Main cast + recurring/guest stars. `kind` lets the client group them.
const { rows } = await pool.query(
- `select cp.id, cp.name, cp.biography_original,
- ch.name as character_name,
+ `select distinct on (cp.id)
+ cp.id, cp.name, cp.biography_original,
+ ch.name as character_name, cr.credit_type as kind, cr.billing_order,
a.file_url, a.attribution_text, a.license_type, a.license_url
from cast_people cp
- left join credits cr on cr.person_id = cp.id and cr.credit_type = 'main-cast'
+ left join credits cr on cr.person_id = cp.id
+ and cr.credit_type in ('main-cast','recurring')
left join characters ch on ch.id = cr.character_id
left join assets a on a.id = cp.headshot_asset_id
- order by cp.name
- limit 200`
+ order by cp.id,
+ (cr.credit_type = 'main-cast') desc nulls last,
+ cr.billing_order nulls last`
);
- const cast = rows.map((r) => ({
- id: r.id,
- name: r.name,
- characterName: r.character_name ?? null,
- bio: r.biography_original ?? null,
- photoUrl: r.file_url ?? null,
- attribution: r.attribution_text ?? null,
- licenseType: r.license_type ?? null,
- licenseUrl: r.license_url ?? null,
- }));
+ const cast = rows
+ .map((r) => ({
+ id: r.id,
+ name: r.name,
+ characterName: r.character_name ?? null,
+ bio: r.biography_original ?? null,
+ photoUrl: r.file_url ?? null,
+ attribution: r.attribution_text ?? null,
+ licenseType: r.license_type ?? null,
+ licenseUrl: r.license_url ?? null,
+ kind: (r.kind as string) ?? "recurring",
+ billingOrder: r.billing_order as number | null,
+ }))
+ // main cast first, then recurring by episode count (billing_order = -eps)
+ .sort((a, b) => {
+ const am = a.kind === "main-cast" ? 0 : 1;
+ const bm = b.kind === "main-cast" ? 0 : 1;
+ if (am !== bm) return am - bm;
+ return (a.billingOrder ?? 0) - (b.billingOrder ?? 0);
+ });
return NextResponse.json({ cast });
} catch (err) {
console.error("[api/cast] DB error:", err);
diff --git a/apps/web/app/api/episodes/route.ts b/apps/web/app/api/episodes/route.ts
index cdd6327..b9502cc 100644
--- a/apps/web/app/api/episodes/route.ts
+++ b/apps/web/app/api/episodes/route.ts
@@ -19,9 +19,40 @@ export async function GET() {
order by season_number, episode_number
limit 500`
);
+ // Series regulars (attach to every episode — they're the show's stars).
+ const { rows: regRows } = await pool.query(
+ `select cp.name, ch.name as character
+ from credits cr
+ join cast_people cp on cp.id = cr.person_id
+ left join characters ch on ch.id = cr.character_id
+ where cr.credit_type = 'main-cast'
+ order by coalesce(cr.billing_order, 999), cp.name`
+ );
+ const regulars = regRows.map((r) => ({
+ name: r.name as string,
+ character: (r.character ?? null) as string | null,
+ }));
+
+ // This episode's guest stars (factual, per-episode, from TVmaze).
+ const { rows: guestRows } = await pool.query(
+ `select episode_id, person_name, character_name
+ from episode_guest_cast
+ order by episode_id, ord`
+ );
+ const guestsByEp = new Map<string, { name: string; character: string | null }[]>();
+ for (const g of guestRows) {
+ const arr = guestsByEp.get(g.episode_id) ?? [];
+ arr.push({ name: g.person_name, character: g.character_name ?? null });
+ guestsByEp.set(g.episode_id, arr);
+ }
+
const episodes = rows
- .map((r) =>
- EpisodeSchema.safeParse({
+ .map((r) => {
+ const seen = new Set<string>();
+ const cast = [...regulars, ...(guestsByEp.get(r.id) ?? [])].filter((c) =>
+ seen.has(c.name) ? false : (seen.add(c.name), true)
+ );
+ return EpisodeSchema.safeParse({
id: r.id,
seasonNumber: r.season_number,
episodeNumber: r.episode_number,
@@ -33,8 +64,9 @@ export async function GET() {
summaryFullOriginal: r.summary_full_original,
spoilerRating: r.spoiler_rating ?? 0,
externalIds: r.external_ids ?? {},
- })
- )
+ cast,
+ });
+ })
.filter((p) => p.success)
.map((p) => p.data);
diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts
index 82df3e8..ad90cca 100644
--- a/packages/core/src/schemas.ts
+++ b/packages/core/src/schemas.ts
@@ -49,6 +49,11 @@ export const EpisodeSchema = z.object({
summaryFullOriginal: z.string().nullable(),
spoilerRating: z.number().int().min(0).max(3).default(0),
externalIds: z.record(z.string()).default({}),
+ // Featured cast for this episode: series regulars + this episode's guest
+ // stars (factual, sourced from TVmaze). Defaults to [] for older payloads.
+ cast: z
+ .array(z.object({ name: z.string(), character: z.string().nullable() }))
+ .default([]),
});
export type Episode = z.infer<typeof EpisodeSchema>;
diff --git a/scripts/seed-recurring-cast.mjs b/scripts/seed-recurring-cast.mjs
new file mode 100644
index 0000000..da6e2f2
--- /dev/null
+++ b/scripts/seed-recurring-cast.mjs
@@ -0,0 +1,70 @@
+// Promote recurring guest actors (>= MIN_EPS episodes) into cast_people +
+// characters + credits(credit_type='recurring'), so the Cast tab lists them
+// alongside the 14 mains. Bios are ORIGINAL and grounded only in our own
+// episode-count data (no fabricated external facts). Idempotent by name.
+//
+// node scripts/seed-recurring-cast.mjs
+//
+import { readFileSync } from "node:fs";
+import { Pool } from "pg";
+
+const MIN_EPS = 4;
+const env = readFileSync(new URL("../apps/web/.env.local", import.meta.url), "utf8");
+const DB = env.match(/^DATABASE_URL=(.+)$/m)?.[1]?.replace(/^["']|["']$/g, "");
+const pool = new Pool({ connectionString: DB, max: 4 });
+
+// show_id (all characters/credits hang off it)
+const { rows: showRows } = await pool.query(`select id from shows limit 1`);
+const showId = showRows[0].id;
+
+const { rows: people } = await pool.query(
+ `select person_name,
+ (array_agg(character_name order by ord))[1] as character,
+ count(distinct episode_id) as eps
+ from episode_guest_cast
+ where person_name not in (select name from cast_people)
+ group by person_name
+ having count(distinct episode_id) >= $1
+ order by eps desc`,
+ [MIN_EPS]
+);
+console.log(`recurring actors to add (>= ${MIN_EPS} eps): ${people.length}`);
+
+let added = 0;
+for (const p of people) {
+ const bio = `Recurring guest star who appears as ${p.character ?? "a recurring character"} in ${p.eps} episodes of the reboot. Part of the extended 90210 ensemble beyond the core cast.`;
+
+ // cast_people (skip if somehow already there)
+ const cp = await pool.query(
+ `insert into cast_people (name, biography_original, bio_status, publicity_risk_level)
+ values ($1,$2,'ai-draft','editorial-only')
+ on conflict do nothing
+ returning id`,
+ [p.person_name, bio]
+ );
+ let personId = cp.rows[0]?.id;
+ if (!personId) {
+ const ex = await pool.query(`select id from cast_people where name=$1`, [p.person_name]);
+ personId = ex.rows[0].id;
+ }
+
+ // character
+ let charId = null;
+ if (p.character) {
+ const ch = await pool.query(
+ `insert into characters (name, show_id) values ($1,$2) returning id`,
+ [p.character, showId]
+ );
+ charId = ch.rows[0].id;
+ }
+
+ // recurring credit — billing_order negative-eps so higher counts sort first
+ await pool.query(
+ `insert into credits (person_id, character_id, credit_type, billing_order)
+ values ($1,$2,'recurring',$3)`,
+ [personId, charId, -p.eps]
+ );
+ added++;
+}
+console.log(`added ${added} recurring cast members`);
+await pool.end();
← 2aba195 auto-save: 2026-07-27T12:20:30 (1 files) — scripts/
·
back to Nineoh Guide
·
auto-save: 2026-07-27T12:50:40 (1 files) — apps/web/app/api/ 7786419 →