Files
playlink/internal/engine/scan.go
T
cdnutterandClaude Opus 5.5 381b804742 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>
2026-09-22 16:58:53 -07:00

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)
}