From fb6c88e8f5f3d5bbeba8c60f6badc8881f62f18a Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 5 Mar 2026 12:07:54 -0500 Subject: [PATCH] Fix N+1 queries, add React.cache() session dedup, and parallelize async ops Replace nested loop queries with batch fetches using inArray() across discovery, tracking, metadata, settings, and system-health services. The biggest win is getContinueWatchingFeed() going from ~375+ queries to 5. Add a cached getSession() wrapper via React.cache() to deduplicate auth lookups shared between layouts and pages. Parallelize independent async operations in importTitle(), cacheImagesJob(), and the explore page. Co-Authored-By: Claude Opus 4.6 --- app/(pages)/dashboard/page.tsx | 5 +- app/(pages)/explore/page.tsx | 8 +- app/(pages)/layout.tsx | 5 +- app/(pages)/settings/page.tsx | 71 +++++----- app/(pages)/titles/[id]/page.tsx | 5 +- lib/auth/server.ts | 4 +- lib/auth/session.ts | 13 ++ lib/cron.ts | 8 +- lib/services/discovery.ts | 224 +++++++++++++++++++------------ lib/services/metadata.ts | 83 +++++++----- lib/services/system-health.ts | 27 ++-- lib/services/tracking.ts | 153 +++++++++++---------- 12 files changed, 361 insertions(+), 245 deletions(-) create mode 100644 lib/auth/session.ts diff --git a/app/(pages)/dashboard/page.tsx b/app/(pages)/dashboard/page.tsx index 9115a9e..bc0e8b8 100644 --- a/app/(pages)/dashboard/page.tsx +++ b/app/(pages)/dashboard/page.tsx @@ -1,11 +1,10 @@ -import { headers } from "next/headers"; import { Suspense } from "react"; import { ContinueWatchingSectionSkeleton, StatsSectionSkeleton, TitleGridSectionSkeleton, } from "@/components/skeletons"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { ContinueWatchingSection } from "./_components/continue-watching-section"; import { LibrarySection } from "./_components/library-section"; import { RecommendationsSection } from "./_components/recommendations-section"; @@ -13,7 +12,7 @@ import { StatsSection } from "./_components/stats-section"; import { WelcomeHeader } from "./_components/welcome-header"; export default async function DashboardPage() { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) return null; return ( diff --git a/app/(pages)/explore/page.tsx b/app/(pages)/explore/page.tsx index 2745894..c8b1dad 100644 --- a/app/(pages)/explore/page.tsx +++ b/app/(pages)/explore/page.tsx @@ -1,6 +1,5 @@ import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { getEpisodeProgressByTmdbIds, getUserStatusesByTmdbIds, @@ -39,13 +38,15 @@ function mapResults( } export default async function ExplorePage() { - const [trending, popularMovies, popularTv, movieGenres, tvGenres] = + // Fetch session in parallel with TMDB calls + const [trending, popularMovies, popularTv, movieGenres, tvGenres, session] = await Promise.all([ getTrending("all", "day"), getPopular("movie"), getPopular("tv"), getGenres("movie"), getGenres("tv"), + getSession(), ]); const trendingItems = mapResults(trending.results, "movie"); @@ -56,7 +57,6 @@ export default async function ExplorePage() { let userStatuses: Record = {}; let episodeProgress: Record = {}; - const session = await auth.api.getSession({ headers: await headers() }); if (session) { const allItems = [ ...trendingItems, diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index bf2c8de..41971a6 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -1,17 +1,16 @@ -import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { CommandPalette } from "@/components/command-palette"; import { MobileTabBar } from "@/components/mobile-tab-bar"; import { NavBar } from "@/components/nav-bar"; import { UpdateToast } from "@/components/update-toast"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; export default async function PagesLayout({ children, }: { children: React.ReactNode; }) { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) redirect("/login"); return ( diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx index 9592b70..9b19653 100644 --- a/app/(pages)/settings/page.tsx +++ b/app/(pages)/settings/page.tsx @@ -3,11 +3,10 @@ import { IconServer2, IconShieldLock, } from "@tabler/icons-react"; -import { desc, eq } from "drizzle-orm"; -import { headers } from "next/headers"; +import { desc, eq, inArray } from "drizzle-orm"; import { redirect } from "next/navigation"; import { Card } from "@/components/ui/card"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { db } from "@/lib/db/client"; import { webhookConnections, webhookEventLog } from "@/lib/db/schema"; import { listBackups } from "@/lib/services/backup"; @@ -28,41 +27,53 @@ import { SystemHealthCards } from "./_components/system-health-section"; import { UpdateCheckSection } from "./_components/update-check-section"; export default async function SettingsPage() { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session?.user) redirect("/login"); const isAdmin = session.user.role === "admin"; - const connections = db + const connRows = db .select() .from(webhookConnections) .where(eq(webhookConnections.userId, session.user.id)) - .all() - .map((conn) => { - const events = db - .select() - .from(webhookEventLog) - .where(eq(webhookEventLog.connectionId, conn.id)) - .orderBy(desc(webhookEventLog.receivedAt)) - .limit(10) - .all(); + .all(); - return { - id: conn.id, - provider: conn.provider, - token: conn.token, - enabled: conn.enabled, - lastEventAt: conn.lastEventAt?.toISOString() ?? null, - recentEvents: events.map((e) => ({ - id: e.id, - eventType: e.eventType, - mediaType: e.mediaType, - mediaTitle: e.mediaTitle, - status: e.status, - receivedAt: e.receivedAt.toISOString(), - })), - }; - }); + 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); + } + + const connections = connRows.map((conn) => ({ + id: conn.id, + provider: conn.provider, + token: conn.token, + enabled: conn.enabled, + lastEventAt: conn.lastEventAt?.toISOString() ?? null, + recentEvents: (eventsByConn.get(conn.id) ?? []).map((e) => ({ + id: e.id, + eventType: e.eventType, + mediaType: e.mediaType, + mediaTitle: e.mediaTitle, + status: e.status, + receivedAt: e.receivedAt.toISOString(), + })), + })); const registrationOpen = isAdmin ? getSetting("registrationOpen") === "true" diff --git a/app/(pages)/titles/[id]/page.tsx b/app/(pages)/titles/[id]/page.tsx index fccd5c5..362a445 100644 --- a/app/(pages)/titles/[id]/page.tsx +++ b/app/(pages)/titles/[id]/page.tsx @@ -1,10 +1,9 @@ import { eq } from "drizzle-orm"; import type { Metadata } from "next"; -import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { Suspense } from "react"; import { RecommendationsSkeleton } from "@/components/skeletons"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { db } from "@/lib/db/client"; import { titles } from "@/lib/db/schema"; import { getTitleWithChildren, importTitle } from "@/lib/services/metadata"; @@ -64,7 +63,7 @@ export default async function TitleDetailPage({ } // Fetch title + user info in parallel - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); const [result, userInfo] = await Promise.all([ getTitleWithChildren(id), session ? getUserTitleInfo(session.user.id, id) : null, diff --git a/lib/auth/server.ts b/lib/auth/server.ts index c12497c..08bd119 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -1,6 +1,6 @@ -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { APIError, createAuthMiddleware } from "better-auth/api"; +import { betterAuth } from "better-auth/minimal"; import { admin, genericOAuth } from "better-auth/plugins"; import { isOidcAutoRegisterEnabled, diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..4a412bd --- /dev/null +++ b/lib/auth/session.ts @@ -0,0 +1,13 @@ +import { headers } from "next/headers"; +import { cache } from "react"; +import { auth } from "./server"; + +/** + * Cached session getter — deduplicated per request via React.cache(). + * Use this instead of calling auth.api.getSession() directly in server + * components and layouts to avoid redundant session lookups within a + * single render pass. + */ +export const getSession = cache(async () => { + return auth.api.getSession({ headers: await headers() }); +}); diff --git a/lib/cron.ts b/lib/cron.ts index ccd81d3..26cc9f1 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -239,9 +239,11 @@ async function cacheImagesJob() { for (const titleId of libraryIds) { try { - await cacheImagesForTitle(titleId); - await cacheEpisodeStills(titleId); - await cacheProviderLogos(titleId); + await Promise.all([ + cacheImagesForTitle(titleId), + cacheEpisodeStills(titleId), + cacheProviderLogos(titleId), + ]); } catch { // Continue with remaining titles } diff --git a/lib/services/discovery.ts b/lib/services/discovery.ts index a06c305..4d5e6dd 100644 --- a/lib/services/discovery.ts +++ b/lib/services/discovery.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { availabilityOffers, @@ -218,58 +218,104 @@ export function getContinueWatchingFeed( ) .all(); - const items: ContinueWatchingItem[] = []; - const today = new Date().toISOString().slice(0, 10); + if (inProgress.length === 0) return []; - for (const row of inProgress) { - const title = db - .select() - .from(titles) - .where(and(eq(titles.id, row.titleId), eq(titles.type, "tv"))) - .get(); - if (!title) continue; + const titleIds = inProgress.map((r) => r.titleId); - // Get all seasons for this title, ordered - const titleSeasons = db - .select() - .from(seasons) - .where(eq(seasons.titleId, title.id)) - .orderBy(seasons.seasonNumber) - .all(); + // Batch fetch all TV titles (1 query) + const tvTitles = db + .select() + .from(titles) + .where(and(inArray(titles.id, titleIds), eq(titles.type, "tv"))) + .all(); - // Find first unwatched episode - let nextEpisode: ContinueWatchingItem["nextEpisode"] = null; - let lastWatchedAt: Date | null = null; - let totalEpisodes = 0; - let watchedEpisodes = 0; + if (tvTitles.length === 0) return []; - // Get most recent watch for this show - for (const s of titleSeasons) { - const eps = db - .select() - .from(episodes) - .where(eq(episodes.seasonId, s.id)) - .orderBy(episodes.episodeNumber) - .all(); + const tvTitleIds = tvTitles.map((t) => t.id); + const titleMap = new Map(tvTitles.map((t) => [t.id, t])); - totalEpisodes += eps.length; + // Batch fetch all seasons for these titles (1 query) + const allSeasons = db + .select() + .from(seasons) + .where(inArray(seasons.titleId, tvTitleIds)) + .orderBy(seasons.titleId, seasons.seasonNumber) + .all(); - for (const ep of eps) { - const watch = db + const seasonIds = allSeasons.map((s) => s.id); + + // Batch fetch all episodes for these seasons (1 query) + const allEpisodes = + seasonIds.length > 0 + ? db + .select() + .from(episodes) + .where(inArray(episodes.seasonId, seasonIds)) + .orderBy(episodes.seasonId, episodes.episodeNumber) + .all() + : []; + + // Batch fetch all watches for this user for these episodes (1 query) + const episodeIds = allEpisodes.map((ep) => ep.id); + const allWatches = + episodeIds.length > 0 + ? db .select() .from(userEpisodeWatches) .where( and( eq(userEpisodeWatches.userId, userId), - eq(userEpisodeWatches.episodeId, ep.id), + inArray(userEpisodeWatches.episodeId, episodeIds), ), ) - .get(); + .all() + : []; - if (watch) { + // Build lookup maps + const watchedEpisodeIds = new Set(allWatches.map((w) => w.episodeId)); + const watchDateMap = new Map( + allWatches.map((w) => [w.episodeId, w.watchedAt]), + ); + + // Group seasons by title + const seasonsByTitle = new Map(); + for (const s of allSeasons) { + const arr = seasonsByTitle.get(s.titleId) ?? []; + arr.push(s); + seasonsByTitle.set(s.titleId, arr); + } + + // Group episodes by season + const episodesBySeason = new Map(); + for (const ep of allEpisodes) { + const arr = episodesBySeason.get(ep.seasonId) ?? []; + arr.push(ep); + episodesBySeason.set(ep.seasonId, arr); + } + + const items: ContinueWatchingItem[] = []; + const today = new Date().toISOString().slice(0, 10); + + for (const row of inProgress) { + const title = titleMap.get(row.titleId); + if (!title) continue; + + const titleSeasonsArr = seasonsByTitle.get(title.id) ?? []; + let nextEpisode: ContinueWatchingItem["nextEpisode"] = null; + let lastWatchedAt: Date | null = null; + let totalEpisodes = 0; + let watchedEpisodes = 0; + + for (const s of titleSeasonsArr) { + const eps = episodesBySeason.get(s.id) ?? []; + totalEpisodes += eps.length; + + for (const ep of eps) { + if (watchedEpisodeIds.has(ep.id)) { watchedEpisodes++; - if (!lastWatchedAt || watch.watchedAt > lastWatchedAt) { - lastWatchedAt = watch.watchedAt; + const watchDate = watchDateMap.get(ep.id); + if (watchDate && (!lastWatchedAt || watchDate > lastWatchedAt)) { + lastWatchedAt = watchDate; } } else if (!nextEpisode) { // Skip episodes not yet aired @@ -383,30 +429,29 @@ export function getRecommendationsFeed(userId: string) { .map((r) => r.titleId), ); + // Batch fetch all recommendations for all source IDs (1 query) + const allRecRows = db + .select({ + recommendedTitleId: titleRecommendations.recommendedTitleId, + rank: titleRecommendations.rank, + }) + .from(titleRecommendations) + .where(inArray(titleRecommendations.titleId, sourceIds)) + .all(); + const recs: Map = new Map(); - for (const sourceId of sourceIds) { - const recRows = db - .select({ - recommendedTitleId: titleRecommendations.recommendedTitleId, - rank: titleRecommendations.rank, - }) - .from(titleRecommendations) - .where(eq(titleRecommendations.titleId, sourceId)) - .all(); - - for (const rec of recRows) { - if (trackedIds.has(rec.recommendedTitleId)) continue; - const existing = recs.get(rec.recommendedTitleId); - const score = 100 - rec.rank; - if (existing) { - existing.score += score; - } else { - recs.set(rec.recommendedTitleId, { - titleId: rec.recommendedTitleId, - score, - }); - } + for (const rec of allRecRows) { + if (trackedIds.has(rec.recommendedTitleId)) continue; + const existing = recs.get(rec.recommendedTitleId); + const score = 100 - rec.rank; + if (existing) { + existing.score += score; + } else { + recs.set(rec.recommendedTitleId, { + titleId: rec.recommendedTitleId, + score, + }); } } @@ -414,9 +459,18 @@ export function getRecommendationsFeed(userId: string) { .sort((a, b) => b.score - a.score) .slice(0, 20); - return sorted - .map((r) => db.select().from(titles).where(eq(titles.id, r.titleId)).get()) - .filter(Boolean); + if (sorted.length === 0) return []; + + // Batch fetch all recommended titles (1 query) + const recTitleIds = sorted.map((r) => r.titleId); + const recTitles = db + .select() + .from(titles) + .where(inArray(titles.id, recTitleIds)) + .all(); + const recTitleMap = new Map(recTitles.map((t) => [t.id, t])); + + return sorted.map((r) => recTitleMap.get(r.titleId)).filter(Boolean); } export function getRecommendationsForTitle(titleId: string) { @@ -434,27 +488,31 @@ export function getRecommendationsForTitle(titleId: string) { .orderBy(titleRecommendations.rank) .all(); - const results = recs + if (recs.length === 0) return []; + + // Batch fetch all recommended titles (1 query) + const recTitleIds = recs.map((r) => r.recommendedTitleId); + const recTitles = db + .select() + .from(titles) + .where(inArray(titles.id, recTitleIds)) + .all(); + const recTitleMap = new Map(recTitles.map((t) => [t.id, t])); + + return recs .map((rec) => { - const recTitle = db - .select() - .from(titles) - .where(eq(titles.id, rec.recommendedTitleId)) - .get(); - return recTitle - ? { ...recTitle, source: rec.source, rank: rec.rank } - : null; + const r = recTitleMap.get(rec.recommendedTitleId); + if (!r) return null; + return { + id: r.id, + tmdbId: r.tmdbId, + type: r.type as "movie" | "tv", + title: r.title, + posterPath: tmdbImageUrl(r.posterPath, "w500"), + releaseDate: r.releaseDate, + firstAirDate: r.firstAirDate, + voteAverage: r.voteAverage, + }; }) .filter((r): r is NonNullable => r !== null); - - return results.map((r) => ({ - id: r.id, - tmdbId: r.tmdbId, - type: r.type as "movie" | "tv", - title: r.title, - posterPath: tmdbImageUrl(r.posterPath, "w500"), - releaseDate: r.releaseDate, - firstAirDate: r.firstAirDate, - voteAverage: r.voteAverage, - })); } diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 61de5a4..132dfd3 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { availabilityOffers, @@ -69,15 +69,16 @@ export async function importTitle( } await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons); if (awaitEnrichment) { - await refreshAvailability(existing.id).catch(() => {}); + await Promise.all([ + refreshAvailability(existing.id).catch(() => {}), + refreshRecommendations(existing.id).catch(() => {}), + extractAndStoreColors(existing.id, show.poster_path).catch( + () => {}, + ), + ]); } else { refreshAvailability(existing.id).catch(() => {}); - } - await refreshRecommendations(existing.id).catch(() => {}); - if (awaitEnrichment) { - await extractAndStoreColors(existing.id, show.poster_path).catch( - () => {}, - ); + refreshRecommendations(existing.id).catch(() => {}); } if (imageCacheEnabled()) { cacheImagesForTitle(existing.id).catch(() => {}); @@ -113,12 +114,14 @@ export async function importTitle( .returning() .get(); if (awaitEnrichment) { - await refreshAvailability(row.id).catch(() => {}); - await refreshRecommendations(row.id).catch(() => {}); - await extractAndStoreColors(row.id, movie.poster_path).catch(() => {}); + await Promise.all([ + refreshAvailability(row.id).catch(() => {}), + refreshRecommendations(row.id).catch(() => {}), + extractAndStoreColors(row.id, movie.poster_path).catch(() => {}), + ]); } else { refreshAvailability(row.id).catch(() => {}); - await refreshRecommendations(row.id).catch(() => {}); + refreshRecommendations(row.id).catch(() => {}); extractAndStoreColors(row.id, movie.poster_path).catch(() => {}); } if (imageCacheEnabled()) cacheImagesForTitle(row.id).catch(() => {}); @@ -148,12 +151,14 @@ export async function importTitle( await refreshTvChildren(row.id, tmdbId, show.number_of_seasons); if (awaitEnrichment) { - await refreshAvailability(row.id).catch(() => {}); - await refreshRecommendations(row.id).catch(() => {}); - await extractAndStoreColors(row.id, show.poster_path).catch(() => {}); + await Promise.all([ + refreshAvailability(row.id).catch(() => {}), + refreshRecommendations(row.id).catch(() => {}), + extractAndStoreColors(row.id, show.poster_path).catch(() => {}), + ]); } else { refreshAvailability(row.id).catch(() => {}); - await refreshRecommendations(row.id).catch(() => {}); + refreshRecommendations(row.id).catch(() => {}); extractAndStoreColors(row.id, show.poster_path).catch(() => {}); } if (imageCacheEnabled()) { @@ -501,27 +506,39 @@ export async function getTitleWithChildren(id: string): Promise<{ .orderBy(seasons.seasonNumber) .all(); + // 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: db - .select() - .from(episodes) - .where(eq(episodes.seasonId, s.id)) - .orderBy(episodes.episodeNumber) - .all() - .map( - (ep): Episode => ({ - id: ep.id, - episodeNumber: ep.episodeNumber, - name: ep.name, - overview: ep.overview, - stillPath: tmdbImageUrl(ep.stillPath, "w1280", "stills"), - airDate: ep.airDate, - runtimeMinutes: ep.runtimeMinutes, - }), - ), + episodes: epsBySeason.get(s.id) ?? [], })); } diff --git a/lib/services/system-health.ts b/lib/services/system-health.ts index 466c19c..e350e37 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, eq } from "drizzle-orm"; +import { count, desc, inArray } 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,15 +151,24 @@ function getJobsHealth(): SystemHealthData["jobs"] { const schedules = getJobSchedules(); const scheduleMap = new Map(schedules.map((s) => [s.jobName, s])); - return JOB_NAMES.map((jobName) => { - const latest = db - .select() - .from(cronRuns) - .where(eq(cronRuns.jobName, jobName)) - .orderBy(desc(cronRuns.startedAt)) - .limit(1) - .get(); + // 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); + } + } + + return JOB_NAMES.map((jobName) => { + const latest = latestByJob.get(jobName); const isCurrentlyRunning = latest?.status === "running"; const schedule = scheduleMap.get(jobName); diff --git a/lib/services/tracking.ts b/lib/services/tracking.ts index 6c20e86..5764158 100644 --- a/lib/services/tracking.ts +++ b/lib/services/tracking.ts @@ -123,34 +123,39 @@ export function markAllEpisodesWatched( .where(eq(seasons.titleId, titleId)) .all(); - for (const s of allSeasons) { - const eps = db - .select() - .from(episodes) - .where(eq(episodes.seasonId, s.id)) - .all(); + const seasonIds = allSeasons.map((s) => s.id); + const allEps = + seasonIds.length > 0 + ? db + .select() + .from(episodes) + .where(inArray(episodes.seasonId, seasonIds)) + .all() + : []; - for (const ep of eps) { - const existing = db - .select() - .from(userEpisodeWatches) - .where( - and( - eq(userEpisodeWatches.userId, userId), - eq(userEpisodeWatches.episodeId, ep.id), - ), + const epIds = allEps.map((ep) => ep.id); + const existingWatches = + epIds.length > 0 + ? new Set( + db + .select({ episodeId: userEpisodeWatches.episodeId }) + .from(userEpisodeWatches) + .where( + and( + eq(userEpisodeWatches.userId, userId), + inArray(userEpisodeWatches.episodeId, epIds), + ), + ) + .all() + .map((w) => w.episodeId), ) - .get(); - if (!existing) { - db.insert(userEpisodeWatches) - .values({ - userId, - episodeId: ep.id, - watchedAt: now, - source, - }) - .run(); - } + : new Set(); + + for (const ep of allEps) { + if (!existingWatches.has(ep.id)) { + db.insert(userEpisodeWatches) + .values({ userId, episodeId: ep.id, watchedAt: now, source }) + .run(); } } @@ -164,33 +169,31 @@ function checkAllEpisodesWatched(userId: string, titleId: string) { .where(eq(seasons.titleId, titleId)) .all(); - let totalEpisodes = 0; - let watchedEpisodes = 0; + if (allSeasons.length === 0) return; - for (const s of allSeasons) { - const eps = db - .select() - .from(episodes) - .where(eq(episodes.seasonId, s.id)) - .all(); - totalEpisodes += eps.length; + const seasonIds = allSeasons.map((s) => s.id); + const allEps = db + .select() + .from(episodes) + .where(inArray(episodes.seasonId, seasonIds)) + .all(); - for (const ep of eps) { - const watch = db - .select() - .from(userEpisodeWatches) - .where( - and( - eq(userEpisodeWatches.userId, userId), - eq(userEpisodeWatches.episodeId, ep.id), - ), - ) - .get(); - if (watch) watchedEpisodes++; - } - } + const totalEpisodes = allEps.length; + if (totalEpisodes === 0) return; - if (totalEpisodes > 0 && watchedEpisodes >= totalEpisodes) { + const epIds = allEps.map((ep) => ep.id); + const [watchCount] = db + .select({ count: count(userEpisodeWatches.id) }) + .from(userEpisodeWatches) + .where( + and( + eq(userEpisodeWatches.userId, userId), + inArray(userEpisodeWatches.episodeId, epIds), + ), + ) + .all(); + + if (watchCount.count >= totalEpisodes) { setTitleStatus(userId, titleId, "completed"); } } @@ -392,34 +395,40 @@ export function getUserTitleInfo(userId: string, titleId: string) { ) .get(); - // Get watched episode IDs for this title + // Batch fetch all episode IDs for this title const titleSeasons = db .select() .from(seasons) .where(eq(seasons.titleId, titleId)) .all(); - const watchedEpisodeIds: string[] = []; - for (const s of titleSeasons) { - const eps = db - .select() - .from(episodes) - .where(eq(episodes.seasonId, s.id)) - .all(); - for (const ep of eps) { - const watch = db - .select() - .from(userEpisodeWatches) - .where( - and( - eq(userEpisodeWatches.userId, userId), - eq(userEpisodeWatches.episodeId, ep.id), - ), - ) - .get(); - if (watch) watchedEpisodeIds.push(ep.id); - } - } + const seasonIds = titleSeasons.map((s) => s.id); + const allEps = + seasonIds.length > 0 + ? db + .select() + .from(episodes) + .where(inArray(episodes.seasonId, seasonIds)) + .all() + : []; + + const epIds = allEps.map((ep) => ep.id); + + // Batch fetch all watches for these episodes + const watchedEpisodeIds = + epIds.length > 0 + ? db + .select({ episodeId: userEpisodeWatches.episodeId }) + .from(userEpisodeWatches) + .where( + and( + eq(userEpisodeWatches.userId, userId), + inArray(userEpisodeWatches.episodeId, epIds), + ), + ) + .all() + .map((w) => w.episodeId) + : []; return { status: status?.status ?? null,