mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 03:55:38 -04:00
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:
@@ -4,7 +4,7 @@ import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import { createStore, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { TitleCardSkeleton } from "@/components/skeletons";
|
||||
import { ExploreTitleCard } from "@/components/title-card";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Carousel,
|
||||
@@ -175,14 +175,13 @@ function FilterableTitleRowInner({
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<ExploreTitleCard
|
||||
<TitleCard
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.type}
|
||||
title={item.title}
|
||||
posterPath={item.posterPath}
|
||||
releaseDate={item.releaseDate}
|
||||
voteAverage={item.voteAverage}
|
||||
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
|
||||
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
|
||||
episodeProgress={
|
||||
episodeProgress[`${item.tmdbId}-${item.type}`]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import { ExploreTitleCard } from "@/components/title-card";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
@@ -57,14 +57,13 @@ export function TitleRow({
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<ExploreTitleCard
|
||||
<TitleCard
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.type}
|
||||
title={item.title}
|
||||
posterPath={item.posterPath}
|
||||
releaseDate={item.releaseDate}
|
||||
voteAverage={item.voteAverage}
|
||||
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
|
||||
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
|
||||
episodeProgress={
|
||||
episodeProgress?.[`${item.tmdbId}-${item.type}`]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { IconMovie } from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -121,44 +120,15 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<Link href={`/titles/${credit.titleId}`} className="group">
|
||||
<div className="overflow-hidden rounded-xl bg-card ring-1 ring-white/[0.06] transition-all group-hover:ring-primary/25">
|
||||
<div className="aspect-[2/3] w-full bg-muted">
|
||||
{credit.posterPath ? (
|
||||
<Image
|
||||
src={credit.posterPath}
|
||||
alt={credit.title}
|
||||
width={200}
|
||||
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>
|
||||
<TitleCard
|
||||
id={credit.titleId}
|
||||
tmdbId={credit.tmdbId}
|
||||
type={credit.type}
|
||||
title={credit.title}
|
||||
posterPath={credit.posterPath}
|
||||
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
||||
voteAverage={credit.voteAverage}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { IconCalendar, IconMapPin } from "@tabler/icons-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { ResolvedPerson } from "@/lib/types/title";
|
||||
|
||||
@@ -23,6 +24,19 @@ function calculateAge(birthday: string, deathday?: string | null): number {
|
||||
|
||||
export function PersonHero({ person }: PersonHeroProps) {
|
||||
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
|
||||
? calculateAge(person.birthday, person.deathday)
|
||||
: null;
|
||||
@@ -34,8 +48,8 @@ export function PersonHero({ person }: PersonHeroProps) {
|
||||
<Image
|
||||
src={person.profilePath}
|
||||
alt={person.name}
|
||||
width={224}
|
||||
height={224}
|
||||
width={500}
|
||||
height={500}
|
||||
className="h-full w-full object-cover"
|
||||
priority
|
||||
/>
|
||||
@@ -63,7 +77,7 @@ export function PersonHero({ person }: PersonHeroProps) {
|
||||
{person.birthday && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<IconCalendar aria-hidden={true} className="size-3.5" />
|
||||
{person.birthday}
|
||||
{format(parseISO(person.birthday), "MMMM d, yyyy")}
|
||||
{age !== null && (
|
||||
<span className="text-muted-foreground/60">
|
||||
({person.deathday ? `died at ${age}` : `age ${age}`})
|
||||
@@ -82,13 +96,14 @@ export function PersonHero({ person }: PersonHeroProps) {
|
||||
{person.biography && (
|
||||
<div className="max-w-3xl">
|
||||
<p
|
||||
ref={bioRef}
|
||||
className={`text-sm leading-relaxed text-muted-foreground ${
|
||||
!bioExpanded ? "line-clamp-6" : ""
|
||||
!bioExpanded ? "line-clamp-3" : ""
|
||||
}`}
|
||||
>
|
||||
{person.biography}
|
||||
</p>
|
||||
{person.biography.length > 400 && (
|
||||
{(isClamped || bioExpanded) && (
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
{title.type}
|
||||
</Badge>
|
||||
{title.contentRating && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded border-border/50 font-semibold uppercase tracking-wider"
|
||||
>
|
||||
{title.contentRating}
|
||||
</Badge>
|
||||
)}
|
||||
{year && <span>{year}</span>}
|
||||
{title.genres.length > 0 && (
|
||||
<span>{title.genres.join(" · ")}</span>
|
||||
)}
|
||||
{title.voteAverage != null && title.voteAverage > 0 && (
|
||||
<span className="flex items-center gap-1 text-primary">
|
||||
★ {title.voteAverage.toFixed(1)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useHydrateAtoms } from "jotai/utils";
|
||||
import { createStore, Provider } from "jotai";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
episodeWatchesAtom,
|
||||
seasonsAtom,
|
||||
@@ -31,15 +32,17 @@ export function TitleProvider({
|
||||
seasons: Season[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useHydrateAtoms([
|
||||
[titleIdAtom, titleId],
|
||||
[titleTypeAtom, titleType],
|
||||
[titleNameAtom, titleName],
|
||||
[seasonsAtom, seasons],
|
||||
[userStatusAtom, initialStatus],
|
||||
[userRatingAtom, initialRating],
|
||||
[episodeWatchesAtom, initialEpisodeWatches],
|
||||
]);
|
||||
const [store] = useState(() => {
|
||||
const s = createStore();
|
||||
s.set(titleIdAtom, titleId);
|
||||
s.set(titleTypeAtom, titleType);
|
||||
s.set(titleNameAtom, titleName);
|
||||
s.set(seasonsAtom, seasons);
|
||||
s.set(userStatusAtom, initialStatus);
|
||||
s.set(userRatingAtom, initialRating);
|
||||
s.set(episodeWatchesAtom, initialEpisodeWatches);
|
||||
return s;
|
||||
});
|
||||
|
||||
return children;
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconChevronUp,
|
||||
IconDeviceTvOld,
|
||||
} from "@tabler/icons-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
@@ -271,7 +272,9 @@ export function TitleSeasons({
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ep.airDate ?? ""}
|
||||
{ep.airDate
|
||||
? format(parseISO(ep.airDate), "MMM d, yyyy")
|
||||
: ""}
|
||||
{ep.airDate && ep.runtimeMinutes ? " · " : ""}
|
||||
{ep.runtimeMinutes ? `${ep.runtimeMinutes}m` : ""}
|
||||
</p>
|
||||
|
||||
@@ -82,6 +82,7 @@ export default async function TitleDetailPage({
|
||||
return (
|
||||
<div className="relative space-y-10" style={themeStyle}>
|
||||
<TitleProvider
|
||||
key={title.id}
|
||||
titleId={title.id}
|
||||
titleType={title.type}
|
||||
titleName={title.title}
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function GET(req: NextRequest) {
|
||||
type: "person" as const,
|
||||
title: r.name ?? "Unknown",
|
||||
posterPath: null,
|
||||
profilePath: tmdbImageUrl(r.profile_path, "w185"),
|
||||
profilePath: tmdbImageUrl(r.profile_path ?? null, "w185"),
|
||||
overview: "",
|
||||
releaseDate: null,
|
||||
popularity: r.popularity,
|
||||
|
||||
@@ -42,18 +42,11 @@ interface CardInnerProps {
|
||||
tiltStyles?: TiltStyles;
|
||||
}
|
||||
|
||||
interface TitleCardProps extends CardInnerProps {
|
||||
id: string;
|
||||
export interface TitleCardProps extends CardInnerProps {
|
||||
id?: string;
|
||||
tmdbId: number;
|
||||
}
|
||||
|
||||
interface ExploreTitleCardProps extends CardInnerProps {
|
||||
tmdbId: number;
|
||||
href: string;
|
||||
userStatus?: TitleStatus | null;
|
||||
episodeProgress?: { watched: number; total: number } | null;
|
||||
}
|
||||
|
||||
type QuickAddState = "idle" | "loading" | "added";
|
||||
|
||||
const statusConfig = {
|
||||
@@ -273,10 +266,9 @@ function CardInner({
|
||||
);
|
||||
}
|
||||
|
||||
/** Linked title card for library grids, recommendations, dashboards */
|
||||
export function TitleCard({
|
||||
id,
|
||||
tmdbId: _tmdbId,
|
||||
tmdbId,
|
||||
type,
|
||||
title,
|
||||
posterPath,
|
||||
@@ -285,41 +277,7 @@ export function TitleCard({
|
||||
userStatus,
|
||||
episodeProgress,
|
||||
}: TitleCardProps) {
|
||||
const tilt = useTiltEffect();
|
||||
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 href = id ? `/titles/${id}` : `/titles/tmdb-${tmdbId}-${type}`;
|
||||
const tilt = useTiltEffect();
|
||||
return (
|
||||
<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
@@ -72,6 +72,29 @@ export const verification = sqliteTable("verification", {
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
export const titles = sqliteTable(
|
||||
@@ -91,6 +114,7 @@ export const titles = sqliteTable(
|
||||
voteAverage: real("voteAverage"),
|
||||
voteCount: int("voteCount"),
|
||||
status: text("status"),
|
||||
contentRating: text("contentRating"),
|
||||
colorPalette: text("colorPalette"),
|
||||
trailerVideoKey: text("trailerVideoKey"),
|
||||
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
||||
|
||||
@@ -3,7 +3,9 @@ import { db } from "@/lib/db/client";
|
||||
import {
|
||||
availabilityOffers,
|
||||
episodes,
|
||||
genres,
|
||||
seasons,
|
||||
titleGenres,
|
||||
titleRecommendations,
|
||||
titles,
|
||||
} from "@/lib/db/schema";
|
||||
@@ -17,7 +19,12 @@ import {
|
||||
getVideos,
|
||||
} from "@/lib/tmdb/client";
|
||||
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 {
|
||||
AvailabilityOffer,
|
||||
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>;
|
||||
|
||||
/** In-flight import promises keyed by tmdbId — coalesces concurrent calls */
|
||||
@@ -111,11 +149,13 @@ async function _importTitle(
|
||||
posterPath: show.poster_path,
|
||||
backdropPath: show.backdrop_path,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.where(eq(titles.id, existing.id))
|
||||
.run();
|
||||
}
|
||||
upsertGenres(existing.id, show.genres);
|
||||
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
||||
if (awaitEnrichment) {
|
||||
await Promise.all([
|
||||
@@ -175,11 +215,13 @@ async function _importTitle(
|
||||
voteAverage: movie.vote_average,
|
||||
voteCount: movie.vote_count,
|
||||
status: movie.status,
|
||||
contentRating: extractMovieContentRating(movie),
|
||||
lastFetchedAt: now,
|
||||
},
|
||||
tmdbId,
|
||||
);
|
||||
if (!row) return undefined;
|
||||
upsertGenres(row.id, movie.genres);
|
||||
if (awaitEnrichment) {
|
||||
await Promise.all([
|
||||
refreshAvailability(row.id).catch((err) =>
|
||||
@@ -236,11 +278,13 @@ async function _importTitle(
|
||||
voteAverage: show.vote_average,
|
||||
voteCount: show.vote_count,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
lastFetchedAt: now,
|
||||
},
|
||||
tmdbId,
|
||||
);
|
||||
if (!row) return undefined;
|
||||
upsertGenres(row.id, show.genres);
|
||||
|
||||
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
|
||||
if (awaitEnrichment) {
|
||||
@@ -307,10 +351,12 @@ export async function refreshTitle(titleId: string) {
|
||||
voteAverage: movie.vote_average,
|
||||
voteCount: movie.vote_count,
|
||||
status: movie.status,
|
||||
contentRating: extractMovieContentRating(movie),
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
upsertGenres(titleId, movie.genres);
|
||||
} else {
|
||||
const show = await getTvDetails(title.tmdbId);
|
||||
db.update(titles)
|
||||
@@ -325,10 +371,12 @@ export async function refreshTitle(titleId: string) {
|
||||
voteAverage: show.vote_average,
|
||||
voteCount: show.vote_count,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
upsertGenres(titleId, show.genres);
|
||||
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
|
||||
}
|
||||
|
||||
@@ -616,10 +664,12 @@ export async function ensureTvHydrated(
|
||||
posterPath: show.poster_path,
|
||||
backdropPath: show.backdrop_path,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
upsertGenres(titleId, show.genres);
|
||||
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
||||
} catch (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,
|
||||
voteCount: movie.vote_count,
|
||||
status: movie.status,
|
||||
contentRating: extractMovieContentRating(movie),
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.where(eq(titles.id, id))
|
||||
.run();
|
||||
upsertGenres(id, movie.genres);
|
||||
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
||||
} catch (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(() => {});
|
||||
}
|
||||
|
||||
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 = {
|
||||
id: title.id,
|
||||
tmdbId: title.tmdbId,
|
||||
@@ -724,8 +783,10 @@ export async function getTitleWithChildren(id: string): Promise<{
|
||||
voteAverage: title.voteAverage,
|
||||
voteCount: title.voteCount,
|
||||
status: title.status,
|
||||
contentRating: title.contentRating,
|
||||
colorPalette: palette,
|
||||
trailerVideoKey: title.trailerVideoKey,
|
||||
genres: titleGenreRows.map((r) => r.name),
|
||||
};
|
||||
|
||||
const cast = getCastForTitle(id);
|
||||
|
||||
+6
-2
@@ -83,11 +83,15 @@ export async function searchTv(query: string, page = 1) {
|
||||
}
|
||||
|
||||
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) {
|
||||
return tmdbFetch<TmdbTvDetails>(`/tv/${tmdbId}`);
|
||||
return tmdbFetch<TmdbTvDetails>(`/tv/${tmdbId}`, {
|
||||
append_to_response: "content_ratings",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface TmdbSearchResult {
|
||||
first_air_date?: string;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
profile_path?: string | null;
|
||||
popularity: number;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
@@ -34,6 +35,13 @@ export interface TmdbMovieDetails {
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
status: string;
|
||||
genres: TmdbGenre[];
|
||||
release_dates?: {
|
||||
results: {
|
||||
iso_3166_1: string;
|
||||
release_dates: { certification: string; type: number }[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TmdbTvDetails {
|
||||
@@ -49,6 +57,10 @@ export interface TmdbTvDetails {
|
||||
vote_count: number;
|
||||
status: string;
|
||||
number_of_seasons: number;
|
||||
genres: TmdbGenre[];
|
||||
content_ratings?: {
|
||||
results: { iso_3166_1: string; rating: string }[];
|
||||
};
|
||||
seasons: TmdbSeasonSummary[];
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface ResolvedTitle {
|
||||
voteAverage: number | null;
|
||||
voteCount: number | null;
|
||||
status: string | null;
|
||||
contentRating: string | null;
|
||||
colorPalette: ColorPalette | null;
|
||||
trailerVideoKey: string | null;
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user