import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Folder, Search, Scissors } from "lucide-react"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import type { ItemFile, ItemMatchSearchRequest, ItemSplitRequest, ItemSplitResponse, MatchCandidate, SplitHistoryMode, } from "@/api/types"; import { useItemFiles, useSearchItemMatchCandidates, useSplitItem } from "@/hooks/queries/items"; import { cn } from "@/lib/utils"; interface SplittableItem { content_id: string; title: string; year?: number; type: string; library_id?: number; } interface SplitItemDialogProps { item: SplittableItem; open: boolean; onOpenChange: (open: boolean) => void; } const HISTORY_MODE_LABELS: Record = { evidence: "Follow play evidence (recommended)", keep: "Keep all history on this item", move_all: "Move everything to the new item", }; /** * Repairs a wrong merge: moves selected files (usually one folder) of this * item to a different — possibly new — item, previews the watch-state * reattribution via a dry run, and persists identity overrides so rescans * keep the corrected assignment. */ export default function SplitItemDialog({ item, open, onOpenChange }: SplitItemDialogProps) { const { data: filesData, isLoading: filesLoading } = useItemFiles( open ? item.content_id : undefined, ); const files = useMemo(() => filesData?.files ?? [], [filesData]); const [selectedIds, setSelectedIds] = useState>(new Set()); const [title, setTitle] = useState(""); const [year, setYear] = useState(""); const [imdbId, setImdbId] = useState(""); const [tmdbId, setTmdbId] = useState(""); const [tvdbId, setTvdbId] = useState(""); const [selectedCandidate, setSelectedCandidate] = useState(null); const [detachUnmatched, setDetachUnmatched] = useState(false); const [historyMode, setHistoryMode] = useState("evidence"); const [preview, setPreview] = useState(null); const searchMutation = useSearchItemMatchCandidates(item.content_id); const splitMutation = useSplitItem(); const candidates = searchMutation.data?.candidates ?? []; // Monotonic token so a slow dry-run response for an outdated plan can't // overwrite the preview of the current one. const previewToken = useRef(0); const filesByRoot = useMemo(() => { const groups = new Map(); for (const file of files) { const group = groups.get(file.observed_root_path) ?? []; group.push(file); groups.set(file.observed_root_path, group); } return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)); }, [files]); const toggleFile = useCallback((id: number) => { setSelectedIds((current) => { const next = new Set(current); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); const toggleRoot = useCallback((rootFiles: ItemFile[]) => { setSelectedIds((current) => { const next = new Set(current); const allSelected = rootFiles.every((file) => next.has(file.id)); for (const file of rootFiles) { if (allSelected) { next.delete(file.id); } else { next.add(file.id); } } return next; }); }, []); const handleSearch = useCallback(() => { setSelectedCandidate(null); setDetachUnmatched(false); const parsedYear = Number.parseInt(year.trim(), 10); const request: ItemMatchSearchRequest = { title: title || undefined, year: Number.isFinite(parsedYear) ? parsedYear : undefined, imdb_id: imdbId || undefined, tmdb_id: tmdbId || undefined, tvdb_id: tvdbId || undefined, library_id: item.library_id, }; searchMutation.mutate(request); }, [title, year, imdbId, tmdbId, tvdbId, item.library_id, searchMutation]); const targetChosen = detachUnmatched || selectedCandidate !== null; const selectionValid = selectedIds.size > 0 && selectedIds.size < files.length; const planValid = selectionValid && targetChosen; const buildRequest = useCallback( (dryRun: boolean): ItemSplitRequest => ({ file_ids: [...selectedIds], target: detachUnmatched ? { unmatched: true } : { provider_ids: selectedCandidate?.provider_ids, title: selectedCandidate?.title, year: selectedCandidate?.year || undefined, }, history_mode: historyMode, dry_run: dryRun, }), [selectedIds, detachUnmatched, selectedCandidate, historyMode], ); // react-query guarantees `mutate` is referentially stable, so the effect // below only re-runs when the plan itself changes. const { mutate: runSplit } = splitMutation; // Any change to the plan invalidates the previous preview; once the plan is // valid, a debounced dry run fetches a fresh one automatically. useEffect(() => { previewToken.current += 1; setPreview(null); if (!open || !planValid) return; const token = previewToken.current; const timer = setTimeout(() => { runSplit( { contentId: item.content_id, request: buildRequest(true) }, { onSuccess: (result) => { if (token === previewToken.current) setPreview(result); }, }, ); }, 400); return () => clearTimeout(timer); }, [open, planValid, buildRequest, runSplit, item.content_id]); const confirmSplit = useCallback(() => { runSplit( { contentId: item.content_id, request: buildRequest(false) }, { onSuccess: () => onOpenChange(false) }, ); }, [runSplit, item.content_id, buildRequest, onOpenChange]); return ( Split Versions
{item.title} {item.year ? ({item.year}) : null} {item.type}
{/* Step 1: pick the files that belong to a different title */}
{filesLoading ? (
Loading files…
) : files.length < 2 ? (
This item has only one file; splitting needs at least two.
) : (
{filesByRoot.map(([root, rootFiles]) => { const allSelected = rootFiles.every((file) => selectedIds.has(file.id)); return (
{rootFiles.map((file) => ( ))}
); })}
)} {selectedIds.size > 0 && selectedIds.size === files.length && (

All files are selected — that is a re-match, not a split. Use “Match Item” instead, or deselect the files that are correct.

)}
{/* Step 2: what the moved files actually are */}
setTitle(e.target.value)} placeholder="Title" aria-label="Search title" />
setYear(e.target.value)} placeholder="Year" type="number" aria-label="Search year" /> setTmdbId(e.target.value)} placeholder="TMDB ID" aria-label="TMDB ID" /> setImdbId(e.target.value)} placeholder="IMDb ID (tt…)" aria-label="IMDb ID" /> setTvdbId(e.target.value)} placeholder="TVDB ID" aria-label="TVDB ID" />
{candidates.length > 0 && (
{candidates.map((candidate, index) => { const key = Object.entries(candidate.provider_ids) .map(([k, v]) => `${k}-${v}`) .join("_"); return ( ); })}
)} {searchMutation.isSuccess && candidates.length === 0 && (

No candidates found.

)}
{/* Step 3: watch-state handling */}

Resume points and downloads tied to the moved files always follow them. This controls history rows without per-file evidence.

{planValid && !preview && (
Previewing…
)} {preview && (
Preview — {preview.files_moved} file{preview.files_moved === 1 ? "" : "s"} →{" "} {preview.target_content_id} {preview.target_created ? ( new item ) : null}
  • {preview.reattribution.progress_moved} resume points move
  • {preview.reattribution.history_moved} history entries move,{" "} {preview.reattribution.history_ambiguous} stay for lack of evidence
  • {preview.reattribution.downloads} downloads move
  • {preview.episode_pairs > 0 &&
  • {preview.episode_pairs} episodes re-anchored
  • } {(preview.root_overrides?.length ?? 0) + (preview.file_overrides?.length ?? 0) > 0 && (
  • {preview.root_overrides?.length ?? 0} folder /{" "} {preview.file_overrides?.length ?? 0} file identity override{"(s)"} pinned for future scans
  • )}
)}
); }