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
+67
-9
@@ -107,6 +107,7 @@ type masterIndex struct {
|
||||
|
||||
approx *approxIndex // nil unless the pair enables approximate matching
|
||||
threshold float64
|
||||
verify VerifyFunc // confirms library-wide approximate matches; nil disables that scope
|
||||
}
|
||||
|
||||
func buildIndex(files map[string]fsops.Info, p store.Pair) *masterIndex {
|
||||
@@ -139,8 +140,21 @@ func extRank(rel string) int {
|
||||
}
|
||||
|
||||
// find locates the master counterpart of a slave file. The reason is only
|
||||
// set for approximate matches.
|
||||
// set for approximate matches. With approximate matching on, a match that
|
||||
// lands on a single, EP or compilation is replaced by the same recording on
|
||||
// a studio album when one exists, so the slave is linked to the album
|
||||
// version and takes on the album's folder structure.
|
||||
func (ix *masterIndex) find(relSlave string) (rel, kind, reason string) {
|
||||
rel, kind, reason = ix.strictFind(relSlave)
|
||||
if ix.approx != nil && rel != "" {
|
||||
if alt, why := ix.approx.albumAlternative(relSlave, rel, ix.threshold); alt != "" {
|
||||
return alt, store.KindApprox, why
|
||||
}
|
||||
}
|
||||
return rel, kind, reason
|
||||
}
|
||||
|
||||
func (ix *masterIndex) strictFind(relSlave string) (rel, kind, reason string) {
|
||||
if _, ok := ix.files[relSlave]; ok {
|
||||
return relSlave, store.KindPath, ""
|
||||
}
|
||||
@@ -176,12 +190,15 @@ func (ix *masterIndex) find(relSlave string) (rel, kind, reason string) {
|
||||
// searches the album (disc folders ignored) first, which resolves titles
|
||||
// that appear on several albums.
|
||||
if ix.approx != nil {
|
||||
if r, why := ix.approx.find(relSlave, ix.threshold); r != "" {
|
||||
if r, why := ix.approx.find(relSlave, ix.threshold, ix.verify); r != "" {
|
||||
return r, store.KindApprox, why
|
||||
}
|
||||
}
|
||||
if top := firstComponent(relSlave); top != "" && norm != "" {
|
||||
if hits := ix.byTitle[strings.ToLower(top)+"|"+norm]; len(hits) == 1 {
|
||||
// The hit is in another album folder, so it must be the same kind of
|
||||
// recording: a studio track never matches one on a live album.
|
||||
if hits := ix.byTitle[strings.ToLower(top)+"|"+norm]; len(hits) == 1 &&
|
||||
sameVersions(parseTitle(relSlave).versions, parseTitle(hits[0]).versions) {
|
||||
return hits[0], store.KindElsewhere, ""
|
||||
}
|
||||
}
|
||||
@@ -287,6 +304,9 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
|
||||
return err
|
||||
}
|
||||
ix := buildIndex(masterFiles, l.pair)
|
||||
if ix.approx != nil {
|
||||
ix.verify = e.artistVerifier(ctx, l)
|
||||
}
|
||||
prev, err := e.st.AllMatches(ctx, pairID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -418,8 +438,11 @@ func (e *Engine) StartLinkIdentical(pairID int64) (*Job, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// refresh re-evaluates a single slave file after an operation.
|
||||
func (e *Engine) refresh(ctx context.Context, l *libs, relSlave string) {
|
||||
// refresh re-evaluates a single slave file after an operation. The optional
|
||||
// hint names the master file the operation paired it with; it takes
|
||||
// precedence over the stored match, which may describe the pre-operation
|
||||
// state (or be gone, after a move).
|
||||
func (e *Engine) refresh(ctx context.Context, l *libs, relSlave string, hint ...store.Match) {
|
||||
si, err := fsops.RegularFile(filepath.Join(l.slave.Path, relSlave))
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
@@ -439,11 +462,18 @@ func (e *Engine) refresh(ctx context.Context, l *libs, relSlave string) {
|
||||
m.RelMaster, m.Kind = relSlave, store.KindPath
|
||||
}
|
||||
}
|
||||
if m.Kind == "" {
|
||||
if pm != nil && pm.RelMaster != "" {
|
||||
if in, err := fsops.RegularFile(filepath.Join(l.master.Path, pm.RelMaster)); err == nil {
|
||||
candidates := hint
|
||||
if pm != nil {
|
||||
candidates = append(candidates, *pm)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if m.Kind != "" || c.RelMaster == "" || c.Kind == "" || c.Kind == store.KindNone {
|
||||
continue
|
||||
}
|
||||
if mp, err := l.master.Resolve(c.RelMaster); err == nil {
|
||||
if in, err := fsops.RegularFile(mp); err == nil {
|
||||
mi = in
|
||||
m.RelMaster, m.Kind, m.Reason = pm.RelMaster, pm.Kind, pm.Reason
|
||||
m.RelMaster, m.Kind, m.Reason = c.RelMaster, c.Kind, c.Reason
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,3 +483,31 @@ func (e *Engine) refresh(ctx context.Context, l *libs, relSlave string) {
|
||||
e.classify(ctx, l, &m, mi, si, pm)
|
||||
_ = e.st.UpsertMatch(ctx, &m)
|
||||
}
|
||||
|
||||
// artistVerifier confirms a library-wide approximate match by comparing the
|
||||
// artist tags of both files. Without ffprobe, or when either file has no
|
||||
// artist tag, the match is rejected: a title alone is not enough evidence
|
||||
// once the folder structure no longer agrees.
|
||||
func (e *Engine) artistVerifier(ctx context.Context, l *libs) VerifyFunc {
|
||||
if e.prober.Bin == "" {
|
||||
return nil
|
||||
}
|
||||
cache := map[string]string{}
|
||||
artist := func(abs string) string {
|
||||
if a, ok := cache[abs]; ok {
|
||||
return a
|
||||
}
|
||||
a := strings.Join(words(e.prober.Probe(ctx, abs).Artist), " ")
|
||||
cache[abs] = a
|
||||
return a
|
||||
}
|
||||
return func(relSlave, relMaster string) bool {
|
||||
sa := artist(filepath.Join(l.slave.Path, relSlave))
|
||||
ma := artist(filepath.Join(l.master.Path, relMaster))
|
||||
if sa == "" || ma == "" {
|
||||
return false
|
||||
}
|
||||
// "Avenged Sevenfold" matches "Avenged Sevenfold feat Someone".
|
||||
return sa == ma || strings.HasPrefix(ma, sa+" ") || strings.HasPrefix(sa, ma+" ")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user