mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat(web): add route loaders to prefetch dashboard, explore, and settings queries
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
||||
credentials: true,
|
||||
maxAge: 86400,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 && <Sparkline data={sparklineData} color={color} />}
|
||||
{sparklineData && (
|
||||
<Suspense>
|
||||
<Sparkline data={sparklineData} color={color} />
|
||||
</Suspense>
|
||||
)}
|
||||
<div className="relative z-10 flex items-center gap-2">
|
||||
<div className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}>
|
||||
<Icon aria-hidden={true} className={`size-[13px] ${color}`} />
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-10">
|
||||
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-[320px] rounded-none" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-hidden">
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
<div key={j} className="w-[140px] shrink-0 sm:w-[160px]">
|
||||
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeMaps<T>(...maps: (Record<string, T> | undefined)[]): Record<string, T> {
|
||||
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 <ExploreSkeletons />;
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-10">
|
||||
{hero && (
|
||||
<HeroBanner
|
||||
id={hero.id}
|
||||
type={hero.type}
|
||||
title={hero.title}
|
||||
overview={hero.overview}
|
||||
backdropPath={hero.backdropPath}
|
||||
voteAverage={hero.voteAverage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<TitleRow
|
||||
heading={t`Trending Today`}
|
||||
icon={<IconFlame aria-hidden={true} className="text-primary size-5" />}
|
||||
items={trendingItems}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
onEndReached={fetchNextTrending}
|
||||
hasNextPage={hasNextTrending}
|
||||
isFetchingNextPage={isFetchingNextTrending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FilterableTitleRow
|
||||
heading={t`Popular Movies`}
|
||||
icon={<IconMovie aria-hidden={true} className="text-primary size-5" />}
|
||||
mediaType="movie"
|
||||
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
|
||||
genres={movieGenreData?.genres ?? []}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
|
||||
<FilterableTitleRow
|
||||
heading={t`Popular TV Shows`}
|
||||
icon={<IconDeviceTv aria-hidden={true} className="text-primary size-5" />}
|
||||
mediaType="tv"
|
||||
defaultItems={(popularTvData?.items ?? []).slice(0, 20)}
|
||||
genres={tvGenreData?.genres ?? []}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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…`), {
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-10">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="mt-2 h-4 w-48" />
|
||||
</div>
|
||||
<StatsSectionSkeleton />
|
||||
<ContinueWatchingSectionSkeleton />
|
||||
<TitleGridSectionSkeleton />
|
||||
<TitleGridSectionSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPage() {
|
||||
const { session } = Route.useRouteContext();
|
||||
return (
|
||||
|
||||
@@ -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: () => <ExploreClient />,
|
||||
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 (
|
||||
<div className="space-y-10">
|
||||
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-[320px] rounded-none" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-hidden">
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
<div key={j} className="w-[140px] shrink-0 sm:w-[160px]">
|
||||
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeMaps<T>(...maps: (Record<string, T> | undefined)[]): Record<string, T> {
|
||||
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 <ExploreSkeletons />;
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-10">
|
||||
{hero && (
|
||||
<HeroBanner
|
||||
id={hero.id}
|
||||
type={hero.type}
|
||||
title={hero.title}
|
||||
overview={hero.overview}
|
||||
backdropPath={hero.backdropPath}
|
||||
voteAverage={hero.voteAverage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<TitleRow
|
||||
heading={t`Trending Today`}
|
||||
icon={<IconFlame aria-hidden={true} className="text-primary size-5" />}
|
||||
items={trendingItems}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
onEndReached={fetchNextTrending}
|
||||
hasNextPage={hasNextTrending}
|
||||
isFetchingNextPage={isFetchingNextTrending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FilterableTitleRow
|
||||
heading={t`Popular Movies`}
|
||||
icon={<IconMovie aria-hidden={true} className="text-primary size-5" />}
|
||||
mediaType="movie"
|
||||
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
|
||||
genres={movieGenreData?.genres ?? []}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
|
||||
<FilterableTitleRow
|
||||
heading={t`Popular TV Shows`}
|
||||
icon={<IconDeviceTv aria-hidden={true} className="text-primary size-5" />}
|
||||
mediaType="tv"
|
||||
defaultItems={(popularTvData?.items ?? []).slice(0, 20)}
|
||||
genres={tvGenreData?.genres ?? []}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<unknown>[] = [
|
||||
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 (
|
||||
<div className="mx-auto max-w-2xl space-y-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
<Skeleton className="mt-2 h-4 w-56" />
|
||||
</div>
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const { session } = Route.useRouteContext();
|
||||
const isAdmin = session.user.role === "admin";
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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<BackupInfo> {
|
||||
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)`);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<typeof drizzle>, {
|
||||
},
|
||||
});
|
||||
|
||||
/** 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();
|
||||
|
||||
@@ -170,14 +170,7 @@ export function getLibraryFeed(userId: string, page = 1, limit = 20) {
|
||||
eq(userTitleStatus.userId, userId),
|
||||
);
|
||||
|
||||
const [{ count }] = db
|
||||
.select({ count: sql<number>`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<number>`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<number>`count(*)` })
|
||||
.from(titles)
|
||||
.innerJoin(userTitleStatus, joinCondition)
|
||||
.where(availabilityFilter)
|
||||
.all();
|
||||
totalResults = count ?? 0;
|
||||
}
|
||||
const items = rows.map(({ totalCount: _, ...item }) => item);
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, "''")}'`));
|
||||
}
|
||||
Reference in New Issue
Block a user