Add genre and content rating storage and display on title pages

Store TMDB genres in normalized tables (genres + titleGenres) and content
ratings as a column on titles. Both are fetched during import/refresh using
append_to_response for content ratings (no extra API calls). Displayed in
the title hero between type badge and year. Also fixes pre-existing type
error for profile_path on TmdbSearchResult.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 20:02:42 -05:00
co-authored by Claude Opus 4.6
parent 9e674f44aa
commit be18531ea7
19 changed files with 5355 additions and 114 deletions
@@ -4,7 +4,7 @@ import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
import { createStore, Provider, useAtom, useAtomValue } from "jotai"; import { createStore, Provider, useAtom, useAtomValue } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { TitleCardSkeleton } from "@/components/skeletons"; import { TitleCardSkeleton } from "@/components/skeletons";
import { ExploreTitleCard } from "@/components/title-card"; import { TitleCard } from "@/components/title-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Carousel, Carousel,
@@ -175,14 +175,13 @@ function FilterableTitleRowInner({
className="animate-stagger-item" className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties} style={{ "--stagger-index": i } as React.CSSProperties}
> >
<ExploreTitleCard <TitleCard
tmdbId={item.tmdbId} tmdbId={item.tmdbId}
type={item.type} type={item.type}
title={item.title} title={item.title}
posterPath={item.posterPath} posterPath={item.posterPath}
releaseDate={item.releaseDate} releaseDate={item.releaseDate}
voteAverage={item.voteAverage} voteAverage={item.voteAverage}
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]} userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
episodeProgress={ episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`] episodeProgress[`${item.tmdbId}-${item.type}`]
@@ -1,7 +1,7 @@
"use client"; "use client";
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
import { ExploreTitleCard } from "@/components/title-card"; import { TitleCard } from "@/components/title-card";
import { import {
Carousel, Carousel,
CarouselContent, CarouselContent,
@@ -57,14 +57,13 @@ export function TitleRow({
className="animate-stagger-item" className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties} style={{ "--stagger-index": i } as React.CSSProperties}
> >
<ExploreTitleCard <TitleCard
tmdbId={item.tmdbId} tmdbId={item.tmdbId}
type={item.type} type={item.type}
title={item.title} title={item.title}
posterPath={item.posterPath} posterPath={item.posterPath}
releaseDate={item.releaseDate} releaseDate={item.releaseDate}
voteAverage={item.voteAverage} voteAverage={item.voteAverage}
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]} userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
episodeProgress={ episodeProgress={
episodeProgress?.[`${item.tmdbId}-${item.type}`] episodeProgress?.[`${item.tmdbId}-${item.type}`]
@@ -1,9 +1,8 @@
"use client"; "use client";
import { IconMovie } from "@tabler/icons-react"; import { IconMovie } from "@tabler/icons-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { TitleCard } from "@/components/title-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
@@ -121,44 +120,15 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
className="animate-stagger-item" className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties} style={{ "--stagger-index": i } as React.CSSProperties}
> >
<Link href={`/titles/${credit.titleId}`} className="group"> <TitleCard
<div className="overflow-hidden rounded-xl bg-card ring-1 ring-white/[0.06] transition-all group-hover:ring-primary/25"> id={credit.titleId}
<div className="aspect-[2/3] w-full bg-muted"> tmdbId={credit.tmdbId}
{credit.posterPath ? ( type={credit.type}
<Image title={credit.title}
src={credit.posterPath} posterPath={credit.posterPath}
alt={credit.title} releaseDate={credit.releaseDate ?? credit.firstAirDate}
width={200} voteAverage={credit.voteAverage}
height={300} />
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground/30">
<IconMovie aria-hidden={true} className="size-10" />
</div>
)}
</div>
<div className="px-3 pb-3 pt-2.5">
<p className="truncate text-xs font-medium">{credit.title}</p>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="uppercase">{credit.type}</span>
{(credit.releaseDate ?? credit.firstAirDate) && (
<span>
{(credit.releaseDate ?? credit.firstAirDate)?.slice(
0,
4,
)}
</span>
)}
</div>
{credit.character && (
<p className="mt-1 truncate text-[10px] text-muted-foreground/70">
as {credit.character}
</p>
)}
</div>
</div>
</Link>
</div> </div>
))} ))}
</div> </div>
@@ -1,8 +1,9 @@
"use client"; "use client";
import { IconCalendar, IconMapPin } from "@tabler/icons-react"; import { IconCalendar, IconMapPin } from "@tabler/icons-react";
import { format, parseISO } from "date-fns";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { ResolvedPerson } from "@/lib/types/title"; import type { ResolvedPerson } from "@/lib/types/title";
@@ -23,6 +24,19 @@ function calculateAge(birthday: string, deathday?: string | null): number {
export function PersonHero({ person }: PersonHeroProps) { export function PersonHero({ person }: PersonHeroProps) {
const [bioExpanded, setBioExpanded] = useState(false); const [bioExpanded, setBioExpanded] = useState(false);
const [isClamped, setIsClamped] = useState(false);
const bioRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const el = bioRef.current;
if (!el) return;
const check = () => setIsClamped(el.scrollHeight > el.clientHeight);
check();
const observer = new ResizeObserver(check);
observer.observe(el);
return () => observer.disconnect();
}, []);
const age = person.birthday const age = person.birthday
? calculateAge(person.birthday, person.deathday) ? calculateAge(person.birthday, person.deathday)
: null; : null;
@@ -34,8 +48,8 @@ export function PersonHero({ person }: PersonHeroProps) {
<Image <Image
src={person.profilePath} src={person.profilePath}
alt={person.name} alt={person.name}
width={224} width={500}
height={224} height={500}
className="h-full w-full object-cover" className="h-full w-full object-cover"
priority priority
/> />
@@ -63,7 +77,7 @@ export function PersonHero({ person }: PersonHeroProps) {
{person.birthday && ( {person.birthday && (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<IconCalendar aria-hidden={true} className="size-3.5" /> <IconCalendar aria-hidden={true} className="size-3.5" />
{person.birthday} {format(parseISO(person.birthday), "MMMM d, yyyy")}
{age !== null && ( {age !== null && (
<span className="text-muted-foreground/60"> <span className="text-muted-foreground/60">
({person.deathday ? `died at ${age}` : `age ${age}`}) ({person.deathday ? `died at ${age}` : `age ${age}`})
@@ -82,13 +96,14 @@ export function PersonHero({ person }: PersonHeroProps) {
{person.biography && ( {person.biography && (
<div className="max-w-3xl"> <div className="max-w-3xl">
<p <p
ref={bioRef}
className={`text-sm leading-relaxed text-muted-foreground ${ className={`text-sm leading-relaxed text-muted-foreground ${
!bioExpanded ? "line-clamp-6" : "" !bioExpanded ? "line-clamp-3" : ""
}`} }`}
> >
{person.biography} {person.biography}
</p> </p>
{person.biography.length > 400 && ( {(isClamped || bioExpanded) && (
<button <button
type="button" type="button"
onClick={() => setBioExpanded(!bioExpanded)} onClick={() => setBioExpanded(!bioExpanded)}
@@ -104,7 +104,18 @@ export function TitleHero({
<Badge className="rounded border-0 bg-primary/10 font-semibold uppercase tracking-wider text-primary"> <Badge className="rounded border-0 bg-primary/10 font-semibold uppercase tracking-wider text-primary">
{title.type} {title.type}
</Badge> </Badge>
{title.contentRating && (
<Badge
variant="outline"
className="rounded border-border/50 font-semibold uppercase tracking-wider"
>
{title.contentRating}
</Badge>
)}
{year && <span>{year}</span>} {year && <span>{year}</span>}
{title.genres.length > 0 && (
<span>{title.genres.join(" · ")}</span>
)}
{title.voteAverage != null && title.voteAverage > 0 && ( {title.voteAverage != null && title.voteAverage > 0 && (
<span className="flex items-center gap-1 text-primary"> <span className="flex items-center gap-1 text-primary">
{title.voteAverage.toFixed(1)} {title.voteAverage.toFixed(1)}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useHydrateAtoms } from "jotai/utils"; import { createStore, Provider } from "jotai";
import { useState } from "react";
import { import {
episodeWatchesAtom, episodeWatchesAtom,
seasonsAtom, seasonsAtom,
@@ -31,15 +32,17 @@ export function TitleProvider({
seasons: Season[]; seasons: Season[];
children: React.ReactNode; children: React.ReactNode;
}) { }) {
useHydrateAtoms([ const [store] = useState(() => {
[titleIdAtom, titleId], const s = createStore();
[titleTypeAtom, titleType], s.set(titleIdAtom, titleId);
[titleNameAtom, titleName], s.set(titleTypeAtom, titleType);
[seasonsAtom, seasons], s.set(titleNameAtom, titleName);
[userStatusAtom, initialStatus], s.set(seasonsAtom, seasons);
[userRatingAtom, initialRating], s.set(userStatusAtom, initialStatus);
[episodeWatchesAtom, initialEpisodeWatches], s.set(userRatingAtom, initialRating);
]); s.set(episodeWatchesAtom, initialEpisodeWatches);
return s;
});
return children; return <Provider store={store}>{children}</Provider>;
} }
@@ -7,6 +7,7 @@ import {
IconChevronUp, IconChevronUp,
IconDeviceTvOld, IconDeviceTvOld,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { format, parseISO } from "date-fns";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
@@ -271,7 +272,9 @@ export function TitleSeasons({
</span> </span>
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{ep.airDate ?? ""} {ep.airDate
? format(parseISO(ep.airDate), "MMM d, yyyy")
: ""}
{ep.airDate && ep.runtimeMinutes ? " · " : ""} {ep.airDate && ep.runtimeMinutes ? " · " : ""}
{ep.runtimeMinutes ? `${ep.runtimeMinutes}m` : ""} {ep.runtimeMinutes ? `${ep.runtimeMinutes}m` : ""}
</p> </p>
+1
View File
@@ -82,6 +82,7 @@ export default async function TitleDetailPage({
return ( return (
<div className="relative space-y-10" style={themeStyle}> <div className="relative space-y-10" style={themeStyle}>
<TitleProvider <TitleProvider
key={title.id}
titleId={title.id} titleId={title.id}
titleType={title.type} titleType={title.type}
titleName={title.title} titleName={title.title}
+1 -1
View File
@@ -86,7 +86,7 @@ export async function GET(req: NextRequest) {
type: "person" as const, type: "person" as const,
title: r.name ?? "Unknown", title: r.name ?? "Unknown",
posterPath: null, posterPath: null,
profilePath: tmdbImageUrl(r.profile_path, "w185"), profilePath: tmdbImageUrl(r.profile_path ?? null, "w185"),
overview: "", overview: "",
releaseDate: null, releaseDate: null,
popularity: r.popularity, popularity: r.popularity,
+4 -46
View File
@@ -42,18 +42,11 @@ interface CardInnerProps {
tiltStyles?: TiltStyles; tiltStyles?: TiltStyles;
} }
interface TitleCardProps extends CardInnerProps { export interface TitleCardProps extends CardInnerProps {
id: string; id?: string;
tmdbId: number; tmdbId: number;
} }
interface ExploreTitleCardProps extends CardInnerProps {
tmdbId: number;
href: string;
userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null;
}
type QuickAddState = "idle" | "loading" | "added"; type QuickAddState = "idle" | "loading" | "added";
const statusConfig = { const statusConfig = {
@@ -273,10 +266,9 @@ function CardInner({
); );
} }
/** Linked title card for library grids, recommendations, dashboards */
export function TitleCard({ export function TitleCard({
id, id,
tmdbId: _tmdbId, tmdbId,
type, type,
title, title,
posterPath, posterPath,
@@ -285,41 +277,7 @@ export function TitleCard({
userStatus, userStatus,
episodeProgress, episodeProgress,
}: TitleCardProps) { }: TitleCardProps) {
const tilt = useTiltEffect(); const href = id ? `/titles/${id}` : `/titles/tmdb-${tmdbId}-${type}`;
return (
<Link href={`/titles/${id}`} className="group">
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
<CardInner
title={title}
type={type}
posterPath={posterPath}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
episodeProgress={episodeProgress}
tiltStyles={{
imageStyle: tilt.imageStyle,
glareBackground: tilt.glareBackground,
glareOpacity: tilt.glareOpacity,
}}
/>
</motion.div>
</Link>
);
}
/** Explore/browse card with quick-add button and custom href */
export function ExploreTitleCard({
tmdbId,
type,
title,
posterPath,
releaseDate,
voteAverage,
href,
userStatus,
episodeProgress,
}: ExploreTitleCardProps) {
const tilt = useTiltEffect(); const tilt = useTiltEffect();
return ( return (
<div className="relative group"> <div className="relative group">
@@ -0,0 +1,16 @@
CREATE TABLE `genres` (
`id` integer PRIMARY KEY,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `titleGenres` (
`titleId` text NOT NULL,
`genreId` integer NOT NULL,
CONSTRAINT `fk_titleGenres_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_titleGenres_genreId_genres_id_fk` FOREIGN KEY (`genreId`) REFERENCES `genres`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `titleGenres_titleId_genreId` ON `titleGenres` (`titleId`,`genreId`);--> statement-breakpoint
CREATE INDEX `titleGenres_genreId` ON `titleGenres` (`genreId`);--> statement-breakpoint
CREATE INDEX `userEpisodeWatches_userId_episodeId` ON `userEpisodeWatches` (`userId`,`episodeId`);--> statement-breakpoint
CREATE INDEX `userMovieWatches_userId_titleId` ON `userMovieWatches` (`userId`,`titleId`);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
ALTER TABLE `titles` ADD `contentRating` text;
File diff suppressed because it is too large Load Diff
+24
View File
@@ -72,6 +72,29 @@ export const verification = sqliteTable("verification", {
updatedAt: int("updatedAt", { mode: "timestamp" }), updatedAt: int("updatedAt", { mode: "timestamp" }),
}); });
// ─── Genres ─────────────────────────────────────────────────────────
export const genres = sqliteTable("genres", {
id: int("id").primaryKey(),
name: text("name").notNull(),
});
export const titleGenres = sqliteTable(
"titleGenres",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
genreId: int("genreId")
.notNull()
.references(() => genres.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("titleGenres_titleId_genreId").on(table.titleId, table.genreId),
index("titleGenres_genreId").on(table.genreId),
],
);
// ─── App tables ────────────────────────────────────────────────────── // ─── App tables ──────────────────────────────────────────────────────
export const titles = sqliteTable( export const titles = sqliteTable(
@@ -91,6 +114,7 @@ export const titles = sqliteTable(
voteAverage: real("voteAverage"), voteAverage: real("voteAverage"),
voteCount: int("voteCount"), voteCount: int("voteCount"),
status: text("status"), status: text("status"),
contentRating: text("contentRating"),
colorPalette: text("colorPalette"), colorPalette: text("colorPalette"),
trailerVideoKey: text("trailerVideoKey"), trailerVideoKey: text("trailerVideoKey"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }), lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
+62 -1
View File
@@ -3,7 +3,9 @@ import { db } from "@/lib/db/client";
import { import {
availabilityOffers, availabilityOffers,
episodes, episodes,
genres,
seasons, seasons,
titleGenres,
titleRecommendations, titleRecommendations,
titles, titles,
} from "@/lib/db/schema"; } from "@/lib/db/schema";
@@ -17,7 +19,12 @@ import {
getVideos, getVideos,
} from "@/lib/tmdb/client"; } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { tmdbImageUrl } from "@/lib/tmdb/image";
import type { TmdbVideo } from "@/lib/tmdb/types"; import type {
TmdbGenre,
TmdbMovieDetails,
TmdbTvDetails,
TmdbVideo,
} from "@/lib/tmdb/types";
import type { import type {
AvailabilityOffer, AvailabilityOffer,
CastMember, CastMember,
@@ -56,6 +63,37 @@ function insertTitleOrGet(values: typeof titles.$inferInsert, tmdbId: number) {
} }
} }
function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
if (tmdbGenres.length === 0) return;
for (const g of tmdbGenres) {
db.insert(genres)
.values({ id: g.id, name: g.name })
.onConflictDoUpdate({ target: genres.id, set: { name: g.name } })
.run();
}
db.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
for (const g of tmdbGenres) {
db.insert(titleGenres)
.values({ titleId, genreId: g.id })
.onConflictDoNothing()
.run();
}
}
function extractMovieContentRating(movie: TmdbMovieDetails): string | null {
const us = movie.release_dates?.results?.find((r) => r.iso_3166_1 === "US");
if (!us) return null;
for (const rd of us.release_dates) {
if (rd.certification) return rd.certification;
}
return null;
}
function extractTvContentRating(show: TmdbTvDetails): string | null {
const us = show.content_ratings?.results?.find((r) => r.iso_3166_1 === "US");
return us?.rating || null;
}
type ImportResult = ReturnType<typeof _importTitle>; type ImportResult = ReturnType<typeof _importTitle>;
/** In-flight import promises keyed by tmdbId — coalesces concurrent calls */ /** In-flight import promises keyed by tmdbId — coalesces concurrent calls */
@@ -111,11 +149,13 @@ async function _importTitle(
posterPath: show.poster_path, posterPath: show.poster_path,
backdropPath: show.backdrop_path, backdropPath: show.backdrop_path,
status: show.status, status: show.status,
contentRating: extractTvContentRating(show),
lastFetchedAt: new Date(), lastFetchedAt: new Date(),
}) })
.where(eq(titles.id, existing.id)) .where(eq(titles.id, existing.id))
.run(); .run();
} }
upsertGenres(existing.id, show.genres);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons); await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
if (awaitEnrichment) { if (awaitEnrichment) {
await Promise.all([ await Promise.all([
@@ -175,11 +215,13 @@ async function _importTitle(
voteAverage: movie.vote_average, voteAverage: movie.vote_average,
voteCount: movie.vote_count, voteCount: movie.vote_count,
status: movie.status, status: movie.status,
contentRating: extractMovieContentRating(movie),
lastFetchedAt: now, lastFetchedAt: now,
}, },
tmdbId, tmdbId,
); );
if (!row) return undefined; if (!row) return undefined;
upsertGenres(row.id, movie.genres);
if (awaitEnrichment) { if (awaitEnrichment) {
await Promise.all([ await Promise.all([
refreshAvailability(row.id).catch((err) => refreshAvailability(row.id).catch((err) =>
@@ -236,11 +278,13 @@ async function _importTitle(
voteAverage: show.vote_average, voteAverage: show.vote_average,
voteCount: show.vote_count, voteCount: show.vote_count,
status: show.status, status: show.status,
contentRating: extractTvContentRating(show),
lastFetchedAt: now, lastFetchedAt: now,
}, },
tmdbId, tmdbId,
); );
if (!row) return undefined; if (!row) return undefined;
upsertGenres(row.id, show.genres);
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons); await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
if (awaitEnrichment) { if (awaitEnrichment) {
@@ -307,10 +351,12 @@ export async function refreshTitle(titleId: string) {
voteAverage: movie.vote_average, voteAverage: movie.vote_average,
voteCount: movie.vote_count, voteCount: movie.vote_count,
status: movie.status, status: movie.status,
contentRating: extractMovieContentRating(movie),
lastFetchedAt: now, lastFetchedAt: now,
}) })
.where(eq(titles.id, titleId)) .where(eq(titles.id, titleId))
.run(); .run();
upsertGenres(titleId, movie.genres);
} else { } else {
const show = await getTvDetails(title.tmdbId); const show = await getTvDetails(title.tmdbId);
db.update(titles) db.update(titles)
@@ -325,10 +371,12 @@ export async function refreshTitle(titleId: string) {
voteAverage: show.vote_average, voteAverage: show.vote_average,
voteCount: show.vote_count, voteCount: show.vote_count,
status: show.status, status: show.status,
contentRating: extractTvContentRating(show),
lastFetchedAt: now, lastFetchedAt: now,
}) })
.where(eq(titles.id, titleId)) .where(eq(titles.id, titleId))
.run(); .run();
upsertGenres(titleId, show.genres);
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons); await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
} }
@@ -616,10 +664,12 @@ export async function ensureTvHydrated(
posterPath: show.poster_path, posterPath: show.poster_path,
backdropPath: show.backdrop_path, backdropPath: show.backdrop_path,
status: show.status, status: show.status,
contentRating: extractTvContentRating(show),
lastFetchedAt: new Date(), lastFetchedAt: new Date(),
}) })
.where(eq(titles.id, titleId)) .where(eq(titles.id, titleId))
.run(); .run();
upsertGenres(titleId, show.genres);
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons); await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
} catch (err) { } catch (err) {
log.debug(`Failed to hydrate shell TV title ${titleId}:`, err); log.debug(`Failed to hydrate shell TV title ${titleId}:`, err);
@@ -676,10 +726,12 @@ export async function getTitleWithChildren(id: string): Promise<{
voteAverage: movie.vote_average, voteAverage: movie.vote_average,
voteCount: movie.vote_count, voteCount: movie.vote_count,
status: movie.status, status: movie.status,
contentRating: extractMovieContentRating(movie),
lastFetchedAt: new Date(), lastFetchedAt: new Date(),
}) })
.where(eq(titles.id, id)) .where(eq(titles.id, id))
.run(); .run();
upsertGenres(id, movie.genres);
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title; title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
} catch (err) { } catch (err) {
log.debug(`Failed to hydrate shell movie title ${id}:`, err); log.debug(`Failed to hydrate shell movie title ${id}:`, err);
@@ -709,6 +761,13 @@ export async function getTitleWithChildren(id: string): Promise<{
extractAndStoreColors(title.id, title.posterPath).catch(() => {}); extractAndStoreColors(title.id, title.posterPath).catch(() => {});
} }
const titleGenreRows = db
.select({ name: genres.name })
.from(titleGenres)
.innerJoin(genres, eq(titleGenres.genreId, genres.id))
.where(eq(titleGenres.titleId, id))
.all();
const resolvedTitle: ResolvedTitle = { const resolvedTitle: ResolvedTitle = {
id: title.id, id: title.id,
tmdbId: title.tmdbId, tmdbId: title.tmdbId,
@@ -724,8 +783,10 @@ export async function getTitleWithChildren(id: string): Promise<{
voteAverage: title.voteAverage, voteAverage: title.voteAverage,
voteCount: title.voteCount, voteCount: title.voteCount,
status: title.status, status: title.status,
contentRating: title.contentRating,
colorPalette: palette, colorPalette: palette,
trailerVideoKey: title.trailerVideoKey, trailerVideoKey: title.trailerVideoKey,
genres: titleGenreRows.map((r) => r.name),
}; };
const cast = getCastForTitle(id); const cast = getCastForTitle(id);
+6 -2
View File
@@ -83,11 +83,15 @@ export async function searchTv(query: string, page = 1) {
} }
export async function getMovieDetails(tmdbId: number) { export async function getMovieDetails(tmdbId: number) {
return tmdbFetch<TmdbMovieDetails>(`/movie/${tmdbId}`); return tmdbFetch<TmdbMovieDetails>(`/movie/${tmdbId}`, {
append_to_response: "release_dates",
});
} }
export async function getTvDetails(tmdbId: number) { export async function getTvDetails(tmdbId: number) {
return tmdbFetch<TmdbTvDetails>(`/tv/${tmdbId}`); return tmdbFetch<TmdbTvDetails>(`/tv/${tmdbId}`, {
append_to_response: "content_ratings",
});
} }
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) { export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
+12
View File
@@ -10,6 +10,7 @@ export interface TmdbSearchResult {
first_air_date?: string; first_air_date?: string;
poster_path: string | null; poster_path: string | null;
backdrop_path: string | null; backdrop_path: string | null;
profile_path?: string | null;
popularity: number; popularity: number;
vote_average: number; vote_average: number;
vote_count: number; vote_count: number;
@@ -34,6 +35,13 @@ export interface TmdbMovieDetails {
vote_average: number; vote_average: number;
vote_count: number; vote_count: number;
status: string; status: string;
genres: TmdbGenre[];
release_dates?: {
results: {
iso_3166_1: string;
release_dates: { certification: string; type: number }[];
}[];
};
} }
export interface TmdbTvDetails { export interface TmdbTvDetails {
@@ -49,6 +57,10 @@ export interface TmdbTvDetails {
vote_count: number; vote_count: number;
status: string; status: string;
number_of_seasons: number; number_of_seasons: number;
genres: TmdbGenre[];
content_ratings?: {
results: { iso_3166_1: string; rating: string }[];
};
seasons: TmdbSeasonSummary[]; seasons: TmdbSeasonSummary[];
} }
+2
View File
@@ -97,6 +97,8 @@ export interface ResolvedTitle {
voteAverage: number | null; voteAverage: number | null;
voteCount: number | null; voteCount: number | null;
status: string | null; status: string | null;
contentRating: string | null;
colorPalette: ColorPalette | null; colorPalette: ColorPalette | null;
trailerVideoKey: string | null; trailerVideoKey: string | null;
genres: string[];
} }