);
}
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);
}