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
+38
-20
@@ -104,6 +104,8 @@ type Pair struct {
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
SettleMinutes int `json:"settle_minutes"`
|
||||
ApproxMatching bool `json:"approx_matching"`
|
||||
ApproxThreshold int `json:"approx_threshold"` // percent
|
||||
LastScanAt *int64 `json:"last_scan_at"`
|
||||
LastScanError string `json:"last_scan_error"`
|
||||
LastScanWarnings []string `json:"last_scan_warnings"`
|
||||
@@ -111,7 +113,8 @@ type Pair struct {
|
||||
}
|
||||
|
||||
const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical,
|
||||
scan_interval_minutes, retention_days, settle_minutes, last_scan_at, last_scan_error, last_scan_warnings, created_at`
|
||||
scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, last_scan_at, last_scan_error,
|
||||
last_scan_warnings, created_at`
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
@@ -120,7 +123,8 @@ func scanPair(r scanner) (Pair, error) {
|
||||
var exts, warns string
|
||||
var last sql.NullInt64
|
||||
err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical,
|
||||
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &last, &p.LastScanError, &warns, &p.CreatedAt)
|
||||
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &last, &p.LastScanError,
|
||||
&warns, &p.CreatedAt)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
@@ -164,10 +168,10 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
|
||||
exts := strings.Join(p.Extensions, ",")
|
||||
if p.ID == 0 {
|
||||
res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions,
|
||||
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, now())
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -175,10 +179,10 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
|
||||
return nil
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?,
|
||||
extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?
|
||||
WHERE id = ?`,
|
||||
extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?,
|
||||
approx_matching = ?, approx_threshold = ? WHERE id = ?`,
|
||||
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ID)
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, p.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -216,11 +220,12 @@ const (
|
||||
|
||||
// Match kinds (how the master candidate was found).
|
||||
const (
|
||||
KindPath = "path" // identical relative path
|
||||
KindPathCase = "path_case" // relative path differs only by case
|
||||
KindExtension = "extension" // same directory and title, different extension
|
||||
KindFuzzy = "fuzzy" // same directory, normalized title matches
|
||||
KindElsewhere = "elsewhere" // same top-level folder and title, different subfolder
|
||||
KindPath = "path" // identical relative path
|
||||
KindPathCase = "path_case" // relative path differs only by case
|
||||
KindExtension = "extension" // same directory and title, different extension
|
||||
KindFuzzy = "fuzzy" // same directory, normalized title matches
|
||||
KindElsewhere = "elsewhere" // same top-level folder and title, different subfolder
|
||||
KindApprox = "approximate" // opt-in: similar title, disc folders ignored
|
||||
KindNone = "none"
|
||||
)
|
||||
|
||||
@@ -241,18 +246,19 @@ type Match struct {
|
||||
SlaveProbe json.RawMessage `json:"slave_probe,omitempty"`
|
||||
Verdict json.RawMessage `json:"verdict,omitempty"`
|
||||
Note string `json:"note"`
|
||||
Reason string `json:"reason"` // why an approximate match was chosen
|
||||
Ignored bool `json:"ignored"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
const matchCols = `id, pair_id, rel_slave, rel_master, kind, status, master_ino, master_size, master_mtime,
|
||||
slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, ignored, updated_at`
|
||||
slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, reason, ignored, updated_at`
|
||||
|
||||
func scanMatch(r scanner) (Match, error) {
|
||||
var m Match
|
||||
var mp, sp, v string
|
||||
err := r.Scan(&m.ID, &m.PairID, &m.RelSlave, &m.RelMaster, &m.Kind, &m.Status, &m.MasterIno, &m.MasterSize, &m.MasterMtime,
|
||||
&m.SlaveIno, &m.SlaveSize, &m.SlaveMtime, &mp, &sp, &v, &m.Note, &m.Ignored, &m.UpdatedAt)
|
||||
&m.SlaveIno, &m.SlaveSize, &m.SlaveMtime, &mp, &sp, &v, &m.Note, &m.Reason, &m.Ignored, &m.UpdatedAt)
|
||||
if mp != "" {
|
||||
m.MasterProbe = json.RawMessage(mp)
|
||||
}
|
||||
@@ -316,6 +322,8 @@ func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Mat
|
||||
where = append(where, "status IN ('different','similar') AND ignored = 0")
|
||||
case "ignored":
|
||||
where = append(where, "ignored = 1")
|
||||
case "upgrade":
|
||||
where = append(where, "status IN ('different','similar') AND ignored = 0 AND "+upgradeCond)
|
||||
default:
|
||||
where = append(where, "status = ? AND ignored = 0")
|
||||
args = append(args, f.Status)
|
||||
@@ -371,15 +379,15 @@ func (s *Store) IdenticalIDs(ctx context.Context, pairID int64) ([]int64, error)
|
||||
func (s *Store) UpsertMatch(ctx context.Context, m *Match) error {
|
||||
nullable := func(r json.RawMessage) string { return string(r) }
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO matches (pair_id, rel_slave, rel_master, kind, status, master_ino, master_size,
|
||||
master_mtime, slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, ignored, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
master_mtime, slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, reason, ignored, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(pair_id, rel_slave) DO UPDATE SET rel_master = excluded.rel_master, kind = excluded.kind,
|
||||
status = excluded.status, master_ino = excluded.master_ino, master_size = excluded.master_size,
|
||||
master_mtime = excluded.master_mtime, slave_ino = excluded.slave_ino, slave_size = excluded.slave_size,
|
||||
slave_mtime = excluded.slave_mtime, master_probe = excluded.master_probe, slave_probe = excluded.slave_probe,
|
||||
verdict = excluded.verdict, note = excluded.note, ignored = excluded.ignored, updated_at = excluded.updated_at`,
|
||||
verdict = excluded.verdict, note = excluded.note, reason = excluded.reason, ignored = excluded.ignored, updated_at = excluded.updated_at`,
|
||||
m.PairID, m.RelSlave, m.RelMaster, m.Kind, m.Status, m.MasterIno, m.MasterSize, m.MasterMtime, m.SlaveIno, m.SlaveSize,
|
||||
m.SlaveMtime, nullable(m.MasterProbe), nullable(m.SlaveProbe), nullable(m.Verdict), m.Note, m.Ignored, now())
|
||||
m.SlaveMtime, nullable(m.MasterProbe), nullable(m.SlaveProbe), nullable(m.Verdict), m.Note, m.Reason, m.Ignored, now())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -433,9 +441,14 @@ func (s *Store) LinkedMasterPaths(ctx context.Context, pairID int64, prefix stri
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// upgradeCond selects review matches where the master copy scored better, so
|
||||
// linking upgrades the slave.
|
||||
const upgradeCond = `verdict LIKE '%"better":"master"%'`
|
||||
|
||||
type Summary struct {
|
||||
Counts map[string]int `json:"counts"`
|
||||
Review int `json:"review"`
|
||||
Upgrades int `json:"upgrades"`
|
||||
Ignored int `json:"ignored"`
|
||||
ReclaimableSize int64 `json:"reclaimable_bytes"`
|
||||
LinkedSize int64 `json:"linked_bytes"`
|
||||
@@ -470,7 +483,12 @@ func (s *Store) Summary(ctx context.Context, pairID int64) (Summary, error) {
|
||||
sum.Review += n
|
||||
}
|
||||
}
|
||||
return sum, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return sum, err
|
||||
}
|
||||
err = s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM matches WHERE pair_id = ? AND ignored = 0
|
||||
AND status IN ('different','similar') AND `+upgradeCond, pairID).Scan(&sum.Upgrades)
|
||||
return sum, err
|
||||
}
|
||||
|
||||
// ---- operations ----
|
||||
|
||||
Reference in new issue
Block a user