- Lost in the credits
+ Scene not found
This page was left on the cutting room floor. It may have been
@@ -50,7 +33,7 @@ export default function NotFound() {
{/* Actions */}
diff --git a/app/setup/_components/copy-button.tsx b/app/setup/_components/copy-button.tsx
new file mode 100644
index 0000000..663201f
--- /dev/null
+++ b/app/setup/_components/copy-button.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { IconCheck, IconCopy } from "@tabler/icons-react";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+
+export function CopyButton({ code }: { code: string }) {
+ const [copied, setCopied] = useState(false);
+
+ function handleCopy() {
+ navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+
+ return (
+
+ {copied ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+ );
+}
diff --git a/app/setup/_components/refresh-button.tsx b/app/setup/_components/refresh-button.tsx
new file mode 100644
index 0000000..fcd0976
--- /dev/null
+++ b/app/setup/_components/refresh-button.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { IconRefresh } from "@tabler/icons-react";
+import { useRouter } from "next/navigation";
+import { useTransition } from "react";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+
+export function RefreshButton() {
+ const router = useRouter();
+ const [isRefreshing, startTransition] = useTransition();
+
+ function handleRefresh() {
+ startTransition(async () => {
+ router.refresh();
+ });
+ }
+
+ return (
+
+ {isRefreshing ? (
+
+ ) : (
+
+ )}
+ {isRefreshing ? "Checking…" : "Check configuration"}
+
+ );
+}
diff --git a/app/setup/page.tsx b/app/setup/page.tsx
new file mode 100644
index 0000000..a82ae24
--- /dev/null
+++ b/app/setup/page.tsx
@@ -0,0 +1,197 @@
+import { IconExternalLink, IconKey } from "@tabler/icons-react";
+import { redirect } from "next/navigation";
+import { connection } from "next/server";
+import { Suspense } from "react";
+import { TmdbLogo } from "@/components/tmdb-logo";
+import { isTmdbConfigured } from "@/lib/config";
+import { CopyButton } from "./_components/copy-button";
+import { RefreshButton } from "./_components/refresh-button";
+
+const steps = [
+ {
+ number: "1",
+ title: "Create a TMDB account",
+ description: (
+ <>
+ Head to{" "}
+
+ themoviedb.org
+
+ {" "}
+ and sign up for a free account.
+ >
+ ),
+ },
+ {
+ number: "2",
+ title: "Request an API key",
+ description: (
+ <>
+ Go to{" "}
+
+ Settings → API
+
+ {" "}
+ and request an API key. Choose “Developer” when asked. You
+ need the{" "}
+
+ API Read Access Token
+ {" "}
+ (the long one).
+ >
+ ),
+ },
+ {
+ number: "3",
+ title: "Add it to your environment",
+ description:
+ "Set the TMDB_API_READ_ACCESS_TOKEN environment variable and restart Sofa.",
+ },
+];
+
+const envSnippets = [
+ {
+ label: ".env file",
+ code: "TMDB_API_READ_ACCESS_TOKEN=your_api_read_access_token_here",
+ },
+ {
+ label: "Docker Compose",
+ code: `environment:
+ - TMDB_API_READ_ACCESS_TOKEN=your_api_read_access_token_here`,
+ },
+ {
+ label: "Docker run",
+ code: "docker run -e TMDB_API_READ_ACCESS_TOKEN=your_api_read_access_token_here ...",
+ },
+];
+
+export default function SetupPage() {
+ return (
+
+
+
+ );
+}
+
+export async function SetupContent() {
+ await connection();
+ if (isTmdbConfigured()) redirect("/");
+
+ return (
+
+ {/* Header */}
+
+
+
+ Setup required
+
+
+ Connect to TMDB
+
+
+ Sofa uses{" "}
+
+ The Movie Database
+
+ {" "}
+ for movie & TV metadata, posters, and streaming availability.
+ You'll need a free API key to get started.
+
+
+
+ {/* Steps */}
+
+ {steps.map((step, i) => (
+
+
+
+ {step.number}
+
+
+
+
{step.title}
+
+ {step.description}
+
+
+ {/* Show env snippets for step 3 */}
+ {i === 2 && (
+
+ {envSnippets.map((snippet) => (
+
+
+
+ {snippet.label}
+
+
+
+
+ {snippet.code}
+
+
+ ))}
+
+ )}
+
+
+ ))}
+
+
+ {/* Status check */}
+
+
+
+
+ After setting the key and restarting:
+
+
+ Click the button to verify your configuration
+
+
+
+
+
+
+
+
+
+
+ This product uses the TMDB API but is not endorsed or certified by
+ TMDB.
+
+
+
+ );
+}
diff --git a/components/auth-form.tsx b/components/auth-form.tsx
index d2305bc..44fc412 100644
--- a/components/auth-form.tsx
+++ b/components/auth-form.tsx
@@ -70,7 +70,6 @@ export function AuthForm({
}
}
router.push("/dashboard");
- router.refresh();
} catch {
setError("Something went wrong");
} finally {
@@ -88,6 +87,7 @@ export function AuthForm({
});
} catch {
setError("Failed to start SSO login");
+ } finally {
setOidcLoading(false);
}
}
diff --git a/components/command-palette.tsx b/components/command-palette.tsx
index edaca43..fe041b3 100644
--- a/components/command-palette.tsx
+++ b/components/command-palette.tsx
@@ -14,6 +14,7 @@ import { useAtom } from "jotai";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import {
Command,
@@ -136,13 +137,25 @@ export function CommandPalette() {
setCommandPaletteOpen(false);
progress.start();
if (result.type === "person") {
- void resolvePerson(result.tmdbId).then((id) => {
- if (id) router.push(`/people/${id}`);
- });
+ void resolvePerson(result.tmdbId)
+ .then((id) => {
+ if (id) router.push(`/people/${id}`);
+ else progress.done();
+ })
+ .catch(() => {
+ progress.done();
+ toast.error("Failed to load person");
+ });
} else {
- void resolveTitle(result.tmdbId, result.type).then((id) => {
- if (id) router.push(`/titles/${id}`);
- });
+ void resolveTitle(result.tmdbId, result.type)
+ .then((id) => {
+ if (id) router.push(`/titles/${id}`);
+ else progress.done();
+ })
+ .catch(() => {
+ progress.done();
+ toast.error("Failed to load title");
+ });
}
},
[router, setCommandPaletteOpen, progress],
diff --git a/components/title-card.tsx b/components/title-card.tsx
index 7274fa2..aa39c96 100644
--- a/components/title-card.tsx
+++ b/components/title-card.tsx
@@ -16,6 +16,7 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
+import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import {
Tooltip,
@@ -324,11 +325,17 @@ export function TitleCard({
onClick={() => {
progress.start();
startTransition(async () => {
- const resolvedId = await resolveTitle(
- tmdbId,
- type as "movie" | "tv",
- );
- if (resolvedId) router.push(`/titles/${resolvedId}`);
+ try {
+ const resolvedId = await resolveTitle(
+ tmdbId,
+ type as "movie" | "tv",
+ );
+ if (resolvedId) router.push(`/titles/${resolvedId}`);
+ else progress.done();
+ } catch {
+ progress.done();
+ toast.error("Failed to load title");
+ }
});
}}
>
diff --git a/components/update-toast.tsx b/components/update-toast.tsx
index bd1743f..c26202c 100644
--- a/components/update-toast.tsx
+++ b/components/update-toast.tsx
@@ -3,32 +3,30 @@
import { useAtom } from "jotai";
import { useEffect } from "react";
import { toast } from "sonner";
-import { getUpdateCheckAction } from "@/lib/actions/settings";
import { updateToastDismissedVersionAtom } from "@/lib/atoms/update-check";
+import type { UpdateCheckResult } from "@/lib/services/update-check";
-export function UpdateToast() {
+export function UpdateToast({ data }: { data: UpdateCheckResult | null }) {
const [dismissedVersion, setDismissedVersion] = useAtom(
updateToastDismissedVersionAtom,
);
useEffect(() => {
- void getUpdateCheckAction().then((data) => {
- if (!data?.updateAvailable) return;
- if (dismissedVersion === data.latestVersion) return;
+ if (!data?.updateAvailable) return;
+ if (dismissedVersion === data.latestVersion) return;
- setDismissedVersion(data.latestVersion);
- toast.info(`Sofa v${data.latestVersion} is available`, {
- description: `You're running v${data.currentVersion}.`,
- duration: 15_000,
- action: data.releaseUrl
- ? {
- label: "View release",
- onClick: () => window.open(data.releaseUrl as string, "_blank"),
- }
- : undefined,
- });
+ setDismissedVersion(data.latestVersion);
+ toast.info(`Sofa v${data.latestVersion} is available`, {
+ description: `You're running v${data.currentVersion}.`,
+ duration: 15_000,
+ action: data.releaseUrl
+ ? {
+ label: "View release",
+ onClick: () => window.open(data.releaseUrl as string, "_blank"),
+ }
+ : undefined,
});
- }, [dismissedVersion, setDismissedVersion]);
+ }, [data, dismissedVersion, setDismissedVersion]);
return null;
}
diff --git a/hooks/use-discover.ts b/hooks/use-discover.ts
new file mode 100644
index 0000000..0d720de
--- /dev/null
+++ b/hooks/use-discover.ts
@@ -0,0 +1,31 @@
+import useSWR from "swr";
+import { fetcher } from "@/lib/swr/fetcher";
+
+type TitleStatus = "watchlist" | "in_progress" | "completed";
+
+interface TitleRowItem {
+ tmdbId: number;
+ type: "movie" | "tv";
+ title: string;
+ posterPath: string | null;
+ releaseDate: string | null;
+ voteAverage: number;
+}
+
+interface DiscoverResponse {
+ items: TitleRowItem[];
+ userStatuses: Record
;
+ episodeProgress: Record;
+}
+
+export function useDiscover(mediaType: "movie" | "tv", genreId: number | null) {
+ const { data, isLoading } = useSWR(
+ genreId != null
+ ? `/api/discover?mediaType=${mediaType}&genreId=${genreId}`
+ : null,
+ fetcher,
+ { revalidateOnFocus: false, dedupingInterval: 2_000 },
+ );
+
+ return { data, isLoading };
+}
diff --git a/hooks/use-stats.ts b/hooks/use-stats.ts
new file mode 100644
index 0000000..8f66f83
--- /dev/null
+++ b/hooks/use-stats.ts
@@ -0,0 +1,18 @@
+import useSWR from "swr";
+import type { HistoryBucket, TimePeriod } from "@/lib/services/discovery";
+import { fetcher } from "@/lib/swr/fetcher";
+
+interface StatsResponse {
+ count: number;
+ history: HistoryBucket[];
+}
+
+export function useStats(type: "movies" | "episodes", period: TimePeriod) {
+ const { data } = useSWR(
+ `/api/stats?type=${type}&period=${period}`,
+ fetcher,
+ { revalidateOnFocus: false },
+ );
+
+ return data;
+}
diff --git a/hooks/use-system-health.ts b/hooks/use-system-health.ts
new file mode 100644
index 0000000..ee7d3c0
--- /dev/null
+++ b/hooks/use-system-health.ts
@@ -0,0 +1,25 @@
+import useSWR from "swr";
+import type { SystemHealthData } from "@/lib/services/system-health";
+import { fetcher } from "@/lib/swr/fetcher";
+
+interface StatusResponse {
+ tmdbConfigured: boolean;
+ health: SystemHealthData;
+}
+
+export function useSystemHealth(initialData: SystemHealthData) {
+ const { data, isValidating, mutate } = useSWR(
+ "/api/status",
+ fetcher,
+ {
+ revalidateOnFocus: false,
+ fallbackData: { tmdbConfigured: true, health: initialData },
+ },
+ );
+
+ return {
+ data: data?.health ?? initialData,
+ isRefreshing: isValidating,
+ refresh: () => mutate(),
+ };
+}
diff --git a/lib/actions/explore.ts b/lib/actions/explore.ts
deleted file mode 100644
index ee50dcf..0000000
--- a/lib/actions/explore.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-"use server";
-
-import { requireSession } from "@/lib/auth/session";
-import { discover } from "@/lib/tmdb/client";
-import { tmdbImageUrl } from "@/lib/tmdb/image";
-
-export async function discoverByGenre(
- mediaType: "movie" | "tv",
- genreId: number,
-) {
- await requireSession();
- const results = await discover(mediaType, {
- sort_by: "popularity.desc",
- "vote_count.gte": "50",
- with_genres: String(genreId),
- });
- // Discover may return movie (title, release_date) or TV (name, first_air_date)
- // fields depending on mediaType. The schema types them separately, so widen.
- type DiscoverResult = NonNullable[number] & {
- title?: string;
- name?: string;
- release_date?: string;
- first_air_date?: string;
- };
- return ((results.results ?? []) as DiscoverResult[])
- .filter((r) => r.poster_path)
- .map((r) => ({
- tmdbId: r.id,
- type: mediaType,
- title: r.title ?? r.name ?? "",
- posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
- releaseDate: r.release_date ?? r.first_air_date ?? null,
- voteAverage: r.vote_average,
- }));
-}
diff --git a/lib/actions/settings.ts b/lib/actions/settings.ts
index c2066fb..9fb6fed 100644
--- a/lib/actions/settings.ts
+++ b/lib/actions/settings.ts
@@ -19,14 +19,6 @@ import {
restoreFromBackup,
} from "@/lib/services/backup";
import { getSetting, setSetting } from "@/lib/services/settings";
-import {
- getSystemHealth,
- type SystemHealthData,
-} from "@/lib/services/system-health";
-import {
- getCachedUpdateCheck,
- type UpdateCheckResult,
-} from "@/lib/services/update-check";
const providerSchema = z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]);
@@ -222,13 +214,6 @@ export async function setBackupScheduleAction(
rescheduleBackup();
}
-// --- System health actions ---
-
-export async function getSystemHealthAction(): Promise {
- await requireAdmin();
- return getSystemHealth();
-}
-
// --- Job trigger action ---
export async function triggerJobAction(
@@ -256,17 +241,6 @@ export async function restoreBackupAction(formData: FormData): Promise {
await restoreFromBackup(buffer);
}
-// --- Update check action ---
-
-export async function getUpdateCheckAction(): Promise {
- try {
- await requireAdmin();
- return getCachedUpdateCheck();
- } catch {
- return null;
- }
-}
-
// --- Avatar actions ---
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
diff --git a/lib/actions/setup.ts b/lib/actions/setup.ts
deleted file mode 100644
index 15111c1..0000000
--- a/lib/actions/setup.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-"use server";
-
-import { isTmdbConfigured } from "@/lib/config";
-
-export async function checkTmdbConfigured() {
- return isTmdbConfigured();
-}
diff --git a/lib/actions/watchlist.ts b/lib/actions/watchlist.ts
index 2a06f36..1dffa7e 100644
--- a/lib/actions/watchlist.ts
+++ b/lib/actions/watchlist.ts
@@ -1,38 +1,11 @@
"use server";
import { and, eq } from "drizzle-orm";
-import { z } from "zod";
-import { getSession, requireSession } from "@/lib/auth/session";
+import { requireSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { userTitleStatus } from "@/lib/db/schema";
-import {
- getWatchCount,
- getWatchHistory,
- type HistoryBucket,
- type TimePeriod,
-} from "@/lib/services/discovery";
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
-import {
- getEpisodeProgressByTmdbIds,
- getUserStatusesByTmdbIds,
- setTitleStatus,
-} from "@/lib/services/tracking";
-
-export async function fetchUserStatuses(
- tmdbIds: { tmdbId: number; type: string }[],
-): Promise> {
- const session = await getSession();
- if (!session) return {};
- return getUserStatusesByTmdbIds(session.user.id, tmdbIds);
-}
-
-export async function fetchEpisodeProgress(
- tmdbIds: { tmdbId: number; type: string }[],
-): Promise> {
- const session = await getSession();
- if (!session) return {};
- return getEpisodeProgressByTmdbIds(session.user.id, tmdbIds);
-}
+import { setTitleStatus } from "@/lib/services/tracking";
export async function quickAddToWatchlist(
tmdbId: number,
@@ -62,19 +35,3 @@ export async function quickAddToWatchlist(
setTitleStatus(userId, title.id, "watchlist");
return { success: true, titleId: title.id, alreadyAdded: false };
}
-
-const statsSchema = z.object({
- type: z.enum(["movies", "episodes"]),
- period: z.enum(["today", "this_week", "this_month", "this_year"]),
-});
-
-export async function getStatsAction(
- type: "movies" | "episodes",
- period: TimePeriod,
-): Promise<{ count: number; history: HistoryBucket[] }> {
- const session = await requireSession();
- const parsed = statsSchema.parse({ type, period });
- const count = getWatchCount(session.user.id, parsed.type, parsed.period);
- const history = getWatchHistory(session.user.id, parsed.type, parsed.period);
- return { count, history };
-}
diff --git a/proxy.ts b/proxy.ts
index 9a520ed..95a111e 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -1,7 +1,7 @@
import { getSessionCookie } from "better-auth/cookies";
import { type NextRequest, NextResponse } from "next/server";
-const authRoutes = new Set(["/login", "/register", "/setup"]);
+const authRoutes = new Set(["/login", "/register"]);
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;