Add opt-in approximate matching and upgrade recommendations
A per-pair "Approximate matching" setting adds a second pass for slave files the strict rules could not place. It searches the same album with disc folders (Disc 02, CD2, ...) ignored, then the top-level folder, and scores titles by edit distance and word containment against a configurable threshold (default 85%), with a small track-number bonus. Guards against false matches: version words (live, remix, reprise, instrumental, ...) must agree, near-tied candidates are left unmatched, and approximate matches always go to manual review, never auto-link. Each match records a human-readable reason. Review now flags matches where the master copy is better as upgrades, with an Upgrades filter and count and an "Upgrade to master copy" action. The redundant "master is lossless" warning note is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b088bb4a6
commit
381b804742
12 files changed
+552
-51
No files matched your search
+7
-1
@@ -54,6 +54,7 @@ export type Job = {
|
||||
export type Summary = {
|
||||
counts: Record<string, number>;
|
||||
review: number;
|
||||
upgrades: number;
|
||||
ignored: number;
|
||||
reclaimable_bytes: number;
|
||||
linked_bytes: number;
|
||||
@@ -70,6 +71,8 @@ export type Pair = {
|
||||
scan_interval_minutes: number;
|
||||
retention_days: number;
|
||||
settle_minutes: number;
|
||||
approx_matching: boolean;
|
||||
approx_threshold: number;
|
||||
last_scan_at: number | null;
|
||||
last_scan_error: string;
|
||||
last_scan_warnings: string[];
|
||||
@@ -88,6 +91,8 @@ export type PairInput = Pick<
|
||||
| "scan_interval_minutes"
|
||||
| "retention_days"
|
||||
| "settle_minutes"
|
||||
| "approx_matching"
|
||||
| "approx_threshold"
|
||||
>;
|
||||
|
||||
export type ProbeInfo = {
|
||||
@@ -115,7 +120,7 @@ export type Match = {
|
||||
pair_id: number;
|
||||
rel_slave: string;
|
||||
rel_master: string;
|
||||
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "none";
|
||||
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "none";
|
||||
status: Status;
|
||||
master_size: number;
|
||||
master_mtime: number;
|
||||
@@ -125,6 +130,7 @@ export type Match = {
|
||||
slave_probe?: ProbeInfo;
|
||||
verdict?: Verdict;
|
||||
note: string;
|
||||
reason: string;
|
||||
ignored: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
scan_interval_minutes: 0,
|
||||
retention_days: 30,
|
||||
settle_minutes: 10,
|
||||
approx_matching: false,
|
||||
approx_threshold: 85,
|
||||
};
|
||||
const [f, setF] = useState<PairInput>(pair ?? blank);
|
||||
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
|
||||
@@ -47,6 +49,8 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
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,
|
||||
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
});
|
||||
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
|
||||
@@ -128,6 +132,37 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
Anything else always waits for manual review. Dry run still applies.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
|
||||
<Toggle
|
||||
checked={f.approx_matching}
|
||||
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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{validation && !validation.ok && <Alert tone="red">{validation.error}</Alert>}
|
||||
{validation?.ok && validation.warnings.length === 0 && <Alert tone="green">Looks good. Both folders are valid and on the same filesystem.</Alert>}
|
||||
|
||||
+31
-11
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, ArrowUpCircle, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
|
||||
import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api";
|
||||
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
const filters = [
|
||||
{ id: "review", label: "Needs review" },
|
||||
{ id: "upgrade", label: "Upgrades" },
|
||||
{ id: "identical", label: "Identical" },
|
||||
{ id: "linked", label: "Linked" },
|
||||
{ id: "unmatched", label: "Slave only" },
|
||||
@@ -21,6 +22,7 @@ const kindLabel: Record<Match["kind"], string> = {
|
||||
extension: "other format",
|
||||
fuzzy: "similar title",
|
||||
elsewhere: "same top folder, other subfolder",
|
||||
approximate: "approximate match",
|
||||
none: "no match",
|
||||
};
|
||||
|
||||
@@ -64,7 +66,7 @@ export function Review({ pair }: { pair: Pair }) {
|
||||
|
||||
const c = pair.summary.counts;
|
||||
const count = (id: string) =>
|
||||
id === "review" ? pair.summary.review : id === "ignored" ? pair.summary.ignored : id === "" ? undefined : c[id] ?? 0;
|
||||
id === "review" ? pair.summary.review : id === "upgrade" ? pair.summary.upgrades : id === "ignored" ? pair.summary.ignored : id === "" ? undefined : c[id] ?? 0;
|
||||
const identical = c.identical ?? 0;
|
||||
const running = pair.job?.state === "running";
|
||||
|
||||
@@ -211,6 +213,7 @@ function MatchRow({ m }: { m: Match }) {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1" onClick={() => reviewable && setOpen(!open)}>
|
||||
<PathLine m={m} />
|
||||
{m.reason && <div className="truncate text-[11px] text-muted" title={m.reason}>{m.reason}</div>}
|
||||
{m.note && <div className="text-xs text-red-300">{m.note}</div>}
|
||||
</div>
|
||||
{reviewable && (
|
||||
@@ -223,7 +226,11 @@ function MatchRow({ m }: { m: Match }) {
|
||||
<AlertTriangle className="size-3" /> master lower
|
||||
</Badge>
|
||||
)}
|
||||
{better === "master" && <Badge tone="green">master better</Badge>}
|
||||
{better === "master" && (
|
||||
<Badge tone="green">
|
||||
<ArrowUpCircle className="size-3" /> upgrade
|
||||
</Badge>
|
||||
)}
|
||||
{better === "equal" && <Badge>equal</Badge>}
|
||||
</div>
|
||||
)}
|
||||
@@ -266,7 +273,7 @@ const rows: [string, (p: ProbeInfo, size: number) => string][] = [
|
||||
function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
|
||||
const toast = useToast();
|
||||
const [confirm, setConfirm] = useState<null | "downgrade" | "promote">(null);
|
||||
const hp = m.master_probe;
|
||||
const mp = m.master_probe;
|
||||
const sp = m.slave_probe;
|
||||
const v = m.verdict;
|
||||
const renamed = m.rel_master.split("/").pop() !== m.rel_slave.split("/").pop();
|
||||
@@ -295,8 +302,8 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
|
||||
|
||||
return (
|
||||
<div className="border-t border-line bg-panel-2/50 px-4 py-4 md:pl-14">
|
||||
{(!hp || !sp) && <Alert tone="amber">No codec details available (ffprobe missing or failed). Compare the files yourself before linking.</Alert>}
|
||||
{hp && sp && (
|
||||
{(!mp || !sp) && <Alert tone="amber">No codec details available (ffprobe missing or failed). Compare the files yourself before linking.</Alert>}
|
||||
{mp && sp && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full max-w-2xl text-sm">
|
||||
<thead>
|
||||
@@ -312,7 +319,7 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([label, f]) => {
|
||||
const a = hp.error ? "–" : f(hp, m.master_size);
|
||||
const a = mp.error ? "–" : f(mp, m.master_size);
|
||||
const b = sp.error ? "–" : f(sp, m.slave_size);
|
||||
return (
|
||||
<tr key={label} className="border-t border-line/60">
|
||||
@@ -327,11 +334,23 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 space-y-2">
|
||||
{v?.notes?.map((n) => (
|
||||
{/* Scans before this change stored an upgrade note; the green alert below covers it. */}
|
||||
{v?.notes?.filter((n) => !n.startsWith("master is lossless")).map((n) => (
|
||||
<Alert key={n} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
|
||||
{n}
|
||||
</Alert>
|
||||
))}
|
||||
{m.kind === "approximate" && (
|
||||
<Alert tone="blue">
|
||||
Approximate match: {m.reason}. Check that the titles and durations really are the same song before linking.
|
||||
</Alert>
|
||||
)}
|
||||
{v?.better === "master" && (
|
||||
<Alert tone="green" icon={<ArrowUpCircle className="mt-0.5 size-4 shrink-0" />}>
|
||||
Recommended: the master copy is better ({qualityLabel(mp)} vs {qualityLabel(sp)}). Linking upgrades the slave to it and frees the
|
||||
slave's copy.
|
||||
</Alert>
|
||||
)}
|
||||
{v?.better === "slave" && (
|
||||
<Alert tone="amber" icon={<Crown className="mt-0.5 size-4 shrink-0" />}>
|
||||
The master copy is lower quality. The master is normally kept because it belongs to a complete album. You can still link to it (the
|
||||
@@ -347,7 +366,8 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="primary" onClick={() => (v?.better === "slave" ? setConfirm("downgrade") : link.mutate(false))} loading={link.isPending && !confirm}>
|
||||
<Link2 className="size-4" /> Link to master copy
|
||||
{v?.better === "master" ? <ArrowUpCircle className="size-4" /> : <Link2 className="size-4" />}
|
||||
{v?.better === "master" ? "Upgrade to master copy" : "Link to master copy"}
|
||||
</Button>
|
||||
<Button variant={v?.better === "slave" ? "warn" : "secondary"} onClick={() => setConfirm("promote")}>
|
||||
<Crown className="size-4" /> Use slave copy as master
|
||||
@@ -357,8 +377,8 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
|
||||
|
||||
<Modal open={confirm === "downgrade"} onClose={() => setConfirm(null)} title="Link to a lower-quality master file?">
|
||||
<p className="text-sm text-gray-300">
|
||||
The slave has the better copy ({qualityLabel(sp)} vs {qualityLabel(hp)}). Linking replaces it with the master's{" "}
|
||||
{qualityLabel(hp)} file. The slave's original goes to quarantine and can be restored until it is purged.
|
||||
The slave has the better copy ({qualityLabel(sp)} vs {qualityLabel(mp)}). Linking replaces it with the master's{" "}
|
||||
{qualityLabel(mp)} file. The slave's original goes to quarantine and can be restored until it is purged.
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setConfirm(null)}>
|
||||
|
||||
Reference in new issue
Block a user