import { useState, useCallback, useMemo, useRef } from "react"; import { Copy, Folder, Plus, Search, X } from "lucide-react"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import type { FileVersion, ItemDetail, ItemMatchSearchRequest, MatchCandidate } from "@/api/types"; import MediaLocations from "@/components/MediaLocations"; import { useSearchItemMatchCandidates, useApplyItemMatch } from "@/hooks/queries/items"; import { useCatalogItemDetail } from "@/hooks/queries/catalogRead"; import { cn } from "@/lib/utils"; type MatchableItem = Pick< ItemDetail, "content_id" | "title" | "year" | "series_id" | "season_number" > & { type: string; library_id?: number; versions?: FileVersion[]; folder_paths?: string[]; }; type ProviderIDInput = { id: number; provider: string; value: string; }; interface MatchItemDialogProps { item: MatchableItem; open: boolean; onOpenChange: (open: boolean) => void; } function isVideoMatchType(type: string): boolean { switch (type.trim().toLowerCase()) { case "movie": case "movies": case "series": case "show": case "shows": case "tv": case "season": case "seasons": case "episode": case "episodes": return true; default: return false; } } export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemDialogProps) { const [title, setTitle] = useState(item.title); const [year, setYear] = useState(item.year ? String(item.year) : ""); const [imdbId, setImdbId] = useState(""); const [tmdbId, setTmdbId] = useState(""); const [tvdbId, setTvdbId] = useState(""); const [providerIdInputs, setProviderIdInputs] = useState([ { id: 0, provider: "", value: "" }, ]); const nextProviderIdInputId = useRef(1); const [selectedCandidate, setSelectedCandidate] = useState(null); const isSeries = item.type === "series"; const showVideoExternalIds = isVideoMatchType(item.type); const needsItemDetail = open && item.versions === undefined; const { data: enrichedItem, isLoading: enrichedItemLoading } = useCatalogItemDetail( needsItemDetail ? item.content_id : undefined, needsItemDetail ? item.library_id : undefined, ); const searchMutation = useSearchItemMatchCandidates(item.content_id); const applyMutation = useApplyItemMatch(); const candidates = searchMutation.data?.candidates ?? []; const effectiveItem = needsItemDetail ? (enrichedItem ?? item) : item; const genericProviderIds = useMemo(() => { if (showVideoExternalIds) return {}; return providerIdInputs.reduce>((acc, entry) => { const provider = entry.provider.trim().toLowerCase(); const value = entry.value.trim(); if (provider && value) { acc[provider] = value; } return acc; }, {}); }, [providerIdInputs, showVideoExternalIds]); const handleSearch = useCallback(() => { setSelectedCandidate(null); const normalizedYear = year.trim(); const parsedYear = normalizedYear === "" ? undefined : Number.parseInt(normalizedYear, 10); const request: ItemMatchSearchRequest = { title: title || undefined, year: parsedYear !== undefined && Number.isFinite(parsedYear) ? parsedYear : undefined, library_id: item.library_id, }; if (showVideoExternalIds) { request.imdb_id = imdbId || undefined; request.tmdb_id = tmdbId || undefined; request.tvdb_id = tvdbId || undefined; } else if (Object.keys(genericProviderIds).length > 0) { request.provider_ids = genericProviderIds; } searchMutation.mutate(request); }, [ title, year, item.library_id, showVideoExternalIds, imdbId, tmdbId, tvdbId, genericProviderIds, searchMutation, ]); const updateProviderIdInput = useCallback( (index: number, field: keyof ProviderIDInput, value: string) => { setProviderIdInputs((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, [field]: value } : entry, ), ); }, [], ); const addProviderIdInput = useCallback(() => { const id = nextProviderIdInputId.current; nextProviderIdInputId.current += 1; setProviderIdInputs((current) => [...current, { id, provider: "", value: "" }]); }, []); const removeProviderIdInput = useCallback((index: number) => { setProviderIdInputs((current) => { const next = current.filter((_, entryIndex) => entryIndex !== index); return next.length > 0 ? next : [{ id: 0, provider: "", value: "" }]; }); }, []); const handleApply = useCallback(() => { if (!selectedCandidate) return; applyMutation.mutate( { item, providerIds: selectedCandidate.provider_ids }, { onSuccess: () => { onOpenChange(false); }, }, ); }, [selectedCandidate, applyMutation, item, onOpenChange]); return ( Match Item
{/* Current item summary */}
{item.title} {item.year ? ({item.year}) : null} {item.type}
{(enrichedItemLoading || (isSeries ? effectiveItem.folder_paths !== undefined : effectiveItem.versions !== undefined)) && (enrichedItemLoading ? (
Loading local media…
) : isSeries ? ( ) : ( ))} {/* Search inputs */}
setTitle(e.target.value)} placeholder="Title" />
setYear(e.target.value)} placeholder="Year" type="number" />
{showVideoExternalIds ? ( <>
setImdbId(e.target.value)} placeholder="tt1234567" />
setTmdbId(e.target.value)} placeholder="12345" />
setTvdbId(e.target.value)} placeholder="12345" />
) : (
{providerIdInputs.map((entry, index) => (
updateProviderIdInput(index, "provider", e.target.value)} placeholder="isbn" aria-label="Provider" /> updateProviderIdInput(index, "value", e.target.value)} placeholder="978..." aria-label="Provider ID" />
))}
)}
{/* Candidate list */} {candidates.length > 0 && (
{candidates.map((candidate, index) => { const candidateKey = Object.entries(candidate.provider_ids) .map(([k, v]) => `${k}-${v}`) .join("_"); return ( ); })}
)} {searchMutation.isSuccess && candidates.length === 0 && (

No candidates found.

)}
{/* Apply button — pinned below scroll area */} {selectedCandidate && (
)}
); } function getFolderDisplayName(path: string): string { const trimmed = path.trim().replace(/[\\/]+$/, ""); if (!trimmed) return path; const segments = trimmed.split(/[\\/]/).filter(Boolean); return segments[segments.length - 1] || trimmed; } function computeRootPath(paths: string[]): string { if (paths.length === 0) return ""; const parentSegments = paths.map((path) => { const trimmed = path.trim().replace(/[\\/]+$/, ""); return trimmed.split(/[\\/]/).filter(Boolean).slice(0, -1); }); const first = parentSegments[0] ?? []; const minLen = Math.min(...parentSegments.map((segments) => segments.length)); let sharedLength = 0; for (let i = 0; i < minLen; i += 1) { if (parentSegments.every((segments) => segments[i] === first[i])) { sharedLength = i + 1; continue; } break; } if (sharedLength === 0) return ""; return `/${first.slice(0, sharedLength).join("/")}`; } function FolderPathsList({ paths }: { paths: string[] }) { const folderData = useMemo(() => { return { folders: paths.map((fullPath) => ({ fullPath, displayName: getFolderDisplayName(fullPath), })), rootPath: computeRootPath(paths), }; }, [paths]); if (paths.length === 0) { return (

Local media

No folder paths are available for this item.
); } return (

Local media

{folderData.rootPath ? (
Root path
{folderData.rootPath}
) : null}
{folderData.folders.map(({ fullPath, displayName }) => (
{displayName}
))}
); }