Files
silo-server/web/src/pages/AdminLibraries.tsx
T
22fec4ed2d feat(metadata): add resilient match queue diagnostics (#463)
* feat(metadata): add resilient match queue diagnostics

* fix(metadata): harden match queue lifecycle

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-24 12:18:49 -04:00

2172 lines
79 KiB
TypeScript

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<Library | null>(null);
const [confirmDeleteLib, setConfirmDeleteLib] = useState<Library | null>(null);
const [confirmEmptyRootLib, setConfirmEmptyRootLib] = useState<Library | null>(null);
const [lastMountCheckByLibraryId, setLastMountCheckByLibraryId] = useState<
Record<number, LibraryMountCheckResponse>
>({});
const mountCheckClearTimeoutsRef = useRef<Record<number, number>>({});
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<Library[]>(libraries);
const [activeId, setActiveId] = useState<number | null>(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<number, AdminJob>();
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<number, ScanRun[]>();
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 <div className="p-8">Loading libraries...</div>;
return (
<div className="space-y-6">
<ConfirmDialog
open={confirmDeleteLib !== null}
onOpenChange={(open) => {
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);
}}
/>
<ConfirmDialog
open={confirmEmptyRootLib !== null}
onOpenChange={(open) => {
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);
}}
/>
<div className="page-header">
<div className="space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Libraries</h1>
<p className="page-subtitle text-sm sm:text-base">
Manage library roots and scans. Catalog import/export now lives under Maintenance.
</p>
</div>
<div className="flex gap-2">
{activeScanGroups.length > 0 && (
<ScanQueuePopover
groups={activeScanGroups}
cancellingLibraryID={
cancelScansMutation.isPending ? (cancelScansMutation.variables ?? null) : null
}
onCancel={(libraryID) => cancelScansMutation.mutate(libraryID)}
/>
)}
<Button
variant="outline"
size="sm"
onClick={() => scanAllMutation.mutate()}
disabled={scanAllMutation.isPending}
>
<ScanLine className="h-3.5 w-3.5" />
Scan All Libraries
</Button>
<Button variant="outline" size="sm" asChild>
<Link to="/admin/maintenance">
<Wrench className="mr-1 h-4 w-4" />
Catalog Maintenance
</Link>
</Button>
<Button
size="sm"
onClick={() => {
setEditingLib(null);
setDialogOpen(true);
}}
>
<Plus className="mr-1 h-4 w-4" /> Add Library
</Button>
<LibraryEditorDialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setEditingLib(null);
}}
library={editingLib}
chapterThumbnailsSupported={
editingLib?.chapter_thumbnails_supported ??
libraries[0]?.chapter_thumbnails_supported ??
true
}
/>
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>Name</TableHead>
<TableHead>Paths</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Scanned</TableHead>
<TableHead className="w-[15rem]">Actions</TableHead>
</TableRow>
</TableHeader>
<SortableContext
items={orderedLibraries.map((l) => l.id)}
strategy={verticalListSortingStrategy}
>
<TableBody>
{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 (
<Fragment key={lib.id}>
<SortableLibraryRow id={lib.id}>
<TableCell className="font-medium">{lib.name}</TableCell>
<TableCell className="font-mono text-xs">
{lib.paths.length === 1 ? (
<span className="text-muted-foreground">{lib.paths[0]}</span>
) : (
<button
type="button"
className="text-muted-foreground hover:text-foreground text-left transition-colors"
onClick={() => {
setEditingLib(lib);
setDialogOpen(true);
}}
>
{lib.paths.length} folders
</button>
)}
</TableCell>
<TableCell>
<Badge variant="secondary">{lib.type}</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<Badge variant={lib.enabled ? "outline" : "destructive"}>
{lib.enabled ? "Enabled" : "Disabled"}
</Badge>
{runningLibraryScans > 0 ? (
<Badge variant="secondary">{runningLibraryScans} running</Badge>
) : null}
{queuedLibraryScans > 0 ? (
<Badge variant="secondary">{queuedLibraryScans} queued</Badge>
) : null}
{lib.scan_warning_code === "empty_root" ? (
<Badge variant="destructive">Empty root guarded</Badge>
) : null}
{lib.scan_warning_code === "dead_root" ? (
<Badge variant="destructive">Root unreachable</Badge>
) : null}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
<div className="space-y-1">
<div>
{lib.last_scanned_at ? formatDateTime(lib.last_scanned_at) : "Never"}
</div>
{lib.scan_warning_at ? (
<div className="text-destructive text-[11px]">
Warning: {formatDateTime(lib.scan_warning_at)}
</div>
) : null}
</div>
</TableCell>
<TableCell className="w-[15rem] align-middle">
<div className="flex flex-nowrap items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7",
activeLibraryScans.length > 0 && "text-destructive",
)}
title={
activeLibraryScans.length > 0
? "Stop Library Scans"
: "Scan Library"
}
aria-label={
activeLibraryScans.length > 0
? "Stop Library Scans"
: "Scan Library"
}
disabled={
activeLibraryScans.length > 0
? isCancellingLibraryScans
: isScanning
}
onClick={() => {
if (activeLibraryScans.length > 0) {
cancelScansMutation.mutate(lib.id);
return;
}
scanMutation.mutate(lib.id);
}}
>
{activeLibraryScans.length > 0 ? (
<Square className="h-3 w-3 fill-current" />
) : (
<RefreshCw
className={`h-3 w-3 ${isScanning ? "animate-spin" : ""}`}
/>
)}
</Button>
<Button
variant="ghost"
size="icon"
className={cn("h-7 w-7", activeRefreshJob && "text-destructive")}
title={activeRefreshJob ? "Stop Metadata Refresh" : "Rescan Metadata"}
aria-label={
activeRefreshJob ? "Stop Metadata Refresh" : "Rescan Metadata"
}
disabled={
activeRefreshJob ? isCancellingRefreshJob : isRefreshStarting
}
onClick={() => {
if (activeRefreshJob) {
cancelAdminJobMutation.mutate(activeRefreshJob.id);
return;
}
refreshMutation.mutate(lib.id);
}}
>
{activeRefreshJob ? (
<Square className="h-3 w-3 fill-current" />
) : (
<DatabaseBackup className="h-3 w-3" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={
mountCheck ? formatMountCheckMessage(mountCheck) : "Verify Mounts"
}
aria-label={
mountCheck ? formatMountCheckMessage(mountCheck) : "Verify Mounts"
}
disabled={isCheckingMount}
onClick={() => handleMountCheck(lib.id)}
>
<MountCheckButtonIcon pending={isCheckingMount} result={mountCheck} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Edit ${lib.name}`}
onClick={() => {
setEditingLib(lib);
setDialogOpen(true);
}}
>
<Pencil className="h-3 w-3" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Delete ${lib.name}`}
onClick={() => handleDelete(lib)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</Button>
{lib.scan_warning_code === "empty_root" ||
lib.scan_warning_code === "dead_root" ? (
<Button
variant="ghost"
size="icon"
className="text-destructive h-7 w-7"
title="Confirm cleanup for missing or empty roots"
disabled={
confirmEmptyRootCleanupMutation.isPending &&
confirmEmptyRootCleanupMutation.variables === lib.id
}
onClick={() => handleConfirmEmptyRootCleanup(lib)}
>
<Trash2 className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
</SortableLibraryRow>
{hasActiveWork ? (
<LibraryActiveWorkRow
activeLibraryScans={activeLibraryScans}
activeRefreshJob={activeRefreshJob}
cancellingJobID={
cancelAdminJobMutation.isPending
? cancelAdminJobMutation.variables
: undefined
}
cancellingLibraryID={
cancelScansMutation.isPending
? cancelScansMutation.variables
: undefined
}
libraryID={lib.id}
onCancelJob={(jobID) => cancelAdminJobMutation.mutate(jobID)}
onCancelScans={(libraryID) => cancelScansMutation.mutate(libraryID)}
/>
) : null}
</Fragment>
);
})}
{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 (
<TableRow key={`${lib.id}-warning`}>
<TableCell colSpan={7} className="bg-destructive/5 text-sm">
<div className="flex flex-col gap-2 py-1">
<div className="text-destructive font-medium">
{lib.scan_warning_code === "dead_root"
? DEAD_ROOT_WARNING_TEXT
: EMPTY_ROOT_WARNING_TEXT}
</div>
<div className="text-muted-foreground">
{lib.scan_warning_message ??
(lib.scan_warning_code === "dead_root"
? DEAD_ROOT_WARNING_HINT
: EMPTY_ROOT_WARNING_HINT)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
title={
mountCheck ? formatMountCheckMessage(mountCheck) : "Check mount"
}
disabled={isCheckingMount}
onClick={() => handleMountCheck(lib.id)}
>
<MountCheckButtonIcon
className="mr-1 h-3.5 w-3.5"
pending={isCheckingMount}
result={mountCheck}
/>
Check Mount
</Button>
{lib.scan_warning_code === "dead_root" ? (
<Button
variant="outline"
size="sm"
title="Confirm cleanup for missing or empty roots"
disabled={
confirmEmptyRootCleanupMutation.isPending &&
confirmEmptyRootCleanupMutation.variables === lib.id
}
onClick={() => handleConfirmEmptyRootCleanup(lib)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Confirm Cleanup
</Button>
) : null}
</div>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</SortableContext>
</Table>
</div>
<DragOverlay>
{activeLibrary ? (
<Table>
<TableBody>
<TableRow className="bg-background shadow-lg">
<TableCell className="w-10">
<GripVertical className="text-muted-foreground h-4 w-4" />
</TableCell>
<TableCell className="font-medium">{activeLibrary.name}</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">
{activeLibrary.paths.length === 1
? activeLibrary.paths[0]
: `${activeLibrary.paths.length} folders`}
</TableCell>
<TableCell>
<Badge variant="secondary">{activeLibrary.type}</Badge>
</TableCell>
<TableCell />
<TableCell />
<TableCell />
</TableRow>
</TableBody>
</Table>
) : null}
</DragOverlay>
</DndContext>
<UnmatchedItemsSection />
<MetadataMatcherQueuesSection libraries={libraries} />
<AmbiguousRootsSection libraries={libraries} />
{skippedRoots.length > 0 ? <SkippedRootsSection skippedRoots={skippedRoots} /> : null}
{staleIDs.length > 0 && <StaleIDsSection staleIDs={staleIDs} />}
</div>
);
}
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 (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<span className="relative flex h-2 w-2">
<span className="bg-success absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-success relative inline-flex h-2 w-2 rounded-full" />
</span>
<span className="tabular-nums">
{totalScans} {totalScans === 1 ? "scan" : "scans"}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[400px] p-0">
{/* Accent bar */}
<div className="scan-queue-accent absolute inset-x-0 top-0 h-px rounded-t-xl" />
{/* Header */}
<div className="border-border/40 flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2.5">
<RefreshCw
className="text-primary h-3.5 w-3.5 animate-spin"
style={{ animationDuration: "3s" }}
/>
<span className="text-sm font-semibold">Scan Queue</span>
</div>
<div className="text-muted-foreground flex items-center gap-1.5 text-[11px]">
{totalRunning > 0 && (
<>
<span className="bg-success inline-block h-1.5 w-1.5 rounded-full" />
<span className="tabular-nums">{totalRunning} running</span>
</>
)}
{totalRunning > 0 && totalQueued > 0 && <span className="text-border">·</span>}
{totalQueued > 0 && (
<>
<span className="bg-muted-foreground/40 inline-block h-1.5 w-1.5 rounded-full" />
<span className="tabular-nums">{totalQueued} queued</span>
</>
)}
</div>
</div>
{/* Library groups */}
<div className="max-h-[360px] overflow-y-auto px-3 py-2">
{groups.map((group, gi) => (
<ScanQueueGroup
key={group.libraryID}
group={group}
groupIndex={gi}
isLast={gi === groups.length - 1}
cancelling={cancellingLibraryID === group.libraryID}
onCancel={onCancel}
/>
))}
</div>
</PopoverContent>
</Popover>
);
}
// 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 (
<div>
{/* Library header row */}
<div className="flex items-center gap-2 py-1.5">
<span className="text-muted-foreground/60 text-[10px] font-semibold tracking-widest uppercase">
{getLibraryScanGroupName(group.library, group.libraryID)}
</span>
<div className="bg-border/25 h-px flex-1" />
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive h-6 gap-1 px-2 text-[10px]"
disabled={cancelling}
onClick={() => onCancel(group.libraryID)}
>
<Square className="h-2.5 w-2.5" />
Cancel
</Button>
</div>
{/* Scan rows */}
{visibleScans.map((scan, si) => (
<div
key={scan.id}
className={cn(
"flex items-start gap-2.5 rounded-lg px-2.5 py-2 transition-colors",
scan.status === "running" ? "bg-primary/[0.04]" : "",
)}
style={{
animation: "fade-in 0.25s ease-out backwards",
animationDelay: `${Math.min(groupIndex * 4 + si, 12) * 40}ms`,
}}
>
{/* Status dot */}
<div className="relative mt-[5px] flex h-2.5 w-2.5 shrink-0 items-center justify-center">
{scan.status === "running" ? (
<>
<span className="bg-success/20 absolute h-2.5 w-2.5 animate-ping rounded-full" />
<span className="bg-success shadow-success/30 relative h-[7px] w-[7px] rounded-full shadow-[0_0_5px_1px]" />
</>
) : (
<span className="bg-muted-foreground/20 ring-muted-foreground/15 h-[5px] w-[5px] rounded-full ring-[1.5px]" />
)}
</div>
{/* Details */}
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span className="text-xs leading-snug font-medium">{formatActiveScanMode(scan)}</span>
{scan.trigger && (
<span className="bg-muted/50 text-muted-foreground rounded px-1 py-px text-[9px] font-medium">
{formatActiveScanTrigger(scan.trigger)}
</span>
)}
</div>
<div className="text-muted-foreground mt-px flex items-center gap-1 text-[10px] leading-relaxed">
<code
className="text-muted-foreground/70 max-w-[200px] truncate font-mono"
title={scan.path}
>
{formatActiveScanTarget(scan)}
</code>
<span className="text-border/50 shrink-0">·</span>
<span className="shrink-0">
{scan.status === "running"
? formatActiveScanTime(scan.started_at, "Started")
: "Waiting for capacity"}
</span>
</div>
{formatActiveScanProgress(scan) && (
<div className="text-muted-foreground/80 mt-1 truncate text-[10px] leading-relaxed">
{formatActiveScanProgress(scan)}
</div>
)}
</div>
</div>
))}
{collapsible ? (
<button
type="button"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[10px] transition-colors"
onClick={() => setExpanded((current) => !current)}
>
{expanded ? (
<>
Show less <ChevronUp className="h-3 w-3" />
</>
) : (
<>
+ {hiddenCount} more{hiddenCount <= group.queuedCount ? " queued" : ""}{" "}
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
) : null}
{/* Separator between groups */}
{!isLast && <div className="bg-border/20 my-1 h-px" />}
</div>
);
}
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 (
<TableRow ref={setNodeRef} style={style}>
<TableCell className="w-10">
<button
type="button"
aria-label="Drag to reorder"
className="hover:bg-surface-hover cursor-grab touch-none rounded-md p-1 transition-colors"
{...attributes}
{...listeners}
>
<GripVertical className="text-muted-foreground h-4 w-4" />
</button>
</TableCell>
{children}
</TableRow>
);
}
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 (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={7} className="bg-muted/20 px-4 py-2">
<div className="text-muted-foreground flex flex-col gap-1.5 pl-12 text-[11px]">
{activeRefreshJob ? (
<div className="flex min-w-0 items-start gap-1.5">
<StopTaskButton
disabled={cancellingJobID === activeRefreshJob.id}
label="Cancel metadata refresh"
onClick={() => onCancelJob(activeRefreshJob.id)}
/>
<DatabaseBackup className="mt-0.5 h-3 w-3 shrink-0" />
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-foreground/80 font-medium">Metadata</span>
<span className="truncate">
{activeRefreshJob.message || "Metadata refresh queued"}
</span>
</div>
<div className="text-muted-foreground/80 truncate text-[10px]">
{formatJobProgress(activeRefreshJob)}
</div>
</div>
</div>
) : null}
{activeLibraryScans.length > 0 ? (
<LibraryScanTasks
scans={activeLibraryScans}
cancelling={cancellingLibraryID === libraryID}
libraryID={libraryID}
onCancelScans={onCancelScans}
/>
) : null}
</div>
</TableCell>
</TableRow>
);
}
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 (
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<StopTaskButton
disabled={cancelling}
label="Cancel library scans"
onClick={() => onCancelScans(libraryID)}
/>
<RefreshCw
className={cn("h-3 w-3 shrink-0", runningCount > 0 && "animate-spin")}
style={{ animationDuration: "2s" }}
/>
<span className="text-foreground/80 font-medium">Scan</span>
<span className="tabular-nums">
{runningCount > 0 ? `${runningCount} running` : null}
{runningCount > 0 && queuedCount > 0 ? " · " : null}
{queuedCount > 0 ? `${queuedCount} queued` : null}
</span>
{collapsible ? (
<button
type="button"
className="text-muted-foreground hover:text-foreground flex items-center gap-0.5 rounded px-1 text-[10px] transition-colors"
onClick={() => setExpanded((current) => !current)}
>
{expanded ? (
<>
Show less <ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all {scans.length} <ChevronDown className="h-3 w-3" />
</>
)}
</button>
) : null}
</div>
<div
className={cn(
"flex min-w-0 flex-col gap-0.5 pl-[3.375rem]",
expanded && "max-h-56 overflow-y-auto",
)}
>
{visibleScans.map((scan) => (
<CompactScanRow key={scan.id} scan={scan} />
))}
</div>
</div>
);
}
function CompactScanRow({ scan }: { scan: ScanRun }) {
const progress = scan.status === "running" ? formatActiveScanProgress(scan) : "";
return (
<div className="flex min-w-0 items-center gap-1.5" title={scan.path}>
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
scan.status === "running" ? "bg-success" : "bg-muted-foreground/30",
)}
/>
{scan.mode !== "file" ? (
<span className="shrink-0 text-[10px]">{formatActiveScanMode(scan)}</span>
) : null}
{scan.path ? (
<code className="text-muted-foreground/80 truncate font-mono text-[10px]">
{formatActiveScanTarget(scan)}
</code>
) : (
<span className="text-muted-foreground/80 truncate text-[10px]">
{formatActiveScanTarget(scan)}
</span>
)}
{progress ? (
<span className="text-muted-foreground/60 truncate text-[10px]">· {progress}</span>
) : null}
</div>
);
}
function StopTaskButton({
disabled,
label,
onClick,
}: {
disabled?: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
className="text-destructive hover:bg-destructive/10 hover:text-destructive focus-visible:ring-ring flex h-5 w-5 shrink-0 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
>
<Square className="h-3 w-3 fill-current" />
</button>
);
}
function MountCheckButtonIcon({
result,
pending,
className = "h-3 w-3",
}: {
result?: LibraryMountCheckResponse;
pending: boolean;
className?: string;
}) {
if (pending) {
return <HardDrive className={cn(className, "animate-pulse")} />;
}
if (result?.healthy) {
return <CheckCircle2 className={cn(className, "text-success")} />;
}
if (result) {
return <AlertTriangle className={cn(className, "text-destructive")} />;
}
return <HardDrive className={className} />;
}
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<T>(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 (
<div className="border-border/40 flex items-center justify-between border-t px-1 pt-3">
<span className="text-muted-foreground text-xs tracking-tight tabular-nums">
{rangeStart}&ndash;{rangeEnd} of {total}
</span>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={!canPrev}
onClick={first}
title="First page"
>
<ChevronsLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={!canPrev}
onClick={prev}
title="Previous page"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={!canNext}
onClick={next}
title="Next page"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={!canNext}
onClick={last}
title="Last page"
>
<ChevronsRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
/* ─── Sortable column header ────────────────────────────────────── */
type SortDir = "asc" | "desc";
function SortableHead<K extends string>({
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 (
<TableHead className={cn("select-none", className)}>
<button
type="button"
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
onClick={() => onSort(field)}
>
{children}
{active ? (
activeDir === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)
) : (
<ChevronDown className="h-3 w-3 opacity-0 group-hover:opacity-30" />
)}
</button>
</TableHead>
);
}
function useSort<K extends string>(defaultField: K, defaultDir: SortDir = "desc") {
const [sortField, setSortField] = useState<K>(defaultField);
const [sortDir, setSortDir] = useState<SortDir>(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<number | undefined>(libraries[0]?.id);
const [search, setSearch] = useState("");
const [editingRoot, setEditingRoot] = useState<LibraryRoot | null>(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 (
<CollapsibleDiagnosticsSection
title="Ambiguous Roots"
description="Scanner roots that stay visible but do not enter unattended metadata matching."
count={roots.length}
icon={<FolderOpen className="h-4 w-4 text-amber-500" />}
open={open}
onOpenChange={setOpen}
>
<div className="mb-2 flex flex-col gap-2 sm:flex-row">
<Select
value={
effectiveSelectedLibraryId != null ? String(effectiveSelectedLibraryId) : undefined
}
onValueChange={(value) => {
setSelectedLibraryId(Number.parseInt(value, 10));
pag.setPage(0);
}}
>
<SelectTrigger className="h-8 w-full text-xs sm:w-[220px]">
<SelectValue placeholder="Select library" />
</SelectTrigger>
<SelectContent>
{libraries.map((library) => (
<SelectItem key={library.id} value={String(library.id)}>
{library.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder="Filter by path, title, or sample file..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
pag.setPage(0);
}}
className="h-8 pl-8 text-xs"
/>
</div>
</div>
<div className="border-border/40 bg-background/40 overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Root</TableHead>
<TableHead>Type</TableHead>
<TableHead>Confidence</TableHead>
<TableHead className="text-right">Files</TableHead>
<TableHead className="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRoots.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground text-center text-sm">
No ambiguous roots for this library.
</TableCell>
</TableRow>
) : (
pag.rows.map((root) => (
<TableRow key={`${root.library_id}:${root.root_path}`}>
<TableCell className="max-w-[28rem]">
<div className="space-y-1">
<div className="truncate text-sm font-medium">
{root.title || root.root_path.split("/").filter(Boolean).pop()}
</div>
<code className="text-muted-foreground block truncate text-[11px]">
{root.root_path}
</code>
{root.evidence_json ? (
<div className="text-muted-foreground truncate text-[11px]">
{buildRootEvidenceSummary(root)}
</div>
) : null}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{root.inferred_type || "unknown"}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">{root.type_confidence}</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-right text-xs tabular-nums">
{root.observed_file_count}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setEditingRoot(root)}
>
Override
</Button>
{root.content_id ? (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
asChild
title="Open the matched item; use its Split Versions action to separate wrongly merged files"
>
<Link to={`/item/${encodeURIComponent(root.content_id)}`}>Resolve</Link>
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<PaginationBar {...pag} />
{editingRoot ? (
<RootOverrideDialog
key={`${editingRoot.library_id}:${editingRoot.root_path}`}
root={editingRoot}
onOpenChange={(open) => {
if (!open) setEditingRoot(null);
}}
/>
) : null}
</CollapsibleDiagnosticsSection>
);
}
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 (
<Dialog open={true} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Root Override</DialogTitle>
<DialogDescription>
Force the inferred identity for <code>{root.root_path}</code>.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Type</Label>
<Select
value={forcedType || "auto"}
onValueChange={(value) => setForcedType(value === "auto" ? "" : value)}
>
<SelectTrigger>
<SelectValue placeholder="Auto" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="movie">Movie</SelectItem>
<SelectItem value="series">Series</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Year</Label>
<Input
value={forcedYear}
onChange={(e) => setForcedYear(e.target.value)}
placeholder="2024"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Title</Label>
<Input
value={forcedTitle}
onChange={(e) => setForcedTitle(e.target.value)}
placeholder="Forced title"
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label>TMDB ID</Label>
<Input value={forcedTmdbID} onChange={(e) => setForcedTmdbID(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>IMDb ID</Label>
<Input value={forcedImdbID} onChange={(e) => setForcedImdbID(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>TVDB ID</Label>
<Input value={forcedTvdbID} onChange={(e) => setForcedTvdbID(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<Label>Note</Label>
<Input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Why this override exists"
/>
</div>
<div className="flex justify-between gap-2">
<Button
variant="outline"
disabled={!root.active_override || deleteOverride.isPending}
onClick={() => {
deleteOverride.mutate(
{ library_id: root.library_id, root_path: root.root_path },
{ onSuccess: () => onOpenChange(false) },
);
}}
>
Remove Override
</Button>
<Button
onClick={() => {
const parsedYear = Number.parseInt(forcedYear, 10);
upsertOverride.mutate(
{
library_id: root.library_id,
root_path: root.root_path,
forced_type: forcedType || undefined,
forced_title: forcedTitle || undefined,
forced_year: Number.isFinite(parsedYear) ? parsedYear : undefined,
forced_tmdb_id: forcedTmdbID || undefined,
forced_imdb_id: forcedImdbID || undefined,
forced_tvdb_id: forcedTvdbID || undefined,
note: note || undefined,
},
{ onSuccess: () => onOpenChange(false) },
);
}}
disabled={upsertOverride.isPending}
>
Save Override
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
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<string | null>(null);
const { sortField, sortDir, toggle } = useSort<SkippedSortField>("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 (
<CollapsibleDiagnosticsSection
title="Troubleshooting"
description="Roots where the inferred canonical folder lacks embedded provider IDs."
count={skippedRoots.length}
icon={<AlertTriangle className="h-4 w-4 text-amber-500" />}
open={open}
onOpenChange={setOpen}
>
<div className="relative mb-2">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder="Filter by path, library, or reason..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
pag.setPage(0);
}}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="border-border/40 bg-background/40 overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<SortableHead
field="root_path"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
Item
</SortableHead>
<SortableHead
field="library"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
Library
</SortableHead>
<SortableHead
field="reason"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
Reason
</SortableHead>
<TableHead className="text-right">Files</TableHead>
<SortableHead
field="first_seen"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
First seen
</SortableHead>
<SortableHead
field="last_seen"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
Last seen
</SortableHead>
</TableRow>
</TableHeader>
<TableBody>
{pag.rows.map((root) => {
const rowKey = `${root.library_id}:${root.root_path}`;
const isExpanded = expandedKey === rowKey;
return (
<Fragment key={rowKey}>
<TableRow
className="cursor-pointer"
onClick={() => setExpandedKey(isExpanded ? null : rowKey)}
>
<TableCell className="max-w-[20rem]">
<div className="flex items-center gap-2">
<ChevronRight
className={cn(
"text-muted-foreground h-3.5 w-3.5 shrink-0 transition-transform",
isExpanded && "rotate-90",
)}
/>
<span className="truncate text-sm font-medium">
{root.root_path.split("/").filter(Boolean).pop()}
</span>
</div>
</TableCell>
<TableCell className="text-sm">{root.library_name}</TableCell>
<TableCell>
<Badge
variant="outline"
className="border-warning/30 bg-warning/5 text-warning"
>
{root.reason}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-right text-xs tabular-nums">
{root.file_count}
</TableCell>
<TableCell className="text-muted-foreground text-xs tabular-nums">
{formatDateTime(root.first_seen_at)}
</TableCell>
<TableCell className="text-muted-foreground text-xs tabular-nums">
{formatDateTime(root.last_seen_at)}
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="bg-muted/30 border-b px-4 py-3">
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<span className="text-muted-foreground font-medium">Root path</span>
<code className="font-mono break-all select-all">{root.root_path}</code>
{root.sample_file_path && (
<>
<span className="text-muted-foreground font-medium">Sample file</span>
<code className="font-mono break-all select-all">
{root.sample_file_path}
</code>
</>
)}
<span className="text-muted-foreground font-medium">Files affected</span>
<span>{root.file_count}</span>
</div>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
<PaginationBar {...pag} />
</CollapsibleDiagnosticsSection>
);
}
/* ─── 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<UnmatchedLibraryItem | null>(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 (
<CollapsibleDiagnosticsSection
title="Unmatched Items"
description="Items that could not be matched to any metadata provider."
count={total}
icon={<Search className="h-4 w-4 text-amber-500" />}
open={open}
onOpenChange={setOpen}
>
<div className="relative mb-2">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder="Search all unmatched items by title, library, or type..."
value={search}
onChange={(e) => {
// 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"
/>
</div>
<div className="border-border/40 bg-background/40 overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Title</TableHead>
<TableHead>Library</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground text-center text-sm">
No unmatched items match your search.
</TableCell>
</TableRow>
) : (
items.map((u) => (
<TableRow key={u.content_id}>
<TableCell className="font-medium">
<Link
to={`/item/${u.content_id}`}
className="hover:text-primary underline-offset-2 hover:underline"
>
{u.title}
</Link>
{u.year ? (
<span className="text-muted-foreground ml-1 text-xs">({u.year})</span>
) : null}
</TableCell>
<TableCell className="text-sm">{u.library_name}</TableCell>
<TableCell>
<Badge variant="outline">{u.content_type}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">{u.status}</Badge>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => setMatchItem(u)}
>
<Search className="h-3 w-3" />
Match
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{total > UNMATCHED_PAGE_SIZE && (
<PaginationBar
total={total}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
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)}
/>
)}
{matchItem && (
<MatchItemDialog
item={{
content_id: matchItem.content_id,
library_id: matchItem.library_id,
title: matchItem.title,
year: matchItem.year,
type: matchItem.content_type,
}}
open={true}
onOpenChange={(open) => {
if (!open) setMatchItem(null);
}}
/>
)}
</CollapsibleDiagnosticsSection>
);
}
/* ─── 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<StaleMediaID | null>(null);
const { sortField, sortDir, toggle } = useSort<StaleSortField>("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 (
<CollapsibleDiagnosticsSection
title="Stale External IDs"
description="Provider IDs no longer resolve; metadata refresh will fail until re-matched."
count={staleIDs.length}
icon={<Unlink className="h-4 w-4 text-red-400" />}
iconClassName="bg-red-500/10"
open={open}
onOpenChange={setOpen}
>
<div className="relative mb-2">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder="Filter by title, provider, or ID..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
pag.setPage(0);
}}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="border-border/40 bg-background/40 overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<SortableHead
field="title"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
Title
</SortableHead>
<SortableHead
field="year"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
Year
</SortableHead>
<SortableHead
field="library"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
Library
</SortableHead>
<SortableHead
field="provider"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
Provider
</SortableHead>
<TableHead>Provider ID</TableHead>
<SortableHead
field="first_seen"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
First seen
</SortableHead>
<SortableHead
field="last_seen"
activeField={sortField}
activeDir={sortDir}
onSort={toggle}
>
Last seen
</SortableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pag.rows.map((s) => (
<TableRow key={`${s.content_id}:${s.provider}`}>
<TableCell className="font-medium">{s.title}</TableCell>
<TableCell className="tabular-nums">{s.year}</TableCell>
<TableCell className="text-sm">{s.library_name}</TableCell>
<TableCell>
<Badge variant="outline">{s.provider}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{s.provider_id}</TableCell>
<TableCell className="text-muted-foreground text-xs tabular-nums">
{formatDateTime(s.first_seen_at)}
</TableCell>
<TableCell className="text-muted-foreground text-xs tabular-nums">
{formatDateTime(s.last_seen_at)}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => setMatchItem(s)}
>
<Search className="h-3 w-3" />
Match
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<PaginationBar {...pag} />
{matchItem && (
<MatchItemDialog
item={{
content_id: matchItem.content_id,
library_id: matchItem.library_id,
title: matchItem.title,
year: matchItem.year,
type: matchItem.content_type,
}}
open={true}
onOpenChange={(open) => {
if (!open) setMatchItem(null);
}}
/>
)}
</CollapsibleDiagnosticsSection>
);
}