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 { 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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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" }),
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user