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