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";
import {
episodePeriodAtom,
episodeStatsLoadable,
episodeStatsAtom,
moviePeriodAtom,
movieStatsLoadable,
movieStatsAtom,
} from "@/lib/atoms/stats";
import type {
DashboardStats,
@@ -120,24 +120,14 @@ function PeriodSelector({
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
const [moviePeriod, setMoviePeriod] = useAtom(moviePeriodAtom);
const [episodePeriod, setEpisodePeriod] = useAtom(episodePeriodAtom);
const movieStats = useAtomValue(movieStatsLoadable);
const episodeStats = useAtomValue(episodeStatsLoadable);
const movieStats = useAtomValue(movieStatsAtom);
const episodeStats = useAtomValue(episodeStatsAtom);
const _movieLoading = movieStats.state === "loading";
const movieCount =
movieStats.state === "hasData"
? movieStats.data.count
: stats.moviesThisMonth;
const movieHistory =
movieStats.state === "hasData" ? movieStats.data.history : undefined;
const movieCount = movieStats?.count ?? stats.moviesThisMonth;
const movieHistory = movieStats?.history;
const _episodeLoading = episodeStats.state === "loading";
const episodeCount =
episodeStats.state === "hasData"
? episodeStats.data.count
: stats.episodesThisWeek;
const episodeHistory =
episodeStats.state === "hasData" ? episodeStats.data.history : undefined;
const episodeCount = episodeStats?.count ?? stats.episodesThisWeek;
const episodeHistory = episodeStats?.history;
return (
<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";
import {
defaultItemsAtom,
genreEpisodeProgressLoadable,
genreResultsLoadable,
genreUserStatusesLoadable,
genreEnrichmentsAtom,
genreResultsAtom,
initialEpisodeProgressAtom,
initialUserStatusesAtom,
mediaTypeAtom,
@@ -81,33 +80,23 @@ function FilterableTitleRowInner({
}) {
const [selectedGenre, setSelectedGenre] = useAtom(selectedGenreAtom);
const defaults = useAtomValue(defaultItemsAtom);
const genreResults = useAtomValue(genreResultsLoadable);
const genreResults = useAtomValue(genreResultsAtom);
const initialStatuses = useAtomValue(initialUserStatusesAtom);
const genreStatuses = useAtomValue(genreUserStatusesLoadable);
const initialProgress = useAtomValue(initialEpisodeProgressAtom);
const genreProgress = useAtomValue(genreEpisodeProgressLoadable);
const genreEnrichments = useAtomValue(genreEnrichmentsAtom);
const loading = selectedGenre !== null && genreResults.state === "loading";
const items =
selectedGenre === null
? defaults
: genreResults.state === "hasData" && genreResults.data !== null
? genreResults.data
: [];
const loading = selectedGenre !== null && genreResults === undefined;
const items = selectedGenre === null ? defaults : (genreResults ?? []);
const userStatuses =
selectedGenre === null
? initialStatuses
: genreStatuses.state === "hasData" && genreStatuses.data !== null
? genreStatuses.data
: initialStatuses;
: (genreEnrichments?.statuses ?? initialStatuses);
const episodeProgress =
selectedGenre === null
? initialProgress
: genreProgress.state === "hasData" && genreProgress.data !== null
? genreProgress.data
: initialProgress;
: (genreEnrichments?.progress ?? initialProgress);
function toggleGenre(genreId: number) {
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 { CommandPalette } from "@/components/command-palette";
import { MobileTabBar } from "@/components/mobile-tab-bar";
@@ -14,7 +15,7 @@ export default async function PagesLayout({
if (!session) redirect("/login");
return (
<>
<StoreProvider>
<div className="min-h-screen pb-14 sm:pb-0">
<NavBar />
{/* Ambient glow */}
@@ -26,6 +27,6 @@ export default async function PagesLayout({
<MobileTabBar />
<CommandPalette />
<UpdateToast />
</>
</StoreProvider>
);
}
@@ -2,9 +2,9 @@
import { IconCalendarWeek, IconChevronDown } from "@tabler/icons-react";
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 { useState } from "react";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import {
@@ -116,23 +116,20 @@ export function BackupScheduleSection({
initialTime: string;
initialDow: number;
}) {
const [store] = useState(() => {
const s = createStore();
s.set(backupScheduleAtom, {
enabled: initialScheduledEnabled,
maxRetention: initialMaxRetention,
frequency: initialFrequency,
time: initialTime,
dow: initialDow,
});
return s;
});
useHydrateAtoms([
[
backupScheduleAtom,
{
enabled: initialScheduledEnabled,
maxRetention: initialMaxRetention,
frequency: initialFrequency,
time: initialTime,
dow: initialDow,
},
],
]);
return (
<Provider store={store}>
<BackupScheduleInner />
</Provider>
);
return <BackupScheduleInner />;
}
function BackupScheduleInner() {
@@ -1,8 +1,7 @@
"use client";
import { IconWebhook } from "@tabler/icons-react";
import { createStore, Provider } from "jotai";
import { useState } from "react";
import { useHydrateAtoms } from "jotai/utils";
import { connectionsAtom } from "@/lib/atoms/integrations";
import { WebhookCard, type WebhookConnection } from "./webhook-card";
@@ -11,27 +10,21 @@ export function IntegrationsSection({
}: {
initialConnections: WebhookConnection[];
}) {
const [store] = useState(() => {
const s = createStore();
s.set(connectionsAtom, initialConnections);
return s;
});
useHydrateAtoms([[connectionsAtom, initialConnections]]);
return (
<Provider store={store}>
<div>
<div className="mb-3 flex items-center gap-2">
<IconWebhook className="size-4 text-muted-foreground" />
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Integrations
</h2>
</div>
<div className="space-y-3">
<WebhookCard provider="plex" />
<WebhookCard provider="jellyfin" />
<WebhookCard provider="emby" />
</div>
<div>
<div className="mb-3 flex items-center gap-2">
<IconWebhook className="size-4 text-muted-foreground" />
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Integrations
</h2>
</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,
IconShieldLock,
} from "@tabler/icons-react";
import { desc, eq, inArray } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { getSession } from "@/lib/auth/session";
@@ -40,23 +40,20 @@ export default async function SettingsPage() {
const connIds = connRows.map((c) => c.id);
// Batch fetch all event logs for all connections (1 query)
const allEvents =
connIds.length > 0
? db
.select()
.from(webhookEventLog)
.where(inArray(webhookEventLog.connectionId, connIds))
.orderBy(desc(webhookEventLog.receivedAt))
.all()
: [];
// Group events by connection, keeping only 10 most recent per connection
const eventsByConn = new Map<string, typeof allEvents>();
for (const e of allEvents) {
const arr = eventsByConn.get(e.connectionId) ?? [];
if (arr.length < 10) arr.push(e);
eventsByConn.set(e.connectionId, arr);
// Fetch only the 10 most recent events per connection (index-optimized)
const eventsByConn = new Map<
string,
(typeof webhookEventLog.$inferSelect)[]
>();
for (const connId of connIds) {
const events = db
.select()
.from(webhookEventLog)
.where(eq(webhookEventLog.connectionId, connId))
.orderBy(desc(webhookEventLog.receivedAt))
.limit(10)
.all();
eventsByConn.set(connId, events);
}
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 { useHotkey } from "@tanstack/react-hotkeys";
import { getDefaultStore, useAtomValue } from "jotai";
import { useAtomValue } from "jotai";
import { useRouter } from "next/navigation";
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
import { titleTypeAtom, userStatusAtom } from "@/lib/atoms/title";
@@ -15,9 +15,7 @@ export function TitleKeyboardShortcuts() {
const { handleStatusChange, handleRating, handleWatchMovie } =
useTitleActions();
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom, {
store: getDefaultStore(),
});
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom);
const enabled = !commandPaletteOpen;
// W: toggle watchlist (add if not in library, remove if in library)
@@ -1,7 +1,6 @@
"use client";
import { createStore, Provider } from "jotai";
import { useState } from "react";
import { useHydrateAtoms } from "jotai/utils";
import {
episodeWatchesAtom,
seasonsAtom,
@@ -32,17 +31,15 @@ export function TitleProvider({
seasons: Season[];
children: React.ReactNode;
}) {
const [store] = useState(() => {
const s = createStore();
s.set(titleIdAtom, titleId);
s.set(titleTypeAtom, titleType);
s.set(titleNameAtom, titleName);
s.set(seasonsAtom, seasons);
s.set(userStatusAtom, initialStatus);
s.set(userRatingAtom, initialRating);
s.set(episodeWatchesAtom, initialEpisodeWatches);
return s;
});
useHydrateAtoms([
[titleIdAtom, titleId],
[titleTypeAtom, titleType],
[titleNameAtom, titleName],
[seasonsAtom, seasons],
[userStatusAtom, initialStatus],
[userRatingAtom, initialRating],
[episodeWatchesAtom, initialEpisodeWatches],
]);
return <Provider store={store}>{children}</Provider>;
return children;
}
@@ -7,10 +7,10 @@ import {
IconChevronUp,
IconDeviceTvOld,
} from "@tabler/icons-react";
import { useAtomValue } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -29,9 +29,23 @@ import {
userStatusAtom,
watchingEpAtom,
} from "@/lib/atoms/title";
import type { Season } from "@/lib/types/title";
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 episodeWatches = useAtomValue(episodeWatchesAtom);
const userStatus = useAtomValue(userStatusAtom);
+14 -3
View File
@@ -2,7 +2,10 @@ import { eq } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import { RecommendationsSkeleton } from "@/components/skeletons";
import {
RecommendationsSkeleton,
SeasonsSkeleton,
} from "@/components/skeletons";
import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { titles } from "@/lib/db/schema";
@@ -10,6 +13,7 @@ import { getTitleWithChildren, importTitle } from "@/lib/services/metadata";
import { getUserTitleInfo } from "@/lib/services/tracking";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { getTitleThemeStyle } from "@/lib/utils/title-theme";
import { AsyncTitleSeasons } from "./_components/async-title-seasons";
import { TitleActions } from "./_components/title-actions";
import { TitleAvailability } from "./_components/title-availability";
import { TitleCast } from "./_components/title-cast";
@@ -71,7 +75,7 @@ export default async function TitleDetailPage({
]);
if (!result) notFound();
const { title, seasons, availability, cast } = result;
const { title, seasons, needsHydration, availability, cast } = result;
const themeStyle = getTitleThemeStyle(title.colorPalette);
@@ -94,7 +98,14 @@ export default async function TitleDetailPage({
<TitleAvailability availability={availability} />
</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} />