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:
1 parent
381b804742
commit
5f895cab6a
12 files changed
+717
-128
No files matched your search
@@ -131,6 +131,7 @@ export type Match = {
|
||||
verdict?: Verdict;
|
||||
note: string;
|
||||
reason: string;
|
||||
link_target: string;
|
||||
ignored: boolean;
|
||||
};
|
||||
|
||||
|
||||
+61
-21
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user