import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronLeft, Folder, FolderOpen } from "lucide-react"; import { api, type LibraryType, type Pair, type PairInput, type ScanWarning } from "../api"; import { WarningList } from "./Warnings"; import { useInfo } from "../App"; import { Alert, Button, Field, Modal, Toggle, cx, inputCls } from "../ui"; import { useToast } from "../toast"; export const LIBRARY_TYPES: { id: LibraryType; label: string; layout: string; hint: string }[] = [ { id: "music", label: "Music", layout: "Artist/Album/01 Title.flac", hint: "Albums by artist." }, { id: "soundtracks", label: "Soundtracks", layout: "Album/01 Title.flac", hint: "Video game and film soundtracks, named by game or film." }, ]; type Validation = { ok: boolean; error?: string; warnings: ScanWarning[] }; export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) { const info = useInfo(); const qc = useQueryClient(); const toast = useToast(); const blank: PairInput = { name: "Music", master_root: "", slave_root: "", quarantine_dir: "", extensions: [], auto_link_identical: false, scan_interval_minutes: 0, retention_days: 30, settle_minutes: 10, approx_matching: false, approx_threshold: 85, auto_organize: "off", library_type: "music", }; const [f, setF] = useState(pair ?? blank); const [exts, setExts] = useState((pair?.extensions ?? []).join(", ")); const [picker, setPicker] = useState(null); const [validation, setValidation] = useState(null); useEffect(() => { if (open) { setF(pair ?? blank); setExts((pair?.extensions ?? info.data?.default_extensions ?? []).join(", ")); setValidation(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, pair?.id]); // Send only editable fields: the API rejects unknown keys. const body = (): PairInput => ({ name: f.name, master_root: f.master_root, slave_root: f.slave_root, quarantine_dir: f.quarantine_dir, auto_link_identical: f.auto_link_identical, scan_interval_minutes: f.scan_interval_minutes, retention_days: f.retention_days, settle_minutes: f.settle_minutes, approx_matching: f.approx_matching, approx_threshold: f.approx_threshold, // Edited on the Organize tab; sent unchanged so saving here keeps it. auto_organize: f.auto_organize, library_type: f.library_type, extensions: exts.split(",").map((s) => s.trim()).filter(Boolean), }); const set = (k: K, v: PairInput[K]) => { setF((p) => ({ ...p, [k]: v })); setValidation(null); }; const validate = useMutation({ mutationFn: () => api.post("/pairs/validate", body()), onSuccess: setValidation, }); const save = useMutation({ mutationFn: () => (pair ? api.put<{ warnings: ScanWarning[] }>(`/pairs/${pair.id}`, body()) : api.post<{ warnings: ScanWarning[] }>("/pairs", body())), onSuccess: () => { qc.invalidateQueries({ queryKey: ["pairs"] }); qc.invalidateQueries({ queryKey: ["pair"] }); toast("ok", pair ? "Library pair updated" : "Library pair created. Start with the Fixer, master library first."); onClose(); }, }); return (
set("name", e.target.value)} />
{LIBRARY_TYPES.map((t) => ( ))}
set("master_root", v)} onBrowse={() => setPicker("master_root")} /> set("slave_root", v)} onBrowse={() => setPicker("slave_root")} />
Both libraries should use the same folder layout (for example Artist/Album/Title.ext), because files are matched by their relative path first. Any layout works as long as it is the same on both sides. Both must be on the same filesystem. On Unraid, mount one common parent folder into the container instead of two separate volumes. set("quarantine_dir", v)} onBrowse={() => setPicker("quarantine_dir")} /> setExts(e.target.value)} />
set("scan_interval_minutes", Number(e.target.value))} /> set("retention_days", Number(e.target.value))} /> set("settle_minutes", Number(e.target.value))} />
set("auto_link_identical", v)} label={Automatically link byte-identical files after each scan} />

Only files whose relative paths match and whose bytes are identical (verified with a full compare right before linking). Anything else always waits for manual review. Dry run still applies.

set("approx_matching", v)} label={Approximate matching} />

Finds songs whose file name or folder doesn't line up exactly with the master library, such as a single that also appears on an album, and recommends linking them to the master's album version.

{approxExamples.map((ex) => (
{ex.label}
slave {ex.slave} master {ex.master}
))}
  • Different recordings never match: live, remix, acoustic, instrumental and similar versions are kept apart.
  • When a song appears on several releases, the studio album is preferred over singles, EPs and compilations.
  • Matches outside the artist's folder need matching artist tags. Anything ambiguous is skipped.
  • Approximate matches are never linked automatically. Each one waits in Review.
{f.approx_matching && (
set("approx_threshold", Number(e.target.value))} className="w-40 accent-violet-500" /> {f.approx_threshold}%

Lower finds more matches but risks more false ones. 85% is a good default.

)}
{validation && !validation.ok && {validation.error}} {validation?.ok && validation.warnings.length === 0 && Looks good. Both folders are valid and on the same filesystem.} {validation?.ok && } {save.error && {save.error.message}}
{picker && ( { set(picker, p); setPicker(null); }} onClose={() => setPicker(null)} /> )}
); } const approxExamples = [ { label: "Single → album version", slave: "Avenged Sevenfold/Hail to the King - Single/Hail to the King.mp3", master: "Avenged Sevenfold/Hail to the King/01 Hail to the King.flac", }, { label: "Disc folders ignored", slave: "Avenged Sevenfold/Nightmare/04 Buried Alive.mp3", master: "Avenged Sevenfold/Nightmare/Disc 1/04 Buried Alive.flac", }, { label: "Small title differences", slave: "Avenged Sevenfold/City of Evil/Bat Country (Remastered).mp3", master: "Avenged Sevenfold/City of Evil/03 Bat Country.flac", }, ]; function PathField(props: { label: string; hint: string; value: string; placeholder?: string; onChange: (v: string) => void; onBrowse: () => void }) { return (
props.onChange(e.target.value)} />
); } function FolderPicker({ start, onPick, onClose }: { start: string; onPick: (p: string) => void; onClose: () => void }) { const [path, setPath] = useState(start); const q = useQuery({ queryKey: ["browse", path], queryFn: () => api.get<{ path: string; parent: string; dirs: string[] }>(`/browse?path=${encodeURIComponent(path)}`), retry: false, }); return (
{q.data?.path || "Allowed roots"}
{q.error && ( {q.error.message}{" "} )}
{q.data?.dirs.map((d) => ( ))} {q.data && q.data.dirs.length === 0 &&
No subfolders
}
); }