[object Object]

← 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

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 &amp; 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 →