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>
456 lines
13 KiB
Go
456 lines
13 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/cdnutter/playlink/internal/fsops"
|
|
"github.com/cdnutter/playlink/internal/probe"
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
)
|
|
|
|
type walkStats struct {
|
|
files int
|
|
symlinks int
|
|
}
|
|
|
|
// walk lists audio files beneath root. Hidden entries, playlink staging files,
|
|
// the quarantine and symlinks are never followed or returned.
|
|
func walk(ctx context.Context, l *libs, root fsops.Root, out map[string]fsops.Info) (walkStats, error) {
|
|
var ws walkStats
|
|
err := filepath.WalkDir(root.Path, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
if p == root.Path {
|
|
return err
|
|
}
|
|
return nil // unreadable subtree: skip rather than abort
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
name := d.Name()
|
|
if p != root.Path && strings.HasPrefix(name, ".") {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
if p == l.quarantine {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.Type()&fs.ModeSymlink != 0 {
|
|
ws.symlinks++
|
|
return nil
|
|
}
|
|
if !d.Type().IsRegular() || !l.hasExt(name) {
|
|
return nil
|
|
}
|
|
in, err := fsops.Lstat(p)
|
|
if err != nil || !in.Mode.IsRegular() {
|
|
return nil
|
|
}
|
|
rel, err := root.Rel(p)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
out[rel] = in
|
|
ws.files++
|
|
return nil
|
|
})
|
|
return ws, err
|
|
}
|
|
|
|
var trackPrefix = regexp.MustCompile(`^\s*(?:\d{1,2}[-.]\s*)?\d{1,3}(?:\s*[-._)]\s*|\s+)`)
|
|
|
|
// normTitle makes a conservative comparison key from a file name: the track
|
|
// number prefix is stripped and punctuation/case ignored. Parenthesised text
|
|
// is kept, so "Song (Live)" never matches "Song".
|
|
func normTitle(name string) string {
|
|
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
|
if s := trackPrefix.ReplaceAllString(stem, ""); strings.TrimSpace(s) != "" {
|
|
stem = s
|
|
}
|
|
var b strings.Builder
|
|
for _, r := range strings.ToLower(stem) {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func firstComponent(rel string) string {
|
|
if i := strings.IndexByte(rel, filepath.Separator); i >= 0 {
|
|
return rel[:i]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// masterIndex supports the fallback lookups for slave files without an exact
|
|
// path counterpart.
|
|
type masterIndex struct {
|
|
files map[string]fsops.Info
|
|
lower map[string]string // lower(rel) -> rel
|
|
byDir map[string][]string // lower(dir) -> rels
|
|
byTitle map[string][]string // lower(top folder)|normTitle -> rels
|
|
|
|
approx *approxIndex // nil unless the pair enables approximate matching
|
|
threshold float64
|
|
}
|
|
|
|
func buildIndex(files map[string]fsops.Info, p store.Pair) *masterIndex {
|
|
ix := &masterIndex{files: files, lower: map[string]string{}, byDir: map[string][]string{}, byTitle: map[string][]string{}}
|
|
rels := sortedKeys(files)
|
|
if p.ApproxMatching {
|
|
ix.approx = buildApproxIndex(rels)
|
|
ix.threshold = float64(p.ApproxThreshold) / 100
|
|
}
|
|
for _, rel := range rels {
|
|
ix.lower[strings.ToLower(rel)] = rel
|
|
d := strings.ToLower(filepath.Dir(rel))
|
|
ix.byDir[d] = append(ix.byDir[d], rel)
|
|
k := strings.ToLower(firstComponent(rel)) + "|" + normTitle(filepath.Base(rel))
|
|
ix.byTitle[k] = append(ix.byTitle[k], rel)
|
|
}
|
|
return ix
|
|
}
|
|
|
|
// losslessRank orders extension candidates so a lossless master file is
|
|
// preferred when several share the same title.
|
|
func extRank(rel string) int {
|
|
switch strings.ToLower(filepath.Ext(rel)) {
|
|
case ".flac", ".wav", ".aiff", ".aif", ".alac", ".ape", ".wv", ".dsf", ".dff":
|
|
return 0
|
|
case ".m4a", ".opus", ".ogg":
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// find locates the master counterpart of a slave file. The reason is only
|
|
// set for approximate matches.
|
|
func (ix *masterIndex) find(relSlave string) (rel, kind, reason string) {
|
|
if _, ok := ix.files[relSlave]; ok {
|
|
return relSlave, store.KindPath, ""
|
|
}
|
|
if r, ok := ix.lower[strings.ToLower(relSlave)]; ok {
|
|
return r, store.KindPathCase, ""
|
|
}
|
|
dir := strings.ToLower(filepath.Dir(relSlave))
|
|
base := filepath.Base(relSlave)
|
|
stem := strings.ToLower(strings.TrimSuffix(base, filepath.Ext(base)))
|
|
var extHits, fuzzyHits []string
|
|
norm := normTitle(base)
|
|
for _, r := range ix.byDir[dir] {
|
|
b := filepath.Base(r)
|
|
if strings.ToLower(strings.TrimSuffix(b, filepath.Ext(b))) == stem {
|
|
extHits = append(extHits, r)
|
|
} else if norm != "" && normTitle(b) == norm {
|
|
fuzzyHits = append(fuzzyHits, r)
|
|
}
|
|
}
|
|
if len(extHits) > 0 {
|
|
best := extHits[0]
|
|
for _, r := range extHits[1:] {
|
|
if extRank(r) < extRank(best) {
|
|
best = r
|
|
}
|
|
}
|
|
return best, store.KindExtension, ""
|
|
}
|
|
if len(fuzzyHits) == 1 {
|
|
return fuzzyHits[0], store.KindFuzzy, ""
|
|
}
|
|
// Approximate matching runs before the top-folder fallback because it
|
|
// 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 != "" {
|
|
return r, store.KindApprox, why
|
|
}
|
|
}
|
|
if top := firstComponent(relSlave); top != "" && norm != "" {
|
|
if hits := ix.byTitle[strings.ToLower(top)+"|"+norm]; len(hits) == 1 {
|
|
return hits[0], store.KindElsewhere, ""
|
|
}
|
|
}
|
|
return "", store.KindNone, ""
|
|
}
|
|
|
|
// classify fills in status, probes and verdict for a match, reusing cached
|
|
// results from prev when neither file changed.
|
|
func (e *Engine) classify(ctx context.Context, l *libs, m *store.Match, mi, si fsops.Info, prev *store.Match) {
|
|
m.SlaveIno, m.SlaveSize, m.SlaveMtime = si.Ino, si.Size, si.MtimeNs
|
|
m.MasterIno, m.MasterSize, m.MasterMtime = 0, 0, 0
|
|
m.MasterProbe, m.SlaveProbe, m.Verdict, m.Note = nil, nil, nil, ""
|
|
if m.Kind == store.KindNone {
|
|
m.Status = store.StatusUnmatched
|
|
return
|
|
}
|
|
m.MasterIno, m.MasterSize, m.MasterMtime = mi.Ino, mi.Size, mi.MtimeNs
|
|
|
|
unchanged := prev != nil && prev.RelMaster == m.RelMaster &&
|
|
prev.MasterIno == mi.Ino && prev.MasterSize == mi.Size && prev.MasterMtime == mi.MtimeNs &&
|
|
prev.SlaveIno == si.Ino && prev.SlaveSize == si.Size && prev.SlaveMtime == si.MtimeNs
|
|
if prev != nil && prev.RelMaster == m.RelMaster {
|
|
m.Ignored = prev.Ignored
|
|
}
|
|
|
|
switch {
|
|
case mi.Dev != si.Dev:
|
|
m.Status, m.Note = store.StatusError, "files are on different filesystems"
|
|
return
|
|
case mi.SameInode(si):
|
|
m.Status = store.StatusLinked
|
|
return
|
|
}
|
|
|
|
pathKind := m.Kind == store.KindPath || m.Kind == store.KindPathCase
|
|
if pathKind && mi.Size == si.Size {
|
|
if unchanged && (prev.Status == store.StatusIdentical || prev.Status == store.StatusDifferent) {
|
|
m.Status = prev.Status
|
|
} else {
|
|
masterAbs := filepath.Join(l.master.Path, m.RelMaster)
|
|
slaveAbs := filepath.Join(l.slave.Path, m.RelSlave)
|
|
same, err := fsops.SameContent(masterAbs, slaveAbs)
|
|
switch {
|
|
case err != nil:
|
|
m.Status, m.Note = store.StatusError, "compare failed: "+err.Error()
|
|
return
|
|
case same:
|
|
m.Status = store.StatusIdentical
|
|
default:
|
|
m.Status = store.StatusDifferent
|
|
}
|
|
}
|
|
if m.Status == store.StatusIdentical {
|
|
return
|
|
}
|
|
} else if pathKind {
|
|
m.Status = store.StatusDifferent
|
|
} else {
|
|
m.Status = store.StatusSimilar
|
|
}
|
|
|
|
// Manual review: gather codec details for both sides.
|
|
if unchanged && len(prev.MasterProbe) > 0 && len(prev.SlaveProbe) > 0 {
|
|
m.MasterProbe, m.SlaveProbe, m.Verdict = prev.MasterProbe, prev.SlaveProbe, prev.Verdict
|
|
return
|
|
}
|
|
mp := e.prober.Probe(ctx, filepath.Join(l.master.Path, m.RelMaster))
|
|
sp := e.prober.Probe(ctx, filepath.Join(l.slave.Path, m.RelSlave))
|
|
v := probe.Compare(mp, sp)
|
|
m.MasterProbe, _ = json.Marshal(mp)
|
|
m.SlaveProbe, _ = json.Marshal(sp)
|
|
m.Verdict, _ = json.Marshal(v)
|
|
}
|
|
|
|
// StartScan launches a background scan of a pair.
|
|
func (e *Engine) StartScan(pairID int64, trigger string) (*Job, error) {
|
|
return e.startJob(pairID, "scan", func(ctx context.Context, j *Job) error {
|
|
return e.scan(ctx, j, pairID, trigger)
|
|
})
|
|
}
|
|
|
|
func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string) error {
|
|
started := time.Now()
|
|
l, err := e.loadPair(ctx, pairID)
|
|
if err != nil {
|
|
_ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), nil)
|
|
return err
|
|
}
|
|
warns := layoutWarnings(l)
|
|
|
|
j.phase("Indexing master library", 0)
|
|
masterFiles := map[string]fsops.Info{}
|
|
ms, err := walk(ctx, l, l.master, masterFiles)
|
|
if err != nil {
|
|
_ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), warns)
|
|
return err
|
|
}
|
|
j.phase("Indexing slave library", 0)
|
|
slaveFiles := map[string]fsops.Info{}
|
|
ss, err := walk(ctx, l, l.slave, slaveFiles)
|
|
if err != nil {
|
|
_ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), warns)
|
|
return err
|
|
}
|
|
ix := buildIndex(masterFiles, l.pair)
|
|
prev, err := e.st.AllMatches(ctx, pairID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
j.phase("Comparing files", int64(len(slaveFiles)))
|
|
keep := map[string]bool{}
|
|
kinds := map[string]int{}
|
|
for _, rel := range sortedKeys(slaveFiles) {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
si := slaveFiles[rel]
|
|
m := store.Match{PairID: pairID, RelSlave: rel}
|
|
m.RelMaster, m.Kind, m.Reason = ix.find(rel)
|
|
var pm *store.Match
|
|
if p, ok := prev[rel]; ok {
|
|
pm = &p
|
|
}
|
|
e.classify(ctx, l, &m, masterFiles[m.RelMaster], si, pm)
|
|
if err := e.st.UpsertMatch(ctx, &m); err != nil {
|
|
return err
|
|
}
|
|
keep[rel] = true
|
|
kinds[m.Kind]++
|
|
j.count(m.Status)
|
|
j.tick()
|
|
}
|
|
if err := e.st.DeleteMatchesExcept(ctx, pairID, keep); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Layout warnings based on what was actually found.
|
|
if ss.files > 0 {
|
|
if n := kinds[store.KindElsewhere] + kinds[store.KindApprox] + kinds[store.KindNone]; n*4 > ss.files {
|
|
warns = append(warns, fmt.Sprintf("%d of %d slave files have no counterpart at the same path in the master library. "+
|
|
"Check that both libraries use the same folder layout.", n, ss.files))
|
|
}
|
|
}
|
|
if ms.symlinks+ss.symlinks > 0 {
|
|
warns = append(warns, fmt.Sprintf("Ignored %d symlinks (playlink never follows or replaces symlinks).", ms.symlinks+ss.symlinks))
|
|
}
|
|
if e.prober.Bin == "" {
|
|
warns = append(warns, "ffprobe was not found, so codec and quality details are unavailable for manual review.")
|
|
}
|
|
if err := e.st.FinishScan(ctx, pairID, time.Now().Unix(), "", warns); err != nil {
|
|
return err
|
|
}
|
|
j.update(func(j *Job) {
|
|
j.Message = fmt.Sprintf("Scanned %d master and %d slave files in %s", ms.files, ss.files, time.Since(started).Round(time.Second))
|
|
})
|
|
|
|
if l.pair.AutoLinkIdentical {
|
|
return e.autoLink(ctx, j, l, trigger)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// autoLink links settled, byte-identical, same-path files after a scan.
|
|
func (e *Engine) autoLink(ctx context.Context, j *Job, l *libs, trigger string) error {
|
|
ids, err := e.st.IdenticalIDs(ctx, l.pair.ID)
|
|
if err != nil || len(ids) == 0 {
|
|
return err
|
|
}
|
|
if e.st.DryRun(ctx) {
|
|
j.update(func(j *Job) {
|
|
j.Message += fmt.Sprintf(". Dry run is on: %d identical files would be auto-linked.", len(ids))
|
|
})
|
|
return nil
|
|
}
|
|
return e.linkIDs(ctx, j, l.pair.ID, ids, "auto:"+trigger, true)
|
|
}
|
|
|
|
// linkIDs links a batch of identical matches.
|
|
func (e *Engine) linkIDs(ctx context.Context, j *Job, pairID int64, ids []int64, trigger string, onlySettled bool) error {
|
|
j.phase("Linking identical files", int64(len(ids)))
|
|
settle := time.Duration(0)
|
|
if onlySettled {
|
|
if p, err := e.st.Pair(ctx, pairID); err == nil {
|
|
settle = time.Duration(p.SettleMinutes) * time.Minute
|
|
}
|
|
}
|
|
cutoff := time.Now().Add(-settle).UnixNano()
|
|
for _, id := range ids {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
m, err := e.st.Match(ctx, id)
|
|
if err != nil {
|
|
j.tick()
|
|
continue
|
|
}
|
|
if onlySettled && m.Kind != store.KindPath {
|
|
j.skip(m.RelSlave + ": path differs by case, link manually")
|
|
j.tick()
|
|
continue
|
|
}
|
|
if settle > 0 && (m.SlaveMtime > cutoff || m.MasterMtime > cutoff) {
|
|
j.skip(m.RelSlave + ": modified recently, waiting for it to settle")
|
|
j.tick()
|
|
continue
|
|
}
|
|
op, err := e.Link(ctx, id, LinkOptions{Trigger: trigger})
|
|
switch {
|
|
case err != nil:
|
|
j.skip(m.RelSlave + ": " + err.Error())
|
|
case op.DryRun:
|
|
j.count("planned")
|
|
default:
|
|
j.count("linked")
|
|
}
|
|
j.tick()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StartLinkIdentical links every identical match of a pair in the background.
|
|
func (e *Engine) StartLinkIdentical(pairID int64) (*Job, error) {
|
|
return e.startJob(pairID, "link-identical", func(ctx context.Context, j *Job) error {
|
|
ids, err := e.st.IdenticalIDs(ctx, pairID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(ids) == 0 {
|
|
j.update(func(j *Job) { j.Message = "Nothing to link" })
|
|
return nil
|
|
}
|
|
return e.linkIDs(ctx, j, pairID, ids, "bulk", false)
|
|
})
|
|
}
|
|
|
|
// refresh re-evaluates a single slave file after an operation.
|
|
func (e *Engine) refresh(ctx context.Context, l *libs, relSlave string) {
|
|
si, err := fsops.RegularFile(filepath.Join(l.slave.Path, relSlave))
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
_ = e.st.DeleteMatch(ctx, l.pair.ID, relSlave)
|
|
}
|
|
return
|
|
}
|
|
m := store.Match{PairID: l.pair.ID, RelSlave: relSlave}
|
|
var pm *store.Match
|
|
if p, err := e.st.MatchBySlave(ctx, l.pair.ID, relSlave); err == nil {
|
|
pm = &p
|
|
}
|
|
var mi fsops.Info
|
|
if mp, err := l.master.Resolve(relSlave); err == nil {
|
|
if in, err := fsops.RegularFile(mp); err == nil {
|
|
mi = in
|
|
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 {
|
|
mi = in
|
|
m.RelMaster, m.Kind, m.Reason = pm.RelMaster, pm.Kind, pm.Reason
|
|
}
|
|
}
|
|
}
|
|
if m.Kind == "" {
|
|
m.Kind = store.KindNone
|
|
}
|
|
e.classify(ctx, l, &m, mi, si, pm)
|
|
_ = e.st.UpsertMatch(ctx, &m)
|
|
}
|