mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Optimize performance: batch DB ops, N+1 fixes, Suspense streaming, and Jotai best practices
- Add composite indexes on userEpisodeWatches and userMovieWatches for hot queries - Batch episode tracking: wrap season/batch watches in single transaction (~8 queries vs 8*N) - Fix N+1 patterns in credits, recommendations, and filmography with batch prefetch+insert - Stream TV season hydration via Suspense instead of blocking page render - Optimize webhook logs (per-connection LIMIT 10) and system health queries - Merge genre filter waterfalls into single Promise.all fetch - Migrate deprecated Jotai loadable() to unwrap() - Replace isolated createStore()+Provider with useHydrateAtoms on root store - Remove unnecessary atomWithStorage SSR guards (handled by Jotai internally) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,9 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
episodePeriodAtom,
|
episodePeriodAtom,
|
||||||
episodeStatsLoadable,
|
episodeStatsAtom,
|
||||||
moviePeriodAtom,
|
moviePeriodAtom,
|
||||||
movieStatsLoadable,
|
movieStatsAtom,
|
||||||
} from "@/lib/atoms/stats";
|
} from "@/lib/atoms/stats";
|
||||||
import type {
|
import type {
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
@@ -120,24 +120,14 @@ function PeriodSelector({
|
|||||||
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
||||||
const [moviePeriod, setMoviePeriod] = useAtom(moviePeriodAtom);
|
const [moviePeriod, setMoviePeriod] = useAtom(moviePeriodAtom);
|
||||||
const [episodePeriod, setEpisodePeriod] = useAtom(episodePeriodAtom);
|
const [episodePeriod, setEpisodePeriod] = useAtom(episodePeriodAtom);
|
||||||
const movieStats = useAtomValue(movieStatsLoadable);
|
const movieStats = useAtomValue(movieStatsAtom);
|
||||||
const episodeStats = useAtomValue(episodeStatsLoadable);
|
const episodeStats = useAtomValue(episodeStatsAtom);
|
||||||
|
|
||||||
const _movieLoading = movieStats.state === "loading";
|
const movieCount = movieStats?.count ?? stats.moviesThisMonth;
|
||||||
const movieCount =
|
const movieHistory = movieStats?.history;
|
||||||
movieStats.state === "hasData"
|
|
||||||
? movieStats.data.count
|
|
||||||
: stats.moviesThisMonth;
|
|
||||||
const movieHistory =
|
|
||||||
movieStats.state === "hasData" ? movieStats.data.history : undefined;
|
|
||||||
|
|
||||||
const _episodeLoading = episodeStats.state === "loading";
|
const episodeCount = episodeStats?.count ?? stats.episodesThisWeek;
|
||||||
const episodeCount =
|
const episodeHistory = episodeStats?.history;
|
||||||
episodeStats.state === "hasData"
|
|
||||||
? episodeStats.data.count
|
|
||||||
: stats.episodesThisWeek;
|
|
||||||
const episodeHistory =
|
|
||||||
episodeStats.state === "hasData" ? episodeStats.data.history : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ import {
|
|||||||
} from "@/components/ui/carousel";
|
} from "@/components/ui/carousel";
|
||||||
import {
|
import {
|
||||||
defaultItemsAtom,
|
defaultItemsAtom,
|
||||||
genreEpisodeProgressLoadable,
|
genreEnrichmentsAtom,
|
||||||
genreResultsLoadable,
|
genreResultsAtom,
|
||||||
genreUserStatusesLoadable,
|
|
||||||
initialEpisodeProgressAtom,
|
initialEpisodeProgressAtom,
|
||||||
initialUserStatusesAtom,
|
initialUserStatusesAtom,
|
||||||
mediaTypeAtom,
|
mediaTypeAtom,
|
||||||
@@ -81,33 +80,23 @@ function FilterableTitleRowInner({
|
|||||||
}) {
|
}) {
|
||||||
const [selectedGenre, setSelectedGenre] = useAtom(selectedGenreAtom);
|
const [selectedGenre, setSelectedGenre] = useAtom(selectedGenreAtom);
|
||||||
const defaults = useAtomValue(defaultItemsAtom);
|
const defaults = useAtomValue(defaultItemsAtom);
|
||||||
const genreResults = useAtomValue(genreResultsLoadable);
|
const genreResults = useAtomValue(genreResultsAtom);
|
||||||
const initialStatuses = useAtomValue(initialUserStatusesAtom);
|
const initialStatuses = useAtomValue(initialUserStatusesAtom);
|
||||||
const genreStatuses = useAtomValue(genreUserStatusesLoadable);
|
|
||||||
const initialProgress = useAtomValue(initialEpisodeProgressAtom);
|
const initialProgress = useAtomValue(initialEpisodeProgressAtom);
|
||||||
const genreProgress = useAtomValue(genreEpisodeProgressLoadable);
|
const genreEnrichments = useAtomValue(genreEnrichmentsAtom);
|
||||||
|
|
||||||
const loading = selectedGenre !== null && genreResults.state === "loading";
|
const loading = selectedGenre !== null && genreResults === undefined;
|
||||||
const items =
|
const items = selectedGenre === null ? defaults : (genreResults ?? []);
|
||||||
selectedGenre === null
|
|
||||||
? defaults
|
|
||||||
: genreResults.state === "hasData" && genreResults.data !== null
|
|
||||||
? genreResults.data
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const userStatuses =
|
const userStatuses =
|
||||||
selectedGenre === null
|
selectedGenre === null
|
||||||
? initialStatuses
|
? initialStatuses
|
||||||
: genreStatuses.state === "hasData" && genreStatuses.data !== null
|
: (genreEnrichments?.statuses ?? initialStatuses);
|
||||||
? genreStatuses.data
|
|
||||||
: initialStatuses;
|
|
||||||
|
|
||||||
const episodeProgress =
|
const episodeProgress =
|
||||||
selectedGenre === null
|
selectedGenre === null
|
||||||
? initialProgress
|
? initialProgress
|
||||||
: genreProgress.state === "hasData" && genreProgress.data !== null
|
: (genreEnrichments?.progress ?? initialProgress);
|
||||||
? genreProgress.data
|
|
||||||
: initialProgress;
|
|
||||||
|
|
||||||
function toggleGenre(genreId: number) {
|
function toggleGenre(genreId: number) {
|
||||||
setSelectedGenre(selectedGenre === genreId ? null : genreId);
|
setSelectedGenre(selectedGenre === genreId ? null : genreId);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Provider as StoreProvider } from "jotai";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { MobileTabBar } from "@/components/mobile-tab-bar";
|
import { MobileTabBar } from "@/components/mobile-tab-bar";
|
||||||
@@ -14,7 +15,7 @@ export default async function PagesLayout({
|
|||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<StoreProvider>
|
||||||
<div className="min-h-screen pb-14 sm:pb-0">
|
<div className="min-h-screen pb-14 sm:pb-0">
|
||||||
<NavBar />
|
<NavBar />
|
||||||
{/* Ambient glow */}
|
{/* Ambient glow */}
|
||||||
@@ -26,6 +27,6 @@ export default async function PagesLayout({
|
|||||||
<MobileTabBar />
|
<MobileTabBar />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
</>
|
</StoreProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { IconCalendarWeek, IconChevronDown } from "@tabler/icons-react";
|
import { IconCalendarWeek, IconChevronDown } from "@tabler/icons-react";
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
import { createStore, Provider, useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useHydrateAtoms } from "jotai/utils";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -116,23 +116,20 @@ export function BackupScheduleSection({
|
|||||||
initialTime: string;
|
initialTime: string;
|
||||||
initialDow: number;
|
initialDow: number;
|
||||||
}) {
|
}) {
|
||||||
const [store] = useState(() => {
|
useHydrateAtoms([
|
||||||
const s = createStore();
|
[
|
||||||
s.set(backupScheduleAtom, {
|
backupScheduleAtom,
|
||||||
enabled: initialScheduledEnabled,
|
{
|
||||||
maxRetention: initialMaxRetention,
|
enabled: initialScheduledEnabled,
|
||||||
frequency: initialFrequency,
|
maxRetention: initialMaxRetention,
|
||||||
time: initialTime,
|
frequency: initialFrequency,
|
||||||
dow: initialDow,
|
time: initialTime,
|
||||||
});
|
dow: initialDow,
|
||||||
return s;
|
},
|
||||||
});
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return <BackupScheduleInner />;
|
||||||
<Provider store={store}>
|
|
||||||
<BackupScheduleInner />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackupScheduleInner() {
|
function BackupScheduleInner() {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconWebhook } from "@tabler/icons-react";
|
import { IconWebhook } from "@tabler/icons-react";
|
||||||
import { createStore, Provider } from "jotai";
|
import { useHydrateAtoms } from "jotai/utils";
|
||||||
import { useState } from "react";
|
|
||||||
import { connectionsAtom } from "@/lib/atoms/integrations";
|
import { connectionsAtom } from "@/lib/atoms/integrations";
|
||||||
import { WebhookCard, type WebhookConnection } from "./webhook-card";
|
import { WebhookCard, type WebhookConnection } from "./webhook-card";
|
||||||
|
|
||||||
@@ -11,27 +10,21 @@ export function IntegrationsSection({
|
|||||||
}: {
|
}: {
|
||||||
initialConnections: WebhookConnection[];
|
initialConnections: WebhookConnection[];
|
||||||
}) {
|
}) {
|
||||||
const [store] = useState(() => {
|
useHydrateAtoms([[connectionsAtom, initialConnections]]);
|
||||||
const s = createStore();
|
|
||||||
s.set(connectionsAtom, initialConnections);
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<div>
|
||||||
<div>
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<IconWebhook className="size-4 text-muted-foreground" />
|
||||||
<IconWebhook className="size-4 text-muted-foreground" />
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
Integrations
|
||||||
Integrations
|
</h2>
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<WebhookCard provider="plex" />
|
|
||||||
<WebhookCard provider="jellyfin" />
|
|
||||||
<WebhookCard provider="emby" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Provider>
|
<div className="space-y-3">
|
||||||
|
<WebhookCard provider="plex" />
|
||||||
|
<WebhookCard provider="jellyfin" />
|
||||||
|
<WebhookCard provider="emby" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
IconServer2,
|
IconServer2,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { desc, eq, inArray } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { getSession } from "@/lib/auth/session";
|
import { getSession } from "@/lib/auth/session";
|
||||||
@@ -40,23 +40,20 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
const connIds = connRows.map((c) => c.id);
|
const connIds = connRows.map((c) => c.id);
|
||||||
|
|
||||||
// Batch fetch all event logs for all connections (1 query)
|
// Fetch only the 10 most recent events per connection (index-optimized)
|
||||||
const allEvents =
|
const eventsByConn = new Map<
|
||||||
connIds.length > 0
|
string,
|
||||||
? db
|
(typeof webhookEventLog.$inferSelect)[]
|
||||||
.select()
|
>();
|
||||||
.from(webhookEventLog)
|
for (const connId of connIds) {
|
||||||
.where(inArray(webhookEventLog.connectionId, connIds))
|
const events = db
|
||||||
.orderBy(desc(webhookEventLog.receivedAt))
|
.select()
|
||||||
.all()
|
.from(webhookEventLog)
|
||||||
: [];
|
.where(eq(webhookEventLog.connectionId, connId))
|
||||||
|
.orderBy(desc(webhookEventLog.receivedAt))
|
||||||
// Group events by connection, keeping only 10 most recent per connection
|
.limit(10)
|
||||||
const eventsByConn = new Map<string, typeof allEvents>();
|
.all();
|
||||||
for (const e of allEvents) {
|
eventsByConn.set(connId, events);
|
||||||
const arr = eventsByConn.get(e.connectionId) ?? [];
|
|
||||||
if (arr.length < 10) arr.push(e);
|
|
||||||
eventsByConn.set(e.connectionId, arr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connections = connRows.map((conn) => ({
|
const connections = connRows.map((conn) => ({
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ensureTvHydrated } from "@/lib/services/metadata";
|
||||||
|
import { TitleSeasons } from "./title-seasons";
|
||||||
|
|
||||||
|
export async function AsyncTitleSeasons({
|
||||||
|
titleId,
|
||||||
|
tmdbId,
|
||||||
|
}: {
|
||||||
|
titleId: string;
|
||||||
|
tmdbId: number;
|
||||||
|
}) {
|
||||||
|
const seasons = await ensureTvHydrated(titleId, tmdbId);
|
||||||
|
if (seasons.length === 0) return null;
|
||||||
|
return <TitleSeasons seasons={seasons} />;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { Hotkey } from "@tanstack/react-hotkeys";
|
import type { Hotkey } from "@tanstack/react-hotkeys";
|
||||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||||
import { getDefaultStore, useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
||||||
import { titleTypeAtom, userStatusAtom } from "@/lib/atoms/title";
|
import { titleTypeAtom, userStatusAtom } from "@/lib/atoms/title";
|
||||||
@@ -15,9 +15,7 @@ export function TitleKeyboardShortcuts() {
|
|||||||
const { handleStatusChange, handleRating, handleWatchMovie } =
|
const { handleStatusChange, handleRating, handleWatchMovie } =
|
||||||
useTitleActions();
|
useTitleActions();
|
||||||
|
|
||||||
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom, {
|
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom);
|
||||||
store: getDefaultStore(),
|
|
||||||
});
|
|
||||||
const enabled = !commandPaletteOpen;
|
const enabled = !commandPaletteOpen;
|
||||||
|
|
||||||
// W: toggle watchlist (add if not in library, remove if in library)
|
// W: toggle watchlist (add if not in library, remove if in library)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createStore, Provider } from "jotai";
|
import { useHydrateAtoms } from "jotai/utils";
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
episodeWatchesAtom,
|
episodeWatchesAtom,
|
||||||
seasonsAtom,
|
seasonsAtom,
|
||||||
@@ -32,17 +31,15 @@ export function TitleProvider({
|
|||||||
seasons: Season[];
|
seasons: Season[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [store] = useState(() => {
|
useHydrateAtoms([
|
||||||
const s = createStore();
|
[titleIdAtom, titleId],
|
||||||
s.set(titleIdAtom, titleId);
|
[titleTypeAtom, titleType],
|
||||||
s.set(titleTypeAtom, titleType);
|
[titleNameAtom, titleName],
|
||||||
s.set(titleNameAtom, titleName);
|
[seasonsAtom, seasons],
|
||||||
s.set(seasonsAtom, seasons);
|
[userStatusAtom, initialStatus],
|
||||||
s.set(userStatusAtom, initialStatus);
|
[userRatingAtom, initialRating],
|
||||||
s.set(userRatingAtom, initialRating);
|
[episodeWatchesAtom, initialEpisodeWatches],
|
||||||
s.set(episodeWatchesAtom, initialEpisodeWatches);
|
]);
|
||||||
return s;
|
|
||||||
});
|
|
||||||
|
|
||||||
return <Provider store={store}>{children}</Provider>;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
IconDeviceTvOld,
|
IconDeviceTvOld,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useAtomValue } 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";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -29,9 +29,23 @@ import {
|
|||||||
userStatusAtom,
|
userStatusAtom,
|
||||||
watchingEpAtom,
|
watchingEpAtom,
|
||||||
} from "@/lib/atoms/title";
|
} from "@/lib/atoms/title";
|
||||||
|
import type { Season } from "@/lib/types/title";
|
||||||
import { useTitleActions } from "./use-title-actions";
|
import { useTitleActions } from "./use-title-actions";
|
||||||
|
|
||||||
export function TitleSeasons() {
|
export function TitleSeasons({
|
||||||
|
seasons: streamedSeasons,
|
||||||
|
}: {
|
||||||
|
seasons?: Season[];
|
||||||
|
} = {}) {
|
||||||
|
const setSeasons = useSetAtom(seasonsAtom);
|
||||||
|
|
||||||
|
// When seasons are streamed via Suspense, sync them into the Jotai store
|
||||||
|
useEffect(() => {
|
||||||
|
if (streamedSeasons && streamedSeasons.length > 0) {
|
||||||
|
setSeasons(streamedSeasons);
|
||||||
|
}
|
||||||
|
}, [streamedSeasons, setSeasons]);
|
||||||
|
|
||||||
const seasons = useAtomValue(seasonsAtom);
|
const seasons = useAtomValue(seasonsAtom);
|
||||||
const episodeWatches = useAtomValue(episodeWatchesAtom);
|
const episodeWatches = useAtomValue(episodeWatchesAtom);
|
||||||
const userStatus = useAtomValue(userStatusAtom);
|
const userStatus = useAtomValue(userStatusAtom);
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { eq } from "drizzle-orm";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { RecommendationsSkeleton } from "@/components/skeletons";
|
import {
|
||||||
|
RecommendationsSkeleton,
|
||||||
|
SeasonsSkeleton,
|
||||||
|
} from "@/components/skeletons";
|
||||||
import { getSession } from "@/lib/auth/session";
|
import { getSession } from "@/lib/auth/session";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { titles } from "@/lib/db/schema";
|
import { titles } from "@/lib/db/schema";
|
||||||
@@ -10,6 +13,7 @@ import { getTitleWithChildren, importTitle } from "@/lib/services/metadata";
|
|||||||
import { getUserTitleInfo } from "@/lib/services/tracking";
|
import { getUserTitleInfo } from "@/lib/services/tracking";
|
||||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||||
import { getTitleThemeStyle } from "@/lib/utils/title-theme";
|
import { getTitleThemeStyle } from "@/lib/utils/title-theme";
|
||||||
|
import { AsyncTitleSeasons } from "./_components/async-title-seasons";
|
||||||
import { TitleActions } from "./_components/title-actions";
|
import { TitleActions } from "./_components/title-actions";
|
||||||
import { TitleAvailability } from "./_components/title-availability";
|
import { TitleAvailability } from "./_components/title-availability";
|
||||||
import { TitleCast } from "./_components/title-cast";
|
import { TitleCast } from "./_components/title-cast";
|
||||||
@@ -71,7 +75,7 @@ export default async function TitleDetailPage({
|
|||||||
]);
|
]);
|
||||||
if (!result) notFound();
|
if (!result) notFound();
|
||||||
|
|
||||||
const { title, seasons, availability, cast } = result;
|
const { title, seasons, needsHydration, availability, cast } = result;
|
||||||
|
|
||||||
const themeStyle = getTitleThemeStyle(title.colorPalette);
|
const themeStyle = getTitleThemeStyle(title.colorPalette);
|
||||||
|
|
||||||
@@ -94,7 +98,14 @@ export default async function TitleDetailPage({
|
|||||||
<TitleAvailability availability={availability} />
|
<TitleAvailability availability={availability} />
|
||||||
</TitleHero>
|
</TitleHero>
|
||||||
|
|
||||||
{title.type === "tv" && seasons.length > 0 && <TitleSeasons />}
|
{title.type === "tv" && needsHydration && (
|
||||||
|
<Suspense fallback={<SeasonsSkeleton />}>
|
||||||
|
<AsyncTitleSeasons titleId={title.id} tmdbId={title.tmdbId} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
{title.type === "tv" && !needsHydration && seasons.length > 0 && (
|
||||||
|
<TitleSeasons />
|
||||||
|
)}
|
||||||
|
|
||||||
<TitleCast cast={cast} titleType={title.type} />
|
<TitleCast cast={cast} titleType={title.type} />
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,35 @@ export function PersonDetailSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SeasonsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="size-5 rounded" />
|
||||||
|
<Skeleton className="h-6 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-8 w-20 rounded-full" />
|
||||||
|
<Skeleton className="h-8 w-20 rounded-full" />
|
||||||
|
<Skeleton className="h-8 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
|
||||||
|
<div key={i} className="flex gap-3 rounded-lg bg-card/50 p-3">
|
||||||
|
<Skeleton className="aspect-video w-32 shrink-0 rounded" />
|
||||||
|
<div className="flex-1 space-y-2 py-1">
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-3 w-full" />
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RecommendationsSkeleton() {
|
export function RecommendationsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { type MotionStyle, type MotionValue, motion } from "motion/react";
|
import { type MotionStyle, type MotionValue, motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { db } from "@/lib/db/client";
|
|||||||
import { episodes } from "@/lib/db/schema";
|
import { episodes } from "@/lib/db/schema";
|
||||||
import {
|
import {
|
||||||
logEpisodeWatch,
|
logEpisodeWatch,
|
||||||
|
logEpisodeWatchBatch,
|
||||||
logMovieWatch,
|
logMovieWatch,
|
||||||
markAllEpisodesWatched,
|
markAllEpisodesWatched,
|
||||||
rateTitleStars,
|
rateTitleStars,
|
||||||
@@ -67,9 +68,10 @@ export async function watchSeason(seasonId: string) {
|
|||||||
.from(episodes)
|
.from(episodes)
|
||||||
.where(eq(episodes.seasonId, seasonId))
|
.where(eq(episodes.seasonId, seasonId))
|
||||||
.all();
|
.all();
|
||||||
for (const ep of seasonEps) {
|
logEpisodeWatchBatch(
|
||||||
logEpisodeWatch(userId, ep.id);
|
userId,
|
||||||
}
|
seasonEps.map((ep) => ep.id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unwatchSeasonAction(seasonId: string) {
|
export async function unwatchSeasonAction(seasonId: string) {
|
||||||
@@ -79,7 +81,5 @@ export async function unwatchSeasonAction(seasonId: string) {
|
|||||||
|
|
||||||
export async function batchWatchEpisodes(episodeIds: string[]) {
|
export async function batchWatchEpisodes(episodeIds: string[]) {
|
||||||
const userId = await getSessionUserId();
|
const userId = await getSessionUserId();
|
||||||
for (const id of episodeIds) {
|
logEpisodeWatchBatch(userId, episodeIds);
|
||||||
logEpisodeWatch(userId, id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-25
@@ -1,5 +1,5 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { loadable } from "jotai/utils";
|
import { unwrap } from "jotai/utils";
|
||||||
import {
|
import {
|
||||||
fetchEpisodeProgress,
|
fetchEpisodeProgress,
|
||||||
fetchUserStatuses,
|
fetchUserStatuses,
|
||||||
@@ -35,30 +35,27 @@ const genreResultsAsyncAtom = atom(async (get) => {
|
|||||||
return (data.results ?? []) as TitleRowItem[];
|
return (data.results ?? []) as TitleRowItem[];
|
||||||
});
|
});
|
||||||
|
|
||||||
export const genreResultsLoadable = loadable(genreResultsAsyncAtom);
|
export const genreResultsAtom = unwrap(genreResultsAsyncAtom);
|
||||||
|
|
||||||
const genreUserStatusesAsyncAtom = atom(async (get) => {
|
interface GenreEnrichments {
|
||||||
const genre = get(selectedGenreAtom);
|
statuses: Record<string, TitleStatus>;
|
||||||
if (genre === null) return null;
|
progress: Record<string, { watched: number; total: number }>;
|
||||||
const genreResults = await get(genreResultsAsyncAtom);
|
}
|
||||||
if (!genreResults || genreResults.length === 0) return {};
|
|
||||||
return fetchUserStatuses(
|
|
||||||
genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type })),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const genreUserStatusesLoadable = loadable(genreUserStatusesAsyncAtom);
|
const genreEnrichmentsAsyncAtom = atom(
|
||||||
|
async (get): Promise<GenreEnrichments | null> => {
|
||||||
const genreEpisodeProgressAsyncAtom = atom(async (get) => {
|
const genre = get(selectedGenreAtom);
|
||||||
const genre = get(selectedGenreAtom);
|
if (genre === null) return null;
|
||||||
if (genre === null) return null;
|
const genreResults = await get(genreResultsAsyncAtom);
|
||||||
const genreResults = await get(genreResultsAsyncAtom);
|
if (!genreResults || genreResults.length === 0)
|
||||||
if (!genreResults || genreResults.length === 0) return {};
|
return { statuses: {}, progress: {} };
|
||||||
return fetchEpisodeProgress(
|
const items = genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
|
||||||
genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type })),
|
const [statuses, progress] = await Promise.all([
|
||||||
);
|
fetchUserStatuses(items),
|
||||||
});
|
fetchEpisodeProgress(items),
|
||||||
|
]);
|
||||||
export const genreEpisodeProgressLoadable = loadable(
|
return { statuses, progress };
|
||||||
genreEpisodeProgressAsyncAtom,
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const genreEnrichmentsAtom = unwrap(genreEnrichmentsAsyncAtom);
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { loadable } from "jotai/utils";
|
import { unwrap } from "jotai/utils";
|
||||||
import type { HistoryBucket, TimePeriod } from "@/lib/services/discovery";
|
import type { HistoryBucket, TimePeriod } from "@/lib/services/discovery";
|
||||||
|
|
||||||
export const moviePeriodAtom = atom<TimePeriod>("this_month");
|
export const moviePeriodAtom = atom<TimePeriod>("this_month");
|
||||||
@@ -25,5 +25,5 @@ const episodeStatsAsyncAtom = atom(async (get) => {
|
|||||||
return fetchStats("episodes", period);
|
return fetchStats("episodes", period);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const movieStatsLoadable = loadable(movieStatsAsyncAtom);
|
export const movieStatsAtom = unwrap(movieStatsAsyncAtom);
|
||||||
export const episodeStatsLoadable = loadable(episodeStatsAsyncAtom);
|
export const episodeStatsAtom = unwrap(episodeStatsAsyncAtom);
|
||||||
|
|||||||
+5
-1
@@ -45,7 +45,11 @@ function getClient() {
|
|||||||
|
|
||||||
function getDb() {
|
function getDb() {
|
||||||
if (!globalForDb._db) {
|
if (!globalForDb._db) {
|
||||||
globalForDb._db = drizzle({ client: getClient(), schema, logger: drizzleLogger });
|
globalForDb._db = drizzle({
|
||||||
|
client: getClient(),
|
||||||
|
schema,
|
||||||
|
logger: drizzleLogger,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return globalForDb._db;
|
return globalForDb._db;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ export const userMovieWatches = sqliteTable(
|
|||||||
table.watchedAt,
|
table.watchedAt,
|
||||||
),
|
),
|
||||||
index("userMovieWatches_titleId").on(table.titleId),
|
index("userMovieWatches_titleId").on(table.titleId),
|
||||||
|
index("userMovieWatches_userId_titleId").on(table.userId, table.titleId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -219,6 +220,10 @@ export const userEpisodeWatches = sqliteTable(
|
|||||||
table.watchedAt,
|
table.watchedAt,
|
||||||
),
|
),
|
||||||
index("userEpisodeWatches_episodeId").on(table.episodeId),
|
index("userEpisodeWatches_episodeId").on(table.episodeId),
|
||||||
|
index("userEpisodeWatches_userId_episodeId").on(
|
||||||
|
table.userId,
|
||||||
|
table.episodeId,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+245
-149
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { persons, titleCast, titles } from "@/lib/db/schema";
|
import { persons, titleCast, titles } from "@/lib/db/schema";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
@@ -17,79 +17,67 @@ const NOTABLE_DEPARTMENTS = new Set([
|
|||||||
"Executive Producer",
|
"Executive Producer",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function upsertPerson(
|
interface PersonData {
|
||||||
tmdbId: number,
|
tmdbId: number;
|
||||||
name: string,
|
name: string;
|
||||||
profilePath: string | null,
|
profilePath: string | null;
|
||||||
popularity?: number,
|
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(
|
function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
||||||
titleId: string,
|
if (people.length === 0) return new Map();
|
||||||
personId: string,
|
|
||||||
character: string | null,
|
// Deduplicate by tmdbId
|
||||||
department: string,
|
const uniqueByTmdbId = new Map<number, PersonData>();
|
||||||
job: string | null,
|
for (const p of people) {
|
||||||
displayOrder: number,
|
if (!uniqueByTmdbId.has(p.tmdbId)) uniqueByTmdbId.set(p.tmdbId, p);
|
||||||
episodeCount: number | null,
|
}
|
||||||
) {
|
const uniquePeople = [...uniqueByTmdbId.values()];
|
||||||
const now = new Date();
|
const tmdbIds = uniquePeople.map((p) => p.tmdbId);
|
||||||
db.insert(titleCast)
|
|
||||||
.values({
|
// Batch prefetch existing persons (1 query)
|
||||||
titleId,
|
const existing = db
|
||||||
personId,
|
.select({ id: persons.id, tmdbId: persons.tmdbId })
|
||||||
character,
|
.from(persons)
|
||||||
department,
|
.where(inArray(persons.tmdbId, tmdbIds))
|
||||||
job,
|
.all();
|
||||||
displayOrder,
|
const idMap = new Map<number, string>(existing.map((p) => [p.tmdbId, p.id]));
|
||||||
episodeCount,
|
|
||||||
lastFetchedAt: now,
|
// Insert only new persons in a transaction
|
||||||
})
|
const newPeople = uniquePeople.filter((p) => !idMap.has(p.tmdbId));
|
||||||
.onConflictDoUpdate({
|
if (newPeople.length > 0) {
|
||||||
target: [
|
db.transaction((tx) => {
|
||||||
titleCast.titleId,
|
for (const p of newPeople) {
|
||||||
titleCast.personId,
|
const row = tx
|
||||||
titleCast.department,
|
.insert(persons)
|
||||||
titleCast.character,
|
.values({
|
||||||
],
|
tmdbId: p.tmdbId,
|
||||||
set: {
|
name: p.name,
|
||||||
job,
|
profilePath: p.profilePath,
|
||||||
displayOrder,
|
popularity: p.popularity ?? null,
|
||||||
episodeCount,
|
})
|
||||||
lastFetchedAt: now,
|
.onConflictDoNothing()
|
||||||
},
|
.returning()
|
||||||
})
|
.get();
|
||||||
.run();
|
if (row) idMap.set(p.tmdbId, row.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// One fallback query for any that conflicted
|
||||||
|
const stillMissing = newPeople
|
||||||
|
.filter((p) => !idMap.has(p.tmdbId))
|
||||||
|
.map((p) => p.tmdbId);
|
||||||
|
if (stillMissing.length > 0) {
|
||||||
|
const fallbacks = db
|
||||||
|
.select({ id: persons.id, tmdbId: persons.tmdbId })
|
||||||
|
.from(persons)
|
||||||
|
.where(inArray(persons.tmdbId, stillMissing))
|
||||||
|
.all();
|
||||||
|
for (const f of fallbacks) idMap.set(f.tmdbId, f.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return idMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshCredits(titleId: string) {
|
export async function refreshCredits(titleId: string) {
|
||||||
@@ -101,105 +89,213 @@ export async function refreshCredits(titleId: string) {
|
|||||||
try {
|
try {
|
||||||
if (title.type === "movie") {
|
if (title.type === "movie") {
|
||||||
const credits = await getMovieCredits(title.tmdbId);
|
const credits = await getMovieCredits(title.tmdbId);
|
||||||
|
|
||||||
// Top 20 cast
|
|
||||||
const castSlice = credits.cast.slice(0, 20);
|
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
|
// Collect notable crew
|
||||||
const seenCrew = new Set<string>();
|
const seenCrew = new Set<string>();
|
||||||
let crewOrder = 100;
|
const notableCrew: typeof credits.crew = [];
|
||||||
for (const c of credits.crew) {
|
for (const c of credits.crew) {
|
||||||
if (!NOTABLE_DEPARTMENTS.has(c.job)) continue;
|
if (!NOTABLE_DEPARTMENTS.has(c.job)) continue;
|
||||||
const key = `${c.id}-${c.job}`;
|
const key = `${c.id}-${c.job}`;
|
||||||
if (seenCrew.has(key)) continue;
|
if (seenCrew.has(key)) continue;
|
||||||
seenCrew.add(key);
|
seenCrew.add(key);
|
||||||
|
notableCrew.push(c);
|
||||||
const personId = upsertPerson(
|
|
||||||
c.id,
|
|
||||||
c.name,
|
|
||||||
c.profile_path,
|
|
||||||
c.popularity,
|
|
||||||
);
|
|
||||||
upsertTitleCast(
|
|
||||||
titleId,
|
|
||||||
personId,
|
|
||||||
null,
|
|
||||||
c.department,
|
|
||||||
c.job,
|
|
||||||
crewOrder++,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch upsert all people at once
|
||||||
|
const allPeople: PersonData[] = [
|
||||||
|
...castSlice.map((c) => ({
|
||||||
|
tmdbId: c.id,
|
||||||
|
name: c.name,
|
||||||
|
profilePath: c.profile_path,
|
||||||
|
popularity: c.popularity,
|
||||||
|
})),
|
||||||
|
...notableCrew.map((c) => ({
|
||||||
|
tmdbId: c.id,
|
||||||
|
name: c.name,
|
||||||
|
profilePath: c.profile_path,
|
||||||
|
popularity: c.popularity,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const personIds = batchUpsertPersons(allPeople);
|
||||||
|
|
||||||
|
// Batch insert titleCast rows
|
||||||
|
db.transaction((tx) => {
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = 0; i < castSlice.length; i++) {
|
||||||
|
const c = castSlice[i];
|
||||||
|
const personId = personIds.get(c.id);
|
||||||
|
if (!personId) continue;
|
||||||
|
tx.insert(titleCast)
|
||||||
|
.values({
|
||||||
|
titleId,
|
||||||
|
personId,
|
||||||
|
character: c.character,
|
||||||
|
department: "Acting",
|
||||||
|
job: null,
|
||||||
|
displayOrder: i,
|
||||||
|
episodeCount: null,
|
||||||
|
lastFetchedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
titleCast.titleId,
|
||||||
|
titleCast.personId,
|
||||||
|
titleCast.department,
|
||||||
|
titleCast.character,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
job: null,
|
||||||
|
displayOrder: i,
|
||||||
|
episodeCount: null,
|
||||||
|
lastFetchedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
let crewOrder = 100;
|
||||||
|
for (const c of notableCrew) {
|
||||||
|
const personId = personIds.get(c.id);
|
||||||
|
if (!personId) continue;
|
||||||
|
tx.insert(titleCast)
|
||||||
|
.values({
|
||||||
|
titleId,
|
||||||
|
personId,
|
||||||
|
character: null,
|
||||||
|
department: c.department,
|
||||||
|
job: c.job,
|
||||||
|
displayOrder: crewOrder,
|
||||||
|
episodeCount: null,
|
||||||
|
lastFetchedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
titleCast.titleId,
|
||||||
|
titleCast.personId,
|
||||||
|
titleCast.department,
|
||||||
|
titleCast.character,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
job: c.job,
|
||||||
|
displayOrder: crewOrder,
|
||||||
|
episodeCount: null,
|
||||||
|
lastFetchedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
crewOrder++;
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const credits = await getTvAggregateCredits(title.tmdbId);
|
const credits = await getTvAggregateCredits(title.tmdbId);
|
||||||
|
|
||||||
// Top 20 cast
|
|
||||||
const castSlice = credits.cast.slice(0, 20);
|
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
|
// Collect notable crew
|
||||||
const seenCrew = new Set<string>();
|
const seenCrew = new Set<string>();
|
||||||
let crewOrder = 100;
|
const notableCrew: Array<{
|
||||||
|
person: (typeof credits.crew)[0];
|
||||||
|
job: string;
|
||||||
|
episodeCount: number;
|
||||||
|
}> = [];
|
||||||
for (const c of credits.crew) {
|
for (const c of credits.crew) {
|
||||||
for (const j of c.jobs) {
|
for (const j of c.jobs) {
|
||||||
if (!NOTABLE_DEPARTMENTS.has(j.job)) continue;
|
if (!NOTABLE_DEPARTMENTS.has(j.job)) continue;
|
||||||
const key = `${c.id}-${j.job}`;
|
const key = `${c.id}-${j.job}`;
|
||||||
if (seenCrew.has(key)) continue;
|
if (seenCrew.has(key)) continue;
|
||||||
seenCrew.add(key);
|
seenCrew.add(key);
|
||||||
|
notableCrew.push({
|
||||||
const personId = upsertPerson(
|
person: c,
|
||||||
c.id,
|
job: j.job,
|
||||||
c.name,
|
episodeCount: j.episode_count,
|
||||||
c.profile_path,
|
});
|
||||||
c.popularity,
|
|
||||||
);
|
|
||||||
upsertTitleCast(
|
|
||||||
titleId,
|
|
||||||
personId,
|
|
||||||
null,
|
|
||||||
c.department,
|
|
||||||
j.job,
|
|
||||||
crewOrder++,
|
|
||||||
j.episode_count,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch upsert all people at once
|
||||||
|
const allPeople: PersonData[] = [
|
||||||
|
...castSlice.map((c) => ({
|
||||||
|
tmdbId: c.id,
|
||||||
|
name: c.name,
|
||||||
|
profilePath: c.profile_path,
|
||||||
|
popularity: c.popularity,
|
||||||
|
})),
|
||||||
|
...notableCrew.map((c) => ({
|
||||||
|
tmdbId: c.person.id,
|
||||||
|
name: c.person.name,
|
||||||
|
profilePath: c.person.profile_path,
|
||||||
|
popularity: c.person.popularity,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const personIds = batchUpsertPersons(allPeople);
|
||||||
|
|
||||||
|
// Batch insert titleCast rows
|
||||||
|
db.transaction((tx) => {
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = 0; i < castSlice.length; i++) {
|
||||||
|
const c = castSlice[i];
|
||||||
|
const personId = personIds.get(c.id);
|
||||||
|
if (!personId) continue;
|
||||||
|
const character = c.roles?.[0]?.character ?? null;
|
||||||
|
tx.insert(titleCast)
|
||||||
|
.values({
|
||||||
|
titleId,
|
||||||
|
personId,
|
||||||
|
character,
|
||||||
|
department: "Acting",
|
||||||
|
job: null,
|
||||||
|
displayOrder: i,
|
||||||
|
episodeCount: c.total_episode_count,
|
||||||
|
lastFetchedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
titleCast.titleId,
|
||||||
|
titleCast.personId,
|
||||||
|
titleCast.department,
|
||||||
|
titleCast.character,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
job: null,
|
||||||
|
displayOrder: i,
|
||||||
|
episodeCount: c.total_episode_count,
|
||||||
|
lastFetchedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
let crewOrder = 100;
|
||||||
|
for (const c of notableCrew) {
|
||||||
|
const personId = personIds.get(c.person.id);
|
||||||
|
if (!personId) continue;
|
||||||
|
tx.insert(titleCast)
|
||||||
|
.values({
|
||||||
|
titleId,
|
||||||
|
personId,
|
||||||
|
character: null,
|
||||||
|
department: c.person.department,
|
||||||
|
job: c.job,
|
||||||
|
displayOrder: crewOrder,
|
||||||
|
episodeCount: c.episodeCount,
|
||||||
|
lastFetchedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
titleCast.titleId,
|
||||||
|
titleCast.personId,
|
||||||
|
titleCast.department,
|
||||||
|
titleCast.character,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
job: c.job,
|
||||||
|
displayOrder: crewOrder,
|
||||||
|
episodeCount: c.episodeCount,
|
||||||
|
lastFetchedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
crewOrder++;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`Credits refreshed for "${title.title}"`);
|
log.debug(`Credits refreshed for "${title.title}"`);
|
||||||
|
|||||||
+181
-182
@@ -442,94 +442,61 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
`Fetched ${recs.results.length} recommendations and ${similar.results.length} similar for title ${titleId}`,
|
`Fetched ${recs.results.length} recommendations and ${similar.results.length} similar for title ${titleId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process recommendations
|
// Collect all valid results with their source/rank
|
||||||
|
interface RecItem {
|
||||||
|
result: (typeof recs.results)[0];
|
||||||
|
type: "movie" | "tv";
|
||||||
|
source: "tmdb_recommendations" | "tmdb_similar";
|
||||||
|
rank: number;
|
||||||
|
}
|
||||||
|
const allItems: RecItem[] = [];
|
||||||
for (let i = 0; i < recs.results.length && i < 20; i++) {
|
for (let i = 0; i < recs.results.length && i < 20; i++) {
|
||||||
const r = recs.results[i];
|
const r = recs.results[i];
|
||||||
const type = r.media_type ?? title.type;
|
const type = r.media_type ?? title.type;
|
||||||
if (type !== "movie" && type !== "tv") continue;
|
if (type === "movie" || type === "tv") {
|
||||||
|
allItems.push({
|
||||||
// Minimal upsert of the recommended title
|
result: r,
|
||||||
const existing = db
|
type,
|
||||||
.select()
|
|
||||||
.from(titles)
|
|
||||||
.where(eq(titles.tmdbId, r.id))
|
|
||||||
.get();
|
|
||||||
let recTitleId: string;
|
|
||||||
if (existing) {
|
|
||||||
recTitleId = existing.id;
|
|
||||||
} else {
|
|
||||||
const row = db
|
|
||||||
.insert(titles)
|
|
||||||
.values({
|
|
||||||
tmdbId: r.id,
|
|
||||||
type,
|
|
||||||
title: r.title ?? r.name ?? "Unknown",
|
|
||||||
originalTitle: r.original_title ?? r.original_name,
|
|
||||||
overview: r.overview,
|
|
||||||
releaseDate: r.release_date,
|
|
||||||
firstAirDate: r.first_air_date,
|
|
||||||
posterPath: r.poster_path,
|
|
||||||
backdropPath: r.backdrop_path,
|
|
||||||
popularity: r.popularity,
|
|
||||||
voteAverage: r.vote_average,
|
|
||||||
voteCount: r.vote_count,
|
|
||||||
lastFetchedAt: null,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning()
|
|
||||||
.get();
|
|
||||||
if (!row) {
|
|
||||||
const found = db
|
|
||||||
.select()
|
|
||||||
.from(titles)
|
|
||||||
.where(eq(titles.tmdbId, r.id))
|
|
||||||
.get();
|
|
||||||
if (!found) continue;
|
|
||||||
recTitleId = found.id;
|
|
||||||
} else {
|
|
||||||
recTitleId = row.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.insert(titleRecommendations)
|
|
||||||
.values({
|
|
||||||
titleId,
|
|
||||||
recommendedTitleId: recTitleId,
|
|
||||||
source: "tmdb_recommendations",
|
source: "tmdb_recommendations",
|
||||||
rank: i + 1,
|
rank: i + 1,
|
||||||
lastFetchedAt: now,
|
});
|
||||||
})
|
}
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [
|
|
||||||
titleRecommendations.titleId,
|
|
||||||
titleRecommendations.recommendedTitleId,
|
|
||||||
titleRecommendations.source,
|
|
||||||
],
|
|
||||||
set: { rank: i + 1, lastFetchedAt: now },
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process similar
|
|
||||||
for (let i = 0; i < similar.results.length && i < 20; i++) {
|
for (let i = 0; i < similar.results.length && i < 20; i++) {
|
||||||
const r = similar.results[i];
|
const r = similar.results[i];
|
||||||
const type = r.media_type ?? title.type;
|
const type = r.media_type ?? title.type;
|
||||||
if (type !== "movie" && type !== "tv") continue;
|
if (type === "movie" || type === "tv") {
|
||||||
|
allItems.push({ result: r, type, source: "tmdb_similar", rank: i + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existing = db
|
if (allItems.length === 0) return;
|
||||||
.select()
|
|
||||||
.from(titles)
|
// Batch prefetch existing titles (1 query)
|
||||||
.where(eq(titles.tmdbId, r.id))
|
const tmdbIds = [...new Set(allItems.map((item) => item.result.id))];
|
||||||
.get();
|
const existingTitles = db
|
||||||
let recTitleId: string;
|
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||||
if (existing) {
|
.from(titles)
|
||||||
recTitleId = existing.id;
|
.where(inArray(titles.tmdbId, tmdbIds))
|
||||||
} else {
|
.all();
|
||||||
const row = db
|
const titleIdMap = new Map<number, string>(
|
||||||
|
existingTitles.map((t) => [t.tmdbId, t.id]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert missing titles + upsert recommendations in a single transaction
|
||||||
|
db.transaction((tx) => {
|
||||||
|
// Insert only new titles
|
||||||
|
const newItems = allItems.filter((item) => !titleIdMap.has(item.result.id));
|
||||||
|
const insertedTmdbIds = new Set<number>();
|
||||||
|
for (const item of newItems) {
|
||||||
|
if (insertedTmdbIds.has(item.result.id)) continue;
|
||||||
|
insertedTmdbIds.add(item.result.id);
|
||||||
|
const r = item.result;
|
||||||
|
const row = tx
|
||||||
.insert(titles)
|
.insert(titles)
|
||||||
.values({
|
.values({
|
||||||
tmdbId: r.id,
|
tmdbId: r.id,
|
||||||
type,
|
type: item.type,
|
||||||
title: r.title ?? r.name ?? "Unknown",
|
title: r.title ?? r.name ?? "Unknown",
|
||||||
originalTitle: r.original_title ?? r.original_name,
|
originalTitle: r.original_title ?? r.original_name,
|
||||||
overview: r.overview,
|
overview: r.overview,
|
||||||
@@ -545,52 +512,104 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
if (!row) {
|
if (row) titleIdMap.set(r.id, row.id);
|
||||||
const found = db
|
|
||||||
.select()
|
|
||||||
.from(titles)
|
|
||||||
.where(eq(titles.tmdbId, r.id))
|
|
||||||
.get();
|
|
||||||
if (!found) continue;
|
|
||||||
recTitleId = found.id;
|
|
||||||
} else {
|
|
||||||
recTitleId = row.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.insert(titleRecommendations)
|
// One fallback query for any that conflicted
|
||||||
.values({
|
const stillMissing = [...insertedTmdbIds].filter(
|
||||||
titleId,
|
(id) => !titleIdMap.has(id),
|
||||||
recommendedTitleId: recTitleId,
|
);
|
||||||
source: "tmdb_similar",
|
if (stillMissing.length > 0) {
|
||||||
rank: i + 1,
|
const fallbacks = tx
|
||||||
lastFetchedAt: now,
|
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||||
})
|
.from(titles)
|
||||||
.onConflictDoUpdate({
|
.where(inArray(titles.tmdbId, stillMissing))
|
||||||
target: [
|
.all();
|
||||||
titleRecommendations.titleId,
|
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
|
||||||
titleRecommendations.recommendedTitleId,
|
}
|
||||||
titleRecommendations.source,
|
|
||||||
],
|
// Upsert all recommendation rows
|
||||||
set: { rank: i + 1, lastFetchedAt: now },
|
for (const item of allItems) {
|
||||||
})
|
const recTitleId = titleIdMap.get(item.result.id);
|
||||||
.run();
|
if (!recTitleId) continue;
|
||||||
}
|
tx.insert(titleRecommendations)
|
||||||
|
.values({
|
||||||
|
titleId,
|
||||||
|
recommendedTitleId: recTitleId,
|
||||||
|
source: item.source,
|
||||||
|
rank: item.rank,
|
||||||
|
lastFetchedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
titleRecommendations.titleId,
|
||||||
|
titleRecommendations.recommendedTitleId,
|
||||||
|
titleRecommendations.source,
|
||||||
|
],
|
||||||
|
set: { rank: item.rank, lastFetchedAt: now },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTitleWithChildren(id: string): Promise<{
|
/** Fetch seasons from the DB, building the Season[] structure. */
|
||||||
title: ResolvedTitle;
|
function fetchSeasonsFromDb(titleId: string): Season[] {
|
||||||
seasons: Season[];
|
const seasonRows = db
|
||||||
availability: AvailabilityOffer[];
|
.select()
|
||||||
cast: CastMember[];
|
.from(seasons)
|
||||||
} | null> {
|
.where(eq(seasons.titleId, titleId))
|
||||||
let title = db.select().from(titles).where(eq(titles.id, id)).get();
|
.orderBy(seasons.seasonNumber)
|
||||||
if (!title) return null;
|
.all();
|
||||||
|
|
||||||
// If this is a shell TV title, fetch full details now
|
if (seasonRows.length === 0) return [];
|
||||||
if (title.type === "tv" && !title.lastFetchedAt) {
|
|
||||||
|
const seasonIds = seasonRows.map((s) => s.id);
|
||||||
|
const allEps = db
|
||||||
|
.select()
|
||||||
|
.from(episodes)
|
||||||
|
.where(inArray(episodes.seasonId, seasonIds))
|
||||||
|
.orderBy(episodes.seasonId, episodes.episodeNumber)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const epsBySeason = new Map<string, Episode[]>();
|
||||||
|
for (const ep of allEps) {
|
||||||
|
const arr = epsBySeason.get(ep.seasonId) ?? [];
|
||||||
|
arr.push({
|
||||||
|
id: ep.id,
|
||||||
|
episodeNumber: ep.episodeNumber,
|
||||||
|
name: ep.name,
|
||||||
|
overview: ep.overview,
|
||||||
|
stillPath: tmdbImageUrl(ep.stillPath, "w1280", "stills"),
|
||||||
|
airDate: ep.airDate,
|
||||||
|
runtimeMinutes: ep.runtimeMinutes,
|
||||||
|
});
|
||||||
|
epsBySeason.set(ep.seasonId, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return seasonRows.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
seasonNumber: s.seasonNumber,
|
||||||
|
name: s.name,
|
||||||
|
episodes: epsBySeason.get(s.id) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a TV title is fully hydrated (seasons/episodes fetched from TMDB).
|
||||||
|
* Returns the hydrated seasons data.
|
||||||
|
*/
|
||||||
|
export async function ensureTvHydrated(
|
||||||
|
titleId: string,
|
||||||
|
tmdbId: number,
|
||||||
|
): Promise<Season[]> {
|
||||||
|
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||||
|
if (!title || title.type !== "tv") return [];
|
||||||
|
|
||||||
|
// Shell title: fetch details + children
|
||||||
|
if (!title.lastFetchedAt) {
|
||||||
try {
|
try {
|
||||||
const show = await getTvDetails(title.tmdbId);
|
const show = await getTvDetails(tmdbId);
|
||||||
db.update(titles)
|
db.update(titles)
|
||||||
.set({
|
.set({
|
||||||
overview: show.overview,
|
overview: show.overview,
|
||||||
@@ -599,16 +618,49 @@ export async function getTitleWithChildren(id: string): Promise<{
|
|||||||
status: show.status,
|
status: show.status,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(titles.id, id))
|
.where(eq(titles.id, titleId))
|
||||||
.run();
|
.run();
|
||||||
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
|
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
||||||
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.debug(`Failed to hydrate shell TV title ${id}:`, err);
|
log.debug(`Failed to hydrate shell TV title ${titleId}:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this is a shell movie title, fetch full details now
|
let result = fetchSeasonsFromDb(titleId);
|
||||||
|
|
||||||
|
// Retry hydration when seasons are still missing
|
||||||
|
if (result.length === 0) {
|
||||||
|
try {
|
||||||
|
const show = await getTvDetails(tmdbId);
|
||||||
|
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
||||||
|
result = fetchSeasonsFromDb(titleId);
|
||||||
|
} catch (err) {
|
||||||
|
log.debug(
|
||||||
|
`Failed to backfill missing seasons for title ${titleId}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTitleWithChildren(id: string): Promise<{
|
||||||
|
title: ResolvedTitle;
|
||||||
|
seasons: Season[];
|
||||||
|
needsHydration: boolean;
|
||||||
|
availability: AvailabilityOffer[];
|
||||||
|
cast: CastMember[];
|
||||||
|
} | null> {
|
||||||
|
let title = db.select().from(titles).where(eq(titles.id, id)).get();
|
||||||
|
if (!title) return null;
|
||||||
|
|
||||||
|
// For shell TV titles, skip blocking hydration — let Suspense stream it
|
||||||
|
const needsTvHydration =
|
||||||
|
title.type === "tv" &&
|
||||||
|
(!title.lastFetchedAt || fetchSeasonsFromDb(id).length === 0);
|
||||||
|
|
||||||
|
// If this is a shell movie title, fetch full details now (movies are fast)
|
||||||
if (title.type === "movie" && !title.lastFetchedAt) {
|
if (title.type === "movie" && !title.lastFetchedAt) {
|
||||||
try {
|
try {
|
||||||
const movie = await getMovieDetails(title.tmdbId);
|
const movie = await getMovieDetails(title.tmdbId);
|
||||||
@@ -634,67 +686,8 @@ export async function getTitleWithChildren(id: string): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let titleSeasons: Season[] = [];
|
// For already-hydrated TV titles, fetch seasons from DB directly
|
||||||
|
const titleSeasons = needsTvHydration ? [] : fetchSeasonsFromDb(id);
|
||||||
if (title.type === "tv") {
|
|
||||||
let seasonRows = db
|
|
||||||
.select()
|
|
||||||
.from(seasons)
|
|
||||||
.where(eq(seasons.titleId, title.id))
|
|
||||||
.orderBy(seasons.seasonNumber)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
// Retry hydration when a TV title exists but no seasons were stored.
|
|
||||||
if (seasonRows.length === 0) {
|
|
||||||
try {
|
|
||||||
const show = await getTvDetails(title.tmdbId);
|
|
||||||
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
|
|
||||||
seasonRows = db
|
|
||||||
.select()
|
|
||||||
.from(seasons)
|
|
||||||
.where(eq(seasons.titleId, title.id))
|
|
||||||
.orderBy(seasons.seasonNumber)
|
|
||||||
.all();
|
|
||||||
} catch (err) {
|
|
||||||
log.debug(`Failed to backfill missing seasons for title ${id}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch fetch all episodes for all seasons (1 query)
|
|
||||||
const seasonIds = seasonRows.map((s) => s.id);
|
|
||||||
const allEps =
|
|
||||||
seasonIds.length > 0
|
|
||||||
? db
|
|
||||||
.select()
|
|
||||||
.from(episodes)
|
|
||||||
.where(inArray(episodes.seasonId, seasonIds))
|
|
||||||
.orderBy(episodes.seasonId, episodes.episodeNumber)
|
|
||||||
.all()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Group episodes by season
|
|
||||||
const epsBySeason = new Map<string, Episode[]>();
|
|
||||||
for (const ep of allEps) {
|
|
||||||
const arr = epsBySeason.get(ep.seasonId) ?? [];
|
|
||||||
arr.push({
|
|
||||||
id: ep.id,
|
|
||||||
episodeNumber: ep.episodeNumber,
|
|
||||||
name: ep.name,
|
|
||||||
overview: ep.overview,
|
|
||||||
stillPath: tmdbImageUrl(ep.stillPath, "w1280", "stills"),
|
|
||||||
airDate: ep.airDate,
|
|
||||||
runtimeMinutes: ep.runtimeMinutes,
|
|
||||||
});
|
|
||||||
epsBySeason.set(ep.seasonId, arr);
|
|
||||||
}
|
|
||||||
|
|
||||||
titleSeasons = seasonRows.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
seasonNumber: s.seasonNumber,
|
|
||||||
name: s.name,
|
|
||||||
episodes: epsBySeason.get(s.id) ?? [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const availability = db
|
const availability = db
|
||||||
.select()
|
.select()
|
||||||
@@ -737,7 +730,13 @@ export async function getTitleWithChildren(id: string): Promise<{
|
|||||||
|
|
||||||
const cast = getCastForTitle(id);
|
const cast = getCastForTitle(id);
|
||||||
|
|
||||||
return { title: resolvedTitle, seasons: titleSeasons, availability, cast };
|
return {
|
||||||
|
title: resolvedTitle,
|
||||||
|
seasons: titleSeasons,
|
||||||
|
needsHydration: needsTvHydration,
|
||||||
|
availability,
|
||||||
|
cast,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickBestTrailer(videos: TmdbVideo[]): string | null {
|
export function pickBestTrailer(videos: TmdbVideo[]): string | null {
|
||||||
|
|||||||
+65
-46
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { persons, titleCast, titles } from "@/lib/db/schema";
|
import { persons, titleCast, titles } from "@/lib/db/schema";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
@@ -173,58 +173,77 @@ export async function fetchFullFilmography(
|
|||||||
|
|
||||||
const credits = await getPersonCombinedCredits(person.tmdbId);
|
const credits = await getPersonCombinedCredits(person.tmdbId);
|
||||||
|
|
||||||
const results: PersonCredit[] = [];
|
// Filter to valid cast entries
|
||||||
|
const validCast = credits.cast.filter(
|
||||||
|
(c) => c.media_type === "movie" || c.media_type === "tv",
|
||||||
|
);
|
||||||
|
if (validCast.length === 0) return [];
|
||||||
|
|
||||||
for (const c of credits.cast) {
|
// Batch prefetch existing titles (1 query)
|
||||||
const type = c.media_type;
|
const tmdbIds = [...new Set(validCast.map((c) => c.id))];
|
||||||
if (type !== "movie" && type !== "tv") continue;
|
const existingTitles = db
|
||||||
|
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||||
|
.from(titles)
|
||||||
|
.where(inArray(titles.tmdbId, tmdbIds))
|
||||||
|
.all();
|
||||||
|
const titleIdMap = new Map<number, string>(
|
||||||
|
existingTitles.map((t) => [t.tmdbId, t.id]),
|
||||||
|
);
|
||||||
|
|
||||||
// Create shell title if not in DB
|
// Batch insert missing titles in a transaction
|
||||||
const existing = db
|
const newCast = validCast.filter((c) => !titleIdMap.has(c.id));
|
||||||
.select()
|
if (newCast.length > 0) {
|
||||||
.from(titles)
|
const insertedTmdbIds = new Set<number>();
|
||||||
.where(eq(titles.tmdbId, c.id))
|
db.transaction((tx) => {
|
||||||
.get();
|
for (const c of newCast) {
|
||||||
let titleId: string;
|
if (insertedTmdbIds.has(c.id)) continue;
|
||||||
if (existing) {
|
insertedTmdbIds.add(c.id);
|
||||||
titleId = existing.id;
|
const row = tx
|
||||||
} else {
|
.insert(titles)
|
||||||
const row = db
|
.values({
|
||||||
.insert(titles)
|
tmdbId: c.id,
|
||||||
.values({
|
type: c.media_type as "movie" | "tv",
|
||||||
tmdbId: c.id,
|
title: c.title ?? c.name ?? "Unknown",
|
||||||
type,
|
overview: c.overview,
|
||||||
title: c.title ?? c.name ?? "Unknown",
|
releaseDate: c.release_date,
|
||||||
overview: c.overview,
|
firstAirDate: c.first_air_date,
|
||||||
releaseDate: c.release_date,
|
posterPath: c.poster_path,
|
||||||
firstAirDate: c.first_air_date,
|
backdropPath: c.backdrop_path,
|
||||||
posterPath: c.poster_path,
|
popularity: c.popularity,
|
||||||
backdropPath: c.backdrop_path,
|
voteAverage: c.vote_average,
|
||||||
popularity: c.popularity,
|
voteCount: c.vote_count,
|
||||||
voteAverage: c.vote_average,
|
lastFetchedAt: null,
|
||||||
voteCount: c.vote_count,
|
})
|
||||||
lastFetchedAt: null,
|
.onConflictDoNothing()
|
||||||
})
|
.returning()
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning()
|
|
||||||
.get();
|
|
||||||
if (!row) {
|
|
||||||
const found = db
|
|
||||||
.select()
|
|
||||||
.from(titles)
|
|
||||||
.where(eq(titles.tmdbId, c.id))
|
|
||||||
.get();
|
.get();
|
||||||
if (!found) continue;
|
if (row) titleIdMap.set(c.id, row.id);
|
||||||
titleId = found.id;
|
|
||||||
} else {
|
|
||||||
titleId = row.id;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// One fallback query for any that conflicted
|
||||||
|
const stillMissing = [...insertedTmdbIds].filter(
|
||||||
|
(id) => !titleIdMap.has(id),
|
||||||
|
);
|
||||||
|
if (stillMissing.length > 0) {
|
||||||
|
const fallbacks = db
|
||||||
|
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||||
|
.from(titles)
|
||||||
|
.where(inArray(titles.tmdbId, stillMissing))
|
||||||
|
.all();
|
||||||
|
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build results from map (0 queries)
|
||||||
|
const results: PersonCredit[] = [];
|
||||||
|
for (const c of validCast) {
|
||||||
|
const tid = titleIdMap.get(c.id);
|
||||||
|
if (!tid) continue;
|
||||||
results.push({
|
results.push({
|
||||||
titleId,
|
titleId: tid,
|
||||||
tmdbId: c.id,
|
tmdbId: c.id,
|
||||||
type,
|
type: c.media_type as "movie" | "tv",
|
||||||
title: c.title ?? c.name ?? "Unknown",
|
title: c.title ?? c.name ?? "Unknown",
|
||||||
posterPath: tmdbImageUrl(c.poster_path, "w500"),
|
posterPath: tmdbImageUrl(c.poster_path, "w500"),
|
||||||
releaseDate: c.release_date ?? null,
|
releaseDate: c.release_date ?? null,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { access, constants, readdir, stat } from "node:fs/promises";
|
import { access, constants, readdir, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { count, desc, inArray } from "drizzle-orm";
|
import { count, desc, eq } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { cronRuns, episodes, titles, user } from "@/lib/db/schema";
|
import { cronRuns, episodes, titles, user } from "@/lib/db/schema";
|
||||||
import { listBackups } from "@/lib/services/backup";
|
import { listBackups } from "@/lib/services/backup";
|
||||||
@@ -151,20 +151,17 @@ function getJobsHealth(): SystemHealthData["jobs"] {
|
|||||||
const schedules = getJobSchedules();
|
const schedules = getJobSchedules();
|
||||||
const scheduleMap = new Map(schedules.map((s) => [s.jobName, s]));
|
const scheduleMap = new Map(schedules.map((s) => [s.jobName, s]));
|
||||||
|
|
||||||
// Batch fetch the latest cron run for each job (1 query)
|
// Fetch only the latest cron run per job (index-optimized LIMIT 1 each)
|
||||||
const allLatestRuns = db
|
const latestByJob = new Map<string, typeof cronRuns.$inferSelect>();
|
||||||
.select()
|
for (const jobName of JOB_NAMES) {
|
||||||
.from(cronRuns)
|
const latest = db
|
||||||
.where(inArray(cronRuns.jobName, JOB_NAMES))
|
.select()
|
||||||
.orderBy(desc(cronRuns.startedAt))
|
.from(cronRuns)
|
||||||
.all();
|
.where(eq(cronRuns.jobName, jobName))
|
||||||
|
.orderBy(desc(cronRuns.startedAt))
|
||||||
// Keep only the most recent run per job
|
.limit(1)
|
||||||
const latestByJob = new Map<string, (typeof allLatestRuns)[0]>();
|
.get();
|
||||||
for (const run of allLatestRuns) {
|
if (latest) latestByJob.set(jobName, latest);
|
||||||
if (!latestByJob.has(run.jobName)) {
|
|
||||||
latestByJob.set(run.jobName, run);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return JOB_NAMES.map((jobName) => {
|
return JOB_NAMES.map((jobName) => {
|
||||||
@@ -202,24 +199,30 @@ async function getImageCacheHealth(): Promise<SystemHealthData["imageCache"]> {
|
|||||||
let totalSizeBytes = 0;
|
let totalSizeBytes = 0;
|
||||||
let imageCount = 0;
|
let imageCount = 0;
|
||||||
|
|
||||||
for (const category of categoryNames) {
|
await Promise.all(
|
||||||
const dir = path.join(CACHE_DIR, category);
|
categoryNames.map(async (category) => {
|
||||||
try {
|
const dir = path.join(CACHE_DIR, category);
|
||||||
const files = await readdir(dir);
|
try {
|
||||||
let sizeBytes = 0;
|
const files = await readdir(dir);
|
||||||
for (const file of files) {
|
const sizes = await Promise.all(
|
||||||
try {
|
files.map(async (file) => {
|
||||||
const s = await stat(path.join(dir, file));
|
try {
|
||||||
if (s.isFile()) sizeBytes += s.size;
|
const s = await stat(path.join(dir, file));
|
||||||
} catch {}
|
return s.isFile() ? s.size : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const sizeBytes = sizes.reduce((sum, s) => sum + s, 0);
|
||||||
|
categories[category] = { count: files.length, sizeBytes };
|
||||||
|
totalSizeBytes += sizeBytes;
|
||||||
|
imageCount += files.length;
|
||||||
|
} catch {
|
||||||
|
categories[category] = { count: 0, sizeBytes: 0 };
|
||||||
}
|
}
|
||||||
categories[category] = { count: files.length, sizeBytes };
|
}),
|
||||||
totalSizeBytes += sizeBytes;
|
);
|
||||||
imageCount += files.length;
|
|
||||||
} catch {
|
|
||||||
categories[category] = { count: 0, sizeBytes: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { enabled: true, totalSizeBytes, imageCount, categories };
|
return { enabled: true, totalSizeBytes, imageCount, categories };
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-6
@@ -108,6 +108,120 @@ export function logEpisodeWatch(
|
|||||||
checkAllEpisodesWatched(userId, titleId);
|
checkAllEpisodesWatched(userId, titleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logEpisodeWatchBatch(
|
||||||
|
userId: string,
|
||||||
|
episodeIds: string[],
|
||||||
|
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||||
|
) {
|
||||||
|
if (episodeIds.length === 0) return;
|
||||||
|
|
||||||
|
db.transaction((tx) => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Batch INSERT all watch records
|
||||||
|
for (const episodeId of episodeIds) {
|
||||||
|
tx.insert(userEpisodeWatches)
|
||||||
|
.values({ userId, episodeId, watchedAt: now, source })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve episode → season → title hierarchy with batch queries
|
||||||
|
const eps = tx
|
||||||
|
.select()
|
||||||
|
.from(episodes)
|
||||||
|
.where(inArray(episodes.id, episodeIds))
|
||||||
|
.all();
|
||||||
|
if (eps.length === 0) return;
|
||||||
|
|
||||||
|
const seasonIds = [...new Set(eps.map((e) => e.seasonId))];
|
||||||
|
const seasonRows = tx
|
||||||
|
.select()
|
||||||
|
.from(seasons)
|
||||||
|
.where(inArray(seasons.id, seasonIds))
|
||||||
|
.all();
|
||||||
|
if (seasonRows.length === 0) return;
|
||||||
|
|
||||||
|
const titleId = seasonRows[0].titleId;
|
||||||
|
|
||||||
|
// Set status to in_progress if not already set
|
||||||
|
const existing = tx
|
||||||
|
.select()
|
||||||
|
.from(userTitleStatus)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userTitleStatus.userId, userId),
|
||||||
|
eq(userTitleStatus.titleId, titleId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const statusNow = new Date();
|
||||||
|
tx.insert(userTitleStatus)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
titleId,
|
||||||
|
status: "in_progress",
|
||||||
|
addedAt: statusNow,
|
||||||
|
updatedAt: statusNow,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [userTitleStatus.userId, userTitleStatus.titleId],
|
||||||
|
set: { status: "in_progress", updatedAt: statusNow },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check completion once (not per-episode)
|
||||||
|
const allSeasons = tx
|
||||||
|
.select()
|
||||||
|
.from(seasons)
|
||||||
|
.where(eq(seasons.titleId, titleId))
|
||||||
|
.all();
|
||||||
|
if (allSeasons.length === 0) return;
|
||||||
|
|
||||||
|
const allSeasonIds = allSeasons.map((s) => s.id);
|
||||||
|
const allEps = tx
|
||||||
|
.select()
|
||||||
|
.from(episodes)
|
||||||
|
.where(inArray(episodes.seasonId, allSeasonIds))
|
||||||
|
.all();
|
||||||
|
const totalEpisodes = allEps.length;
|
||||||
|
if (totalEpisodes === 0) return;
|
||||||
|
|
||||||
|
const allEpIds = allEps.map((ep) => ep.id);
|
||||||
|
const [watchCount] = tx
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
|
||||||
|
})
|
||||||
|
.from(userEpisodeWatches)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userEpisodeWatches.userId, userId),
|
||||||
|
inArray(userEpisodeWatches.episodeId, allEpIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (watchCount.count >= totalEpisodes) {
|
||||||
|
const completeNow = new Date();
|
||||||
|
tx.insert(userTitleStatus)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
titleId,
|
||||||
|
status: "completed",
|
||||||
|
addedAt: completeNow,
|
||||||
|
updatedAt: completeNow,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [userTitleStatus.userId, userTitleStatus.titleId],
|
||||||
|
set: { status: "completed", updatedAt: completeNow },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function markAllEpisodesWatched(
|
export function markAllEpisodesWatched(
|
||||||
userId: string,
|
userId: string,
|
||||||
titleId: string,
|
titleId: string,
|
||||||
@@ -151,13 +265,15 @@ export function markAllEpisodesWatched(
|
|||||||
)
|
)
|
||||||
: new Set<string>();
|
: new Set<string>();
|
||||||
|
|
||||||
for (const ep of allEps) {
|
db.transaction((tx) => {
|
||||||
if (!existingWatches.has(ep.id)) {
|
for (const ep of allEps) {
|
||||||
db.insert(userEpisodeWatches)
|
if (!existingWatches.has(ep.id)) {
|
||||||
.values({ userId, episodeId: ep.id, watchedAt: now, source })
|
tx.insert(userEpisodeWatches)
|
||||||
.run();
|
.values({ userId, episodeId: ep.id, watchedAt: now, source })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
setTitleStatus(userId, titleId, "completed", source);
|
setTitleStatus(userId, titleId, "completed", source);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user