From 89f005dfa803cddfa3a37790010bf2b607cde505 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:23:10 -0400 Subject: [PATCH] feat(web): regroup admin navigation and settings into intuitive sections Reorganize the admin sidebar into Overview / Content / Automation / Users / System so media pipeline tools no longer hide under Users and Settings is not buried in a seven-item Server grab-bag. Group the flat 13-tab admin settings page into Server / Media / Connections / Data sections, and split the Integrations tab into dedicated Subtitles (search providers + AI translation) and Watch Providers (Trakt/Simkl OAuth) pages, leaving Integrations for one-off keys like MDBList. All routes and ?tab= ids are unchanged so deep links keep working. Extract the shared grouped-rail markup used by the admin sidebar, admin settings nav, and user settings nav into a SideNav component, and remove the orphaned admin-settings PluginsSettings page (superseded by /admin/plugins). Co-Authored-By: Claude Fable 5 --- web/src/components/AdminSidebar.test.tsx | 18 +- web/src/components/AdminSidebar.tsx | 234 +++------ web/src/components/SideNav.tsx | 100 ++++ web/src/pages/SettingsLayout.tsx | 59 +-- .../AdminSettingsLayout.test.tsx | 67 +++ .../admin-settings/AdminSettingsLayout.tsx | 143 +++-- .../pages/admin-settings/CredentialStatus.tsx | 18 + .../admin-settings/IntegrationsSettings.tsx | 489 +----------------- .../pages/admin-settings/OverlaySettings.tsx | 2 +- .../admin-settings/PluginsSettings.test.tsx | 316 ----------- .../pages/admin-settings/PluginsSettings.tsx | 391 -------------- .../admin-settings/SubtitlesSettings.tsx | 390 ++++++++++++++ .../admin-settings/WatchProvidersSettings.tsx | 100 ++++ 13 files changed, 878 insertions(+), 1449 deletions(-) create mode 100644 web/src/components/SideNav.tsx create mode 100644 web/src/pages/admin-settings/AdminSettingsLayout.test.tsx create mode 100644 web/src/pages/admin-settings/CredentialStatus.tsx delete mode 100644 web/src/pages/admin-settings/PluginsSettings.test.tsx delete mode 100644 web/src/pages/admin-settings/PluginsSettings.tsx create mode 100644 web/src/pages/admin-settings/SubtitlesSettings.tsx create mode 100644 web/src/pages/admin-settings/WatchProvidersSettings.tsx diff --git a/web/src/components/AdminSidebar.test.tsx b/web/src/components/AdminSidebar.test.tsx index 44adddb1..bf340c3d 100644 --- a/web/src/components/AdminSidebar.test.tsx +++ b/web/src/components/AdminSidebar.test.tsx @@ -54,32 +54,40 @@ function renderSidebar() { } describe("AdminSidebar", () => { - it("includes a Sections link in the manage navigation", () => { + it("renders the grouped navigation sections", () => { + const markup = renderSidebar(); + + for (const section of ["Overview", "Content", "Automation", "Users", "System"]) { + expect(markup).toContain(`>${section}<`); + } + }); + + it("includes a Sections link in the content navigation", () => { const markup = renderSidebar(); expect(markup).toContain('href="/admin/sections"'); expect(markup).toContain(">Sections<"); }); - it("includes a Maintenance link in the server navigation", () => { + it("includes a Maintenance link in the system navigation", () => { const markup = renderSidebar(); expect(markup).toContain('href="/admin/maintenance"'); expect(markup).toContain(">Maintenance<"); }); - it("includes a Recommendations link in the server navigation", () => { + it("includes a Recommendations link in the automation navigation", () => { const markup = renderSidebar(); expect(markup).toContain('href="/admin/recommendations"'); expect(markup).toContain(">Recommendations<"); }); - it("includes a Marker History link in the users navigation", () => { + it("includes a Markers link in the automation navigation", () => { const markup = renderSidebar(); expect(markup).toContain('href="/admin/marker-history"'); - expect(markup).toContain(">Marker History<"); + expect(markup).toContain(">Markers<"); }); it("renders the build identifier in the footer", () => { diff --git a/web/src/components/AdminSidebar.tsx b/web/src/components/AdminSidebar.tsx index 44f7db1e..ae753aaf 100644 --- a/web/src/components/AdminSidebar.tsx +++ b/web/src/components/AdminSidebar.tsx @@ -22,8 +22,11 @@ import { Puzzle, Send, RefreshCw, + SkipForward, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; +import { SideNavItem, SideNavSection } from "@/components/SideNav"; import { SiloBrand } from "@/components/SiloBrand"; import { navigateToPluginRoute } from "@/lib/buildPluginHref"; import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins"; @@ -33,7 +36,7 @@ import { pluginRouteHref } from "@/lib/pluginRouteHref"; interface SidebarItem { label: string; - icon: ReactNode; + icon: LucideIcon; href: string; exact?: boolean; badge?: ReactNode; @@ -72,133 +75,60 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) { buildDisplay = buildInfo.data.display; } + // Grouped by admin intent: monitoring, curating the catalog, background + // processing the server runs on its own, people and their data, and the + // server installation itself. const sections: SidebarSection[] = [ { label: "Overview", items: [ - { - label: "Dashboard", - icon: , - href: "/admin", - exact: true, - }, + { label: "Dashboard", icon: LayoutDashboard, href: "/admin", exact: true }, { label: "Activity", - icon: , + icon: Radio, href: "/admin/activity", badge: sessionCount > 0 ? {sessionCount} live : undefined, }, - { - label: "Logs", - icon: , - href: "/admin/logs", - }, + { label: "Logs", icon: ScrollText, href: "/admin/logs" }, ], }, { label: "Content", items: [ - { - label: "Libraries", - icon: , - href: "/admin/libraries", - }, - { - label: "Collections", - icon: , - href: "/admin/collections", - }, - { - label: "Requests", - icon: , - href: "/admin/requests", - }, - { - label: "Autoscan", - icon: , - href: "/admin/autoscan", - }, - { - label: "Sections", - icon: , - href: "/admin/sections", - }, - { - label: "Subtitles", - icon: , - href: "/admin/subtitles", - }, + { label: "Libraries", icon: Library, href: "/admin/libraries" }, + { label: "Collections", icon: LayoutPanelTop, href: "/admin/collections" }, + { label: "Sections", icon: PanelsTopLeft, href: "/admin/sections" }, + { label: "Requests", icon: Send, href: "/admin/requests" }, + ], + }, + { + label: "Automation", + items: [ + { label: "Autoscan", icon: RefreshCw, href: "/admin/autoscan" }, + { label: "Scheduled Tasks", icon: CalendarClock, href: "/admin/tasks" }, + { label: "Subtitles", icon: Captions, href: "/admin/subtitles" }, + { label: "Markers", icon: SkipForward, href: "/admin/marker-history" }, + { label: "Recommendations", icon: Bot, href: "/admin/recommendations" }, ], }, { label: "Users", items: [ - { - label: "Users", - icon: , - href: "/admin/users", - }, - { - label: "Devices", - icon: , - href: "/admin/devices", - }, - { - label: "Playback History", - icon: , - href: "/admin/history", - }, - { - label: "Marker History", - icon: , - href: "/admin/marker-history", - }, - { - label: "History Import", - icon: , - href: "/admin/history-import", - }, + { label: "Users", icon: Users, href: "/admin/users" }, + { label: "Devices", icon: MonitorSmartphone, href: "/admin/devices" }, + { label: "Playback History", icon: History, href: "/admin/history" }, + { label: "History Import", icon: Download, href: "/admin/history-import" }, ], }, { - label: "Server", + label: "System", items: [ - { - label: "Scheduled Tasks", - icon: , - href: "/admin/tasks", - }, - { - label: "Nodes", - icon: , - href: "/admin/nodes", - }, - { - label: "Maintenance", - icon: , - href: "/admin/maintenance", - }, - { - label: "Plugins", - icon: , - href: "/admin/plugins", - }, - { - label: "Settings", - icon: , - href: "/admin/settings", - }, - { - label: "Recommendations", - icon: , - href: "/admin/recommendations", - }, - { - label: "API Keys", - icon: , - href: "/admin/api-keys", - }, + { label: "Settings", icon: SlidersHorizontal, href: "/admin/settings" }, + { label: "Plugins", icon: Blocks, href: "/admin/plugins" }, + { label: "Nodes", icon: Server, href: "/admin/nodes" }, + { label: "API Keys", icon: KeyRound, href: "/admin/api-keys" }, + { label: "Maintenance", icon: Wrench, href: "/admin/maintenance" }, ], }, ]; @@ -216,7 +146,7 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) { if (!route.navigable || route.navigation_kind !== "admin") continue; adminPluginItems.push({ label: route.navigation_label || inst.plugin_id, - icon: , + icon: Puzzle, href: pluginRouteHref(inst.id, route.path), external: true, }); @@ -250,70 +180,36 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) { className="sidebar-scroll flex-1 space-y-5 overflow-y-auto px-3" > {sections.map((section) => ( -
-

- {section.label} -

- -
+ + {section.items.map((item) => + item.external ? ( + { + e.preventDefault(); + void navigateToPluginRoute(item.href); + onNavigate?.(); + }} + /> + ) : ( + + ), + )} + ))} diff --git a/web/src/components/SideNav.tsx b/web/src/components/SideNav.tsx new file mode 100644 index 00000000..7b625b4c --- /dev/null +++ b/web/src/components/SideNav.tsx @@ -0,0 +1,100 @@ +import { Link } from "react-router"; +import type { LucideIcon } from "lucide-react"; +import type { MouseEvent, ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +// Shared building blocks for the grouped vertical navigation rails used by the +// admin sidebar, the admin settings page, and the user settings page. Keeping +// the markup here means active states, spacing, and section headers stay +// consistent across all three. + +interface SideNavSectionProps { + label: string; + /** Prefix for the section heading id, e.g. "admin-nav". */ + idPrefix: string; + children: ReactNode; +} + +export function SideNavSection({ label, idPrefix, children }: SideNavSectionProps) { + const headingId = `${idPrefix}-${label.toLowerCase().replace(/\s+/g, "-")}`; + return ( +
+

+ {label} +

+
    {children}
+
+ ); +} + +interface SideNavItemProps { + label: string; + icon: LucideIcon; + active?: boolean; + badge?: ReactNode; + /** Internal route rendered as a react-router . */ + href?: string; + /** + * With href, render a plain (full page navigation) instead of a + * react-router . Used for plugin routes mounted at /api/v1/plugins/... + */ + external?: boolean; + /** Without href, the item renders as a + )} + + ); +} diff --git a/web/src/pages/SettingsLayout.tsx b/web/src/pages/SettingsLayout.tsx index f2774817..339171c8 100644 --- a/web/src/pages/SettingsLayout.tsx +++ b/web/src/pages/SettingsLayout.tsx @@ -3,6 +3,7 @@ import { Play, Library, Clock, + Cloud, Subtitles, LayoutDashboard, Palette, @@ -16,6 +17,7 @@ import { // Sparkles is used by the Personalization nav entry below. import type { LucideIcon } from "lucide-react"; import PageBack from "@/components/PageBack"; +import { SideNavItem, SideNavSection } from "@/components/SideNav"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { useAuth } from "@/hooks/useAuth"; import { resolveSettingsDocumentTitle } from "@/lib/documentTitle"; @@ -117,7 +119,7 @@ const NAV_SECTIONS: NavSection[] = [ { path: "watch-providers", label: "Watch Providers", - icon: Clock, + icon: Cloud, description: "Trakt watch history and scrobbling", }, ], @@ -206,48 +208,19 @@ export default function SettingsLayout() {
diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx new file mode 100644 index 00000000..5c88a0e9 --- /dev/null +++ b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx @@ -0,0 +1,67 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +import AdminSettingsLayout from "./AdminSettingsLayout"; + +// The layout only needs the active tab's component to render; a loading form +// keeps every settings page on its skeleton state so no other hooks fire. +vi.mock("@/hooks/useSettingsForm", () => ({ + useSettingsForm: () => ({ isLoading: true }), +})); + +function renderLayout(search = "") { + return renderToStaticMarkup( + + + , + ); +} + +describe("AdminSettingsLayout", () => { + it("renders the grouped navigation sections", () => { + const markup = renderLayout(); + + for (const group of ["Server", "Media", "Connections", "Data"]) { + expect(markup).toContain(`>${group}<`); + } + }); + + it("renders every settings tab", () => { + const markup = renderLayout(); + + for (const label of [ + "General", + "Theming", + "Card Overlays", + "Scanner & Matcher", + "Intro Markers", + "Subtitles", + "Playback", + "Downloads", + "Watch Providers", + "Integrations", + "Compatibility Proxies", + "Rate Limiting", + "Database", + "Storage", + "Log Retention", + ]) { + expect(markup).toContain(label); + } + }); + + it("defaults to the General tab", () => { + const markup = renderLayout(); + + expect(markup).toContain('aria-current="page"'); + expect(markup).toBe(renderLayout("?tab=general")); + }); + + it("resolves the legacy jellyfin tab alias to Compatibility Proxies", () => { + const withAlias = renderLayout("?tab=jellyfin"); + const direct = renderLayout("?tab=compatibility-proxies"); + + expect(withAlias).toBe(direct); + }); +}); diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.tsx index 0b0d3b9c..d729312f 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.tsx @@ -2,6 +2,7 @@ import { useSearchParams } from "react-router"; import { Settings2, Captions, + Cloud, PlayCircle, ScanSearch, Gauge, @@ -13,14 +14,20 @@ import { ScrollText, Paintbrush, Layers, + Subtitles, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +import { SideNavItem, SideNavSection } from "@/components/SideNav"; +import { cn } from "@/lib/utils"; + import GeneralSettings from "./GeneralSettings"; import PlaybackSettings from "./PlaybackSettings"; import ScannerSettings from "./ScannerSettings"; import IntroSettings from "./IntroSettings"; +import SubtitlesSettings from "./SubtitlesSettings"; import RateLimitSettings from "./RateLimitSettings"; +import WatchProvidersSettings from "./WatchProvidersSettings"; import IntegrationsSettings from "./IntegrationsSettings"; import CompatibilityProxiesSettings from "./CompatibilityProxiesSettings"; import DatabaseSettings from "./DatabaseSettings"; @@ -37,32 +44,68 @@ interface SettingsNav { component: React.ComponentType; } -const SETTINGS_NAV: SettingsNav[] = [ - { id: "general", label: "General", icon: Settings2, component: GeneralSettings }, - { id: "theming", label: "Theming", icon: Paintbrush, component: ThemeSettings }, - { id: "playback", label: "Playback", icon: PlayCircle, component: PlaybackSettings }, - { id: "intro", label: "Intro Markers", icon: Captions, component: IntroSettings }, - { id: "scanner", label: "Scanner & Matcher", icon: ScanSearch, component: ScannerSettings }, - { id: "rate-limiting", label: "Rate Limiting", icon: Gauge, component: RateLimitSettings }, - { id: "downloads", label: "Downloads", icon: Download, component: DownloadSettings }, - { id: "integrations", label: "Integrations", icon: Puzzle, component: IntegrationsSettings }, +interface SettingsNavGroup { + label: string; + items: SettingsNav[]; +} + +// Tab ids are stable URL state (?tab=...) — regroup or reorder freely, but +// renaming an id breaks bookmarks and deep links. +const SETTINGS_GROUPS: SettingsNavGroup[] = [ { - id: "compatibility-proxies", - label: "Compatibility Proxies", - icon: Network, - component: CompatibilityProxiesSettings, + label: "Server", + items: [ + { id: "general", label: "General", icon: Settings2, component: GeneralSettings }, + { id: "theming", label: "Theming", icon: Paintbrush, component: ThemeSettings }, + { id: "overlays", label: "Card Overlays", icon: Layers, component: OverlaySettings }, + ], }, - { id: "database", label: "Database", icon: Database, component: DatabaseSettings }, - { id: "storage", label: "Storage", icon: HardDrive, component: StorageSettings }, { - id: "log-retention", - label: "Log Retention", - icon: ScrollText, - component: LogRetentionSettings, + label: "Media", + items: [ + { id: "scanner", label: "Scanner & Matcher", icon: ScanSearch, component: ScannerSettings }, + { id: "intro", label: "Intro Markers", icon: Captions, component: IntroSettings }, + { id: "subtitles", label: "Subtitles", icon: Subtitles, component: SubtitlesSettings }, + { id: "playback", label: "Playback", icon: PlayCircle, component: PlaybackSettings }, + { id: "downloads", label: "Downloads", icon: Download, component: DownloadSettings }, + ], + }, + { + label: "Connections", + items: [ + { + id: "watch-providers", + label: "Watch Providers", + icon: Cloud, + component: WatchProvidersSettings, + }, + { id: "integrations", label: "Integrations", icon: Puzzle, component: IntegrationsSettings }, + { + id: "compatibility-proxies", + label: "Compatibility Proxies", + icon: Network, + component: CompatibilityProxiesSettings, + }, + { id: "rate-limiting", label: "Rate Limiting", icon: Gauge, component: RateLimitSettings }, + ], + }, + { + label: "Data", + items: [ + { id: "database", label: "Database", icon: Database, component: DatabaseSettings }, + { id: "storage", label: "Storage", icon: HardDrive, component: StorageSettings }, + { + id: "log-retention", + label: "Log Retention", + icon: ScrollText, + component: LogRetentionSettings, + }, + ], }, - { id: "overlays", label: "Card Overlays", icon: Layers, component: OverlaySettings }, ]; +const SETTINGS_NAV: SettingsNav[] = SETTINGS_GROUPS.flatMap((group) => group.items); + export default function AdminSettingsLayout() { const [searchParams, setSearchParams] = useSearchParams(); const rawActiveId = searchParams.get("tab") || "general"; @@ -84,50 +127,58 @@ export default function AdminSettingsLayout() {
- {/* Sub-nav sidebar */} + {/* Mobile: horizontal scrolling pill bar */} - {/* Content area */} -
+ {SETTINGS_GROUPS.map((group) => ( + + {group.items.map((item) => ( + setActiveId(item.id)} + /> + ))} + + ))} + + + {/* Content area */} +
diff --git a/web/src/pages/admin-settings/CredentialStatus.tsx b/web/src/pages/admin-settings/CredentialStatus.tsx new file mode 100644 index 00000000..1ad83da7 --- /dev/null +++ b/web/src/pages/admin-settings/CredentialStatus.tsx @@ -0,0 +1,18 @@ +import { CircleCheck, CircleAlert } from "lucide-react"; + +export function CredentialStatus({ configured }: { configured: boolean }) { + if (configured) { + return ( + + + Configured + + ); + } + return ( + + + Not configured + + ); +} diff --git a/web/src/pages/admin-settings/IntegrationsSettings.tsx b/web/src/pages/admin-settings/IntegrationsSettings.tsx index 663f6602..b257f99c 100644 --- a/web/src/pages/admin-settings/IntegrationsSettings.tsx +++ b/web/src/pages/admin-settings/IntegrationsSettings.tsx @@ -1,357 +1,10 @@ -import { useState, useEffect } from "react"; -import { toast } from "sonner"; -import { - useSubtitleProviders, - useUpdateSubtitleProvider, - useTestSubtitleProvider, -} from "@/hooks/queries/admin/subtitles"; -import { - useAdminSensitiveStatus, - useAdminServerSettings, - useUpdateServerSetting, -} from "@/hooks/queries/admin/settings"; -import type { SubtitleProviderConfig } from "@/api/types"; +import { useState } from "react"; +import { useAdminSensitiveStatus, useUpdateServerSetting } from "@/hooks/queries/admin/settings"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Eye, EyeOff, CircleCheck, CircleAlert } from "lucide-react"; +import { CredentialStatus } from "./CredentialStatus"; import { SettingField } from "./SettingField"; -// ============================================================================ -// Subtitles -// ============================================================================ - -const SUBTITLE_PROVIDER_NAMES: Record = { - opensubtitles: "OpenSubtitles", - subdl: "SubDL", - subsource: "SubSource", -}; - -interface SubtitleProviderFormState { - enabled: boolean; - api_key: string; - username: string; - password: string; - showApiKey: boolean; -} - -interface SubtitleTestResult { - success: boolean; - error?: string; -} - -function defaultSubtitleFormState(config: SubtitleProviderConfig): SubtitleProviderFormState { - return { - enabled: config.enabled, - api_key: "", - username: "", - password: "", - showApiKey: false, - }; -} - -function SubtitleCredentialStatus({ configured }: { configured: boolean }) { - if (configured) { - return ( - - - Configured - - ); - } - return ( - - - Not configured - - ); -} - -function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) { - const [form, setForm] = useState(() => - defaultSubtitleFormState(config), - ); - const [testResult, setTestResult] = useState(null); - - const updateProvider = useUpdateSubtitleProvider(); - const testProvider = useTestSubtitleProvider(); - - useEffect(() => { - setForm((prev) => ({ - ...prev, - enabled: config.enabled, - })); - }, [config.enabled]); - - const providerName = config.provider_name; - const displayName = SUBTITLE_PROVIDER_NAMES[providerName] ?? providerName; - const isOpenSubtitles = providerName === "opensubtitles"; - - function handleSave() { - updateProvider.mutate({ - provider: providerName, - config: { - enabled: form.enabled, - ...(isOpenSubtitles - ? { username: form.username, password: form.password } - : { api_key: form.api_key }), - }, - }); - } - - function handleTest() { - setTestResult(null); - testProvider.mutate(providerName, { - onSuccess: (result) => { - setTestResult({ success: result.success, error: result.error }); - }, - onError: (err) => { - setTestResult({ - success: false, - error: err instanceof Error ? err.message : "Test failed", - }); - }, - }); - } - - return ( -
- {/* Header row */} -
-
- {displayName} - -
-
- - setForm((prev) => ({ ...prev, enabled: checked }))} - /> -
-
- - {/* Credentials: username/password for OpenSubtitles, API key for others */} - {isOpenSubtitles ? ( - <> -
- - setForm((prev) => ({ ...prev, username: e.target.value }))} - /> -
-
- - setForm((prev) => ({ ...prev, password: e.target.value }))} - /> -
- - ) : ( -
- -
- setForm((prev) => ({ ...prev, api_key: e.target.value }))} - className="flex-1" - /> - -
-
- )} - - {/* Actions */} -
- - - {testResult !== null && ( - - {testResult.success - ? "Connection successful" - : (testResult.error ?? "Connection failed")} - - )} -
-
- ); -} - -const SUBTITLE_PROVIDER_ORDER = ["opensubtitles", "subdl", "subsource"]; - -function SubtitlesContent() { - const { data, isLoading } = useSubtitleProviders(); - - if (isLoading) - return ( -
- -
- - - -
- Loading settings -
- ); - - const providers = data?.providers ?? []; - - // Sort by known order, putting unknown providers at end - const sorted = [...providers].sort((a, b) => { - const ai = SUBTITLE_PROVIDER_ORDER.indexOf(a.provider_name); - const bi = SUBTITLE_PROVIDER_ORDER.indexOf(b.provider_name); - if (ai === -1 && bi === -1) return 0; - if (ai === -1) return 1; - if (bi === -1) return -1; - return ai - bi; - }); - - return ( -
-

- Configure external subtitle search providers. Credentials are stored securely and never - returned by the API. -

- -
- {sorted.map((provider) => ( - - ))} - {sorted.length === 0 && ( -
-

No subtitle providers configured.

-
- )} -
-
- ); -} - -interface WatchProviderCredentials { - key: string; - displayName: string; -} - -const WATCH_PROVIDER_CREDENTIALS: WatchProviderCredentials[] = [ - { key: "trakt", displayName: "Trakt" }, - { key: "simkl", displayName: "Simkl" }, -]; - -function WatchProviderCredentialCard({ provider }: { provider: WatchProviderCredentials }) { - const { data: sensitive } = useAdminSensitiveStatus(); - const updateSetting = useUpdateServerSetting(); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const configured = new Set(sensitive?.configured ?? []); - const clientIdKey = `watchsync.${provider.key}.client_id`; - const clientSecretKey = `watchsync.${provider.key}.client_secret`; - - function save() { - const updates = []; - if (clientId.trim() !== "") { - updates.push(updateSetting.mutateAsync({ key: clientIdKey, value: clientId })); - } - if (clientSecret.trim() !== "") { - updates.push( - updateSetting.mutateAsync({ - key: clientSecretKey, - value: clientSecret, - }), - ); - } - void Promise.all(updates).then(() => { - setClientId(""); - setClientSecret(""); - }); - } - - return ( -
-
-
-

{provider.displayName}

-

- OAuth credentials for profile connections. -

-
- -
- - - -
- ); -} - -function WatchProviderCredentialsContent() { - return ( -
- {WATCH_PROVIDER_CREDENTIALS.map((provider) => ( - - ))} -
- ); -} - function MDBListCredentialCard() { const { data: sensitive } = useAdminSensitiveStatus(); const updateSetting = useUpdateServerSetting(); @@ -384,7 +37,7 @@ function MDBListCredentialCard() { .

- + { - if (!settings) return; - setEnabled(settings["subtitle_ai.enabled"] ?? "false"); - setBaseUrl(settings["subtitle_ai.base_url"] ?? "https://api.openai.com"); - setChatModel(settings["subtitle_ai.chat_model"] ?? "gpt-4o-mini"); - setMaxConcurrent(settings["subtitle_ai.max_concurrent_jobs"] ?? "2"); - }, [settings]); - - function save() { - const trimmedBaseUrl = baseUrl.trim(); - const trimmedChatModel = chatModel.trim(); - const parsedMaxConcurrent = Number.parseInt(maxConcurrent, 10); - - // Don't let an admin persist a config that would break translation for - // everyone (a blank endpoint/model when enabled, or a bad concurrency value). - if (enabled === "true" && (trimmedBaseUrl === "" || trimmedChatModel === "")) { - toast.error("Base URL and chat model are required to enable AI translation."); - return; - } - if (!Number.isInteger(parsedMaxConcurrent) || parsedMaxConcurrent < 1) { - toast.error("Max concurrent jobs must be a positive whole number."); - return; - } - - const updates = [ - updateSetting.mutateAsync({ key: "subtitle_ai.enabled", value: enabled }), - updateSetting.mutateAsync({ key: "subtitle_ai.base_url", value: trimmedBaseUrl }), - updateSetting.mutateAsync({ key: "subtitle_ai.chat_model", value: trimmedChatModel }), - updateSetting.mutateAsync({ - key: "subtitle_ai.max_concurrent_jobs", - value: String(parsedMaxConcurrent), - }), - ]; - if (apiKey.trim() !== "") { - updates.push(updateSetting.mutateAsync({ key: "subtitle_ai.api_key", value: apiKey })); - } - void Promise.all(updates).then(() => setApiKey("")); - } - - return ( -
-
-
-

AI Subtitle Translation

-

- On-demand subtitle translation via any OpenAI-compatible chat API (OpenAI, Groq, a local - Ollama server, …). Translated tracks are generated once on the server and served to - every client. -

-
- -
- - - - - -
- -

- Changes take effect after a server restart. -

-
-
- ); -} - export default function IntegrationsSettings() { return (
-
-

Integrations

-

External services and provider credentials

+
+

Integrations

+

+ API keys for external services. Watch provider and subtitle credentials have their own + pages in the sidebar. +

-
- -
-
- -
-
- -
- +
); } diff --git a/web/src/pages/admin-settings/OverlaySettings.tsx b/web/src/pages/admin-settings/OverlaySettings.tsx index d1f5a3c5..40ac26e3 100644 --- a/web/src/pages/admin-settings/OverlaySettings.tsx +++ b/web/src/pages/admin-settings/OverlaySettings.tsx @@ -73,7 +73,7 @@ function DefaultsEditor({ value, onChange }: DefaultsEditorProps) { if (overlays.length === 0) return null; return (
-
+
{CATEGORY_META[category].title}
diff --git a/web/src/pages/admin-settings/PluginsSettings.test.tsx b/web/src/pages/admin-settings/PluginsSettings.test.tsx deleted file mode 100644 index 0d5b17c4..00000000 --- a/web/src/pages/admin-settings/PluginsSettings.test.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { MemoryRouter } from "react-router"; -import { describe, expect, it, vi } from "vitest"; - -import PluginsSettings from "./PluginsSettings"; -import Login from "@/pages/Login"; - -const useAdminPluginsMock = vi.fn(); -const useAuthMock = vi.fn(); -const installPluginMutateMock = vi.fn(); -const savePluginConfigMutateMock = vi.fn(); -const testPluginConfigMutateAsyncMock = vi.fn(); -const capturedButtonProps: Array> = []; - -vi.mock("@/components/ui/button", () => ({ - Button: (props: Record) => { - capturedButtonProps.push(props); - return props.children; - }, -})); - -vi.mock("@/hooks/queries/admin/plugins", () => ({ - useAdminPlugins: () => useAdminPluginsMock(), - useCreatePluginRepository: () => ({ mutate: vi.fn(), isPending: false }), - useUpdatePluginRepository: () => ({ mutate: vi.fn(), isPending: false }), - useDeletePluginRepository: () => ({ mutate: vi.fn(), isPending: false }), - useInstallPlugin: () => ({ mutate: installPluginMutateMock, isPending: false }), - useUploadPlugin: () => ({ mutate: vi.fn(), isPending: false }), - usePluginUpload: () => ({ upload: vi.fn(), progress: null, isPending: false }), - useUpdatePluginInstallation: () => ({ mutate: vi.fn(), isPending: false }), - useDeletePluginInstallation: () => ({ mutate: vi.fn(), isPending: false }), - useSavePluginConfig: () => ({ mutate: savePluginConfigMutateMock, isPending: false }), - useTestPluginConfig: () => ({ mutateAsync: testPluginConfigMutateAsyncMock, isPending: false }), - useSavePluginAuthBinding: () => ({ mutate: vi.fn(), isPending: false }), - useSavePluginTaskBinding: () => ({ mutate: vi.fn(), isPending: false }), -})); - -vi.mock("@/hooks/queries/admin/subtitles", () => ({ - useSubtitleProviders: () => ({ data: { providers: [] }, isLoading: false }), - useUpdateSubtitleProvider: () => ({ mutate: vi.fn(), isPending: false }), - useTestSubtitleProvider: () => ({ mutate: vi.fn(), isPending: false }), -})); - -vi.mock("@/hooks/useSettingsForm", () => ({ - useSettingsForm: () => ({ - isLoading: false, - getValue: () => "", - setValue: vi.fn(), - dirtyCount: 0, - save: vi.fn(), - discard: vi.fn(), - isSaving: false, - restartRequired: false, - sensitiveConfigured: [], - }), -})); - -vi.mock("@/hooks/useAuth", () => ({ - useAuth: () => useAuthMock(), -})); - -vi.mock("@/hooks/useServerBranding", () => ({ - useServerBranding: () => ({ - serverName: "Silo", - loginTitle: "Sign in", - loginSubtitle: "", - loginBadge: "", - heroImageUrl: "", - }), -})); - -describe("PluginsSettings", () => { - it("installs catalog plugins by repository identity instead of archive url", () => { - capturedButtonProps.length = 0; - installPluginMutateMock.mockReset(); - - useAdminPluginsMock.mockReturnValue({ - repositories: [], - catalog: [ - { - repository_id: 7, - plugin_id: "example.remote", - version: "1.2.3", - archive_url: "https://plugins.example.test/example.remote.zip", - }, - ], - installations: [], - isLoading: false, - }); - - renderToStaticMarkup( - - - , - ); - - const installButton = capturedButtonProps.find((props) => props.children === "Install"); - expect(installButton).toBeTruthy(); - expect(typeof installButton?.onClick).toBe("function"); - - (installButton?.onClick as () => void)(); - - expect(installPluginMutateMock).toHaveBeenCalledWith({ - repository_id: 7, - plugin_id: "example.remote", - version: "1.2.3", - }); - }); - - it("renders repositories, catalog entries, installations, and plugin-hosted admin links", () => { - useAdminPluginsMock.mockReturnValue({ - repositories: [ - { id: 1, url: "https://plugins.example.test/index.json", display_name: "Example Repo" }, - ], - catalog: [ - { - repository_id: 1, - plugin_id: "example.remote", - version: "1.2.3", - archive_url: "https://plugins.example.test/example.remote.zip", - }, - ], - installations: [ - { - id: 11, - plugin_id: "example.remote", - version: "1.2.3", - enabled: true, - legacy_metadata_import_types: ["tmdb", "tvdb"], - routes: [ - { - id: "admin-page", - method: "GET", - path: "/admin", - access: "admin", - navigable: true, - navigation_label: "Admin Console", - navigation_kind: "admin", - static_asset: false, - }, - ], - }, - ], - isLoading: false, - }); - - const markup = renderToStaticMarkup( - - - , - ); - - expect(markup).toContain("Repositories"); - expect(markup).toContain("Example Repo"); - expect(markup).toContain("Catalog"); - expect(markup).toContain("example.remote"); - expect(markup).toContain("Installed Plugins"); - expect(markup).toContain("Admin Console"); - expect(markup).not.toContain("Import legacy"); - }); - - it("does not restrict manual plugin uploads to zip files", () => { - useAdminPluginsMock.mockReturnValue({ - repositories: [], - catalog: [], - installations: [], - isLoading: false, - }); - - const markup = renderToStaticMarkup( - - - , - ); - - expect(markup).toContain("Upload package"); - expect(markup).not.toContain('accept=".zip"'); - }); - - it("renders admin form labels instead of raw json schema blobs", () => { - useAdminPluginsMock.mockReturnValue({ - repositories: [], - catalog: [], - installations: [ - { - id: 11, - plugin_id: "silo.tmdb", - version: "1.0.0", - enabled: true, - global_configs: [{ key: "connection", value: { api_key: "secret-value" } }], - global_config_schema: [ - { - key: "connection", - title: "Connection", - description: "TMDB credentials", - json_schema: - '{"type":"object","properties":{"api_key":{"type":"string"}},"required":["api_key"],"additionalProperties":false}', - required: true, - admin_form: { - fields: [ - { - key: "api_key", - label: "TMDB API Key", - description: "Paste your TMDB API key.", - control: "PASSWORD", - required: true, - }, - ], - }, - }, - ], - }, - ], - isLoading: false, - }); - - const markup = renderToStaticMarkup( - - - , - ); - - expect(markup).toContain("TMDB API Key"); - expect(markup).not.toContain("{"api_key":"secret-value"}"); - }); - - it("only shows plugin connection checks for metadata provider installations", () => { - capturedButtonProps.length = 0; - useAdminPluginsMock.mockReturnValue({ - repositories: [], - catalog: [], - installations: [ - { - id: 11, - plugin_id: "silo.tmdb", - version: "1.0.0", - enabled: true, - capabilities: [{ type: "metadata_provider.v1", id: "tmdb", display_name: "TMDB" }], - global_configs: [{ key: "connection", value: { api_key: "secret-value" } }], - global_config_schema: [ - { - key: "connection", - title: "Connection", - description: "TMDB credentials", - json_schema: - '{"type":"object","properties":{"api_key":{"type":"string"}},"required":["api_key"],"additionalProperties":false}', - required: true, - admin_form: { - fields: [ - { - key: "api_key", - label: "TMDB API Key", - description: "Paste your TMDB API key.", - control: "PASSWORD", - required: true, - }, - ], - }, - }, - ], - }, - { - id: 12, - plugin_id: "silo.jobs", - version: "1.0.0", - enabled: true, - capabilities: [{ type: "scheduled_task.v1", id: "refresh", display_name: "Refresh" }], - global_configs: [], - global_config_schema: [ - { - key: "task", - title: "Task", - json_schema: '{"type":"object","properties":{"enabled":{"type":"boolean"}}}', - required: false, - }, - ], - }, - ], - isLoading: false, - }); - - renderToStaticMarkup( - - - , - ); - - const checkButtons = capturedButtonProps.filter( - (props) => props.children === "Check Connection", - ); - expect(checkButtons).toHaveLength(1); - }); - - it("shows credential providers on the login screen", () => { - useAuthMock.mockReturnValue({ - login: vi.fn(), - user: null, - loading: false, - setupLoading: false, - setupRequired: false, - providers: [ - { id: "local", display_name: "Local", mode: "credentials", default: true }, - { id: "plugin:41:ldap", display_name: "LDAP", mode: "credentials", default: false }, - ], - }); - - const markup = renderToStaticMarkup( - - - , - ); - - expect(markup).toContain("Sign in with"); - expect(markup).toContain('role="combobox"'); - }); -}); diff --git a/web/src/pages/admin-settings/PluginsSettings.tsx b/web/src/pages/admin-settings/PluginsSettings.tsx deleted file mode 100644 index 6554af19..00000000 --- a/web/src/pages/admin-settings/PluginsSettings.tsx +++ /dev/null @@ -1,391 +0,0 @@ -import { useState } from "react"; -import type { FormEvent } from "react"; -import { PluginConfigForm } from "@/components/admin/plugins/PluginConfigForm"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Progress } from "@/components/ui/progress"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Switch } from "@/components/ui/switch"; -import type { PluginInstallation } from "@/api/types"; -import { pluginRouteHref } from "@/lib/pluginRouteHref"; -import { navigateToPluginRoute } from "@/lib/buildPluginHref"; -import { - useAdminPlugins, - useCreatePluginRepository, - useDeletePluginInstallation, - useDeletePluginRepository, - useInstallPlugin, - usePluginUpload, - useSavePluginAuthBinding, - useSavePluginConfig, - useSavePluginTaskBinding, - useTestPluginConfig, - useUpdatePluginInstallation, - useUpdatePluginRepository, -} from "@/hooks/queries/admin/plugins"; - -function PluginInstallationCard({ installation }: { installation: PluginInstallation }) { - const updateInstallation = useUpdatePluginInstallation(); - const deleteInstallation = useDeletePluginInstallation(); - const saveConfig = useSavePluginConfig(); - const testConfig = useTestPluginConfig(); - const saveAuthBinding = useSavePluginAuthBinding(); - const saveTaskBinding = useSavePluginTaskBinding(); - const capabilities = installation.capabilities ?? []; - const globalConfigs = installation.global_configs ?? []; - const globalConfigSchema = installation.global_config_schema ?? []; - const authBindings = installation.auth_bindings ?? []; - const taskBindings = installation.task_bindings ?? []; - const routes = installation.routes ?? []; - const [enabledOverride, setEnabledOverride] = useState(null); - - const adminRoutes = routes.filter( - (route) => route.navigable && route.navigation_kind === "admin", - ); - const authCapabilities = capabilities.filter( - (capability) => capability.type === "auth_provider.v1", - ); - const taskCapabilities = capabilities.filter( - (capability) => capability.type === "scheduled_task.v1", - ); - const supportsConnectionTest = capabilities.some( - (capability) => capability.type === "metadata_provider.v1", - ); - const enabled = enabledOverride ?? installation.enabled; - - return ( -
-
-
-
-

{installation.plugin_id}

- {installation.version} - - {enabled ? "Enabled" : "Disabled"} - -
-
- {capabilities.map((capability) => ( - - {capability.display_name || capability.id} - - ))} -
-
-
-
- - -
- - -
-
- - {globalConfigSchema.length > 0 && ( -
-

Global Config

-
- {globalConfigSchema.map((schema) => ( - entry.key === schema.key)?.value} - isSaving={saveConfig.isPending} - isTesting={testConfig.isPending} - onSave={(key, nextValue) => - saveConfig.mutate({ id: installation.id, body: { key, value: nextValue } }) - } - onTest={ - supportsConnectionTest - ? (key, nextValue) => - testConfig.mutateAsync({ - id: installation.id, - body: { key, value: nextValue }, - }) - : undefined - } - /> - ))} -
-
- )} - - {authCapabilities.length > 0 && ( -
-

Auth Providers

- {authCapabilities.map((capability, index) => { - const binding = authBindings.find((entry) => entry.capability_id === capability.id); - return ( -
-
-

{capability.display_name || capability.id}

-

{capability.id}

-
-
- -
-
- ); - })} -
- )} - - {taskCapabilities.length > 0 && ( -
-

Scheduled Tasks

- {taskCapabilities.map((capability) => { - const binding = taskBindings.find((entry) => entry.capability_id === capability.id); - return ( -
-
-

{capability.display_name || capability.id}

-

{capability.id}

-
- -
- ); - })} -
- )} - - {adminRoutes.length > 0 && ( -
-

Plugin-hosted Admin Pages

- -
- )} -
- ); -} - -export default function PluginsSettings() { - const { repositories, catalog, installations, isLoading } = useAdminPlugins(); - const createRepository = useCreatePluginRepository(); - const updateRepository = useUpdatePluginRepository(); - const deleteRepository = useDeletePluginRepository(); - const installPlugin = useInstallPlugin(); - const uploadPlugin = usePluginUpload(); - - const [repositoryName, setRepositoryName] = useState(""); - const [repositoryURL, setRepositoryURL] = useState(""); - const [uploadFile, setUploadFile] = useState(null); - - function handleRepositorySubmit(event: FormEvent) { - event.preventDefault(); - if (!repositoryName.trim() || !repositoryURL.trim()) { - return; - } - createRepository.mutate({ - display_name: repositoryName.trim(), - url: repositoryURL.trim(), - enabled: true, - }); - setRepositoryName(""); - setRepositoryURL(""); - } - - function handleUploadSubmit(event: FormEvent) { - event.preventDefault(); - if (!uploadFile) { - return; - } - uploadPlugin.upload(uploadFile, { onSuccess: () => setUploadFile(null) }); - } - - if (isLoading) { - return ( -
- -
- - -
-
- -
- Loading settings -
- ); - } - - return ( -
-
-
-

Repositories

- {repositories.length} -
-
- setRepositoryName(event.target.value)} - placeholder="Repository name" - /> - setRepositoryURL(event.target.value)} - placeholder="https://plugins.example.test/index.json" - /> - -
-
- {repositories.map((repository) => ( -
-
-

{repository.display_name}

-

{repository.url}

-
-
- - -
-
- ))} -
-
- -
-
-

Catalog

- {catalog.length} -
-
- setUploadFile(event.target.files?.[0] ?? null)} - /> - -
- {uploadPlugin.progress !== null && ( - - )} -
- {catalog.map((entry) => ( -
-
-

{entry.plugin_id}

-

{entry.version}

-
- -
- ))} -
-
- -
-
-

Installed Plugins

- {installations.length} -
-
- {installations.map((installation) => ( - - ))} -
-
-
- ); -} diff --git a/web/src/pages/admin-settings/SubtitlesSettings.tsx b/web/src/pages/admin-settings/SubtitlesSettings.tsx new file mode 100644 index 00000000..bfd7b805 --- /dev/null +++ b/web/src/pages/admin-settings/SubtitlesSettings.tsx @@ -0,0 +1,390 @@ +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { + useSubtitleProviders, + useUpdateSubtitleProvider, + useTestSubtitleProvider, +} from "@/hooks/queries/admin/subtitles"; +import { + useAdminSensitiveStatus, + useAdminServerSettings, + useUpdateServerSetting, +} from "@/hooks/queries/admin/settings"; +import type { SubtitleProviderConfig } from "@/api/types"; + +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Eye, EyeOff } from "lucide-react"; +import { CredentialStatus } from "./CredentialStatus"; +import { SettingField } from "./SettingField"; + +// ============================================================================ +// Search providers +// ============================================================================ + +const SUBTITLE_PROVIDER_NAMES: Record = { + opensubtitles: "OpenSubtitles", + subdl: "SubDL", + subsource: "SubSource", +}; + +interface SubtitleProviderFormState { + enabled: boolean; + api_key: string; + username: string; + password: string; + showApiKey: boolean; +} + +interface SubtitleTestResult { + success: boolean; + error?: string; +} + +function defaultSubtitleFormState(config: SubtitleProviderConfig): SubtitleProviderFormState { + return { + enabled: config.enabled, + api_key: "", + username: "", + password: "", + showApiKey: false, + }; +} + +function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) { + const [form, setForm] = useState(() => + defaultSubtitleFormState(config), + ); + const [testResult, setTestResult] = useState(null); + + const updateProvider = useUpdateSubtitleProvider(); + const testProvider = useTestSubtitleProvider(); + + useEffect(() => { + setForm((prev) => ({ + ...prev, + enabled: config.enabled, + })); + }, [config.enabled]); + + const providerName = config.provider_name; + const displayName = SUBTITLE_PROVIDER_NAMES[providerName] ?? providerName; + const isOpenSubtitles = providerName === "opensubtitles"; + + function handleSave() { + updateProvider.mutate({ + provider: providerName, + config: { + enabled: form.enabled, + ...(isOpenSubtitles + ? { username: form.username, password: form.password } + : { api_key: form.api_key }), + }, + }); + } + + function handleTest() { + setTestResult(null); + testProvider.mutate(providerName, { + onSuccess: (result) => { + setTestResult({ success: result.success, error: result.error }); + }, + onError: (err) => { + setTestResult({ + success: false, + error: err instanceof Error ? err.message : "Test failed", + }); + }, + }); + } + + return ( +
+ {/* Header row */} +
+
+ {displayName} + +
+
+ + setForm((prev) => ({ ...prev, enabled: checked }))} + /> +
+
+ + {/* Credentials: username/password for OpenSubtitles, API key for others */} + {isOpenSubtitles ? ( + <> +
+ + setForm((prev) => ({ ...prev, username: e.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, password: e.target.value }))} + /> +
+ + ) : ( +
+ +
+ setForm((prev) => ({ ...prev, api_key: e.target.value }))} + className="flex-1" + /> + +
+
+ )} + + {/* Actions */} +
+ + + {testResult !== null && ( + + {testResult.success + ? "Connection successful" + : (testResult.error ?? "Connection failed")} + + )} +
+
+ ); +} + +const SUBTITLE_PROVIDER_ORDER = ["opensubtitles", "subdl", "subsource"]; + +function SearchProvidersContent() { + const { data, isLoading } = useSubtitleProviders(); + + if (isLoading) + return ( +
+ +
+ + + +
+ Loading settings +
+ ); + + const providers = data?.providers ?? []; + + // Sort by known order, putting unknown providers at end + const sorted = [...providers].sort((a, b) => { + const ai = SUBTITLE_PROVIDER_ORDER.indexOf(a.provider_name); + const bi = SUBTITLE_PROVIDER_ORDER.indexOf(b.provider_name); + if (ai === -1 && bi === -1) return 0; + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }); + + return ( +
+

+ Configure external subtitle search providers. Credentials are stored securely and never + returned by the API. +

+ +
+ {sorted.map((provider) => ( + + ))} + {sorted.length === 0 && ( +
+

No subtitle providers configured.

+
+ )} +
+
+ ); +} + +// ============================================================================ +// AI translation +// ============================================================================ + +function AISubtitleTranslationCard() { + const { data: settings } = useAdminServerSettings(); + const { data: sensitive } = useAdminSensitiveStatus(); + const updateSetting = useUpdateServerSetting(); + + const apiKeyConfigured = new Set(sensitive?.configured ?? []).has("subtitle_ai.api_key"); + + const [enabled, setEnabled] = useState("false"); + const [baseUrl, setBaseUrl] = useState(""); + const [chatModel, setChatModel] = useState(""); + const [maxConcurrent, setMaxConcurrent] = useState("2"); + const [apiKey, setApiKey] = useState(""); + + // Hydrate the form from current server settings once loaded. + useEffect(() => { + if (!settings) return; + setEnabled(settings["subtitle_ai.enabled"] ?? "false"); + setBaseUrl(settings["subtitle_ai.base_url"] ?? "https://api.openai.com"); + setChatModel(settings["subtitle_ai.chat_model"] ?? "gpt-4o-mini"); + setMaxConcurrent(settings["subtitle_ai.max_concurrent_jobs"] ?? "2"); + }, [settings]); + + function save() { + const trimmedBaseUrl = baseUrl.trim(); + const trimmedChatModel = chatModel.trim(); + const parsedMaxConcurrent = Number.parseInt(maxConcurrent, 10); + + // Don't let an admin persist a config that would break translation for + // everyone (a blank endpoint/model when enabled, or a bad concurrency value). + if (enabled === "true" && (trimmedBaseUrl === "" || trimmedChatModel === "")) { + toast.error("Base URL and chat model are required to enable AI translation."); + return; + } + if (!Number.isInteger(parsedMaxConcurrent) || parsedMaxConcurrent < 1) { + toast.error("Max concurrent jobs must be a positive whole number."); + return; + } + + const updates = [ + updateSetting.mutateAsync({ key: "subtitle_ai.enabled", value: enabled }), + updateSetting.mutateAsync({ key: "subtitle_ai.base_url", value: trimmedBaseUrl }), + updateSetting.mutateAsync({ key: "subtitle_ai.chat_model", value: trimmedChatModel }), + updateSetting.mutateAsync({ + key: "subtitle_ai.max_concurrent_jobs", + value: String(parsedMaxConcurrent), + }), + ]; + if (apiKey.trim() !== "") { + updates.push(updateSetting.mutateAsync({ key: "subtitle_ai.api_key", value: apiKey })); + } + void Promise.all(updates).then(() => setApiKey("")); + } + + return ( +
+
+
+

AI Subtitle Translation

+

+ On-demand subtitle translation via any OpenAI-compatible chat API (OpenAI, Groq, a local + Ollama server, …). Translated tracks are generated once on the server and served to + every client. +

+
+ +
+ + + + + +
+ +

+ Changes take effect after a server restart. +

+
+
+ ); +} + +export default function SubtitlesSettings() { + return ( +
+
+

Subtitles

+

+ Search providers for downloading subtitles and AI translation for generating new language + tracks. +

+
+ +
+ + +
+
+ ); +} diff --git a/web/src/pages/admin-settings/WatchProvidersSettings.tsx b/web/src/pages/admin-settings/WatchProvidersSettings.tsx new file mode 100644 index 00000000..5e8f4946 --- /dev/null +++ b/web/src/pages/admin-settings/WatchProvidersSettings.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { useAdminSensitiveStatus, useUpdateServerSetting } from "@/hooks/queries/admin/settings"; + +import { Button } from "@/components/ui/button"; +import { CredentialStatus } from "./CredentialStatus"; +import { SettingField } from "./SettingField"; + +interface WatchProviderCredentials { + key: string; + displayName: string; +} + +const WATCH_PROVIDER_CREDENTIALS: WatchProviderCredentials[] = [ + { key: "trakt", displayName: "Trakt" }, + { key: "simkl", displayName: "Simkl" }, +]; + +function WatchProviderCredentialCard({ provider }: { provider: WatchProviderCredentials }) { + const { data: sensitive } = useAdminSensitiveStatus(); + const updateSetting = useUpdateServerSetting(); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const configured = new Set(sensitive?.configured ?? []); + const clientIdKey = `watchsync.${provider.key}.client_id`; + const clientSecretKey = `watchsync.${provider.key}.client_secret`; + + function save() { + const updates = []; + if (clientId.trim() !== "") { + updates.push(updateSetting.mutateAsync({ key: clientIdKey, value: clientId })); + } + if (clientSecret.trim() !== "") { + updates.push( + updateSetting.mutateAsync({ + key: clientSecretKey, + value: clientSecret, + }), + ); + } + void Promise.all(updates).then(() => { + setClientId(""); + setClientSecret(""); + }); + } + + return ( +
+
+
+

{provider.displayName}

+

+ OAuth credentials for profile connections. +

+
+ +
+ + + +
+ ); +} + +export default function WatchProvidersSettings() { + return ( +
+
+

Watch Providers

+

+ OAuth credentials for watch history and scrobbling services. Users connect their own + accounts from their profile settings once a provider is configured here. +

+
+ +
+ {WATCH_PROVIDER_CREDENTIALS.map((provider) => ( + + ))} +
+
+ ); +}