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
+49
-10
@@ -21,7 +21,26 @@ import (
|
||||
type LinkOptions struct {
|
||||
// AllowDowngrade confirms linking when the master copy scored lower.
|
||||
AllowDowngrade bool
|
||||
Trigger string
|
||||
// KeepFolder keeps a cross-folder match in the slave's current folder
|
||||
// instead of mirroring the master's folder structure.
|
||||
KeepFolder bool
|
||||
Trigger string
|
||||
}
|
||||
|
||||
// LinkTarget is where the slave file ends up after linking. A match in the
|
||||
// same folder keeps the slave's folder (only the file name follows the
|
||||
// master). A match found in another folder, such as a single matched to its
|
||||
// album version, mirrors the master's path so the slave gets the album's
|
||||
// folder structure, unless keepFolder is set.
|
||||
func LinkTarget(relSlave, relMaster string, keepFolder bool) string {
|
||||
if relMaster == "" {
|
||||
return relSlave
|
||||
}
|
||||
sd := filepath.Dir(relSlave)
|
||||
if keepFolder || strings.EqualFold(sd, filepath.Dir(relMaster)) {
|
||||
return filepath.Join(sd, filepath.Base(relMaster))
|
||||
}
|
||||
return relMaster
|
||||
}
|
||||
|
||||
// snapshot holds the verified state of both files right before an operation.
|
||||
@@ -186,7 +205,7 @@ func (e *Engine) Link(ctx context.Context, matchID int64, opt LinkOptions) (stor
|
||||
}
|
||||
}
|
||||
|
||||
newRel := filepath.Join(filepath.Dir(m.RelSlave), filepath.Base(m.RelMaster))
|
||||
newRel := LinkTarget(m.RelSlave, m.RelMaster, opt.KeepFolder)
|
||||
newAbs, err := l.slave.Resolve(newRel)
|
||||
if err != nil {
|
||||
return store.Operation{}, err
|
||||
@@ -199,7 +218,7 @@ func (e *Engine) Link(ctx context.Context, matchID int64, opt LinkOptions) (stor
|
||||
}
|
||||
|
||||
o := store.Operation{PairID: l.pair.ID, Action: store.ActLink, RelMaster: m.RelMaster, RelSlave: m.RelSlave,
|
||||
RelNew: newRel, Trigger: opt.Trigger}
|
||||
RelNew: newRel, Trigger: opt.Trigger, MatchKind: m.Kind, MatchReason: m.Reason}
|
||||
if identical {
|
||||
o.Bytes = s.si.Size // space freed
|
||||
} else {
|
||||
@@ -237,19 +256,28 @@ func (e *Engine) Link(ctx context.Context, matchID int64, opt LinkOptions) (stor
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := fsops.LinkNew(s.masterAbs, s.mi, newAbs); err != nil {
|
||||
err := fsops.MkdirAllLike(filepath.Dir(newAbs))
|
||||
if err == nil {
|
||||
err = fsops.LinkNew(s.masterAbs, s.mi, newAbs)
|
||||
}
|
||||
if err != nil {
|
||||
pruneEmpty(filepath.Dir(newAbs), l.slave.Path)
|
||||
if q != "" {
|
||||
dropQuarantine(l, q, s.si)
|
||||
e.clearQuarantine(ctx, o.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return removeOriginal(s.slaveAbs, s.si)
|
||||
if err := removeOriginal(s.slaveAbs, s.si); err != nil {
|
||||
return err
|
||||
}
|
||||
pruneEmpty(filepath.Dir(s.slaveAbs), l.slave.Path) // e.g. the single's now-empty folder
|
||||
return nil
|
||||
}()
|
||||
err = e.finish(ctx, &o, err)
|
||||
e.refresh(ctx, l, m.RelSlave)
|
||||
e.refresh(ctx, l, m.RelSlave, m)
|
||||
if rename {
|
||||
e.refresh(ctx, l, newRel)
|
||||
e.refresh(ctx, l, newRel, m)
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
@@ -295,7 +323,7 @@ func (e *Engine) Promote(ctx context.Context, matchID int64, confirmed bool) (st
|
||||
}
|
||||
|
||||
o := store.Operation{PairID: l.pair.ID, Action: store.ActPromote, RelMaster: m.RelMaster, RelSlave: m.RelSlave,
|
||||
RelNew: newMasterRel, QuarantineSide: "master", Trigger: "manual"}
|
||||
RelNew: newMasterRel, QuarantineSide: "master", Trigger: "manual", MatchKind: m.Kind, MatchReason: m.Reason}
|
||||
if err := e.begin(ctx, &o); err != nil {
|
||||
return o, err
|
||||
}
|
||||
@@ -330,7 +358,11 @@ func (e *Engine) Promote(ctx context.Context, matchID int64, confirmed bool) (st
|
||||
return removeOriginal(s.masterAbs, s.mi)
|
||||
}()
|
||||
err = e.finish(ctx, &o, err)
|
||||
e.refresh(ctx, l, m.RelSlave)
|
||||
promoted := m
|
||||
if err == nil {
|
||||
promoted.RelMaster = newMasterRel // the master file may have been renamed
|
||||
}
|
||||
e.refresh(ctx, l, m.RelSlave, promoted)
|
||||
return o, err
|
||||
}
|
||||
|
||||
@@ -583,6 +615,11 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
|
||||
if _, err := os.Lstat(slaveAbs); err == nil {
|
||||
return fmt.Errorf("%s: %w", o.RelSlave, fsops.ErrExists)
|
||||
}
|
||||
// The original folder may have been pruned when the link moved
|
||||
// the file into the master's folder structure.
|
||||
if err := fsops.MkdirAllLike(filepath.Dir(slaveAbs)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if o.QuarantinePath == "" {
|
||||
// Identical link: give the slave its own copy of the bytes again.
|
||||
@@ -608,6 +645,7 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
|
||||
if err := fsops.RemoveLink(newAbs, mi); err != nil {
|
||||
return err
|
||||
}
|
||||
pruneEmpty(filepath.Dir(newAbs), l.slave.Path)
|
||||
}
|
||||
e.refresh(ctx, l, o.RelNew)
|
||||
|
||||
@@ -653,7 +691,8 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
|
||||
return err
|
||||
}
|
||||
e.log.Info("operation undone", "op", o.ID, "action", o.Action)
|
||||
e.refresh(ctx, l, o.RelSlave)
|
||||
// Restore the original pairing (the master path before any promote rename).
|
||||
e.refresh(ctx, l, o.RelSlave, store.Match{RelMaster: o.RelMaster, Kind: o.MatchKind, Reason: o.MatchReason})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user