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:
2026-03-05 17:33:27 -05:00
co-authored by Claude Opus 4.6
parent ed825f3a78
commit f36ca0cbf8
23 changed files with 839 additions and 571 deletions
@@ -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">
+8 -19
View File
@@ -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);
+3 -2
View File
@@ -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>
); );
} }
+15 -18
View File
@@ -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);
+14 -3
View File
@@ -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} />
+29
View File
@@ -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">
-1
View File
@@ -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";
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+35 -32
View File
@@ -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
View File
@@ -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);
} }