diff --git a/web/src/components/admin/libraries/LibraryEditorDialog.tsx b/web/src/components/admin/libraries/LibraryEditorDialog.tsx new file mode 100644 index 00000000..825b4038 --- /dev/null +++ b/web/src/components/admin/libraries/LibraryEditorDialog.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Database, FolderOpen, Settings2, SlidersHorizontal } from "lucide-react"; + +import type { Library } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +import { AdvancedFields, FolderFields, GeneralFields, MetadataFields } from "./LibraryFormSections"; +import { libraryTypeMeta } from "./libraryTypes"; +import { LibraryPosterSection } from "./LibraryPosterSection"; +import { useLibraryForm } from "./useLibraryForm"; +import type { LibraryFormErrors } from "./useLibraryForm"; + +type SectionId = "general" | "folders" | "metadata" | "advanced"; + +const SECTIONS: Array<{ + id: SectionId; + label: string; + icon: typeof Settings2; + title: string; + description: string; +}> = [ + { + id: "general", + label: "General", + icon: Settings2, + title: "General", + description: "Name this library and choose the kind of media it holds.", + }, + { + id: "folders", + label: "Folders", + icon: FolderOpen, + title: "Folders", + description: "Silo scans these folders for media and watches them for changes.", + }, + { + id: "metadata", + label: "Metadata", + icon: Database, + title: "Metadata", + description: "Control where artwork and descriptions come from, and in which language.", + }, + { + id: "advanced", + label: "Advanced", + icon: SlidersHorizontal, + title: "Advanced", + description: "Optional background processing for this library.", + }, +]; + +function sectionForErrors(errors: LibraryFormErrors): SectionId | null { + if (errors.name) return "general"; + if (errors.paths) return "folders"; + return null; +} + +export interface LibraryEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + library: Library | null; + chapterThumbnailsSupported: boolean; +} + +export function LibraryEditorDialog({ + open, + onOpenChange, + library, + chapterThumbnailsSupported, +}: LibraryEditorDialogProps) { + return ( + + + onOpenChange(false)} + /> + + + ); +} + +function LibraryEditorBody({ + library, + chapterThumbnailsSupported, + onClose, +}: { + library: Library | null; + chapterThumbnailsSupported: boolean; + onClose: () => void; +}) { + const [section, setSection] = useState("general"); + const form = useLibraryForm({ library, onClose }); + + const typeMeta = libraryTypeMeta(form.type); + const folderCount = form.paths.filter((p) => p.trim()).length; + const errorSections = new Set(); + if (form.errors.name) errorSections.add("general"); + if (form.errors.paths) errorSections.add("folders"); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + const result = form.submit(); + if (!result.ok) { + const target = sectionForErrors(result.errors); + if (target) setSection(target); + } + } + + return ( +
+ +
+
+ +
+
+ {library ? "Edit Library" : "Add Library"} + + {library + ? `Configure how “${library.name}” is scanned and matched.` + : "Set up a new library from folders on your server."} + +
+
+
+ + setSection(value as SectionId)} + orientation="vertical" + className="min-h-0 flex-1 gap-0" + > +
+ + {SECTIONS.map(({ id, label, icon: Icon }) => ( + + + {label} + {errorSections.has(id) ? ( + + ) : id === "folders" && folderCount > 0 ? ( + + {folderCount} + + ) : null} + + ))} + +
+
+ {SECTIONS.map(({ id, title, description }) => ( + +
+

{title}

+

{description}

+
+ {id === "general" && ( + : null} + /> + )} + {id === "folders" && } + {id === "metadata" && } + {id === "advanced" && ( + + )} +
+ ))} +
+
+ +
+ {errorSections.size > 0 ? ( +

+ {[form.errors.name, form.errors.paths].filter(Boolean).join(" ")} +

+ ) : null} + + + + +
+
+ ); +} diff --git a/web/src/components/admin/libraries/LibraryForm.tsx b/web/src/components/admin/libraries/LibraryForm.tsx index cf946bf4..ad39b515 100644 --- a/web/src/components/admin/libraries/LibraryForm.tsx +++ b/web/src/components/admin/libraries/LibraryForm.tsx @@ -1,52 +1,10 @@ -import { useMemo, useState } from "react"; import type { FormEvent, ReactNode } from "react"; -import { - ArrowDown, - ArrowUp, - ChevronDown, - ChevronRight, - FolderOpen, - Plus, - Trash2, -} from "lucide-react"; -import type { CreateLibraryRequest, Library } from "@/api/types"; +import type { Library } from "@/api/types"; import { Button } from "@/components/ui/button"; -import FolderBrowser from "@/components/FolderBrowser"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import PathAutocompleteInput from "@/components/PathAutocompleteInput"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { - useCreateLibrary, - useLibraryProviders, - useSetLibraryProviders, - useUpdateLibrary, -} from "@/hooks/queries/admin/libraries"; -import { useAdminPlugins } from "@/hooks/queries/admin/plugins"; -import { cn } from "@/lib/utils"; -import { LANGUAGES } from "@/player/utils/languageNames"; -type LevelChainItem = { - plugin_installation_id: number; - capability_id: string; - provider_slug: string; - enabled: boolean; -}; - -type MetadataProvider = { - plugin_installation_id: number; - capability_id: string; - slug: string; - defaultPriority: Record; -}; +import { AdvancedFields, FolderFields, GeneralFields, MetadataFields } from "./LibraryFormSections"; +import { useLibraryForm } from "./useLibraryForm"; export interface LibraryFormProps { library: Library | null; @@ -56,189 +14,24 @@ export interface LibraryFormProps { resetAfterCreate?: boolean; submitLabel?: string; savingLabel?: string; - extraContent?: ReactNode; } -function contentLevelsForType(libraryType: string): string[] { - switch (libraryType) { - case "series": - return ["series", "season", "episode"]; - case "movies": - return ["movie"]; - case "mixed": - return ["movie", "series", "season", "episode"]; - case "audiobooks": - return ["audiobook"]; - case "podcasts": - return ["podcast", "podcast_episode"]; - default: - return []; - } -} - -function buildDefaultLevelChains( - metadataProviders: MetadataProvider[], - libraryType: string, -): Record { - const defaultChain: Record = {}; - for (const level of contentLevelsForType(libraryType)) { - const sorted = [...metadataProviders].sort((a, b) => { - const pa = a.defaultPriority[level] ?? 0; - const pb = b.defaultPriority[level] ?? 0; - if ((pa === 0) !== (pb === 0)) return pa === 0 ? 1 : -1; - return pa - pb; - }); - defaultChain[level] = sorted.map((provider) => ({ - plugin_installation_id: provider.plugin_installation_id, - capability_id: provider.capability_id, - provider_slug: provider.slug, - enabled: (provider.defaultPriority[level] ?? 0) > 0, - })); - } - return defaultChain; -} - -function buildLevelChainsFromServer( - currentChain: { - levels?: Record< - string, - Array<{ - plugin_installation_id: number; - capability_id: string; - provider_slug: string; - enabled: boolean; - }> - >; - } | null, - metadataProviders: MetadataProvider[], - libraryType: string, -): Record { - const mapped: Record = {}; - if (currentChain?.levels) { - for (const [level, entries] of Object.entries(currentChain.levels)) { - mapped[level] = entries.map((entry) => ({ - plugin_installation_id: entry.plugin_installation_id, - capability_id: entry.capability_id, - provider_slug: entry.provider_slug, - enabled: entry.enabled, - })); - } - } - - const defaults = buildDefaultLevelChains(metadataProviders, libraryType); - for (const level of contentLevelsForType(libraryType)) { - if (!mapped[level] || mapped[level].length === 0) { - mapped[level] = defaults[level] ?? []; - } - } - return mapped; -} - -function contentLevelLabel(level: string): string { - return level - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function buildProviderChainBody(activeLevelChains: Record) { - return { - levels: Object.fromEntries( - Object.entries(activeLevelChains).map(([level, items]) => [ - level, - items.map((item, i) => ({ - plugin_installation_id: item.plugin_installation_id, - capability_id: item.capability_id, - priority: i, - enabled: item.enabled, - })), - ]), - ), - }; -} - -function ProviderLevelSection({ - level, - items, - onReorder, - onToggleEnabled, -}: { - level: string; - items: LevelChainItem[]; - onReorder: (items: LevelChainItem[]) => void; - onToggleEnabled: (index: number) => void; -}) { - const [collapsed, setCollapsed] = useState(false); - - const moveItem = (index: number, direction: -1 | 1) => { - const newItems = [...items]; - const target = index + direction; - if (target < 0 || target >= newItems.length) return; - [newItems[index], newItems[target]] = [newItems[target]!, newItems[index]!]; - onReorder(newItems); - }; - +function FormSection({ title, children }: { title: string; children: ReactNode }) { return ( -
- - {!collapsed && ( -
- {items.map((item, i) => ( -
- onToggleEnabled(i)} - className="h-3.5 w-3.5" - style={{ accentColor: "var(--primary)" }} - /> - {item.provider_slug} -
- - -
- {i + 1} -
- ))} -
- )} -
+
+

+ {title} +

+ {children} +
); } +/** + * Inline (non-dialog) library form used by the setup wizard. The admin + * Libraries page uses LibraryEditorDialog, which renders the same sections + * behind a left-hand navigation rail. + */ export function LibraryForm({ library, chapterThumbnailsSupported, @@ -247,324 +40,30 @@ export function LibraryForm({ resetAfterCreate = false, submitLabel = "Save", savingLabel = "Saving...", - extraContent, }: LibraryFormProps) { - const [name, setName] = useState(library?.name ?? ""); - const [paths, setPaths] = useState(library?.paths?.length ? library.paths : [""]); - const [type, setType] = useState(library?.type ?? "movies"); - const [enabled, setEnabled] = useState(library?.enabled ?? true); - const [metadataLanguage, setMetadataLanguage] = useState(library?.metadata_language ?? "en"); - const [chapterThumbnailsEnabled, setChapterThumbnailsEnabled] = useState( - library?.chapter_thumbnails_enabled ?? false, - ); - const [introDetectionEnabled, setIntroDetectionEnabled] = useState( - library?.intro_detection_enabled ?? false, - ); - const [levelChains, setLevelChains] = useState>({}); - const [chainDirty, setChainDirty] = useState(false); - const [browserOpen, setBrowserOpen] = useState(false); + const form = useLibraryForm({ library, onClose, onSaved, resetAfterCreate }); - const createMutation = useCreateLibrary(); - const updateMutation = useUpdateLibrary(); - const setChainMutation = useSetLibraryProviders(); - const { installations } = useAdminPlugins(); - const { data: currentChain } = useLibraryProviders(library?.id ?? null); - - const metadataProviders = useMemo(() => { - const result: MetadataProvider[] = []; - for (const inst of installations) { - if (!inst.enabled) continue; - for (const cap of inst.capabilities ?? []) { - if (cap.type === "metadata_provider.v1") { - const dp = - (cap.metadata?.default_priority as Record) ?? - ((cap.metadata?.metadata as Record)?.default_priority as Record< - string, - number - >) ?? - {}; - result.push({ - plugin_installation_id: inst.id, - capability_id: cap.id, - slug: cap.display_name || cap.id, - defaultPriority: dp, - }); - } - } - } - return result; - }, [installations]); - - const isPending = - createMutation.isPending || updateMutation.isPending || setChainMutation.isPending; - - const defaultLevelChains = useMemo( - () => buildDefaultLevelChains(metadataProviders, type), - [metadataProviders, type], - ); - const resolvedLevelChains = useMemo(() => { - if (!library) { - return defaultLevelChains; - } - if (currentChain === undefined) { - return levelChains; - } - return buildLevelChainsFromServer(currentChain, metadataProviders, type); - }, [currentChain, defaultLevelChains, levelChains, library, metadataProviders, type]); - const activeLevelChains = chainDirty ? levelChains : resolvedLevelChains; - - function updatePath(index: number, value: string) { - const next = [...paths]; - next[index] = value; - setPaths(next); - } - - function addPath() { - setPaths([...paths, ""]); - } - - function removePath(index: number) { - setPaths(paths.filter((_, i) => i !== index)); - } - - function handleBrowseSelect(selectedPaths: string[]) { - const merged = [...paths.filter((path) => path.trim())]; - for (const selectedPath of selectedPaths) { - if (!merged.includes(selectedPath)) { - merged.push(selectedPath); - } - } - setPaths(merged.length > 0 ? merged : [""]); - setBrowserOpen(false); - } - - function handleTypeChange(newType: string) { - setType(newType); - if (!library) { - setLevelChains(buildDefaultLevelChains(metadataProviders, newType)); - setChainDirty(true); - } - } - - function finishCreate(created: Library) { - onSaved?.(created); - if (resetAfterCreate) { - setName(""); - setPaths([""]); - } else { - onClose?.(); - } - } - - async function handleSubmit(e: FormEvent) { + function handleSubmit(e: FormEvent) { e.preventDefault(); - const filteredPaths = paths.filter((p) => p.trim()); - if (filteredPaths.length === 0) return; - - const body: CreateLibraryRequest = { - name, - paths: filteredPaths, - type, - enabled, - metadata_language: metadataLanguage, - chapter_thumbnails_enabled: chapterThumbnailsEnabled, - intro_detection_enabled: introDetectionEnabled, - }; - - if (library) { - updateMutation.mutate( - { id: library.id, body }, - { - onSuccess: () => { - if (chainDirty) { - setChainMutation.mutate( - { - id: library.id, - body: buildProviderChainBody(activeLevelChains), - }, - { onSuccess: () => onClose?.() }, - ); - } else { - onClose?.(); - } - }, - }, - ); - return; - } - - createMutation.mutate(body, { - onSuccess: (created) => { - if (chainDirty) { - setChainMutation.mutate( - { - id: created.id, - body: buildProviderChainBody(activeLevelChains), - }, - { onSuccess: () => finishCreate(created) }, - ); - } else { - finishCreate(created); - } - }, - }); + form.submit(); } return ( -
-
-
- - setName(e.target.value)} required /> -
-
- - -
-
-
- - {paths.map((p, i) => ( -
- updatePath(i, value)} - placeholder="/mnt/media/movies" - required - /> - {paths.length > 1 && ( - - )} -
- ))} -
- - -
- path.trim())} - /> -
-
-
- - -
-
- - -
-
-
-
-
- -

- Stores chapter preview images in the configured public asset S3 bucket. Chapter - markers and chapter menus still work without thumbnails. -

- {!chapterThumbnailsSupported ? ( -

- Public asset S3 storage is required before this can be enabled. -

- ) : null} -
- -
-
-
-
-
- -

- Runs background audio analysis for episodes in this library. Embedded intro chapters - are used when available. -

-
- -
-
- {extraContent} - - {contentLevelsForType(type).length > 0 && ( -
-

Metadata Providers

- {contentLevelsForType(type).map((level) => { - const items = activeLevelChains[level] ?? []; - return ( - { - setLevelChains({ ...activeLevelChains, [level]: newItems }); - setChainDirty(true); - }} - onToggleEnabled={(index) => { - setLevelChains((prev) => { - const source = prev[level] ?? activeLevelChains[level] ?? []; - const updated = [...source]; - updated[index] = { ...updated[index]!, enabled: !updated[index]!.enabled }; - return { ...prev, [level]: updated }; - }); - setChainDirty(true); - }} - /> - ); - })} -
- )} - -
); diff --git a/web/src/components/admin/libraries/LibraryFormSections.tsx b/web/src/components/admin/libraries/LibraryFormSections.tsx new file mode 100644 index 00000000..b9c2d895 --- /dev/null +++ b/web/src/components/admin/libraries/LibraryFormSections.tsx @@ -0,0 +1,359 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + ChevronRight, + FolderOpen, + FolderSearch, + Plus, + Trash2, +} from "lucide-react"; + +import FolderBrowser from "@/components/FolderBrowser"; +import PathAutocompleteInput from "@/components/PathAutocompleteInput"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { LANGUAGES } from "@/player/utils/languageNames"; + +import { LIBRARY_TYPES } from "./libraryTypes"; +import { contentLevelLabel } from "./useLibraryForm"; +import type { LevelChainItem, LibraryFormController } from "./useLibraryForm"; + +function SettingCard({ + htmlFor, + title, + description, + children, + footer, +}: { + htmlFor?: string; + title: string; + description: string; + children: ReactNode; + footer?: ReactNode; +}) { + return ( +
+
+
+ +

{description}

+ {footer} +
+ {children} +
+
+ ); +} + +export function GeneralFields({ + form, + posterSlot, +}: { + form: LibraryFormController; + posterSlot?: ReactNode; +}) { + return ( +
+
+ + form.setName(e.target.value)} + placeholder="e.g. Movies" + aria-invalid={form.errors.name ? true : undefined} + /> + {form.errors.name ?

{form.errors.name}

: null} +
+
+ +
+ {LIBRARY_TYPES.map(({ value, label, icon: Icon }) => { + const selected = form.type === value; + return ( + + ); + })} +
+ {form.library && form.type !== form.library.type ? ( +

+ Changing the type of an existing library may require a full rescan to rematch items. +

+ ) : null} +
+ + + + {posterSlot} +
+ ); +} + +export function FolderFields({ form }: { form: LibraryFormController }) { + const [browserOpen, setBrowserOpen] = useState(false); + + return ( +
+
+ {form.paths.map((path, i) => ( +
+ + form.updatePath(i, value)} + placeholder="/mnt/media/movies" + aria-invalid={form.errors.paths ? true : undefined} + /> + {form.paths.length > 1 && ( + + )} +
+ ))} +
+ {form.errors.paths ?

{form.errors.paths}

: null} +
+ + +
+ { + form.mergeBrowsedPaths(selected); + setBrowserOpen(false); + }} + existingPaths={form.paths.filter((path) => path.trim())} + /> +
+ ); +} + +function ProviderLevelSection({ + level, + items, + onReorder, + onToggleEnabled, +}: { + level: string; + items: LevelChainItem[]; + onReorder: (items: LevelChainItem[]) => void; + onToggleEnabled: (index: number) => void; +}) { + const [collapsed, setCollapsed] = useState(false); + + const moveItem = (index: number, direction: -1 | 1) => { + const newItems = [...items]; + const target = index + direction; + if (target < 0 || target >= newItems.length) return; + [newItems[index], newItems[target]] = [newItems[target]!, newItems[index]!]; + onReorder(newItems); + }; + + return ( +
+ + {!collapsed && ( +
+ {items.map((item, i) => ( +
+ onToggleEnabled(i)} + className="h-3.5 w-3.5" + style={{ accentColor: "var(--primary)" }} + /> + {item.provider_slug} +
+ + +
+ {i + 1} +
+ ))} +
+ )} +
+ ); +} + +export function MetadataFields({ form }: { form: LibraryFormController }) { + return ( +
+
+ + +

+ Preferred language for titles, summaries, and artwork fetched from providers. +

+
+ {form.contentLevels.length > 0 && ( +
+ +

+ Providers are asked in order from top to bottom. Uncheck a provider to skip it for that + level. +

+ {form.hasMetadataProviders ? ( + form.contentLevels.map((level) => ( + form.reorderLevel(level, newItems)} + onToggleEnabled={(index) => form.toggleLevelProvider(level, index)} + /> + )) + ) : ( +

+ No metadata provider plugins are installed. Install one under Admin → Plugins to fetch + artwork and descriptions. +

+ )} +
+ )} +
+ ); +} + +export function AdvancedFields({ + form, + chapterThumbnailsSupported, +}: { + form: LibraryFormController; + chapterThumbnailsSupported: boolean; +}) { + return ( +
+ + Public asset S3 storage is required before this can be enabled. +

+ ) : null + } + > + +
+ + + +
+ ); +} diff --git a/web/src/components/admin/libraries/LibraryPosterSection.tsx b/web/src/components/admin/libraries/LibraryPosterSection.tsx new file mode 100644 index 00000000..cd945dac --- /dev/null +++ b/web/src/components/admin/libraries/LibraryPosterSection.tsx @@ -0,0 +1,73 @@ +import type { ChangeEvent } from "react"; +import { ImageIcon, Trash2 } from "lucide-react"; + +import type { Library } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { useDeleteLibraryPoster, useUploadLibraryPoster } from "@/hooks/queries/admin/libraries"; + +export function LibraryPosterSection({ library }: { library: Library }) { + const uploadMutation = useUploadLibraryPoster(); + const deleteMutation = useDeleteLibraryPoster(); + const fileInputId = `poster-upload-${library.id}`; + + function handleFileChange(e: ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + uploadMutation.mutate({ id: library.id, file }); + e.target.value = ""; + } + + return ( +
+ +
+ {library.poster_url ? ( + {`${library.name} + ) : ( +
+ +
+ )} + + + {library.poster_url && ( + + )} +
+
+ ); +} diff --git a/web/src/components/admin/libraries/libraryTypes.ts b/web/src/components/admin/libraries/libraryTypes.ts new file mode 100644 index 00000000..30f1c406 --- /dev/null +++ b/web/src/components/admin/libraries/libraryTypes.ts @@ -0,0 +1,13 @@ +import { BookHeadphones, Film, Layers, Podcast, Tv } from "lucide-react"; + +export const LIBRARY_TYPES = [ + { value: "movies", label: "Movies", icon: Film }, + { value: "series", label: "Series", icon: Tv }, + { value: "mixed", label: "Mixed", icon: Layers }, + { value: "audiobooks", label: "Audiobooks", icon: BookHeadphones }, + { value: "podcasts", label: "Podcasts", icon: Podcast }, +] as const; + +export function libraryTypeMeta(type: string) { + return LIBRARY_TYPES.find((t) => t.value === type) ?? LIBRARY_TYPES[0]; +} diff --git a/web/src/components/admin/libraries/useLibraryForm.ts b/web/src/components/admin/libraries/useLibraryForm.ts new file mode 100644 index 00000000..4024ede1 --- /dev/null +++ b/web/src/components/admin/libraries/useLibraryForm.ts @@ -0,0 +1,358 @@ +import { useMemo, useState } from "react"; + +import type { CreateLibraryRequest, Library } from "@/api/types"; +import { + useCreateLibrary, + useLibraryProviders, + useSetLibraryProviders, + useUpdateLibrary, +} from "@/hooks/queries/admin/libraries"; +import { useAdminPlugins } from "@/hooks/queries/admin/plugins"; + +export type LevelChainItem = { + plugin_installation_id: number; + capability_id: string; + provider_slug: string; + enabled: boolean; +}; + +type MetadataProvider = { + plugin_installation_id: number; + capability_id: string; + slug: string; + defaultPriority: Record; +}; + +export interface LibraryFormErrors { + name?: string; + paths?: string; +} + +export interface UseLibraryFormOptions { + library: Library | null; + onClose?: () => void; + onSaved?: (library: Library) => void; + resetAfterCreate?: boolean; +} + +export function contentLevelsForType(libraryType: string): string[] { + switch (libraryType) { + case "series": + return ["series", "season", "episode"]; + case "movies": + return ["movie"]; + case "mixed": + return ["movie", "series", "season", "episode"]; + case "audiobooks": + return ["audiobook"]; + case "podcasts": + return ["podcast", "podcast_episode"]; + default: + return []; + } +} + +export function contentLevelLabel(level: string): string { + return level + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function buildDefaultLevelChains( + metadataProviders: MetadataProvider[], + libraryType: string, +): Record { + const defaultChain: Record = {}; + for (const level of contentLevelsForType(libraryType)) { + const sorted = [...metadataProviders].sort((a, b) => { + const pa = a.defaultPriority[level] ?? 0; + const pb = b.defaultPriority[level] ?? 0; + if ((pa === 0) !== (pb === 0)) return pa === 0 ? 1 : -1; + return pa - pb; + }); + defaultChain[level] = sorted.map((provider) => ({ + plugin_installation_id: provider.plugin_installation_id, + capability_id: provider.capability_id, + provider_slug: provider.slug, + enabled: (provider.defaultPriority[level] ?? 0) > 0, + })); + } + return defaultChain; +} + +function buildLevelChainsFromServer( + currentChain: { + levels?: Record< + string, + Array<{ + plugin_installation_id: number; + capability_id: string; + provider_slug: string; + enabled: boolean; + }> + >; + } | null, + metadataProviders: MetadataProvider[], + libraryType: string, +): Record { + const mapped: Record = {}; + if (currentChain?.levels) { + for (const [level, entries] of Object.entries(currentChain.levels)) { + mapped[level] = entries.map((entry) => ({ + plugin_installation_id: entry.plugin_installation_id, + capability_id: entry.capability_id, + provider_slug: entry.provider_slug, + enabled: entry.enabled, + })); + } + } + + const defaults = buildDefaultLevelChains(metadataProviders, libraryType); + for (const level of contentLevelsForType(libraryType)) { + if (!mapped[level] || mapped[level].length === 0) { + mapped[level] = defaults[level] ?? []; + } + } + return mapped; +} + +function buildProviderChainBody(activeLevelChains: Record) { + return { + levels: Object.fromEntries( + Object.entries(activeLevelChains).map(([level, items]) => [ + level, + items.map((item, i) => ({ + plugin_installation_id: item.plugin_installation_id, + capability_id: item.capability_id, + priority: i, + enabled: item.enabled, + })), + ]), + ), + }; +} + +export function useLibraryForm({ + library, + onClose, + onSaved, + resetAfterCreate = false, +}: UseLibraryFormOptions) { + const [name, setName] = useState(library?.name ?? ""); + const [paths, setPaths] = useState(library?.paths?.length ? library.paths : [""]); + const [type, setType] = useState(library?.type ?? "movies"); + const [enabled, setEnabled] = useState(library?.enabled ?? true); + const [metadataLanguage, setMetadataLanguage] = useState(library?.metadata_language ?? "en"); + const [chapterThumbnailsEnabled, setChapterThumbnailsEnabled] = useState( + library?.chapter_thumbnails_enabled ?? false, + ); + const [introDetectionEnabled, setIntroDetectionEnabled] = useState( + library?.intro_detection_enabled ?? false, + ); + const [levelChains, setLevelChains] = useState>({}); + const [chainDirty, setChainDirty] = useState(false); + const [submitAttempted, setSubmitAttempted] = useState(false); + + const createMutation = useCreateLibrary(); + const updateMutation = useUpdateLibrary(); + const setChainMutation = useSetLibraryProviders(); + const { installations } = useAdminPlugins(); + const { data: currentChain } = useLibraryProviders(library?.id ?? null); + + const metadataProviders = useMemo(() => { + const result: MetadataProvider[] = []; + for (const inst of installations) { + if (!inst.enabled) continue; + for (const cap of inst.capabilities ?? []) { + if (cap.type === "metadata_provider.v1") { + const dp = + (cap.metadata?.default_priority as Record) ?? + ((cap.metadata?.metadata as Record)?.default_priority as Record< + string, + number + >) ?? + {}; + result.push({ + plugin_installation_id: inst.id, + capability_id: cap.id, + slug: cap.display_name || cap.id, + defaultPriority: dp, + }); + } + } + } + return result; + }, [installations]); + + const isPending = + createMutation.isPending || updateMutation.isPending || setChainMutation.isPending; + + const defaultLevelChains = useMemo( + () => buildDefaultLevelChains(metadataProviders, type), + [metadataProviders, type], + ); + const resolvedLevelChains = useMemo(() => { + if (!library) { + return defaultLevelChains; + } + if (currentChain === undefined) { + return levelChains; + } + return buildLevelChainsFromServer(currentChain, metadataProviders, type); + }, [currentChain, defaultLevelChains, levelChains, library, metadataProviders, type]); + const activeLevelChains = chainDirty ? levelChains : resolvedLevelChains; + + const allErrors = useMemo(() => { + const next: LibraryFormErrors = {}; + if (!name.trim()) next.name = "Give this library a name."; + if (!paths.some((p) => p.trim())) next.paths = "Add at least one folder to scan."; + return next; + }, [name, paths]); + const errors: LibraryFormErrors = submitAttempted ? allErrors : {}; + + function updatePath(index: number, value: string) { + const next = [...paths]; + next[index] = value; + setPaths(next); + } + + function addPath() { + setPaths([...paths, ""]); + } + + function removePath(index: number) { + setPaths(paths.filter((_, i) => i !== index)); + } + + function mergeBrowsedPaths(selectedPaths: string[]) { + const merged = [...paths.filter((path) => path.trim())]; + for (const selectedPath of selectedPaths) { + if (!merged.includes(selectedPath)) { + merged.push(selectedPath); + } + } + setPaths(merged.length > 0 ? merged : [""]); + } + + function handleTypeChange(newType: string) { + setType(newType); + if (!library) { + setLevelChains(buildDefaultLevelChains(metadataProviders, newType)); + setChainDirty(true); + } + } + + function reorderLevel(level: string, items: LevelChainItem[]) { + setLevelChains({ ...activeLevelChains, [level]: items }); + setChainDirty(true); + } + + function toggleLevelProvider(level: string, index: number) { + setLevelChains((prev) => { + const source = prev[level] ?? activeLevelChains[level] ?? []; + const updated = [...source]; + updated[index] = { ...updated[index]!, enabled: !updated[index]!.enabled }; + return { ...prev, [level]: updated }; + }); + setChainDirty(true); + } + + function finishCreate(created: Library) { + onSaved?.(created); + if (resetAfterCreate) { + setName(""); + setPaths([""]); + setSubmitAttempted(false); + } else { + onClose?.(); + } + } + + function submit(): { ok: boolean; errors: LibraryFormErrors } { + setSubmitAttempted(true); + if (allErrors.name || allErrors.paths) { + return { ok: false, errors: allErrors }; + } + + const body: CreateLibraryRequest = { + name: name.trim(), + paths: paths.filter((p) => p.trim()), + type, + enabled, + metadata_language: metadataLanguage, + chapter_thumbnails_enabled: chapterThumbnailsEnabled, + intro_detection_enabled: introDetectionEnabled, + }; + + if (library) { + updateMutation.mutate( + { id: library.id, body }, + { + onSuccess: () => { + if (chainDirty) { + setChainMutation.mutate( + { + id: library.id, + body: buildProviderChainBody(activeLevelChains), + }, + { onSuccess: () => onClose?.() }, + ); + } else { + onClose?.(); + } + }, + }, + ); + return { ok: true, errors: {} }; + } + + createMutation.mutate(body, { + onSuccess: (created) => { + if (chainDirty) { + setChainMutation.mutate( + { + id: created.id, + body: buildProviderChainBody(activeLevelChains), + }, + { onSuccess: () => finishCreate(created) }, + ); + } else { + finishCreate(created); + } + }, + }); + return { ok: true, errors: {} }; + } + + return { + library, + name, + setName, + paths, + updatePath, + addPath, + removePath, + mergeBrowsedPaths, + type, + handleTypeChange, + enabled, + setEnabled, + metadataLanguage, + setMetadataLanguage, + chapterThumbnailsEnabled, + setChapterThumbnailsEnabled, + introDetectionEnabled, + setIntroDetectionEnabled, + contentLevels: contentLevelsForType(type), + activeLevelChains, + reorderLevel, + toggleLevelProvider, + hasMetadataProviders: metadataProviders.length > 0, + errors, + isPending, + submit, + }; +} + +export type LibraryFormController = ReturnType; diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx index 14170626..5e7ef3b3 100644 --- a/web/src/pages/AdminLibraries.tsx +++ b/web/src/pages/AdminLibraries.tsx @@ -29,15 +29,13 @@ import { useRefreshLibraryMetadata, useCancelAdminJob, useConfirmEmptyRootCleanup, - useUploadLibraryPoster, - useDeleteLibraryPoster, useUnmatchedLibraryItems, UNMATCHED_PAGE_SIZE, } from "@/hooks/queries/admin/libraries"; import { useActiveScans } from "@/hooks/queries/admin/scans"; import { buildLibraryReorderEntries } from "./adminLibraryOrder"; import MatchItemDialog from "@/components/MatchItemDialog"; -import { LibraryForm } from "@/components/admin/libraries/LibraryForm"; +import { LibraryEditorDialog } from "@/components/admin/libraries/LibraryEditorDialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -56,7 +54,6 @@ import { DialogHeader, DialogDescription, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { @@ -78,7 +75,6 @@ import { GripVertical, Wrench, HardDrive, - ImageIcon, ChevronLeft, ChevronRight, ChevronsLeft, @@ -348,48 +344,28 @@ export default function AdminLibraries() { Catalog Maintenance - { + setEditingLib(null); + setDialogOpen(true); + }} + > + Add Library + + { setDialogOpen(open); if (!open) setEditingLib(null); }} - > - - - - - - {editingLib ? "Edit Library" : "Add Library"} - - Configure scan roots, metadata sources, and optional chapter thumbnails for this - library. - - - { - setDialogOpen(false); - setEditingLib(null); - }} - extraContent={ - editingLib ? ( -
- -
- ) : null - } - /> -
-
+ library={editingLib} + chapterThumbnailsSupported={ + editingLib?.chapter_thumbnails_supported ?? + libraries[0]?.chapter_thumbnails_supported ?? + true + } + /> @@ -2082,69 +2058,3 @@ function StaleIDsSection({ staleIDs }: { staleIDs: StaleMediaID[] }) { ); } - -function LibraryPosterSection({ library }: { library: Library }) { - const uploadMutation = useUploadLibraryPoster(); - const deleteMutation = useDeleteLibraryPoster(); - const fileInputId = `poster-upload-${library.id}`; - - function handleFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - uploadMutation.mutate({ id: library.id, file }); - e.target.value = ""; - } - - return ( -
- -
- {library.poster_url ? ( - {`${library.name} - ) : ( -
- -
- )} - - - {library.poster_url && ( - - )} -
-
- ); -}