Loosen approximate matching and link to the album version's folders

Matching:
- Prefer the studio album over singles, EPs and compilations when a song
  exists on several releases, including when the master also has the
  single at the slave's exact folder.
- Ignore release labels (Single Version, Remastered, Explicit, Deluxe)
  inside brackets or a trailing " - ..." part; "Clean" stays a version.
- Treat live/acoustic/remix/... album folders as different recordings;
  the strict top-folder fallback now respects this too.
- Add a last-resort library-wide search that only accepts candidates whose
  artist tag agrees with the slave file.

Linking:
- Cross-folder matches now mirror the master's path in the slave (e.g. a
  single moves into the album folder) and prune the emptied folder; a
  "keep in current folder" option preserves the old behaviour. Undo
  recreates and prunes folders accordingly.
- Operations record the match kind and reason, so undo (and promotes that
  rename the master file) restore the correct pairing instead of leaving
  the file unmatched until the next scan.

UI: rewrite the approximate matching description with Avenged Sevenfold
examples and clear safeguards, and show where linking will place a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-22 17:25:21 -07:00
1 parent 381b804742
commit 5f895cab6a
12 files changed
+717 -128

No files matched your search

+1
View File
@@ -131,6 +131,7 @@ export type Match = {
verdict?: Verdict;
note: string;
reason: string;
link_target: string;
ignored: boolean;
};
+61 -21
View File
@@ -138,28 +138,50 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
onChange={(v) => set("approx_matching", v)}
label={<span className="font-medium">Approximate matching</span>}
/>
<p className="mt-1.5 pl-11.5 text-xs text-muted">
Also match songs whose file name is only approximately the same, and ignore disc folders. For example{" "}
<code className="mono">Cats/Disc 02/07 Memory.flac</code> matches <code className="mono">Cats/07 Memory.mp3</code>. Version words
must agree ("Live", "Remix", "Reprise"…), ambiguous candidates are skipped, and approximate matches always go to manual review.
</p>
<div className="mt-1.5 space-y-3 pl-11.5 text-xs text-muted">
<p>
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.
</p>
<div className="space-y-2 rounded-md border border-line bg-bg/60 p-3">
{approxExamples.map((ex) => (
<div key={ex.label}>
<div className="font-medium text-gray-300">{ex.label}</div>
<div className="mt-0.5 grid grid-cols-[auto_1fr] gap-x-2 mono text-[11px] leading-relaxed">
<span className="text-gray-500">slave</span>
<span className="break-all">{ex.slave}</span>
<span className="text-gray-500">master</span>
<span className="break-all text-gray-200">{ex.master}</span>
</div>
</div>
))}
</div>
<ul className="list-disc space-y-0.5 pl-4">
<li>Different recordings never match: live, remix, acoustic, instrumental and similar versions are kept apart.</li>
<li>When a song appears on several releases, the studio album is preferred over singles, EPs and compilations.</li>
<li>Matches outside the artist's folder need matching artist tags. Anything ambiguous is skipped.</li>
<li>Approximate matches are never linked automatically. Each one waits in Review.</li>
</ul>
</div>
{f.approx_matching && (
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 pl-11.5">
<label className="whitespace-nowrap text-xs text-muted" htmlFor="approx-threshold">
Minimum title similarity
</label>
<input
id="approx-threshold"
type="range"
min={60}
max={100}
step={1}
value={f.approx_threshold}
onChange={(e) => set("approx_threshold", Number(e.target.value))}
className="w-40 accent-violet-500"
/>
<span className="w-10 text-sm tabular-nums">{f.approx_threshold}%</span>
<span className="basis-full text-xs text-muted">Lower finds more matches, but also more false positives. 85% is a good default.</span>
<div className="mt-4 pl-11.5">
<div className="flex items-center gap-3">
<label className="whitespace-nowrap text-sm font-medium" htmlFor="approx-threshold">
Minimum title similarity
</label>
<input
id="approx-threshold"
type="range"
min={60}
max={100}
step={1}
value={f.approx_threshold}
onChange={(e) => set("approx_threshold", Number(e.target.value))}
className="w-40 accent-violet-500"
/>
<span className="w-10 text-sm tabular-nums">{f.approx_threshold}%</span>
</div>
<p className="mt-1 text-xs text-muted">Lower finds more matches but risks more false ones. 85% is a good default.</p>
</div>
)}
</div>
@@ -199,6 +221,24 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
);
}
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 (
<Field label={props.label} hint={props.hint}>
+19 -4
View File
@@ -277,9 +277,15 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
const sp = m.slave_probe;
const v = m.verdict;
const renamed = m.rel_master.split("/").pop() !== m.rel_slave.split("/").pop();
const dir = (p: string) => p.split("/").slice(0, -1).join("/");
const base = (p: string) => p.split("/").pop() ?? p;
// Cross-folder matches mirror the master's folders unless the user keeps the slave's folder.
const [keepFolder, setKeepFolder] = useState(false);
const movesFolder = dir(m.link_target) !== dir(m.rel_slave);
const target = keepFolder && movesFolder ? [dir(m.rel_slave), base(m.rel_master)].filter(Boolean).join("/") : m.link_target;
const link = useMutation({
mutationFn: (allow: boolean) => api.post<{ dry_run: boolean }>(`/matches/${m.id}/link`, { allow_downgrade: allow }),
mutationFn: (allow: boolean) => api.post<{ dry_run: boolean }>(`/matches/${m.id}/link`, { allow_downgrade: allow, keep_folder: keepFolder }),
onSuccess: (op) => {
setConfirm(null);
toast("ok", op.dry_run ? "Planned (dry run): nothing changed" : "Linked. The original slave file is in quarantine.");
@@ -357,10 +363,19 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
slave's better copy goes to quarantine), or override and make the slave's copy the master file.
</Alert>
)}
{renamed && (
{target !== m.rel_slave && (
<Alert tone="blue">
File names differ. Linking puts <code className="mono">{m.rel_master.split("/").pop()}</code> into the slave folder in place of{" "}
<code className="mono">{m.rel_slave.split("/").pop()}</code>, so playlists that point at the old name will need updating.
<div>
Linking places the file at <code className="mono break-all text-white">{target}</code> in the slave library
{movesFolder && !keepFolder ? ", matching the master's folders. The old folder is removed if it ends up empty." : "."} Playlists
that point at the old path will need updating.
</div>
{movesFolder && (
<label className="mt-2 flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" className="accent-violet-500" checked={keepFolder} onChange={(e) => setKeepFolder(e.target.checked)} />
Keep it in the slave's current folder instead: <code className="mono">{dir(m.rel_slave) || "library root"}</code>
</label>
)}
</Alert>
)}
</div>