import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useDebounce } from "@/hooks/useDebounce"; import { useEventChannel } from "@/components/realtimeEventsContext"; import type { AdminJob, Library, LibraryMountCheckResponse, LibraryRoot, LibrarySkippedRoot, ScanRun, StaleMediaID, UnmatchedLibraryItem, } from "@/api/types"; import { useAdminLibraries, useCancelLibraryScans, useReorderLibraries, useSkippedLibraryRoots, useLibraryRoots, useUpsertLibraryRootOverride, useDeleteLibraryRootOverride, useStaleMediaIDs, useCheckLibraryMount, useDeleteLibrary, useScanLibrary, useScanAllLibraries, useLibraryRefreshJobs, useRefreshLibraryMetadata, useCancelAdminJob, useConfirmEmptyRootCleanup, 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 { LibraryEditorDialog } from "@/components/admin/libraries/LibraryEditorDialog"; import { MetadataMatcherQueuesSection } from "@/components/admin/libraries/MetadataMatcherQueuesSection"; import { CollapsibleDiagnosticsSection } from "@/components/admin/CollapsibleDiagnosticsSection"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Dialog, DialogContent, DialogHeader, DialogDescription, DialogTitle, } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Link } from "react-router"; import { Plus, Pencil, Trash2, RefreshCw, ScanLine, DatabaseBackup, CheckCircle2, GripVertical, Wrench, HardDrive, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronUp, ChevronDown, AlertTriangle, Square, Unlink, Search, FolderOpen, } from "lucide-react"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { getLibraryRefreshLibraryID } from "@/lib/adminJobs"; import { compareActiveScans, formatActiveScanMode, formatActiveScanProgress, formatActiveScanTarget, formatActiveScanTime, formatActiveScanTrigger, } from "@/lib/scanRuns"; import { DndContext, DragOverlay, PointerSensor, KeyboardSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core"; import type { DragStartEvent, DragEndEvent } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove, } from "@dnd-kit/sortable"; import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { formatJobProgress } from "@/components/adminCatalogMaintenanceFormatters"; import { formatDateTime } from "@/lib/datetime"; // Keep toast lifetime and delayed state cleanup coordinated so visible feedback // disappears before state is cleared. const MOUNT_CHECK_FEEDBACK_MS = 5_000; const DEAD_ROOT_WARNING_TEXT = "One or more library roots are unreachable or mounted but returned no files. Their files are hidden, but nothing will be deleted until the root is back or cleanup is confirmed."; const DEAD_ROOT_WARNING_HINT = "Run another scan after storage returns, or use Check Mount to verify connectivity."; const EMPTY_ROOT_WARNING_TEXT = "Scan found 0 media files for this library. Cleanup was paused to avoid accidental deletion."; const EMPTY_ROOT_WARNING_HINT = "Run another scan after storage returns, or confirm deletion before the next empty-root scan."; export default function AdminLibraries() { useEventChannel("scans"); const { data: libraries = [], isLoading } = useAdminLibraries(); const { data: activeScans = [] } = useActiveScans(); const { data: libraryRefreshJobs = [] } = useLibraryRefreshJobs(); const { data: skippedRoots = [] } = useSkippedLibraryRoots(); const { data: staleIDs = [] } = useStaleMediaIDs(); const [dialogOpen, setDialogOpen] = useState(false); const [editingLib, setEditingLib] = useState(null); const [confirmDeleteLib, setConfirmDeleteLib] = useState(null); const [confirmEmptyRootLib, setConfirmEmptyRootLib] = useState(null); const [lastMountCheckByLibraryId, setLastMountCheckByLibraryId] = useState< Record >({}); const mountCheckClearTimeoutsRef = useRef>({}); const deleteMutation = useDeleteLibrary(); const mountCheckMutation = useCheckLibraryMount(); const scanMutation = useScanLibrary(); const scanAllMutation = useScanAllLibraries(); const cancelScansMutation = useCancelLibraryScans(); const cancelAdminJobMutation = useCancelAdminJob(); // DnD reorder state const reorderMutation = useReorderLibraries(); const [orderedLibraries, setOrderedLibraries] = useState(libraries); const [activeId, setActiveId] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); useEffect(() => { setOrderedLibraries(libraries); }, [libraries]); useEffect(() => { return () => { // Cancel pending mount-check cleanup timers so unmount cannot clear state // after the page leaves. for (const timeoutID of Object.values(mountCheckClearTimeoutsRef.current)) { window.clearTimeout(timeoutID); } }; }, []); function handleDragStart(event: DragStartEvent) { setActiveId(event.active.id as number); } function handleDragEnd(event: DragEndEvent) { setActiveId(null); const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = orderedLibraries.findIndex((l) => l.id === active.id); const newIndex = orderedLibraries.findIndex((l) => l.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const next = arrayMove(orderedLibraries, oldIndex, newIndex); const prev = orderedLibraries; setOrderedLibraries(next); reorderMutation.mutate(buildLibraryReorderEntries(next), { onError: () => setOrderedLibraries(prev), }); } function handleDragCancel() { setActiveId(null); } const activeLibrary = activeId != null ? orderedLibraries.find((l) => l.id === activeId) : null; const refreshMutation = useRefreshLibraryMetadata(); const confirmEmptyRootCleanupMutation = useConfirmEmptyRootCleanup(); const activeRefreshJobsByLibraryId = useMemo(() => { const jobsByLibraryID = new Map(); for (const job of libraryRefreshJobs) { if (job.status !== "queued" && job.status !== "running") { continue; } const libraryID = getLibraryRefreshLibraryID(job); if (libraryID === null || jobsByLibraryID.has(libraryID)) { continue; } jobsByLibraryID.set(libraryID, job); } return jobsByLibraryID; }, [libraryRefreshJobs]); const activeScansByLibraryId = useMemo(() => { const scansByLibraryID = new Map(); for (const scan of activeScans) { const list = scansByLibraryID.get(scan.library_id) ?? []; list.push(scan); scansByLibraryID.set(scan.library_id, list); } for (const scans of scansByLibraryID.values()) { scans.sort(compareActiveScans); } return scansByLibraryID; }, [activeScans]); const activeScanGroups = useMemo(() => { return Array.from(activeScansByLibraryId.entries()) .map(([libraryID, scans]) => { const library = libraries.find((entry) => entry.id === libraryID) ?? null; const runningCount = scans.filter((scan) => scan.status === "running").length; return { libraryID, library, scans, runningCount, queuedCount: scans.length - runningCount, }; }) .sort((left, right) => { if (left.runningCount !== right.runningCount) { return right.runningCount - left.runningCount; } return getLibraryScanGroupName(left.library, left.libraryID).localeCompare( getLibraryScanGroupName(right.library, right.libraryID), ); }); }, [activeScansByLibraryId, libraries]); function handleDelete(lib: Library) { setConfirmDeleteLib(lib); } function handleConfirmEmptyRootCleanup(lib: Library) { setConfirmEmptyRootLib(lib); } function handleMountCheck(libraryId: number) { mountCheckMutation.mutate(libraryId, { onSuccess: (result) => { setLastMountCheckByLibraryId((current) => ({ ...current, [libraryId]: result, })); if (result.healthy) { toast.success(formatMountCheckMessage(result), { duration: MOUNT_CHECK_FEEDBACK_MS }); } else { toast.error(formatMountCheckMessage(result), { duration: MOUNT_CHECK_FEEDBACK_MS }); } const existingTimeout = mountCheckClearTimeoutsRef.current[libraryId]; if (existingTimeout) { window.clearTimeout(existingTimeout); } // Match the toast duration so the inline mount-check result stays visible // for the same window. mountCheckClearTimeoutsRef.current[libraryId] = window.setTimeout(() => { setLastMountCheckByLibraryId((current) => { const next = { ...current }; delete next[libraryId]; return next; }); delete mountCheckClearTimeoutsRef.current[libraryId]; }, MOUNT_CHECK_FEEDBACK_MS); }, }); } if (isLoading) return
Loading libraries...
; return (
{ if (!open) setConfirmDeleteLib(null); }} title="Delete library" description={`Delete library "${confirmDeleteLib?.name}"? This action cannot be undone.`} confirmLabel="Delete" variant="destructive" onConfirm={() => { if (confirmDeleteLib) deleteMutation.mutate(confirmDeleteLib.id); setConfirmDeleteLib(null); }} /> { if (!open) setConfirmEmptyRootLib(null); }} title="Confirm empty root cleanup" description={`On the next scan of "${confirmEmptyRootLib?.name}", remove items from roots that are reachable but still empty? Unreachable roots remain protected.`} confirmLabel="Confirm" variant="destructive" onConfirm={() => { if (confirmEmptyRootLib) confirmEmptyRootCleanupMutation.mutate(confirmEmptyRootLib.id); setConfirmEmptyRootLib(null); }} />

Libraries

Manage library roots and scans. Catalog import/export now lives under Maintenance.

{activeScanGroups.length > 0 && ( cancelScansMutation.mutate(libraryID)} /> )} { setDialogOpen(open); if (!open) setEditingLib(null); }} library={editingLib} chapterThumbnailsSupported={ editingLib?.chapter_thumbnails_supported ?? libraries[0]?.chapter_thumbnails_supported ?? true } />
Name Paths Type Status Last Scanned Actions l.id)} strategy={verticalListSortingStrategy} > {orderedLibraries.map((lib) => { const isScanning = scanMutation.isPending && scanMutation.variables === lib.id; const activeRefreshJob = activeRefreshJobsByLibraryId.get(lib.id); const activeLibraryScans = activeScansByLibraryId.get(lib.id) ?? []; const runningLibraryScans = activeLibraryScans.filter( (scan) => scan.status === "running", ).length; const queuedLibraryScans = activeLibraryScans.length - runningLibraryScans; const isRefreshStarting = refreshMutation.isPending && refreshMutation.variables === lib.id; const isCheckingMount = mountCheckMutation.isPending && mountCheckMutation.variables === lib.id; const mountCheck = lastMountCheckByLibraryId[lib.id]; const hasActiveWork = activeRefreshJob !== undefined || activeLibraryScans.length > 0; const isCancellingLibraryScans = cancelScansMutation.isPending && cancelScansMutation.variables === lib.id; const isCancellingRefreshJob = activeRefreshJob !== undefined && cancelAdminJobMutation.isPending && cancelAdminJobMutation.variables === activeRefreshJob.id; return ( {lib.name} {lib.paths.length === 1 ? ( {lib.paths[0]} ) : ( )} {lib.type}
{lib.enabled ? "Enabled" : "Disabled"} {runningLibraryScans > 0 ? ( {runningLibraryScans} running ) : null} {queuedLibraryScans > 0 ? ( {queuedLibraryScans} queued ) : null} {lib.scan_warning_code === "empty_root" ? ( Empty root guarded ) : null} {lib.scan_warning_code === "dead_root" ? ( Root unreachable ) : null}
{lib.last_scanned_at ? formatDateTime(lib.last_scanned_at) : "Never"}
{lib.scan_warning_at ? (
Warning: {formatDateTime(lib.scan_warning_at)}
) : null}
{lib.scan_warning_code === "empty_root" || lib.scan_warning_code === "dead_root" ? ( ) : null}
{hasActiveWork ? ( cancelAdminJobMutation.mutate(jobID)} onCancelScans={(libraryID) => cancelScansMutation.mutate(libraryID)} /> ) : null}
); })} {orderedLibraries .filter( (lib) => lib.scan_warning_code === "empty_root" || lib.scan_warning_code === "dead_root", ) .map((lib) => { const mountCheck = lastMountCheckByLibraryId[lib.id]; const isCheckingMount = mountCheckMutation.isPending && mountCheckMutation.variables === lib.id; return (
{lib.scan_warning_code === "dead_root" ? DEAD_ROOT_WARNING_TEXT : EMPTY_ROOT_WARNING_TEXT}
{lib.scan_warning_message ?? (lib.scan_warning_code === "dead_root" ? DEAD_ROOT_WARNING_HINT : EMPTY_ROOT_WARNING_HINT)}
{lib.scan_warning_code === "dead_root" ? ( ) : null}
); })}
{activeLibrary ? ( {activeLibrary.name} {activeLibrary.paths.length === 1 ? activeLibrary.paths[0] : `${activeLibrary.paths.length} folders`} {activeLibrary.type}
) : null}
{skippedRoots.length > 0 ? : null} {staleIDs.length > 0 && }
); } function ScanQueuePopover({ groups, cancellingLibraryID, onCancel, }: { groups: Array<{ libraryID: number; library: Library | null; scans: ScanRun[]; runningCount: number; queuedCount: number; }>; cancellingLibraryID: number | null; onCancel: (libraryID: number) => void; }) { const totalRunning = groups.reduce((sum, group) => sum + group.runningCount, 0); const totalQueued = groups.reduce((sum, group) => sum + group.queuedCount, 0); const totalScans = totalRunning + totalQueued; return ( {/* Accent bar */}
{/* Header */}
Scan Queue
{totalRunning > 0 && ( <> {totalRunning} running )} {totalRunning > 0 && totalQueued > 0 && ·} {totalQueued > 0 && ( <> {totalQueued} queued )}
{/* Library groups */}
{groups.map((group, gi) => ( ))}
); } // How many scan rows a group shows before the rest collapse behind an expander. const COLLAPSED_SCAN_ROW_LIMIT = 4; function useCollapsedScans(scans: ScanRun[]) { const [expanded, setExpanded] = useState(false); const visibleScans = expanded ? scans : scans.slice(0, COLLAPSED_SCAN_ROW_LIMIT); return { expanded, setExpanded, visibleScans, hiddenCount: scans.length - visibleScans.length, collapsible: scans.length > COLLAPSED_SCAN_ROW_LIMIT, }; } function ScanQueueGroup({ group, groupIndex, isLast, cancelling, onCancel, }: { group: { libraryID: number; library: Library | null; scans: ScanRun[]; runningCount: number; queuedCount: number; }; groupIndex: number; isLast: boolean; cancelling: boolean; onCancel: (libraryID: number) => void; }) { const { expanded, setExpanded, visibleScans, hiddenCount, collapsible } = useCollapsedScans( group.scans, ); return (
{/* Library header row */}
{getLibraryScanGroupName(group.library, group.libraryID)}
{/* Scan rows */} {visibleScans.map((scan, si) => (
{/* Status dot */}
{scan.status === "running" ? ( <> ) : ( )}
{/* Details */}
{formatActiveScanMode(scan)} {scan.trigger && ( {formatActiveScanTrigger(scan.trigger)} )}
{formatActiveScanTarget(scan)} · {scan.status === "running" ? formatActiveScanTime(scan.started_at, "Started") : "Waiting for capacity"}
{formatActiveScanProgress(scan) && (
{formatActiveScanProgress(scan)}
)}
))} {collapsible ? ( ) : null} {/* Separator between groups */} {!isLast &&
}
); } function getLibraryScanGroupName(library: Library | null, libraryID: number) { return library?.name ?? `Library #${libraryID}`; } function SortableLibraryRow({ id, children }: { id: number; children: React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1, }; return ( {children} ); } function LibraryActiveWorkRow({ activeRefreshJob, activeLibraryScans, cancellingJobID, cancellingLibraryID, libraryID, onCancelJob, onCancelScans, }: { activeRefreshJob: AdminJob | undefined; activeLibraryScans: ScanRun[]; cancellingJobID?: string; cancellingLibraryID?: number; libraryID: number; onCancelJob: (jobID: string) => void; onCancelScans: (libraryID: number) => void; }) { return (
{activeRefreshJob ? (
onCancelJob(activeRefreshJob.id)} />
Metadata {activeRefreshJob.message || "Metadata refresh queued"}
{formatJobProgress(activeRefreshJob)}
) : null} {activeLibraryScans.length > 0 ? ( ) : null}
); } function LibraryScanTasks({ scans, cancelling, libraryID, onCancelScans, }: { scans: ScanRun[]; cancelling: boolean; libraryID: number; onCancelScans: (libraryID: number) => void; }) { const { expanded, setExpanded, visibleScans, collapsible } = useCollapsedScans(scans); const runningCount = scans.filter((scan) => scan.status === "running").length; const queuedCount = scans.length - runningCount; return (
onCancelScans(libraryID)} /> 0 && "animate-spin")} style={{ animationDuration: "2s" }} /> Scan {runningCount > 0 ? `${runningCount} running` : null} {runningCount > 0 && queuedCount > 0 ? " · " : null} {queuedCount > 0 ? `${queuedCount} queued` : null} {collapsible ? ( ) : null}
{visibleScans.map((scan) => ( ))}
); } function CompactScanRow({ scan }: { scan: ScanRun }) { const progress = scan.status === "running" ? formatActiveScanProgress(scan) : ""; return (
{scan.mode !== "file" ? ( {formatActiveScanMode(scan)} ) : null} {scan.path ? ( {formatActiveScanTarget(scan)} ) : ( {formatActiveScanTarget(scan)} )} {progress ? ( · {progress} ) : null}
); } function StopTaskButton({ disabled, label, onClick, }: { disabled?: boolean; label: string; onClick: () => void; }) { return ( ); } function MountCheckButtonIcon({ result, pending, className = "h-3 w-3", }: { result?: LibraryMountCheckResponse; pending: boolean; className?: string; }) { if (pending) { return ; } if (result?.healthy) { return ; } if (result) { return ; } return ; } function formatMountCheckMessage(result: LibraryMountCheckResponse) { const failingRoots = result.roots.filter((root) => !root.reachable); const firstFailure = failingRoots[0]; if (!result.healthy && firstFailure) { const detail = firstFailure.error_message ? ` (${firstFailure.error_message})` : ""; return `${result.summary}: ${firstFailure.path}${detail}`; } return result.summary; } /* ─── Pagination helper ─────────────────────────────────────────── */ const PAGE_SIZE = 10; function usePagination(items: T[], pageSize = PAGE_SIZE) { const [page, setPage] = useState(0); const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); const clamped = Math.min(page, totalPages - 1); const slice = useMemo( () => items.slice(clamped * pageSize, clamped * pageSize + pageSize), [items, clamped, pageSize], ); return { page: clamped, totalPages, total: items.length, rows: slice, setPage, canPrev: clamped > 0, canNext: clamped < totalPages - 1, first: () => setPage(0), prev: () => setPage((p) => Math.max(0, p - 1)), next: () => setPage((p) => Math.min(totalPages - 1, p + 1)), last: () => setPage(totalPages - 1), // 1-indexed display range rangeStart: items.length === 0 ? 0 : clamped * pageSize + 1, rangeEnd: Math.min((clamped + 1) * pageSize, items.length), }; } function PaginationBar({ total, rangeStart, rangeEnd, canPrev, canNext, first, prev, next, last, }: { total: number; rangeStart: number; rangeEnd: number; canPrev: boolean; canNext: boolean; first: () => void; prev: () => void; next: () => void; last: () => void; }) { if (total <= PAGE_SIZE) return null; return (
{rangeStart}–{rangeEnd} of {total}
); } /* ─── Sortable column header ────────────────────────────────────── */ type SortDir = "asc" | "desc"; function SortableHead({ field, activeField, activeDir, onSort, children, className, }: { field: K; activeField: K; activeDir: SortDir; onSort: (field: K) => void; children: React.ReactNode; className?: string; }) { const active = field === activeField; return ( ); } function useSort(defaultField: K, defaultDir: SortDir = "desc") { const [sortField, setSortField] = useState(defaultField); const [sortDir, setSortDir] = useState(defaultDir); const toggle = useCallback( (field: K) => { if (field === sortField) { setSortDir((d) => (d === "asc" ? "desc" : "asc")); } else { setSortField(field); setSortDir("asc"); } }, [sortField], ); return { sortField, sortDir, toggle }; } /* ─── Skipped Roots (Troubleshooting) ───────────────────────────── */ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) { const [open, setOpen] = useState(false); const [selectedLibraryId, setSelectedLibraryId] = useState(libraries[0]?.id); const [search, setSearch] = useState(""); const [editingRoot, setEditingRoot] = useState(null); const effectiveSelectedLibraryId = selectedLibraryId ?? libraries[0]?.id; const { data: roots = [] } = useLibraryRoots(effectiveSelectedLibraryId, "ambiguous"); const filteredRoots = useMemo(() => { if (!search) return roots; const q = search.toLowerCase(); return roots.filter( (root) => root.root_path.toLowerCase().includes(q) || root.title.toLowerCase().includes(q) || (root.sample_file_path ?? "").toLowerCase().includes(q), ); }, [roots, search]); const pag = usePagination(filteredRoots); if (libraries.length === 0) { return null; } return ( } open={open} onOpenChange={setOpen} >
{ setSearch(e.target.value); pag.setPage(0); }} className="h-8 pl-8 text-xs" />
Root Type Confidence Files Actions {filteredRoots.length === 0 ? ( No ambiguous roots for this library. ) : ( pag.rows.map((root) => (
{root.title || root.root_path.split("/").filter(Boolean).pop()}
{root.root_path} {root.evidence_json ? (
{buildRootEvidenceSummary(root)}
) : null}
{root.inferred_type || "unknown"} {root.type_confidence} {root.observed_file_count}
{root.content_id ? ( ) : null}
)) )}
{editingRoot ? ( { if (!open) setEditingRoot(null); }} /> ) : null}
); } function buildRootEvidenceSummary(root: LibraryRoot): string { const evidence = root.evidence_json ?? {}; const parts: string[] = []; if (typeof evidence.has_folder_ids === "boolean") { parts.push(evidence.has_folder_ids ? "folder IDs" : "no folder IDs"); } if (typeof evidence.season_structure_files === "number" && evidence.season_structure_files > 0) { parts.push(`${evidence.season_structure_files} season-structured files`); } if (typeof evidence.movie_evidence_files === "number" && evidence.movie_evidence_files > 0) { parts.push(`${evidence.movie_evidence_files} movie-shaped files`); } if (typeof evidence.wrapper_collapses === "number" && evidence.wrapper_collapses > 0) { parts.push(`${evidence.wrapper_collapses} wrapper collapses`); } if (typeof evidence.ancestor_promotions === "number" && evidence.ancestor_promotions > 0) { parts.push(`${evidence.ancestor_promotions} ancestor promotions`); } return parts.join(" · "); } function RootOverrideDialog({ root, onOpenChange, }: { root: LibraryRoot; onOpenChange: (open: boolean) => void; }) { const upsertOverride = useUpsertLibraryRootOverride(); const deleteOverride = useDeleteLibraryRootOverride(); const [forcedType, setForcedType] = useState( root.active_override?.forced_type || root.inferred_type || "", ); const [forcedTitle, setForcedTitle] = useState( root.active_override?.forced_title || root.title || "", ); const [forcedYear, setForcedYear] = useState( root.active_override?.forced_year ? String(root.active_override.forced_year) : root.year ? String(root.year) : "", ); const [forcedTmdbID, setForcedTmdbID] = useState( root.active_override?.forced_tmdb_id || root.tmdb_id || "", ); const [forcedImdbID, setForcedImdbID] = useState( root.active_override?.forced_imdb_id || root.imdb_id || "", ); const [forcedTvdbID, setForcedTvdbID] = useState( root.active_override?.forced_tvdb_id || root.tvdb_id || "", ); const [note, setNote] = useState(root.active_override?.note || ""); return ( Root Override Force the inferred identity for {root.root_path}.
setForcedYear(e.target.value)} placeholder="2024" />
setForcedTitle(e.target.value)} placeholder="Forced title" />
setForcedTmdbID(e.target.value)} />
setForcedImdbID(e.target.value)} />
setForcedTvdbID(e.target.value)} />
setNote(e.target.value)} placeholder="Why this override exists" />
); } type SkippedSortField = "root_path" | "library" | "reason" | "first_seen" | "last_seen"; function SkippedRootsSection({ skippedRoots }: { skippedRoots: LibrarySkippedRoot[] }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [expandedKey, setExpandedKey] = useState(null); const { sortField, sortDir, toggle } = useSort("last_seen", "desc"); const filtered = useMemo(() => { if (!search) return skippedRoots; const q = search.toLowerCase(); return skippedRoots.filter( (r) => r.root_path.toLowerCase().includes(q) || r.library_name.toLowerCase().includes(q) || r.reason.toLowerCase().includes(q), ); }, [skippedRoots, search]); const sorted = useMemo(() => { const cmp = sortDir === "asc" ? 1 : -1; return [...filtered].sort((a, b) => { switch (sortField) { case "root_path": return cmp * a.root_path.localeCompare(b.root_path); case "library": return cmp * a.library_name.localeCompare(b.library_name); case "reason": return cmp * a.reason.localeCompare(b.reason); case "first_seen": return cmp * a.first_seen_at.localeCompare(b.first_seen_at); case "last_seen": return cmp * a.last_seen_at.localeCompare(b.last_seen_at); default: return 0; } }); }, [filtered, sortField, sortDir]); const pag = usePagination(sorted); return ( } open={open} onOpenChange={setOpen} >
{ setSearch(e.target.value); pag.setPage(0); }} className="h-8 pl-8 text-xs" />
Item Library Reason Files First seen Last seen {pag.rows.map((root) => { const rowKey = `${root.library_id}:${root.root_path}`; const isExpanded = expandedKey === rowKey; return ( setExpandedKey(isExpanded ? null : rowKey)} >
{root.root_path.split("/").filter(Boolean).pop()}
{root.library_name} {root.reason} {root.file_count} {formatDateTime(root.first_seen_at)} {formatDateTime(root.last_seen_at)}
{isExpanded && (
Root path {root.root_path} {root.sample_file_path && ( <> Sample file {root.sample_file_path} )} Files affected {root.file_count}
)}
); })}
); } /* ─── Unmatched Items ──────────────────────────────────────────── */ function UnmatchedItemsSection() { const [open, setOpen] = useState(false); const [page, setPage] = useState(0); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search, 250); const [matchItem, setMatchItem] = useState(null); const { data } = useUnmatchedLibraryItems(page, debouncedSearch); const total = data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / UNMATCHED_PAGE_SIZE)); const clamped = Math.min(page, totalPages - 1); const rangeStart = total === 0 ? 0 : clamped * UNMATCHED_PAGE_SIZE + 1; const rangeEnd = Math.min((clamped + 1) * UNMATCHED_PAGE_SIZE, total); const items = data?.items ?? []; // Hide the section only when there are genuinely no unmatched items and no // active search — keep it mounted while searching so the box and the // "no matches" state stay visible even when a query returns nothing. if (total === 0 && page === 0 && search.trim() === "") return null; return ( } open={open} onOpenChange={setOpen} >
{ // Search is server-side and spans the whole table; jump back to // the first page so results start at the top as the query changes. setSearch(e.target.value); setPage(0); }} className="h-8 pl-8 text-xs" />
Title Library Type Status Actions {items.length === 0 ? ( No unmatched items match your search. ) : ( items.map((u) => ( {u.title} {u.year ? ( ({u.year}) ) : null} {u.library_name} {u.content_type} {u.status} )) )}
{total > UNMATCHED_PAGE_SIZE && ( 0} canNext={clamped < totalPages - 1} first={() => setPage(0)} prev={() => setPage((p) => Math.max(0, p - 1))} next={() => setPage((p) => Math.min(totalPages - 1, p + 1))} last={() => setPage(totalPages - 1)} /> )} {matchItem && ( { if (!open) setMatchItem(null); }} /> )}
); } /* ─── Stale External IDs ────────────────────────────────────────── */ type StaleSortField = "title" | "year" | "library" | "provider" | "first_seen" | "last_seen"; function StaleIDsSection({ staleIDs }: { staleIDs: StaleMediaID[] }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [matchItem, setMatchItem] = useState(null); const { sortField, sortDir, toggle } = useSort("last_seen", "desc"); const filtered = useMemo(() => { if (!search) return staleIDs; const q = search.toLowerCase(); return staleIDs.filter( (s) => s.title.toLowerCase().includes(q) || s.provider_id.toLowerCase().includes(q) || s.provider.toLowerCase().includes(q) || s.library_name.toLowerCase().includes(q), ); }, [staleIDs, search]); const sorted = useMemo(() => { const cmp = sortDir === "asc" ? 1 : -1; return [...filtered].sort((a, b) => { switch (sortField) { case "title": return cmp * a.title.localeCompare(b.title); case "year": return cmp * (a.year - b.year); case "library": return cmp * a.library_name.localeCompare(b.library_name); case "provider": return cmp * a.provider.localeCompare(b.provider); case "first_seen": return cmp * a.first_seen_at.localeCompare(b.first_seen_at); case "last_seen": return cmp * a.last_seen_at.localeCompare(b.last_seen_at); default: return 0; } }); }, [filtered, sortField, sortDir]); const pag = usePagination(sorted); return ( } iconClassName="bg-red-500/10" open={open} onOpenChange={setOpen} >
{ setSearch(e.target.value); pag.setPage(0); }} className="h-8 pl-8 text-xs" />
Title Year Library Provider Provider ID First seen Last seen Actions {pag.rows.map((s) => ( {s.title} {s.year} {s.library_name} {s.provider} {s.provider_id} {formatDateTime(s.first_seen_at)} {formatDateTime(s.last_seen_at)} ))}
{matchItem && ( { if (!open) setMatchItem(null); }} /> )}
); }