diff --git a/AGENTS.md b/AGENTS.md
index 959dd7c..99eb7dd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -88,7 +88,7 @@ Cross-package imports:
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types
- `@sofa/db/queries/*` — Query layer (e.g., `@sofa/db/queries/tracking`, `@sofa/db/queries/metadata`)
-- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/utils`, `@sofa/db/migrate`, `@sofa/db/test-utils`
+- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/migrate`, `@sofa/db/test-utils`
- `@sofa/tmdb/client`, `@sofa/tmdb/image`
- `@sofa/core/metadata`, `@sofa/core/tracking`, `@sofa/core/imports`, etc.
- `@sofa/auth/server`, `@sofa/auth/config`
diff --git a/apps/server/src/cron.ts b/apps/server/src/cron.ts
index d74a4d3..b776d68 100644
--- a/apps/server/src/cron.ts
+++ b/apps/server/src/cron.ts
@@ -298,6 +298,10 @@ export function startJobs() {
schedule("telemetryReport", "30 0 * * *", async () => {
await performTelemetryReport();
});
+ schedule("optimizeDb", "0 4 * * 0", async () => {
+ const { optimizeDatabase } = await import("@sofa/db/client");
+ optimizeDatabase();
+ });
log.info(`Started ${jobs.size} jobs`);
}
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 9003833..50fdb15 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -50,6 +50,7 @@ app.use(
cors({
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
credentials: true,
+ maxAge: 86400,
}),
);
diff --git a/apps/web/package.json b/apps/web/package.json
index ddad2ed..1bc40b6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -34,7 +34,6 @@
"clsx": "2.1.1",
"cmdk": "1.1.1",
"jotai": "2.18.1",
- "media-chrome": "4.18.1",
"motion": "12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
diff --git a/apps/web/src/components/dashboard/stats-display.tsx b/apps/web/src/components/dashboard/stats-display.tsx
index 1fd5f7c..54cc1d6 100644
--- a/apps/web/src/components/dashboard/stats-display.tsx
+++ b/apps/web/src/components/dashboard/stats-display.tsx
@@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconCheck, IconLibrary, IconMovie, IconPlayerPlay } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
-import { useState } from "react";
+import { Suspense, lazy, useState } from "react";
import {
Select,
@@ -14,7 +14,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import type { DashboardStats, HistoryBucket, TimePeriod } from "@sofa/api/schemas";
-import { Sparkline } from "./sparkline";
+const Sparkline = lazy(() => import("./sparkline").then((m) => ({ default: m.Sparkline })));
function StatCardSkeleton() {
return (
@@ -66,7 +66,11 @@ function StatCard({
className="animate-stagger-item border-border/30 bg-card/50 relative overflow-hidden rounded-xl border p-4"
style={{ "--stagger-index": index } as React.CSSProperties}
>
- {sparklineData && }
+ {sparklineData && (
+
+
+
+ )}
diff --git a/apps/web/src/components/explore/explore-client.tsx b/apps/web/src/components/explore/explore-client.tsx
deleted file mode 100644
index 98bd532..0000000
--- a/apps/web/src/components/explore/explore-client.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import { useLingui } from "@lingui/react/macro";
-import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
-import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
-import { useMemo } from "react";
-
-import { Skeleton } from "@/components/ui/skeleton";
-import { orpc } from "@/lib/orpc/client";
-
-import { FilterableTitleRow } from "./filterable-title-row";
-import { HeroBanner } from "./hero-banner";
-import { TitleRow } from "./title-row";
-
-function ExploreSkeletons() {
- return (
-
-
- {[1, 2, 3].map((i) => (
-
-
-
-
-
-
- {Array.from({ length: 8 }).map((_, j) => (
-
-
-
- ))}
-
-
- ))}
-
- );
-}
-
-function mergeMaps
(...maps: (Record | undefined)[]): Record {
- return Object.assign({}, ...maps.filter(Boolean));
-}
-
-export function ExploreClient() {
- const { t } = useLingui();
- const {
- data: trendingData,
- isPending: trendingPending,
- fetchNextPage: fetchNextTrending,
- hasNextPage: hasNextTrending,
- isFetchingNextPage: isFetchingNextTrending,
- } = useInfiniteQuery(
- orpc.explore.trending.infiniteOptions({
- input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
- initialPageParam: 1,
- getNextPageParam: (lastPage) =>
- lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
- }),
- );
-
- const { data: popularMoviesData, isPending: moviesPending } = useQuery(
- orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
- );
- const { data: popularTvData, isPending: tvPending } = useQuery(
- orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
- );
- const { data: movieGenreData } = useQuery(
- orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
- );
- const { data: tvGenreData } = useQuery(
- orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
- );
-
- const isPending = trendingPending || moviesPending || tvPending;
-
- const trendingItems = useMemo(
- () => trendingData?.pages.flatMap((p) => p.items) ?? [],
- [trendingData?.pages],
- );
-
- const hero = trendingData?.pages[0]?.hero ?? null;
-
- if (isPending) return ;
-
- // Merge user statuses and episode progress from all responses
- const userStatuses = mergeMaps(
- ...(trendingData?.pages.map((p) => p.userStatuses) ?? []),
- popularMoviesData?.userStatuses,
- popularTvData?.userStatuses,
- );
- const episodeProgress = mergeMaps(
- ...(trendingData?.pages.map((p) => p.episodeProgress) ?? []),
- popularMoviesData?.episodeProgress,
- popularTvData?.episodeProgress,
- );
-
- return (
-
- {hero && (
-
- )}
-
-
- }
- items={trendingItems}
- userStatuses={userStatuses}
- episodeProgress={episodeProgress}
- onEndReached={fetchNextTrending}
- hasNextPage={hasNextTrending}
- isFetchingNextPage={isFetchingNextTrending}
- />
-
-
-
}
- mediaType="movie"
- defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
- genres={movieGenreData?.genres ?? []}
- userStatuses={userStatuses}
- episodeProgress={episodeProgress}
- />
-
-
}
- mediaType="tv"
- defaultItems={(popularTvData?.items ?? []).slice(0, 20)}
- genres={tvGenreData?.genres ?? []}
- userStatuses={userStatuses}
- episodeProgress={episodeProgress}
- />
-
- );
-}
diff --git a/apps/web/src/lib/orpc/client.ts b/apps/web/src/lib/orpc/client.ts
index 5074367..cbbaf73 100644
--- a/apps/web/src/lib/orpc/client.ts
+++ b/apps/web/src/lib/orpc/client.ts
@@ -10,6 +10,13 @@ import type { contract } from "@sofa/api/contract";
import { i18n } from "@sofa/i18n";
export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 60_000,
+ gcTime: 10 * 60_000,
+ refetchOnWindowFocus: false,
+ },
+ },
queryCache: new QueryCache({
onError: (_error, query) => {
toast.error(i18n._(msg`Something went wrong…`), {
diff --git a/apps/web/src/routes/_app/dashboard.tsx b/apps/web/src/routes/_app/dashboard.tsx
index bdaef49..95e040c 100644
--- a/apps/web/src/routes/_app/dashboard.tsx
+++ b/apps/web/src/routes/_app/dashboard.tsx
@@ -1,15 +1,43 @@
import { createFileRoute } from "@tanstack/react-router";
+import { ContinueWatchingSectionSkeleton } from "@/components/dashboard/continue-watching-list";
import { ContinueWatchingSection } from "@/components/dashboard/continue-watching-section";
import { LibrarySection } from "@/components/dashboard/library-section";
import { RecommendationsSection } from "@/components/dashboard/recommendations-section";
+import { StatsSectionSkeleton } from "@/components/dashboard/stats-display";
import { StatsSection } from "@/components/dashboard/stats-section";
+import { TitleGridSectionSkeleton } from "@/components/dashboard/title-grid";
import { WelcomeHeader } from "@/components/dashboard/welcome-header";
+import { Skeleton } from "@/components/ui/skeleton";
+import { orpc } from "@/lib/orpc/client";
export const Route = createFileRoute("/_app/dashboard")({
+ loader: async ({ context }) => {
+ await Promise.all([
+ context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()),
+ context.queryClient.ensureQueryData(orpc.dashboard.continueWatching.queryOptions()),
+ context.queryClient.ensureQueryData(orpc.dashboard.recommendations.queryOptions()),
+ ]);
+ },
+ pendingComponent: DashboardSkeleton,
component: DashboardPage,
});
+function DashboardSkeleton() {
+ return (
+
+ );
+}
+
function DashboardPage() {
const { session } = Route.useRouteContext();
return (
diff --git a/apps/web/src/routes/_app/explore.tsx b/apps/web/src/routes/_app/explore.tsx
index 0feb9f5..b9aa445 100644
--- a/apps/web/src/routes/_app/explore.tsx
+++ b/apps/web/src/routes/_app/explore.tsx
@@ -1,7 +1,159 @@
+import { useLingui } from "@lingui/react/macro";
+import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
+import { useMemo } from "react";
-import { ExploreClient } from "@/components/explore/explore-client";
+import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
+import { HeroBanner } from "@/components/explore/hero-banner";
+import { TitleRow } from "@/components/explore/title-row";
+import { Skeleton } from "@/components/ui/skeleton";
+import { orpc } from "@/lib/orpc/client";
export const Route = createFileRoute("/_app/explore")({
- component: () => ,
+ loader: ({ context }) => {
+ context.queryClient.ensureQueryData(
+ orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
+ );
+ context.queryClient.ensureQueryData(
+ orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
+ );
+ context.queryClient.ensureQueryData(
+ orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
+ );
+ context.queryClient.ensureQueryData(
+ orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
+ );
+ },
+ pendingComponent: ExploreSkeletons,
+ component: ExplorePage,
});
+
+function ExploreSkeletons() {
+ return (
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, j) => (
+
+
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+function mergeMaps(...maps: (Record | undefined)[]): Record {
+ return Object.assign({}, ...maps.filter(Boolean));
+}
+
+function ExplorePage() {
+ const { t } = useLingui();
+ const {
+ data: trendingData,
+ isPending: trendingPending,
+ fetchNextPage: fetchNextTrending,
+ hasNextPage: hasNextTrending,
+ isFetchingNextPage: isFetchingNextTrending,
+ } = useInfiniteQuery(
+ orpc.explore.trending.infiniteOptions({
+ input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) =>
+ lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
+ }),
+ );
+
+ const { data: popularMoviesData, isPending: moviesPending } = useQuery(
+ orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
+ );
+ const { data: popularTvData, isPending: tvPending } = useQuery(
+ orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
+ );
+ const { data: movieGenreData } = useQuery(
+ orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
+ );
+ const { data: tvGenreData } = useQuery(
+ orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
+ );
+
+ const isPending = trendingPending || moviesPending || tvPending;
+
+ const trendingItems = useMemo(
+ () => trendingData?.pages.flatMap((p) => p.items) ?? [],
+ [trendingData?.pages],
+ );
+
+ const hero = trendingData?.pages[0]?.hero ?? null;
+
+ if (isPending) return ;
+
+ // Merge user statuses and episode progress from all responses
+ const userStatuses = mergeMaps(
+ ...(trendingData?.pages.map((p) => p.userStatuses) ?? []),
+ popularMoviesData?.userStatuses,
+ popularTvData?.userStatuses,
+ );
+ const episodeProgress = mergeMaps(
+ ...(trendingData?.pages.map((p) => p.episodeProgress) ?? []),
+ popularMoviesData?.episodeProgress,
+ popularTvData?.episodeProgress,
+ );
+
+ return (
+
+ {hero && (
+
+ )}
+
+
+ }
+ items={trendingItems}
+ userStatuses={userStatuses}
+ episodeProgress={episodeProgress}
+ onEndReached={fetchNextTrending}
+ hasNextPage={hasNextTrending}
+ isFetchingNextPage={isFetchingNextTrending}
+ />
+
+
+
}
+ mediaType="movie"
+ defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
+ genres={movieGenreData?.genres ?? []}
+ userStatuses={userStatuses}
+ episodeProgress={episodeProgress}
+ />
+
+
}
+ mediaType="tv"
+ defaultItems={(popularTvData?.items ?? []).slice(0, 20)}
+ genres={tvGenreData?.genres ?? []}
+ userStatuses={userStatuses}
+ episodeProgress={episodeProgress}
+ />
+
+ );
+}
diff --git a/apps/web/src/routes/_app/settings.tsx b/apps/web/src/routes/_app/settings.tsx
index b53b057..726956d 100644
--- a/apps/web/src/routes/_app/settings.tsx
+++ b/apps/web/src/routes/_app/settings.tsx
@@ -22,13 +22,49 @@ import { SystemHealthCards } from "@/components/settings/system-health-section";
import { UpdateCheckSection } from "@/components/settings/update-check-section";
import { TmdbLogo } from "@/components/tmdb-logo";
import { Card } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { orpc } from "@/lib/orpc/client";
const GITHUB_REPO = "jakejarvis/sofa";
export const Route = createFileRoute("/_app/settings")({
+ loader: async ({ context }) => {
+ const promises: Promise[] = [
+ context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
+ context.queryClient.ensureQueryData(orpc.system.status.queryOptions()),
+ ];
+ const isAdmin = context.session.user.role === "admin";
+ if (isAdmin) {
+ promises.push(
+ context.queryClient.ensureQueryData(orpc.admin.systemHealth.queryOptions()),
+ context.queryClient.ensureQueryData(orpc.admin.backups.list.queryOptions()),
+ context.queryClient.ensureQueryData(orpc.admin.backups.schedule.queryOptions()),
+ );
+ }
+ await Promise.all(promises);
+ },
+ pendingComponent: SettingsSkeleton,
component: SettingsPage,
});
+function SettingsSkeleton() {
+ return (
+
+ );
+}
+
function SettingsPage() {
const { session } = Route.useRouteContext();
const isAdmin = session.user.role === "admin";
diff --git a/bun.lock b/bun.lock
index d655e02..bbce0d5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -162,7 +162,6 @@
"clsx": "2.1.1",
"cmdk": "1.1.1",
"jotai": "2.18.1",
- "media-chrome": "4.18.1",
"motion": "12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
diff --git a/packages/core/src/backup.ts b/packages/core/src/backup.ts
index 733e495..9d4fc47 100644
--- a/packages/core/src/backup.ts
+++ b/packages/core/src/backup.ts
@@ -4,9 +4,8 @@ import { mkdir, readdir } from "node:fs/promises";
import path from "node:path";
import { BACKUP_DIR, DATABASE_URL } from "@sofa/config";
-import { closeDatabase } from "@sofa/db/client";
+import { closeDatabase, vacuumDatabase } from "@sofa/db/client";
import { runMigrations } from "@sofa/db/migrate";
-import { vacuumInto } from "@sofa/db/utils";
import { createLogger } from "@sofa/logger";
function formatTimestamp(date: Date): string {
@@ -153,7 +152,7 @@ async function createBackupInternal(prefix: BackupPrefix): Promise {
const filename = `${prefix}-${timestamp}.db`;
const dest = path.join(BACKUP_DIR, filename);
- vacuumInto(dest);
+ vacuumDatabase(dest);
const s = await Bun.file(dest).stat();
log.info(`Created backup: ${filename} (${s.size} bytes)`);
diff --git a/packages/db/package.json b/packages/db/package.json
index 120e508..4496373 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -8,7 +8,6 @@
"./migrate": "./src/migrate.ts",
"./schema": "./src/schema.ts",
"./test-utils": "./src/test-utils.ts",
- "./utils": "./src/utils.ts",
"./queries/*": "./src/queries/*.ts"
},
"scripts": {
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
index 7d67df6..5ea9c42 100644
--- a/packages/db/src/client.ts
+++ b/packages/db/src/client.ts
@@ -38,6 +38,10 @@ function getClient() {
globalForDb._client.run("PRAGMA journal_mode = WAL");
globalForDb._client.run("PRAGMA foreign_keys = ON");
globalForDb._client.run("PRAGMA busy_timeout = 5000");
+ globalForDb._client.run("PRAGMA synchronous = NORMAL");
+ globalForDb._client.run("PRAGMA cache_size = -64000");
+ globalForDb._client.run("PRAGMA temp_store = MEMORY");
+ globalForDb._client.run("PRAGMA mmap_size = 268435456");
}
return globalForDb._client;
}
@@ -59,6 +63,15 @@ export const db = new Proxy({} as ReturnType, {
},
});
+/** Run PRAGMA optimize to refresh query planner statistics. */
+export function optimizeDatabase() {
+ getClient().run("PRAGMA optimize");
+}
+
+export function vacuumDatabase(into: string): void {
+ getClient().run("VACUUM INTO ?", [into.replace(/'/g, "''")]);
+}
+
/** Close the current connection, and clear singletons so the Proxy re-initializes on next access. */
export function closeDatabase() {
globalForDb._client?.close();
diff --git a/packages/db/src/queries/discovery.ts b/packages/db/src/queries/discovery.ts
index cc9cee5..f11e2a3 100644
--- a/packages/db/src/queries/discovery.ts
+++ b/packages/db/src/queries/discovery.ts
@@ -170,14 +170,7 @@ export function getLibraryFeed(userId: string, page = 1, limit = 20) {
eq(userTitleStatus.userId, userId),
);
- const [{ count }] = db
- .select({ count: sql`count(*)` })
- .from(titles)
- .innerJoin(userTitleStatus, joinCondition)
- .where(availabilityFilter)
- .all();
-
- const items = db
+ const rows = db
.select({
titleId: titles.id,
title: titles.title,
@@ -190,6 +183,7 @@ export function getLibraryFeed(userId: string, page = 1, limit = 20) {
voteAverage: titles.voteAverage,
popularity: titles.popularity,
userStatus: userTitleStatus.status,
+ totalCount: sql`count(*) over()`.as("totalCount"),
})
.from(titles)
.innerJoin(userTitleStatus, joinCondition)
@@ -199,7 +193,17 @@ export function getLibraryFeed(userId: string, page = 1, limit = 20) {
.offset(offset)
.all();
- const totalResults = count ?? 0;
+ let totalResults = rows[0]?.totalCount ?? 0;
+ if (rows.length === 0 && offset > 0) {
+ const [{ count }] = db
+ .select({ count: sql`count(*)` })
+ .from(titles)
+ .innerJoin(userTitleStatus, joinCondition)
+ .where(availabilityFilter)
+ .all();
+ totalResults = count ?? 0;
+ }
+ const items = rows.map(({ totalCount: _, ...item }) => item);
return {
items,
page,
diff --git a/packages/db/src/queries/tracking.ts b/packages/db/src/queries/tracking.ts
index a590cdd..ae81605 100644
--- a/packages/db/src/queries/tracking.ts
+++ b/packages/db/src/queries/tracking.ts
@@ -275,49 +275,34 @@ export function getEpisodeProgressByTitleIds(userId: string, titleIds: string[])
}
export function getUserTitleInfo(userId: string, titleId: string) {
- const status = db
- .select()
+ const info = db
+ .select({
+ status: userTitleStatus.status,
+ ratingStars: userRatings.ratingStars,
+ })
.from(userTitleStatus)
+ .leftJoin(
+ userRatings,
+ and(
+ eq(userRatings.userId, userTitleStatus.userId),
+ eq(userRatings.titleId, userTitleStatus.titleId),
+ ),
+ )
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
- const rating = db
- .select()
- .from(userRatings)
- .where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
- .get();
-
- const allEps = db
- .select({ id: episodes.id })
- .from(episodes)
+ const watchedEpisodeIds = db
+ .selectDistinct({ episodeId: userEpisodeWatches.episodeId })
+ .from(userEpisodeWatches)
+ .innerJoin(episodes, eq(userEpisodeWatches.episodeId, episodes.id))
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
- .where(eq(seasons.titleId, titleId))
- .all();
-
- const epIds = allEps.map((ep) => ep.id);
-
- const watchedEpisodeIds =
- epIds.length > 0
- ? Array.from(
- new Set(
- db
- .select({ episodeId: userEpisodeWatches.episodeId })
- .from(userEpisodeWatches)
- .where(
- and(
- eq(userEpisodeWatches.userId, userId),
- inArray(userEpisodeWatches.episodeId, epIds),
- ),
- )
- .all()
- .map((w) => w.episodeId),
- ),
- )
- : [];
+ .where(and(eq(userEpisodeWatches.userId, userId), eq(seasons.titleId, titleId)))
+ .all()
+ .map((w) => w.episodeId);
return {
- status: status?.status ?? null,
- rating: rating?.ratingStars ?? null,
+ status: info?.status ?? null,
+ rating: info?.ratingStars ?? null,
episodeWatches: watchedEpisodeIds,
};
}
diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts
deleted file mode 100644
index b7f938d..0000000
--- a/packages/db/src/utils.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { sql } from "drizzle-orm";
-
-import { db } from "./client";
-
-export function vacuumInto(destPath: string): void {
- db.run(sql.raw(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`));
-}