From cd5d773e98be9369cb90cf30c81afdb3829c8077 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 5 Mar 2026 14:36:39 -0500 Subject: [PATCH] Add actor/cast information with person pages and search support Integrate TMDB credits data into the app: cast carousels on title detail pages, person detail pages with biography and filmography, and person search results in the command palette. Includes new persons/titleCast DB tables, profile image caching, and a nightly credits refresh cron job. Co-Authored-By: Claude Opus 4.6 --- .../[id]/_components/filmography-grid.tsx | 158 ++ .../person/[id]/_components/person-hero.tsx | 110 + app/(pages)/person/[id]/loading.tsx | 5 + app/(pages)/person/[id]/not-found.tsx | 19 + app/(pages)/person/[id]/page.tsx | 57 + .../titles/[id]/_components/cast-carousel.tsx | 128 + .../titles/[id]/_components/title-cast.tsx | 16 + app/(pages)/titles/[id]/page.tsx | 5 +- app/api/images/[...path]/route.ts | 1 + app/api/person/[id]/filmography/route.ts | 19 + app/api/person/[id]/route.ts | 35 + app/api/search/route.ts | 76 +- components/command-palette.tsx | 68 +- components/skeletons.tsx | 44 + .../migration.sql | 34 + .../snapshot.json | 2421 +++++++++++++++++ hooks/use-search.ts | 5 +- lib/cron.ts | 30 + lib/db/schema.ts | 56 + lib/services/credits.ts | 241 ++ lib/services/image-cache.ts | 42 +- lib/services/metadata.ts | 28 +- lib/services/person.ts | 240 ++ lib/tmdb/client.ts | 34 + lib/tmdb/image.ts | 1 + lib/tmdb/types.ts | 88 + lib/types/title.ts | 40 + 27 files changed, 3957 insertions(+), 44 deletions(-) create mode 100644 app/(pages)/person/[id]/_components/filmography-grid.tsx create mode 100644 app/(pages)/person/[id]/_components/person-hero.tsx create mode 100644 app/(pages)/person/[id]/loading.tsx create mode 100644 app/(pages)/person/[id]/not-found.tsx create mode 100644 app/(pages)/person/[id]/page.tsx create mode 100644 app/(pages)/titles/[id]/_components/cast-carousel.tsx create mode 100644 app/(pages)/titles/[id]/_components/title-cast.tsx create mode 100644 app/api/person/[id]/filmography/route.ts create mode 100644 app/api/person/[id]/route.ts create mode 100644 drizzle/20260305193509_overjoyed_pestilence/migration.sql create mode 100644 drizzle/20260305193509_overjoyed_pestilence/snapshot.json create mode 100644 lib/services/credits.ts create mode 100644 lib/services/person.ts diff --git a/app/(pages)/person/[id]/_components/filmography-grid.tsx b/app/(pages)/person/[id]/_components/filmography-grid.tsx new file mode 100644 index 0000000..05c9b94 --- /dev/null +++ b/app/(pages)/person/[id]/_components/filmography-grid.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { IconMovie } from "@tabler/icons-react"; +import { motion } from "motion/react"; +import Image from "next/image"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import type { PersonCredit } from "@/lib/types/title"; + +type Filter = "all" | "movie" | "tv"; +type Sort = "newest" | "rating"; + +const staggerContainer = { + hidden: {}, + visible: { transition: { staggerChildren: 0.04 } }, +}; + +const staggerItem = { + hidden: { opacity: 0, y: 12, scale: 0.98 }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: { type: "spring" as const, stiffness: 300, damping: 24 }, + }, +}; + +interface FilmographyGridProps { + credits: PersonCredit[]; +} + +export function FilmographyGrid({ credits }: FilmographyGridProps) { + const [filter, setFilter] = useState("all"); + const [sort, setSort] = useState("newest"); + + const filtered = useMemo(() => { + let list = credits; + if (filter !== "all") { + list = list.filter((c) => c.type === filter); + } + + // Deduplicate by titleId (keep the first credit per title) + const seen = new Set(); + list = list.filter((c) => { + if (seen.has(c.titleId)) return false; + seen.add(c.titleId); + return true; + }); + + return list.sort((a, b) => { + if (sort === "rating") { + return (b.voteAverage ?? 0) - (a.voteAverage ?? 0); + } + const dateA = a.releaseDate ?? a.firstAirDate ?? ""; + const dateB = b.releaseDate ?? b.firstAirDate ?? ""; + return dateB.localeCompare(dateA); + }); + }, [credits, filter, sort]); + + if (credits.length === 0) return null; + + const filters: { value: Filter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "movie", label: "Movies" }, + { value: "tv", label: "TV" }, + ]; + + return ( +
+
+
+ +

Filmography

+ + ({filtered.length}) + +
+ + +
+ +
+ {filters.map((f) => ( + + ))} +
+ + + {filtered.map((credit) => ( + + +
+
+ {credit.posterPath ? ( + {credit.title} + ) : ( +
+ +
+ )} +
+
+

{credit.title}

+
+ {credit.type} + {(credit.releaseDate ?? credit.firstAirDate) && ( + + {(credit.releaseDate ?? credit.firstAirDate)?.slice( + 0, + 4, + )} + + )} +
+ {credit.character && ( +

+ as {credit.character} +

+ )} +
+
+ +
+ ))} +
+
+ ); +} diff --git a/app/(pages)/person/[id]/_components/person-hero.tsx b/app/(pages)/person/[id]/_components/person-hero.tsx new file mode 100644 index 0000000..02e460f --- /dev/null +++ b/app/(pages)/person/[id]/_components/person-hero.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { IconCalendar, IconMapPin } from "@tabler/icons-react"; +import { motion } from "motion/react"; +import Image from "next/image"; +import { useState } from "react"; +import type { ResolvedPerson } from "@/lib/types/title"; + +interface PersonHeroProps { + person: ResolvedPerson; +} + +function calculateAge(birthday: string, deathday?: string | null): number { + const birth = new Date(birthday); + const end = deathday ? new Date(deathday) : new Date(); + let age = end.getFullYear() - birth.getFullYear(); + const m = end.getMonth() - birth.getMonth(); + if (m < 0 || (m === 0 && end.getDate() < birth.getDate())) { + age--; + } + return age; +} + +export function PersonHero({ person }: PersonHeroProps) { + const [bioExpanded, setBioExpanded] = useState(false); + const age = person.birthday + ? calculateAge(person.birthday, person.deathday) + : null; + + return ( + +
+ {person.profilePath ? ( + {person.name} + ) : ( +
+ + {person.name.charAt(0)} + +
+ )} +
+ +
+

+ {person.name} +

+ + {person.knownForDepartment && ( + + {person.knownForDepartment} + + )} + +
+ {person.birthday && ( + + + {person.birthday} + {age !== null && ( + + ({person.deathday ? `died at ${age}` : `age ${age}`}) + + )} + + )} + {person.placeOfBirth && ( + + + {person.placeOfBirth} + + )} +
+ + {person.biography && ( +
+

+ {person.biography} +

+ {person.biography.length > 400 && ( + + )} +
+ )} +
+
+ ); +} diff --git a/app/(pages)/person/[id]/loading.tsx b/app/(pages)/person/[id]/loading.tsx new file mode 100644 index 0000000..2d1baf9 --- /dev/null +++ b/app/(pages)/person/[id]/loading.tsx @@ -0,0 +1,5 @@ +import { PersonDetailSkeleton } from "@/components/skeletons"; + +export default function PersonLoading() { + return ; +} diff --git a/app/(pages)/person/[id]/not-found.tsx b/app/(pages)/person/[id]/not-found.tsx new file mode 100644 index 0000000..536ea85 --- /dev/null +++ b/app/(pages)/person/[id]/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +export default function PersonNotFound() { + return ( +
+

Person not found

+

+ The person you're looking for doesn't exist or may have been + removed. +

+ + Explore titles + +
+ ); +} diff --git a/app/(pages)/person/[id]/page.tsx b/app/(pages)/person/[id]/page.tsx new file mode 100644 index 0000000..48274c3 --- /dev/null +++ b/app/(pages)/person/[id]/page.tsx @@ -0,0 +1,57 @@ +import { eq } from "drizzle-orm"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getSession } from "@/lib/auth/session"; +import { db } from "@/lib/db/client"; +import { persons } from "@/lib/db/schema"; +import { + getLocalFilmography, + getOrFetchPerson, + getOrFetchPersonByTmdbId, +} from "@/lib/services/person"; +import { FilmographyGrid } from "./_components/filmography-grid"; +import { PersonHero } from "./_components/person-hero"; + +const TMDB_PATTERN = /^tmdb-(\d+)$/; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string }>; +}): Promise { + const { id } = await params; + if (TMDB_PATTERN.test(id)) return { title: "Sofa" }; + + const person = db.select().from(persons).where(eq(persons.id, id)).get(); + if (!person) return { title: "Not Found — Sofa" }; + + return { + title: `${person.name} — Sofa`, + description: person.biography?.slice(0, 160), + }; +} + +export default async function PersonDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + await getSession(); + + const tmdbMatch = TMDB_PATTERN.exec(id); + const person = tmdbMatch + ? await getOrFetchPersonByTmdbId(Number(tmdbMatch[1])) + : await getOrFetchPerson(id); + + if (!person) notFound(); + + const filmography = getLocalFilmography(person.id); + + return ( +
+ + +
+ ); +} diff --git a/app/(pages)/titles/[id]/_components/cast-carousel.tsx b/app/(pages)/titles/[id]/_components/cast-carousel.tsx new file mode 100644 index 0000000..a17b586 --- /dev/null +++ b/app/(pages)/titles/[id]/_components/cast-carousel.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { IconUser, IconUsers } from "@tabler/icons-react"; +import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; +import { motion } from "motion/react"; +import Image from "next/image"; +import Link from "next/link"; +import { + Carousel, + CarouselContent, + CarouselItem, +} from "@/components/ui/carousel"; +import type { CastMember } from "@/lib/types/title"; + +const staggerContainer = { + hidden: {}, + visible: { transition: { staggerChildren: 0.04 } }, +}; + +const staggerItem = { + hidden: { opacity: 0, y: 12, scale: 0.98 }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: { type: "spring" as const, stiffness: 300, damping: 24 }, + }, +}; + +interface CastCarouselProps { + actors: CastMember[]; + crew: CastMember[]; + titleType: "movie" | "tv"; +} + +export function CastCarousel({ actors, crew, titleType }: CastCarouselProps) { + return ( +
+
+ +

Cast

+
+ + {actors.length > 0 && ( + + + + {actors.map((member) => ( + + + +
+ {member.profilePath ? ( + {member.name} + ) : ( +
+ +
+ )} +
+
+

+ {member.name} +

+ {member.character && ( +

+ {member.character} +

+ )} + {titleType === "tv" && member.episodeCount && ( +

+ {member.episodeCount} ep + {member.episodeCount !== 1 ? "s" : ""} +

+ )} +
+ +
+
+ ))} +
+
+
+ )} + + {crew.length > 0 && ( +
+ {crew.map((member) => ( + + {member.name} + + ({member.job}) + + + ))} +
+ )} +
+ ); +} diff --git a/app/(pages)/titles/[id]/_components/title-cast.tsx b/app/(pages)/titles/[id]/_components/title-cast.tsx new file mode 100644 index 0000000..e4e805d --- /dev/null +++ b/app/(pages)/titles/[id]/_components/title-cast.tsx @@ -0,0 +1,16 @@ +import type { CastMember } from "@/lib/types/title"; +import { CastCarousel } from "./cast-carousel"; + +interface TitleCastProps { + cast: CastMember[]; + titleType: "movie" | "tv"; +} + +export function TitleCast({ cast, titleType }: TitleCastProps) { + const actors = cast.filter((c) => c.department === "Acting"); + const crew = cast.filter((c) => c.department !== "Acting"); + + if (actors.length === 0 && crew.length === 0) return null; + + return ; +} diff --git a/app/(pages)/titles/[id]/page.tsx b/app/(pages)/titles/[id]/page.tsx index f6af0dc..d349dc2 100644 --- a/app/(pages)/titles/[id]/page.tsx +++ b/app/(pages)/titles/[id]/page.tsx @@ -12,6 +12,7 @@ import { tmdbImageUrl } from "@/lib/tmdb/image"; import { getTitleThemeStyle } from "@/lib/utils/title-theme"; import { TitleActions } from "./_components/title-actions"; import { TitleAvailability } from "./_components/title-availability"; +import { TitleCast } from "./_components/title-cast"; import { TitleHero } from "./_components/title-hero"; import { TitleKeyboardShortcuts } from "./_components/title-keyboard-shortcuts"; import { TitleProvider } from "./_components/title-provider"; @@ -70,7 +71,7 @@ export default async function TitleDetailPage({ ]); if (!result) notFound(); - const { title, seasons, availability } = result; + const { title, seasons, availability, cast } = result; const themeStyle = getTitleThemeStyle(title.colorPalette); @@ -93,6 +94,8 @@ export default async function TitleDetailPage({ + + {title.type === "tv" && seasons.length > 0 && } diff --git a/app/api/images/[...path]/route.ts b/app/api/images/[...path]/route.ts index ec4ac84..76845a9 100644 --- a/app/api/images/[...path]/route.ts +++ b/app/api/images/[...path]/route.ts @@ -11,6 +11,7 @@ const VALID_CATEGORIES = new Set([ "backdrops", "stills", "logos", + "profiles", ]); const IMMUTABLE_CACHE = "public, max-age=31536000, immutable"; diff --git a/app/api/person/[id]/filmography/route.ts b/app/api/person/[id]/filmography/route.ts new file mode 100644 index 0000000..e1d3454 --- /dev/null +++ b/app/api/person/[id]/filmography/route.ts @@ -0,0 +1,19 @@ +import { headers } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth/server"; +import { fetchFullFilmography } from "@/lib/services/person"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const filmography = await fetchFullFilmography(id); + + return NextResponse.json({ filmography }); +} diff --git a/app/api/person/[id]/route.ts b/app/api/person/[id]/route.ts new file mode 100644 index 0000000..03b4a41 --- /dev/null +++ b/app/api/person/[id]/route.ts @@ -0,0 +1,35 @@ +import { headers } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth/server"; +import { + getLocalFilmography, + getOrFetchPerson, + getOrFetchPersonByTmdbId, +} from "@/lib/services/person"; + +const TMDB_PATTERN = /^tmdb-(\d+)$/; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const tmdbMatch = TMDB_PATTERN.exec(id); + const person = tmdbMatch + ? await getOrFetchPersonByTmdbId(Number(tmdbMatch[1])) + : await getOrFetchPerson(id); + + if (!person) { + return NextResponse.json({ error: "Person not found" }, { status: 404 }); + } + + const filmography = getLocalFilmography(person.id); + + return NextResponse.json({ person, filmography }); +} diff --git a/app/api/search/route.ts b/app/api/search/route.ts index fef6b42..f30f857 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -2,7 +2,12 @@ import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth/server"; import { isTmdbConfigured } from "@/lib/config"; -import { searchMovies, searchMulti, searchTv } from "@/lib/tmdb/client"; +import { + searchMovies, + searchMulti, + searchPerson, + searchTv, +} from "@/lib/tmdb/client"; import { tmdbImageUrl } from "@/lib/tmdb/image"; import type { TmdbSearchResponse } from "@/lib/tmdb/types"; @@ -26,12 +31,14 @@ export async function GET(req: NextRequest) { const query = req.nextUrl.searchParams.get("query")?.trim(); const rawType = req.nextUrl.searchParams.get("type"); - const type: "movie" | "tv" | null = - rawType === "movie" || rawType === "tv" ? rawType : null; + const type: "movie" | "tv" | "person" | null = + rawType === "movie" || rawType === "tv" || rawType === "person" + ? rawType + : null; if (rawType && !type) { return NextResponse.json( - { error: "type must be movie or tv" }, + { error: "type must be movie, tv, or person" }, { status: 400 }, ); } @@ -42,8 +49,26 @@ export async function GET(req: NextRequest) { { status: 400 }, ); - let results: TmdbSearchResponse; try { + // Person-specific search + if (type === "person") { + const personResults = await searchPerson(query); + return NextResponse.json({ + results: personResults.results.map((r) => ({ + tmdbId: r.id, + type: "person" as const, + title: r.name, + profilePath: tmdbImageUrl(r.profile_path, "w185"), + knownForDepartment: r.known_for_department, + knownFor: r.known_for + ?.slice(0, 3) + .map((k) => k.title ?? k.name) + .filter(Boolean), + })), + }); + } + + let results: TmdbSearchResponse; if (type === "movie") { results = await searchMovies(query); } else if (type === "tv") { @@ -51,22 +76,24 @@ export async function GET(req: NextRequest) { } else { results = await searchMulti(query); } - } catch { - return NextResponse.json( - { error: "Failed to fetch search results" }, - { status: 502 }, - ); - } - // Filter out person results for multi search - const filtered = - type === "movie" || type === "tv" - ? results.results - : results.results.filter((r) => r.media_type !== "person"); - - return NextResponse.json({ - results: filtered + const mapped = results.results .map((r) => { + // Include person results from multi search + if (r.media_type === "person") { + return { + tmdbId: r.id, + type: "person" as const, + title: r.name ?? "Unknown", + posterPath: null, + profilePath: tmdbImageUrl(r.poster_path, "w185"), + overview: "", + releaseDate: null, + popularity: r.popularity, + voteAverage: 0, + }; + } + const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type @@ -84,6 +111,13 @@ export async function GET(req: NextRequest) { voteAverage: r.vote_average, }; }) - .filter((r): r is NonNullable => r !== null), - }); + .filter((r): r is NonNullable => r !== null); + + return NextResponse.json({ results: mapped }); + } catch { + return NextResponse.json( + { error: "Failed to fetch search results" }, + { status: 502 }, + ); + } } diff --git a/components/command-palette.tsx b/components/command-palette.tsx index eabc685..45219d7 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -6,6 +6,7 @@ import { IconKeyboard, IconMovie, IconSearch, + IconUser, IconX, } from "@tabler/icons-react"; import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys"; @@ -89,7 +90,11 @@ export function CommandPalette() { const handleSelect = useCallback( (result: SearchResult) => { setCommandPaletteOpen(false); - router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`); + if (result.type === "person") { + router.push(`/person/tmdb-${result.tmdbId}`); + } else { + router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`); + } }, [router, setCommandPaletteOpen], ); @@ -168,35 +173,62 @@ export function CommandPalette() { onSelect={() => handleSelect(r)} className="flex items-center gap-3 py-2" > -
- {r.posterPath ? ( - {r.title} - ) : ( -
- ? -
- )} -
+ {r.type === "person" ? ( +
+ {r.profilePath ? ( + {r.title} + ) : ( +
+ +
+ )} +
+ ) : ( +
+ {r.posterPath ? ( + {r.title} + ) : ( +
+ ? +
+ )} +
+ )}

{r.title}

- {r.type === "movie" ? ( + {r.type === "person" ? ( + + ) : r.type === "movie" ? ( ) : ( )} {r.type} - {r.releaseDate && ( + {r.type !== "person" && r.releaseDate && ( {r.releaseDate.slice(0, 4)} )} + {r.type === "person" && + r.knownFor && + r.knownFor.length > 0 && ( + + {r.knownFor.join(", ")} + + )}
diff --git a/components/skeletons.tsx b/components/skeletons.tsx index efee6be..fa32777 100644 --- a/components/skeletons.tsx +++ b/components/skeletons.tsx @@ -82,6 +82,50 @@ export function TitleGridSectionSkeleton() { ); } +export function CastSkeleton() { + return ( +
+
+ + +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders +
+ + + +
+ ))} +
+
+ ); +} + +export function PersonDetailSkeleton() { + return ( +
+
+ +
+ + +
+ + +
+
+ + + +
+
+
+
+ ); +} + export function RecommendationsSkeleton() { return (
diff --git a/drizzle/20260305193509_overjoyed_pestilence/migration.sql b/drizzle/20260305193509_overjoyed_pestilence/migration.sql new file mode 100644 index 0000000..a695f28 --- /dev/null +++ b/drizzle/20260305193509_overjoyed_pestilence/migration.sql @@ -0,0 +1,34 @@ +CREATE TABLE `persons` ( + `id` text PRIMARY KEY, + `tmdbId` integer NOT NULL, + `name` text NOT NULL, + `biography` text, + `birthday` text, + `deathday` text, + `placeOfBirth` text, + `profilePath` text, + `knownForDepartment` text, + `popularity` real, + `imdbId` text, + `lastFetchedAt` integer +); +--> statement-breakpoint +CREATE TABLE `titleCast` ( + `id` text PRIMARY KEY, + `titleId` text NOT NULL, + `personId` text NOT NULL, + `character` text, + `department` text DEFAULT 'Acting' NOT NULL, + `job` text, + `displayOrder` integer DEFAULT 0 NOT NULL, + `episodeCount` integer, + `lastFetchedAt` integer, + CONSTRAINT `fk_titleCast_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_titleCast_personId_persons_id_fk` FOREIGN KEY (`personId`) REFERENCES `persons`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `persons_tmdbId_unique` ON `persons` (`tmdbId`);--> statement-breakpoint +CREATE INDEX `persons_name` ON `persons` (`name`);--> statement-breakpoint +CREATE UNIQUE INDEX `titleCast_unique` ON `titleCast` (`titleId`,`personId`,`department`,`character`);--> statement-breakpoint +CREATE INDEX `titleCast_titleId_displayOrder` ON `titleCast` (`titleId`,`displayOrder`);--> statement-breakpoint +CREATE INDEX `titleCast_personId` ON `titleCast` (`personId`); \ No newline at end of file diff --git a/drizzle/20260305193509_overjoyed_pestilence/snapshot.json b/drizzle/20260305193509_overjoyed_pestilence/snapshot.json new file mode 100644 index 0000000..2a29721 --- /dev/null +++ b/drizzle/20260305193509_overjoyed_pestilence/snapshot.json @@ -0,0 +1,2421 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "662445d8-e2df-4ae9-8019-89785f2fd5bc", + "prevIds": [ + "3925784e-d0d4-4e49-abdb-8b2469070957" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "appSettings", + "entityType": "tables" + }, + { + "name": "availabilityOffers", + "entityType": "tables" + }, + { + "name": "cronRuns", + "entityType": "tables" + }, + { + "name": "episodes", + "entityType": "tables" + }, + { + "name": "persons", + "entityType": "tables" + }, + { + "name": "seasons", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "titleCast", + "entityType": "tables" + }, + { + "name": "titleRecommendations", + "entityType": "tables" + }, + { + "name": "titles", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "userEpisodeWatches", + "entityType": "tables" + }, + { + "name": "userMovieWatches", + "entityType": "tables" + }, + { + "name": "userRatings", + "entityType": "tables" + }, + { + "name": "userTitleStatus", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "name": "webhookConnections", + "entityType": "tables" + }, + { + "name": "webhookEventLog", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accountId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "idToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'US'", + "generated": null, + "name": "region", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerName", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logoPath", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "offerType", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "jobName", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "startedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finishedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "durationMs", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonId", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeNumber", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillPath", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "runtimeMinutes", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "biography", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "birthday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deathday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "placeOfBirth", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profilePath", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "knownForDepartment", + "entityType": "columns", + "table": "persons" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "imdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonNumber", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ipAddress", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userAgent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonatedBy", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "character", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Acting'", + "generated": null, + "name": "department", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeCount", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "recommendedTitleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rank", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "originalTitle", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "releaseDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "firstAirDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteAverage", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteCount", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "colorPalette", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "trailerVideoKey", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "emailVerified", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banReason", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banExpires", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratingStars", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratedAt", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "addedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastEventAt", + "entityType": "columns", + "table": "webhookConnections" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "connectionId", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "eventType", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaType", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaTitle", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "receivedAt", + "entityType": "columns", + "table": "webhookEventLog" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_account_userId_user_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_availabilityOffers_titleId_titles_id_fk", + "entityType": "fks", + "table": "availabilityOffers" + }, + { + "columns": [ + "seasonId" + ], + "tableTo": "seasons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_episodes_seasonId_seasons_id_fk", + "entityType": "fks", + "table": "episodes" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_seasons_titleId_titles_id_fk", + "entityType": "fks", + "table": "seasons" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_userId_user_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "personId" + ], + "tableTo": "persons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_personId_persons_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "recommendedTitleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_recommendedTitleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "episodeId" + ], + "tableTo": "episodes", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_episodeId_episodes_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_titleId_titles_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_userId_user_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_titleId_titles_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_userId_user_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_titleId_titles_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_webhookConnections_userId_user_id_fk", + "entityType": "fks", + "table": "webhookConnections" + }, + { + "columns": [ + "connectionId" + ], + "tableTo": "webhookConnections", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_webhookEventLog_connectionId_webhookConnections_id_fk", + "entityType": "fks", + "table": "webhookEventLog" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "appSettings_pk", + "table": "appSettings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cronRuns_pk", + "table": "cronRuns", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "episodes_pk", + "table": "episodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "persons_pk", + "table": "persons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "seasons_pk", + "table": "seasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "titleCast_pk", + "table": "titleCast", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "titles_pk", + "table": "titles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pk", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userEpisodeWatches_pk", + "table": "userEpisodeWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userMovieWatches_pk", + "table": "userMovieWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "webhookConnections_pk", + "table": "webhookConnections", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "webhookEventLog_pk", + "table": "webhookEventLog", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "region", + "isExpression": false + }, + { + "value": "providerId", + "isExpression": false + }, + { + "value": "offerType", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "availabilityOffers_unique", + "entityType": "indexes", + "table": "availabilityOffers" + }, + { + "columns": [ + { + "value": "jobName", + "isExpression": false + }, + { + "value": "startedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "cronRuns_jobName_startedAt", + "entityType": "indexes", + "table": "cronRuns" + }, + { + "columns": [ + { + "value": "seasonId", + "isExpression": false + }, + { + "value": "episodeNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "episodes_seasonId_episodeNumber", + "entityType": "indexes", + "table": "episodes" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "persons_tmdbId_unique", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "name", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "persons_name", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "seasonNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "seasons_titleId_seasonNumber", + "entityType": "indexes", + "table": "seasons" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "personId", + "isExpression": false + }, + { + "value": "department", + "isExpression": false + }, + { + "value": "character", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleCast_unique", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_titleId_displayOrder", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "personId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_personId", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "recommendedTitleId", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleRecommendations_unique", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titles_tmdbId_unique", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "releaseDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_releaseDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "firstAirDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_firstAirDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userRatings_userId_titleId", + "entityType": "indexes", + "table": "userRatings" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_titleId", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_status", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "webhookConnections_userId_provider", + "entityType": "indexes", + "table": "webhookConnections" + }, + { + "columns": [ + { + "value": "token", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "webhookConnections_token", + "entityType": "indexes", + "table": "webhookConnections" + }, + { + "columns": [ + { + "value": "connectionId", + "isExpression": false + }, + { + "value": "receivedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "webhookEventLog_connectionId_receivedAt", + "entityType": "indexes", + "table": "webhookEventLog" + }, + { + "columns": [ + "token" + ], + "nameExplicit": false, + "name": "session_token_unique", + "entityType": "uniques", + "table": "session" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "user_email_unique", + "entityType": "uniques", + "table": "user" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/hooks/use-search.ts b/hooks/use-search.ts index a5abff3..710a56c 100644 --- a/hooks/use-search.ts +++ b/hooks/use-search.ts @@ -4,11 +4,14 @@ import { fetcher } from "@/lib/swr/fetcher"; interface SearchResponse { results: { tmdbId: number; - type: "movie" | "tv"; + type: "movie" | "tv" | "person"; title: string; posterPath: string | null; + profilePath?: string | null; releaseDate: string | null; voteAverage: number; + knownFor?: string[]; + knownForDepartment?: string; }[]; } diff --git a/lib/cron.ts b/lib/cron.ts index f9f8932..039d546 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -5,6 +5,7 @@ import { availabilityOffers, cronRuns, seasons, + titleCast, titles, userTitleStatus, } from "@/lib/db/schema"; @@ -15,9 +16,11 @@ import { ensureBackupDir, pruneBackups, } from "@/lib/services/backup"; +import { refreshCredits } from "@/lib/services/credits"; import { cacheEpisodeStills, cacheImagesForTitle, + cacheProfilePhotos, cacheProviderLogos, imageCacheEnabled, } from "@/lib/services/image-cache"; @@ -251,6 +254,7 @@ async function cacheImagesJob() { cacheImagesForTitle(titleId), cacheEpisodeStills(titleId), cacheProviderLogos(titleId), + cacheProfilePhotos(titleId), ]); } catch (err) { log.warn(`Failed to cache images for title ${titleId}:`, err); @@ -259,6 +263,31 @@ async function cacheImagesJob() { } } +// Refresh credits for library titles where cast is stale or missing +async function refreshCreditsJob() { + const libraryIds = getLibraryTitleIds(); + log.debug(`Checking credits for ${libraryIds.length} library titles`); + const stale = new Date(Date.now() - 30 * DAY); + + for (const titleId of libraryIds) { + const castEntry = db + .select() + .from(titleCast) + .where(eq(titleCast.titleId, titleId)) + .limit(1) + .get(); + + const needsRefresh = + !castEntry || + (castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale); + + if (needsRefresh) { + await refreshCredits(titleId); + await Bun.sleep(RATE_LIMIT_MS); + } + } +} + async function scheduledBackupJob() { const enabled = getSetting("scheduledBackups"); if (enabled !== "true") { @@ -324,6 +353,7 @@ export function startJobs() { schedule("refreshRecommendations", "0 */12 * * *", refreshRecommendationsJob); schedule("refreshTvChildren", "30 */12 * * *", refreshTvChildrenJob); schedule("cacheImages", "0 1,13 * * *", cacheImagesJob); + schedule("refreshCredits", "0 2 * * *", refreshCreditsJob); schedule("updateCheck", "0 */6 * * *", async () => { await performUpdateCheck(); }); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 5ca613c..8f5a0a1 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -289,6 +289,62 @@ export const titleRecommendations = sqliteTable( ], ); +// ─── Persons & Cast ───────────────────────────────────────────────── + +export const persons = sqliteTable( + "persons", + { + id: uuidPk(), + tmdbId: int("tmdbId").notNull(), + name: text("name").notNull(), + biography: text("biography"), + birthday: text("birthday"), + deathday: text("deathday"), + placeOfBirth: text("placeOfBirth"), + profilePath: text("profilePath"), + knownForDepartment: text("knownForDepartment"), + popularity: real("popularity"), + imdbId: text("imdbId"), + lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }), + }, + (table) => [ + uniqueIndex("persons_tmdbId_unique").on(table.tmdbId), + index("persons_name").on(table.name), + ], +); + +export const titleCast = sqliteTable( + "titleCast", + { + id: uuidPk(), + titleId: text("titleId") + .notNull() + .references(() => titles.id, { onDelete: "cascade" }), + personId: text("personId") + .notNull() + .references(() => persons.id, { onDelete: "cascade" }), + character: text("character"), + department: text("department").notNull().default("Acting"), + job: text("job"), + displayOrder: int("displayOrder").notNull().default(0), + episodeCount: int("episodeCount"), + lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }), + }, + (table) => [ + uniqueIndex("titleCast_unique").on( + table.titleId, + table.personId, + table.department, + table.character, + ), + index("titleCast_titleId_displayOrder").on( + table.titleId, + table.displayOrder, + ), + index("titleCast_personId").on(table.personId), + ], +); + // ─── Webhook Connections ───────────────────────────────────────────── export const webhookConnections = sqliteTable( diff --git a/lib/services/credits.ts b/lib/services/credits.ts new file mode 100644 index 0000000..030830e --- /dev/null +++ b/lib/services/credits.ts @@ -0,0 +1,241 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { persons, titleCast, titles } from "@/lib/db/schema"; +import { createLogger } from "@/lib/logger"; +import { getMovieCredits, getTvAggregateCredits } from "@/lib/tmdb/client"; +import { tmdbImageUrl } from "@/lib/tmdb/image"; +import type { CastMember } from "@/lib/types/title"; +import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache"; + +const log = createLogger("credits"); + +const NOTABLE_DEPARTMENTS = new Set([ + "Director", + "Writer", + "Screenplay", + "Creator", + "Executive Producer", +]); + +function upsertPerson( + tmdbId: number, + name: string, + profilePath: string | null, + popularity?: number, +): string { + const existing = db + .select() + .from(persons) + .where(eq(persons.tmdbId, tmdbId)) + .get(); + if (existing) return existing.id; + + const row = db + .insert(persons) + .values({ + tmdbId, + name, + profilePath, + popularity: popularity ?? null, + }) + .onConflictDoNothing() + .returning() + .get(); + + if (row) return row.id; + + // Race condition: another insert beat us + const found = db + .select() + .from(persons) + .where(eq(persons.tmdbId, tmdbId)) + .get(); + // biome-ignore lint/style/noNonNullAssertion: guaranteed by onConflictDoNothing + prior existence check + return found!.id; +} + +function upsertTitleCast( + titleId: string, + personId: string, + character: string | null, + department: string, + job: string | null, + displayOrder: number, + episodeCount: number | null, +) { + const now = new Date(); + db.insert(titleCast) + .values({ + titleId, + personId, + character, + department, + job, + displayOrder, + episodeCount, + lastFetchedAt: now, + }) + .onConflictDoUpdate({ + target: [ + titleCast.titleId, + titleCast.personId, + titleCast.department, + titleCast.character, + ], + set: { + job, + displayOrder, + episodeCount, + lastFetchedAt: now, + }, + }) + .run(); +} + +export async function refreshCredits(titleId: string) { + const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); + if (!title) return; + + log.debug(`Refreshing credits for "${title.title}" (${title.type})`); + + try { + if (title.type === "movie") { + const credits = await getMovieCredits(title.tmdbId); + + // Top 20 cast + const castSlice = credits.cast.slice(0, 20); + for (let i = 0; i < castSlice.length; i++) { + const c = castSlice[i]; + const personId = upsertPerson( + c.id, + c.name, + c.profile_path, + c.popularity, + ); + upsertTitleCast( + titleId, + personId, + c.character, + "Acting", + null, + i, + null, + ); + } + + // Notable crew + const seenCrew = new Set(); + let crewOrder = 100; + for (const c of credits.crew) { + if (!NOTABLE_DEPARTMENTS.has(c.job)) continue; + const key = `${c.id}-${c.job}`; + if (seenCrew.has(key)) continue; + seenCrew.add(key); + + const personId = upsertPerson( + c.id, + c.name, + c.profile_path, + c.popularity, + ); + upsertTitleCast( + titleId, + personId, + null, + c.department, + c.job, + crewOrder++, + null, + ); + } + } else { + const credits = await getTvAggregateCredits(title.tmdbId); + + // Top 20 cast + const castSlice = credits.cast.slice(0, 20); + for (let i = 0; i < castSlice.length; i++) { + const c = castSlice[i]; + const personId = upsertPerson( + c.id, + c.name, + c.profile_path, + c.popularity, + ); + const character = c.roles?.[0]?.character ?? null; + upsertTitleCast( + titleId, + personId, + character, + "Acting", + null, + i, + c.total_episode_count, + ); + } + + // Notable crew + const seenCrew = new Set(); + let crewOrder = 100; + for (const c of credits.crew) { + for (const j of c.jobs) { + if (!NOTABLE_DEPARTMENTS.has(j.job)) continue; + const key = `${c.id}-${j.job}`; + if (seenCrew.has(key)) continue; + seenCrew.add(key); + + const personId = upsertPerson( + c.id, + c.name, + c.profile_path, + c.popularity, + ); + upsertTitleCast( + titleId, + personId, + null, + c.department, + j.job, + crewOrder++, + j.episode_count, + ); + } + } + } + + log.debug(`Credits refreshed for "${title.title}"`); + + if (imageCacheEnabled()) { + cacheProfilePhotos(titleId).catch((err) => + log.debug("Profile photo caching failed:", err), + ); + } + } catch (err) { + log.error(`Failed to refresh credits for title ${titleId}:`, err); + } +} + +export function getCastForTitle(titleId: string): CastMember[] { + const rows = db + .select({ + id: titleCast.id, + personId: titleCast.personId, + name: persons.name, + character: titleCast.character, + department: titleCast.department, + job: titleCast.job, + displayOrder: titleCast.displayOrder, + episodeCount: titleCast.episodeCount, + profilePath: persons.profilePath, + tmdbId: persons.tmdbId, + }) + .from(titleCast) + .innerJoin(persons, eq(titleCast.personId, persons.id)) + .where(eq(titleCast.titleId, titleId)) + .orderBy(titleCast.displayOrder) + .all(); + + return rows.map((r) => ({ + ...r, + profilePath: tmdbImageUrl(r.profilePath, "w185"), + })); +} diff --git a/lib/services/image-cache.ts b/lib/services/image-cache.ts index 51c4f8a..1222f06 100644 --- a/lib/services/image-cache.ts +++ b/lib/services/image-cache.ts @@ -2,18 +2,31 @@ import { mkdir, rename } from "node:fs/promises"; import path from "node:path"; import { eq } from "drizzle-orm"; import { db } from "@/lib/db/client"; -import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema"; +import { + availabilityOffers, + episodes, + persons, + seasons, + titleCast, + titles, +} from "@/lib/db/schema"; import { createLogger } from "@/lib/logger"; const log = createLogger("image-cache"); -export type ImageCategory = "posters" | "backdrops" | "stills" | "logos"; +export type ImageCategory = + | "posters" + | "backdrops" + | "stills" + | "logos" + | "profiles"; const CATEGORY_SIZES: Record = { posters: "w500", backdrops: "w1280", stills: "w1280", logos: "w92", + profiles: "w185", }; const IMAGE_BASE_URL = @@ -218,3 +231,28 @@ export async function cacheProviderLogos(titleId: string) { } await Promise.allSettled(tasks); } + +export async function cacheProfilePhotos(titleId: string) { + const castRows = db + .select({ profilePath: persons.profilePath }) + .from(titleCast) + .innerJoin(persons, eq(titleCast.personId, persons.id)) + .where(eq(titleCast.titleId, titleId)) + .all(); + + const tasks: Promise[] = []; + const seen = new Set(); + for (const row of castRows) { + if (row.profilePath) { + const basename = path.basename(row.profilePath); + if (!seen.has(basename) && !(await isImageCached("profiles", basename))) { + seen.add(basename); + tasks.push(downloadAndCacheImage(row.profilePath, "profiles")); + } + } + } + if (tasks.length > 0) { + log.debug(`Caching ${tasks.length} profile photos for title ${titleId}`); + } + await Promise.allSettled(tasks); +} diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 69b6121..57e2bea 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -20,12 +20,14 @@ import { tmdbImageUrl } from "@/lib/tmdb/image"; import type { TmdbVideo } from "@/lib/tmdb/types"; import type { AvailabilityOffer, + CastMember, Episode, ResolvedTitle, Season, } from "@/lib/types/title"; import { refreshAvailability } from "./availability"; import { extractAndStoreColors, parseColorPalette } from "./colors"; +import { getCastForTitle, refreshCredits } from "./credits"; import { cacheEpisodeStills, cacheImagesForTitle, @@ -83,6 +85,9 @@ export async function importTitle( extractAndStoreColors(existing.id, show.poster_path).catch((err) => log.debug("Color extraction failed:", err), ), + refreshCredits(existing.id).catch((err) => + log.debug("Credits enrichment failed:", err), + ), ]); } else { refreshAvailability(existing.id).catch((err) => @@ -91,6 +96,9 @@ export async function importTitle( refreshRecommendations(existing.id).catch((err) => log.debug("Recommendations enrichment failed:", err), ); + refreshCredits(existing.id).catch((err) => + log.debug("Credits enrichment failed:", err), + ); } if (imageCacheEnabled()) { cacheImagesForTitle(existing.id).catch((err) => @@ -140,6 +148,9 @@ export async function importTitle( extractAndStoreColors(row.id, movie.poster_path).catch((err) => log.debug("Color extraction failed:", err), ), + refreshCredits(row.id).catch((err) => + log.debug("Credits enrichment failed:", err), + ), ]); } else { refreshAvailability(row.id).catch((err) => @@ -151,6 +162,9 @@ export async function importTitle( extractAndStoreColors(row.id, movie.poster_path).catch((err) => log.debug("Color extraction failed:", err), ); + refreshCredits(row.id).catch((err) => + log.debug("Credits enrichment failed:", err), + ); } refreshTrailer(row.id).catch((err) => log.debug("Trailer enrichment failed:", err), @@ -197,6 +211,9 @@ export async function importTitle( extractAndStoreColors(row.id, show.poster_path).catch((err) => log.debug("Color extraction failed:", err), ), + refreshCredits(row.id).catch((err) => + log.debug("Credits enrichment failed:", err), + ), ]); } else { refreshAvailability(row.id).catch((err) => @@ -208,6 +225,9 @@ export async function importTitle( extractAndStoreColors(row.id, show.poster_path).catch((err) => log.debug("Color extraction failed:", err), ); + refreshCredits(row.id).catch((err) => + log.debug("Credits enrichment failed:", err), + ); } refreshTrailer(row.id).catch((err) => log.debug("Trailer enrichment failed:", err), @@ -277,6 +297,9 @@ export async function refreshTitle(titleId: string) { refreshTrailer(updated.id).catch((err) => log.debug("Trailer enrichment failed:", err), ); + refreshCredits(updated.id).catch((err) => + log.debug("Credits enrichment failed:", err), + ); if (imageCacheEnabled()) { cacheImagesForTitle(updated.id).catch((err) => log.debug("Image caching failed:", err), @@ -516,6 +539,7 @@ export async function getTitleWithChildren(id: string): Promise<{ title: ResolvedTitle; seasons: Season[]; availability: AvailabilityOffer[]; + cast: CastMember[]; } | null> { let title = db.select().from(titles).where(eq(titles.id, id)).get(); if (!title) return null; @@ -671,7 +695,9 @@ export async function getTitleWithChildren(id: string): Promise<{ trailerVideoKey: title.trailerVideoKey, }; - return { title: resolvedTitle, seasons: titleSeasons, availability }; + const cast = getCastForTitle(id); + + return { title: resolvedTitle, seasons: titleSeasons, availability, cast }; } export function pickBestTrailer(videos: TmdbVideo[]): string | null { diff --git a/lib/services/person.ts b/lib/services/person.ts new file mode 100644 index 0000000..4af0698 --- /dev/null +++ b/lib/services/person.ts @@ -0,0 +1,240 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { persons, titleCast, titles } from "@/lib/db/schema"; +import { createLogger } from "@/lib/logger"; +import { getPersonCombinedCredits, getPersonDetails } from "@/lib/tmdb/client"; +import { tmdbImageUrl } from "@/lib/tmdb/image"; +import type { PersonCredit, ResolvedPerson } from "@/lib/types/title"; + +const log = createLogger("person"); + +export async function getOrFetchPerson( + personId: string, +): Promise { + const person = db + .select() + .from(persons) + .where(eq(persons.id, personId)) + .get(); + if (!person) return null; + + // Shell record — lazily hydrate from TMDB + if (!person.lastFetchedAt) { + try { + const details = await getPersonDetails(person.tmdbId); + db.update(persons) + .set({ + name: details.name, + biography: details.biography || null, + birthday: details.birthday, + deathday: details.deathday, + placeOfBirth: details.place_of_birth, + profilePath: details.profile_path, + knownForDepartment: details.known_for_department, + popularity: details.popularity, + imdbId: details.imdb_id, + lastFetchedAt: new Date(), + }) + .where(eq(persons.id, personId)) + .run(); + + return { + id: person.id, + tmdbId: person.tmdbId, + name: details.name, + biography: details.biography || null, + birthday: details.birthday, + deathday: details.deathday, + placeOfBirth: details.place_of_birth, + profilePath: tmdbImageUrl(details.profile_path, "w185"), + knownForDepartment: details.known_for_department, + imdbId: details.imdb_id, + }; + } catch (err) { + log.error(`Failed to hydrate person ${personId}:`, err); + } + } + + return { + id: person.id, + tmdbId: person.tmdbId, + name: person.name, + biography: person.biography, + birthday: person.birthday, + deathday: person.deathday, + placeOfBirth: person.placeOfBirth, + profilePath: tmdbImageUrl(person.profilePath, "w185"), + knownForDepartment: person.knownForDepartment, + imdbId: person.imdbId, + }; +} + +export async function getOrFetchPersonByTmdbId( + tmdbId: number, +): Promise { + const existing = db + .select() + .from(persons) + .where(eq(persons.tmdbId, tmdbId)) + .get(); + + if (existing) { + return getOrFetchPerson(existing.id); + } + + // Create from TMDB + try { + const details = await getPersonDetails(tmdbId); + const row = db + .insert(persons) + .values({ + tmdbId, + name: details.name, + biography: details.biography || null, + birthday: details.birthday, + deathday: details.deathday, + placeOfBirth: details.place_of_birth, + profilePath: details.profile_path, + knownForDepartment: details.known_for_department, + popularity: details.popularity, + imdbId: details.imdb_id, + lastFetchedAt: new Date(), + }) + .onConflictDoNothing() + .returning() + .get(); + + const person = + row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get(); + if (!person) return null; + + return { + id: person.id, + tmdbId: person.tmdbId, + name: person.name, + biography: person.biography, + birthday: person.birthday, + deathday: person.deathday, + placeOfBirth: person.placeOfBirth, + profilePath: tmdbImageUrl(person.profilePath, "w185"), + knownForDepartment: person.knownForDepartment, + imdbId: person.imdbId, + }; + } catch (err) { + log.error(`Failed to fetch person TMDB ${tmdbId}:`, err); + return null; + } +} + +export function getLocalFilmography(personId: string): PersonCredit[] { + const rows = db + .select({ + titleId: titles.id, + tmdbId: titles.tmdbId, + type: titles.type, + title: titles.title, + posterPath: titles.posterPath, + releaseDate: titles.releaseDate, + firstAirDate: titles.firstAirDate, + voteAverage: titles.voteAverage, + character: titleCast.character, + department: titleCast.department, + job: titleCast.job, + }) + .from(titleCast) + .innerJoin(titles, eq(titleCast.titleId, titles.id)) + .where(eq(titleCast.personId, personId)) + .all(); + + return rows.map((r) => ({ + titleId: r.titleId, + tmdbId: r.tmdbId, + type: r.type as "movie" | "tv", + title: r.title, + posterPath: tmdbImageUrl(r.posterPath, "w500"), + releaseDate: r.releaseDate, + firstAirDate: r.firstAirDate, + voteAverage: r.voteAverage, + character: r.character, + department: r.department, + job: r.job, + })); +} + +export async function fetchFullFilmography( + personId: string, +): Promise { + const person = db + .select() + .from(persons) + .where(eq(persons.id, personId)) + .get(); + if (!person) return []; + + const credits = await getPersonCombinedCredits(person.tmdbId); + + const results: PersonCredit[] = []; + + for (const c of credits.cast) { + const type = c.media_type; + if (type !== "movie" && type !== "tv") continue; + + // Create shell title if not in DB + const existing = db + .select() + .from(titles) + .where(eq(titles.tmdbId, c.id)) + .get(); + let titleId: string; + if (existing) { + titleId = existing.id; + } else { + const row = db + .insert(titles) + .values({ + tmdbId: c.id, + type, + title: c.title ?? c.name ?? "Unknown", + overview: c.overview, + releaseDate: c.release_date, + firstAirDate: c.first_air_date, + posterPath: c.poster_path, + backdropPath: c.backdrop_path, + popularity: c.popularity, + voteAverage: c.vote_average, + voteCount: c.vote_count, + lastFetchedAt: null, + }) + .onConflictDoNothing() + .returning() + .get(); + if (!row) { + const found = db + .select() + .from(titles) + .where(eq(titles.tmdbId, c.id)) + .get(); + if (!found) continue; + titleId = found.id; + } else { + titleId = row.id; + } + } + + results.push({ + titleId, + tmdbId: c.id, + type, + title: c.title ?? c.name ?? "Unknown", + posterPath: tmdbImageUrl(c.poster_path, "w500"), + releaseDate: c.release_date ?? null, + firstAirDate: c.first_air_date ?? null, + voteAverage: c.vote_average, + character: c.character ?? null, + department: "Acting", + job: null, + }); + } + + return results; +} diff --git a/lib/tmdb/client.ts b/lib/tmdb/client.ts index e16acca..035ed2b 100644 --- a/lib/tmdb/client.ts +++ b/lib/tmdb/client.ts @@ -2,10 +2,15 @@ import { createLogger } from "@/lib/logger"; import type { TmdbFindResult, TmdbGenreListResponse, + TmdbMovieCreditsResponse, TmdbMovieDetails, + TmdbPersonCombinedCredits, + TmdbPersonDetails, + TmdbPersonSearchResponse, TmdbRecommendationResponse, TmdbSearchResponse, TmdbSeasonDetails, + TmdbTvAggregateCreditsResponse, TmdbTvDetails, TmdbVideosResponse, TmdbWatchProviderResponse, @@ -155,4 +160,33 @@ export async function findByExternalId( }); } +// ─── Person / Credits endpoints ───────────────────────────────────── + +export async function getMovieCredits(tmdbId: number) { + return tmdbFetch(`/movie/${tmdbId}/credits`); +} + +export async function getTvAggregateCredits(tmdbId: number) { + return tmdbFetch( + `/tv/${tmdbId}/aggregate_credits`, + ); +} + +export async function getPersonDetails(tmdbId: number) { + return tmdbFetch(`/person/${tmdbId}`); +} + +export async function getPersonCombinedCredits(tmdbId: number) { + return tmdbFetch( + `/person/${tmdbId}/combined_credits`, + ); +} + +export async function searchPerson(query: string, page = 1) { + return tmdbFetch("/search/person", { + query, + page: String(page), + }); +} + export { tmdbImageUrl } from "./image"; diff --git a/lib/tmdb/image.ts b/lib/tmdb/image.ts index a8175f5..73e7685 100644 --- a/lib/tmdb/image.ts +++ b/lib/tmdb/image.ts @@ -5,6 +5,7 @@ const IMAGE_BASE_URL = function sizeToCategory(size: string): ImageCategory { if (size === "w92") return "logos"; + if (size === "w185") return "profiles"; if (size === "w1280") return "backdrops"; return "posters"; } diff --git a/lib/tmdb/types.ts b/lib/tmdb/types.ts index 4eda18f..f3fa171 100644 --- a/lib/tmdb/types.ts +++ b/lib/tmdb/types.ts @@ -149,3 +149,91 @@ export interface TmdbGenre { export interface TmdbGenreListResponse { genres: TmdbGenre[]; } + +// ─── Person / Credits types ───────────────────────────────────────── + +export interface TmdbCastMember { + id: number; + name: string; + profile_path: string | null; + character: string; + order: number; + popularity: number; +} + +export interface TmdbCrewMember { + id: number; + name: string; + profile_path: string | null; + department: string; + job: string; + popularity: number; +} + +export interface TmdbMovieCreditsResponse { + id: number; + cast: TmdbCastMember[]; + crew: TmdbCrewMember[]; +} + +export interface TmdbTvAggregateCastMember { + id: number; + name: string; + profile_path: string | null; + roles: { character: string; episode_count: number }[]; + total_episode_count: number; + order: number; + popularity: number; +} + +export interface TmdbTvAggregateCrewMember { + id: number; + name: string; + profile_path: string | null; + jobs: { job: string; episode_count: number }[]; + department: string; + total_episode_count: number; + popularity: number; +} + +export interface TmdbTvAggregateCreditsResponse { + id: number; + cast: TmdbTvAggregateCastMember[]; + crew: TmdbTvAggregateCrewMember[]; +} + +export interface TmdbPersonDetails { + id: number; + name: string; + biography: string; + birthday: string | null; + deathday: string | null; + place_of_birth: string | null; + profile_path: string | null; + known_for_department: string; + imdb_id: string | null; + also_known_as: string[]; + popularity: number; +} + +export interface TmdbPersonCombinedCredits { + id: number; + cast: (TmdbSearchResult & { character?: string; episode_count?: number })[]; + crew: (TmdbSearchResult & { department?: string; job?: string })[]; +} + +export interface TmdbPersonSearchResult { + id: number; + name: string; + profile_path: string | null; + known_for_department: string; + popularity: number; + known_for: TmdbSearchResult[]; +} + +export interface TmdbPersonSearchResponse { + page: number; + results: TmdbPersonSearchResult[]; + total_pages: number; + total_results: number; +} diff --git a/lib/types/title.ts b/lib/types/title.ts index da9e31e..9367a1f 100644 --- a/lib/types/title.ts +++ b/lib/types/title.ts @@ -42,6 +42,46 @@ export interface ColorPalette { lightMuted: string | null; } +export interface CastMember { + id: string; + personId: string; + name: string; + character: string | null; + department: string; + job: string | null; + displayOrder: number; + episodeCount: number | null; + profilePath: string | null; + tmdbId: number; +} + +export interface ResolvedPerson { + id: string; + tmdbId: number; + name: string; + biography: string | null; + birthday: string | null; + deathday: string | null; + placeOfBirth: string | null; + profilePath: string | null; + knownForDepartment: string | null; + imdbId: string | null; +} + +export interface PersonCredit { + titleId: string; + tmdbId: number; + type: "movie" | "tv"; + title: string; + posterPath: string | null; + releaseDate: string | null; + firstAirDate: string | null; + voteAverage: number | null; + character: string | null; + department: string; + job: string | null; +} + export interface ResolvedTitle { id: string; tmdbId: number;