diff --git a/app/(pages)/dashboard/_components/stats-display.tsx b/app/(pages)/dashboard/_components/stats-display.tsx index ae14099..98ba36e 100644 --- a/app/(pages)/dashboard/_components/stats-display.tsx +++ b/app/(pages)/dashboard/_components/stats-display.tsx @@ -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 (
diff --git a/app/(pages)/explore/filterable-title-row.tsx b/app/(pages)/explore/filterable-title-row.tsx index 3c56553..e4c2539 100644 --- a/app/(pages)/explore/filterable-title-row.tsx +++ b/app/(pages)/explore/filterable-title-row.tsx @@ -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); diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index 41971a6..47737b9 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -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 ( - <> +
{/* Ambient glow */} @@ -26,6 +27,6 @@ export default async function PagesLayout({ - + ); } diff --git a/app/(pages)/settings/_components/backup-schedule-section.tsx b/app/(pages)/settings/_components/backup-schedule-section.tsx index 7c10648..5f6c138 100644 --- a/app/(pages)/settings/_components/backup-schedule-section.tsx +++ b/app/(pages)/settings/_components/backup-schedule-section.tsx @@ -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 ( - - - - ); + return ; } function BackupScheduleInner() { diff --git a/app/(pages)/settings/_components/integrations-section.tsx b/app/(pages)/settings/_components/integrations-section.tsx index 827ec90..2bbefb6 100644 --- a/app/(pages)/settings/_components/integrations-section.tsx +++ b/app/(pages)/settings/_components/integrations-section.tsx @@ -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 ( - -
-
- -

- Integrations -

-
-
- - - -
+
+
+ +

+ Integrations +

- +
+ + + +
+
); } diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx index 9b19653..0d76b5b 100644 --- a/app/(pages)/settings/page.tsx +++ b/app/(pages)/settings/page.tsx @@ -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(); - 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) => ({ diff --git a/app/(pages)/titles/[id]/_components/async-title-seasons.tsx b/app/(pages)/titles/[id]/_components/async-title-seasons.tsx new file mode 100644 index 0000000..efe2149 --- /dev/null +++ b/app/(pages)/titles/[id]/_components/async-title-seasons.tsx @@ -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 ; +} diff --git a/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx b/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx index b76f5f9..d239857 100644 --- a/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx +++ b/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx @@ -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) diff --git a/app/(pages)/titles/[id]/_components/title-provider.tsx b/app/(pages)/titles/[id]/_components/title-provider.tsx index 2812c66..37284d2 100644 --- a/app/(pages)/titles/[id]/_components/title-provider.tsx +++ b/app/(pages)/titles/[id]/_components/title-provider.tsx @@ -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 {children}; + return children; } diff --git a/app/(pages)/titles/[id]/_components/title-seasons.tsx b/app/(pages)/titles/[id]/_components/title-seasons.tsx index f64934e..67c6849 100644 --- a/app/(pages)/titles/[id]/_components/title-seasons.tsx +++ b/app/(pages)/titles/[id]/_components/title-seasons.tsx @@ -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); diff --git a/app/(pages)/titles/[id]/page.tsx b/app/(pages)/titles/[id]/page.tsx index 4a576a1..45a13f9 100644 --- a/app/(pages)/titles/[id]/page.tsx +++ b/app/(pages)/titles/[id]/page.tsx @@ -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({ - {title.type === "tv" && seasons.length > 0 && } + {title.type === "tv" && needsHydration && ( + }> + + + )} + {title.type === "tv" && !needsHydration && seasons.length > 0 && ( + + )} diff --git a/components/skeletons.tsx b/components/skeletons.tsx index fa32777..1ba1e20 100644 --- a/components/skeletons.tsx +++ b/components/skeletons.tsx @@ -126,6 +126,35 @@ export function PersonDetailSkeleton() { ); } +export function SeasonsSkeleton() { + return ( +
+
+ + +
+
+ + + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders +
+ +
+ + + +
+
+ ))} +
+
+ ); +} + export function RecommendationsSkeleton() { return (
diff --git a/components/title-card.tsx b/components/title-card.tsx index 429153b..d2139df 100644 --- a/components/title-card.tsx +++ b/components/title-card.tsx @@ -11,7 +11,6 @@ import { IconPlus, IconStarFilled, } from "@tabler/icons-react"; - import { type MotionStyle, type MotionValue, motion } from "motion/react"; import Image from "next/image"; import Link from "next/link"; diff --git a/lib/actions/titles.ts b/lib/actions/titles.ts index b189d34..17bed99 100644 --- a/lib/actions/titles.ts +++ b/lib/actions/titles.ts @@ -7,6 +7,7 @@ import { db } from "@/lib/db/client"; import { episodes } from "@/lib/db/schema"; import { logEpisodeWatch, + logEpisodeWatchBatch, logMovieWatch, markAllEpisodesWatched, rateTitleStars, @@ -67,9 +68,10 @@ export async function watchSeason(seasonId: string) { .from(episodes) .where(eq(episodes.seasonId, seasonId)) .all(); - for (const ep of seasonEps) { - logEpisodeWatch(userId, ep.id); - } + logEpisodeWatchBatch( + userId, + seasonEps.map((ep) => ep.id), + ); } export async function unwatchSeasonAction(seasonId: string) { @@ -79,7 +81,5 @@ export async function unwatchSeasonAction(seasonId: string) { export async function batchWatchEpisodes(episodeIds: string[]) { const userId = await getSessionUserId(); - for (const id of episodeIds) { - logEpisodeWatch(userId, id); - } + logEpisodeWatchBatch(userId, episodeIds); } diff --git a/lib/atoms/filterable-row.ts b/lib/atoms/filterable-row.ts index fb2beda..f8a03f4 100644 --- a/lib/atoms/filterable-row.ts +++ b/lib/atoms/filterable-row.ts @@ -1,5 +1,5 @@ import { atom } from "jotai"; -import { loadable } from "jotai/utils"; +import { unwrap } from "jotai/utils"; import { fetchEpisodeProgress, fetchUserStatuses, @@ -35,30 +35,27 @@ const genreResultsAsyncAtom = atom(async (get) => { return (data.results ?? []) as TitleRowItem[]; }); -export const genreResultsLoadable = loadable(genreResultsAsyncAtom); +export const genreResultsAtom = unwrap(genreResultsAsyncAtom); -const genreUserStatusesAsyncAtom = atom(async (get) => { - const genre = get(selectedGenreAtom); - if (genre === null) return null; - const genreResults = await get(genreResultsAsyncAtom); - if (!genreResults || genreResults.length === 0) return {}; - return fetchUserStatuses( - genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type })), - ); -}); +interface GenreEnrichments { + statuses: Record; + progress: Record; +} -export const genreUserStatusesLoadable = loadable(genreUserStatusesAsyncAtom); - -const genreEpisodeProgressAsyncAtom = atom(async (get) => { - const genre = get(selectedGenreAtom); - if (genre === null) return null; - const genreResults = await get(genreResultsAsyncAtom); - if (!genreResults || genreResults.length === 0) return {}; - return fetchEpisodeProgress( - genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type })), - ); -}); - -export const genreEpisodeProgressLoadable = loadable( - genreEpisodeProgressAsyncAtom, +const genreEnrichmentsAsyncAtom = atom( + async (get): Promise => { + const genre = get(selectedGenreAtom); + if (genre === null) return null; + const genreResults = await get(genreResultsAsyncAtom); + if (!genreResults || genreResults.length === 0) + return { statuses: {}, progress: {} }; + const items = genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type })); + const [statuses, progress] = await Promise.all([ + fetchUserStatuses(items), + fetchEpisodeProgress(items), + ]); + return { statuses, progress }; + }, ); + +export const genreEnrichmentsAtom = unwrap(genreEnrichmentsAsyncAtom); diff --git a/lib/atoms/stats.ts b/lib/atoms/stats.ts index df34fd3..3a291e1 100644 --- a/lib/atoms/stats.ts +++ b/lib/atoms/stats.ts @@ -1,5 +1,5 @@ import { atom } from "jotai"; -import { loadable } from "jotai/utils"; +import { unwrap } from "jotai/utils"; import type { HistoryBucket, TimePeriod } from "@/lib/services/discovery"; export const moviePeriodAtom = atom("this_month"); @@ -25,5 +25,5 @@ const episodeStatsAsyncAtom = atom(async (get) => { return fetchStats("episodes", period); }); -export const movieStatsLoadable = loadable(movieStatsAsyncAtom); -export const episodeStatsLoadable = loadable(episodeStatsAsyncAtom); +export const movieStatsAtom = unwrap(movieStatsAsyncAtom); +export const episodeStatsAtom = unwrap(episodeStatsAsyncAtom); diff --git a/lib/db/client.ts b/lib/db/client.ts index 47141bd..253d5d5 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -45,7 +45,11 @@ function getClient() { function getDb() { if (!globalForDb._db) { - globalForDb._db = drizzle({ client: getClient(), schema, logger: drizzleLogger }); + globalForDb._db = drizzle({ + client: getClient(), + schema, + logger: drizzleLogger, + }); } return globalForDb._db; } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8f5a0a1..e6b71a4 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -193,6 +193,7 @@ export const userMovieWatches = sqliteTable( table.watchedAt, ), 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, ), index("userEpisodeWatches_episodeId").on(table.episodeId), + index("userEpisodeWatches_userId_episodeId").on( + table.userId, + table.episodeId, + ), ], ); diff --git a/lib/services/credits.ts b/lib/services/credits.ts index 030830e..313af46 100644 --- a/lib/services/credits.ts +++ b/lib/services/credits.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { persons, titleCast, titles } from "@/lib/db/schema"; import { createLogger } from "@/lib/logger"; @@ -17,79 +17,67 @@ const NOTABLE_DEPARTMENTS = new Set([ "Executive Producer", ]); -function upsertPerson( - tmdbId: number, - name: string, - profilePath: string | null, - 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; +interface PersonData { + tmdbId: number; + name: string; + profilePath: string | null; + popularity?: number; } -function upsertTitleCast( - titleId: string, - personId: string, - character: string | null, - department: string, - job: string | null, - displayOrder: number, - episodeCount: number | null, -) { - const now = new Date(); - db.insert(titleCast) - .values({ - titleId, - personId, - character, - department, - job, - displayOrder, - episodeCount, - lastFetchedAt: now, - }) - .onConflictDoUpdate({ - target: [ - titleCast.titleId, - titleCast.personId, - titleCast.department, - titleCast.character, - ], - set: { - job, - displayOrder, - episodeCount, - lastFetchedAt: now, - }, - }) - .run(); +function batchUpsertPersons(people: PersonData[]): Map { + if (people.length === 0) return new Map(); + + // Deduplicate by tmdbId + const uniqueByTmdbId = new Map(); + for (const p of people) { + if (!uniqueByTmdbId.has(p.tmdbId)) uniqueByTmdbId.set(p.tmdbId, p); + } + const uniquePeople = [...uniqueByTmdbId.values()]; + const tmdbIds = uniquePeople.map((p) => p.tmdbId); + + // Batch prefetch existing persons (1 query) + const existing = db + .select({ id: persons.id, tmdbId: persons.tmdbId }) + .from(persons) + .where(inArray(persons.tmdbId, tmdbIds)) + .all(); + const idMap = new Map(existing.map((p) => [p.tmdbId, p.id])); + + // Insert only new persons in a transaction + const newPeople = uniquePeople.filter((p) => !idMap.has(p.tmdbId)); + if (newPeople.length > 0) { + db.transaction((tx) => { + for (const p of newPeople) { + const row = tx + .insert(persons) + .values({ + tmdbId: p.tmdbId, + name: p.name, + profilePath: p.profilePath, + popularity: p.popularity ?? null, + }) + .onConflictDoNothing() + .returning() + .get(); + 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) { @@ -101,105 +89,213 @@ export async function refreshCredits(titleId: string) { try { if (title.type === "movie") { const credits = await getMovieCredits(title.tmdbId); - - // Top 20 cast 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(); - let crewOrder = 100; + const notableCrew: typeof credits.crew = []; for (const c of credits.crew) { if (!NOTABLE_DEPARTMENTS.has(c.job)) continue; const key = `${c.id}-${c.job}`; if (seenCrew.has(key)) continue; seenCrew.add(key); - - const personId = upsertPerson( - c.id, - c.name, - c.profile_path, - c.popularity, - ); - upsertTitleCast( - titleId, - personId, - null, - c.department, - c.job, - crewOrder++, - null, - ); + notableCrew.push(c); } + + // 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 { const credits = await getTvAggregateCredits(title.tmdbId); - - // Top 20 cast 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(); - let crewOrder = 100; + const notableCrew: Array<{ + person: (typeof credits.crew)[0]; + job: string; + episodeCount: number; + }> = []; for (const c of credits.crew) { for (const j of c.jobs) { if (!NOTABLE_DEPARTMENTS.has(j.job)) continue; const key = `${c.id}-${j.job}`; if (seenCrew.has(key)) continue; seenCrew.add(key); - - const personId = upsertPerson( - c.id, - c.name, - c.profile_path, - c.popularity, - ); - upsertTitleCast( - titleId, - personId, - null, - c.department, - j.job, - crewOrder++, - j.episode_count, - ); + notableCrew.push({ + person: c, + job: j.job, + episodeCount: 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}"`); diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 8aeef46..6b1ce3b 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -442,94 +442,61 @@ export async function refreshRecommendations(titleId: string) { `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++) { const r = recs.results[i]; const type = r.media_type ?? title.type; - if (type !== "movie" && type !== "tv") continue; - - // Minimal upsert of the recommended title - const existing = db - .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, + if (type === "movie" || type === "tv") { + allItems.push({ + result: r, + type, source: "tmdb_recommendations", 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++) { const r = similar.results[i]; 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 - .select() - .from(titles) - .where(eq(titles.tmdbId, r.id)) - .get(); - let recTitleId: string; - if (existing) { - recTitleId = existing.id; - } else { - const row = db + if (allItems.length === 0) return; + + // Batch prefetch existing titles (1 query) + const tmdbIds = [...new Set(allItems.map((item) => item.result.id))]; + const existingTitles = db + .select({ id: titles.id, tmdbId: titles.tmdbId }) + .from(titles) + .where(inArray(titles.tmdbId, tmdbIds)) + .all(); + const titleIdMap = new Map( + 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(); + 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) .values({ tmdbId: r.id, - type, + type: item.type, title: r.title ?? r.name ?? "Unknown", originalTitle: r.original_title ?? r.original_name, overview: r.overview, @@ -545,52 +512,104 @@ export async function refreshRecommendations(titleId: string) { .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; - } + if (row) titleIdMap.set(r.id, row.id); } - db.insert(titleRecommendations) - .values({ - titleId, - recommendedTitleId: recTitleId, - source: "tmdb_similar", - rank: i + 1, - lastFetchedAt: now, - }) - .onConflictDoUpdate({ - target: [ - titleRecommendations.titleId, - titleRecommendations.recommendedTitleId, - titleRecommendations.source, - ], - set: { rank: i + 1, lastFetchedAt: now }, - }) - .run(); - } + // One fallback query for any that conflicted + const stillMissing = [...insertedTmdbIds].filter( + (id) => !titleIdMap.has(id), + ); + if (stillMissing.length > 0) { + const fallbacks = tx + .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); + } + + // Upsert all recommendation rows + for (const item of allItems) { + const recTitleId = titleIdMap.get(item.result.id); + 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<{ - title: ResolvedTitle; - seasons: Season[]; - availability: AvailabilityOffer[]; - cast: CastMember[]; -} | null> { - let title = db.select().from(titles).where(eq(titles.id, id)).get(); - if (!title) return null; +/** Fetch seasons from the DB, building the Season[] structure. */ +function fetchSeasonsFromDb(titleId: string): Season[] { + const seasonRows = db + .select() + .from(seasons) + .where(eq(seasons.titleId, titleId)) + .orderBy(seasons.seasonNumber) + .all(); - // If this is a shell TV title, fetch full details now - if (title.type === "tv" && !title.lastFetchedAt) { + if (seasonRows.length === 0) return []; + + 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(); + 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 { + 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 { - const show = await getTvDetails(title.tmdbId); + const show = await getTvDetails(tmdbId); db.update(titles) .set({ overview: show.overview, @@ -599,16 +618,49 @@ export async function getTitleWithChildren(id: string): Promise<{ status: show.status, lastFetchedAt: new Date(), }) - .where(eq(titles.id, id)) + .where(eq(titles.id, titleId)) .run(); - await refreshTvChildren(id, title.tmdbId, show.number_of_seasons); - title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title; + await refreshTvChildren(titleId, tmdbId, show.number_of_seasons); } 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) { try { const movie = await getMovieDetails(title.tmdbId); @@ -634,67 +686,8 @@ export async function getTitleWithChildren(id: string): Promise<{ } } - let titleSeasons: Season[] = []; - - 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(); - 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) ?? [], - })); - } + // For already-hydrated TV titles, fetch seasons from DB directly + const titleSeasons = needsTvHydration ? [] : fetchSeasonsFromDb(id); const availability = db .select() @@ -737,7 +730,13 @@ export async function getTitleWithChildren(id: string): Promise<{ 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 { diff --git a/lib/services/person.ts b/lib/services/person.ts index 4af0698..ab7072e 100644 --- a/lib/services/person.ts +++ b/lib/services/person.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { persons, titleCast, titles } from "@/lib/db/schema"; import { createLogger } from "@/lib/logger"; @@ -173,58 +173,77 @@ export async function fetchFullFilmography( 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) { - const type = c.media_type; - if (type !== "movie" && type !== "tv") continue; + // Batch prefetch existing titles (1 query) + const tmdbIds = [...new Set(validCast.map((c) => c.id))]; + const existingTitles = db + .select({ id: titles.id, tmdbId: titles.tmdbId }) + .from(titles) + .where(inArray(titles.tmdbId, tmdbIds)) + .all(); + const titleIdMap = new Map( + existingTitles.map((t) => [t.tmdbId, t.id]), + ); - // Create shell title if not in DB - const existing = db - .select() - .from(titles) - .where(eq(titles.tmdbId, c.id)) - .get(); - let titleId: string; - if (existing) { - titleId = existing.id; - } else { - const row = db - .insert(titles) - .values({ - tmdbId: c.id, - type, - title: c.title ?? c.name ?? "Unknown", - overview: c.overview, - releaseDate: c.release_date, - firstAirDate: c.first_air_date, - posterPath: c.poster_path, - backdropPath: c.backdrop_path, - popularity: c.popularity, - voteAverage: c.vote_average, - voteCount: c.vote_count, - lastFetchedAt: null, - }) - .onConflictDoNothing() - .returning() - .get(); - if (!row) { - const found = db - .select() - .from(titles) - .where(eq(titles.tmdbId, c.id)) + // Batch insert missing titles in a transaction + const newCast = validCast.filter((c) => !titleIdMap.has(c.id)); + if (newCast.length > 0) { + const insertedTmdbIds = new Set(); + db.transaction((tx) => { + for (const c of newCast) { + if (insertedTmdbIds.has(c.id)) continue; + insertedTmdbIds.add(c.id); + const row = tx + .insert(titles) + .values({ + tmdbId: c.id, + type: c.media_type as "movie" | "tv", + title: c.title ?? c.name ?? "Unknown", + overview: c.overview, + releaseDate: c.release_date, + firstAirDate: c.first_air_date, + posterPath: c.poster_path, + backdropPath: c.backdrop_path, + popularity: c.popularity, + voteAverage: c.vote_average, + voteCount: c.vote_count, + lastFetchedAt: null, + }) + .onConflictDoNothing() + .returning() .get(); - if (!found) continue; - titleId = found.id; - } else { - titleId = row.id; + if (row) titleIdMap.set(c.id, 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({ - titleId, + titleId: tid, tmdbId: c.id, - type, + type: c.media_type as "movie" | "tv", title: c.title ?? c.name ?? "Unknown", posterPath: tmdbImageUrl(c.poster_path, "w500"), releaseDate: c.release_date ?? null, diff --git a/lib/services/system-health.ts b/lib/services/system-health.ts index e350e37..e725f54 100644 --- a/lib/services/system-health.ts +++ b/lib/services/system-health.ts @@ -1,6 +1,6 @@ import { access, constants, readdir, stat } from "node:fs/promises"; 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 { cronRuns, episodes, titles, user } from "@/lib/db/schema"; import { listBackups } from "@/lib/services/backup"; @@ -151,20 +151,17 @@ function getJobsHealth(): SystemHealthData["jobs"] { const schedules = getJobSchedules(); const scheduleMap = new Map(schedules.map((s) => [s.jobName, s])); - // Batch fetch the latest cron run for each job (1 query) - const allLatestRuns = db - .select() - .from(cronRuns) - .where(inArray(cronRuns.jobName, JOB_NAMES)) - .orderBy(desc(cronRuns.startedAt)) - .all(); - - // Keep only the most recent run per job - const latestByJob = new Map(); - for (const run of allLatestRuns) { - if (!latestByJob.has(run.jobName)) { - latestByJob.set(run.jobName, run); - } + // Fetch only the latest cron run per job (index-optimized LIMIT 1 each) + const latestByJob = new Map(); + for (const jobName of JOB_NAMES) { + const latest = db + .select() + .from(cronRuns) + .where(eq(cronRuns.jobName, jobName)) + .orderBy(desc(cronRuns.startedAt)) + .limit(1) + .get(); + if (latest) latestByJob.set(jobName, latest); } return JOB_NAMES.map((jobName) => { @@ -202,24 +199,30 @@ async function getImageCacheHealth(): Promise { let totalSizeBytes = 0; let imageCount = 0; - for (const category of categoryNames) { - const dir = path.join(CACHE_DIR, category); - try { - const files = await readdir(dir); - let sizeBytes = 0; - for (const file of files) { - try { - const s = await stat(path.join(dir, file)); - if (s.isFile()) sizeBytes += s.size; - } catch {} + await Promise.all( + categoryNames.map(async (category) => { + const dir = path.join(CACHE_DIR, category); + try { + const files = await readdir(dir); + const sizes = await Promise.all( + files.map(async (file) => { + try { + const s = await stat(path.join(dir, file)); + 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 }; } diff --git a/lib/services/tracking.ts b/lib/services/tracking.ts index 88fd3af..ddf7944 100644 --- a/lib/services/tracking.ts +++ b/lib/services/tracking.ts @@ -108,6 +108,120 @@ export function logEpisodeWatch( 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`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( userId: string, titleId: string, @@ -151,13 +265,15 @@ export function markAllEpisodesWatched( ) : new Set(); - for (const ep of allEps) { - if (!existingWatches.has(ep.id)) { - db.insert(userEpisodeWatches) - .values({ userId, episodeId: ep.id, watchedAt: now, source }) - .run(); + db.transaction((tx) => { + for (const ep of allEps) { + if (!existingWatches.has(ep.id)) { + tx.insert(userEpisodeWatches) + .values({ userId, episodeId: ep.id, watchedAt: now, source }) + .run(); + } } - } + }); setTitleStatus(userId, titleId, "completed", source); }