// Package engine implements scanning, matching and the hardlink operations. package engine import ( "context" "errors" "fmt" "log/slog" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" ) // DefaultExtensions are the audio file types considered by default. var DefaultExtensions = []string{"flac", "mp3", "m4a", "aac", "alac", "ogg", "oga", "opus", "wav", "aif", "aiff", "wma", "ape", "wv", "dsf", "dff", "mka"} // QuarantineDirName is the default quarantine folder created in the slave root. const QuarantineDirName = ".playlink-trash" var ( ErrBusy = errors.New("another job is already running for this library pair") ErrDowngrade = errors.New("the master copy is lower quality than the slave copy; confirm the downgrade to continue") ErrConfirm = errors.New("this action modifies the master library and must be confirmed") ) type Engine struct { st *store.Store prober probe.Prober allowedRoots []string log *slog.Logger // mu serializes every filesystem mutation across all pairs. Throughput // is irrelevant next to the guarantee that two operations never race on // the same files. mu sync.Mutex jobsMu sync.Mutex jobs map[int64]*Job nextID int64 } func New(st *store.Store, prober probe.Prober, allowedRoots []string, log *slog.Logger) *Engine { return &Engine{st: st, prober: prober, allowedRoots: allowedRoots, log: log, jobs: map[int64]*Job{}} } func (e *Engine) AllowedRoots() []string { return e.allowedRoots } // ---- jobs ---- type Job struct { ID int64 `json:"id"` PairID int64 `json:"pair_id"` Kind string `json:"kind"` State string `json:"state"` // running, done, failed, cancelled Phase string `json:"phase"` Done int64 `json:"done"` Total int64 `json:"total"` Message string `json:"message"` Error string `json:"error"` Results map[string]int `json:"results"` Skipped []string `json:"skipped"` StartedAt int64 `json:"started_at"` FinishedAt int64 `json:"finished_at,omitempty"` cancel context.CancelFunc e *Engine } func (j *Job) update(f func(j *Job)) { j.e.jobsMu.Lock() f(j) j.e.jobsMu.Unlock() } func (j *Job) phase(p string, total int64) { j.update(func(j *Job) { j.Phase, j.Total, j.Done = p, total, 0 }) } func (j *Job) tick() { j.update(func(j *Job) { j.Done++ }) } func (j *Job) count(key string) { j.update(func(j *Job) { j.Results[key]++ }) } func (j *Job) skip(msg string) { j.update(func(j *Job) { j.Results["skipped"]++ if len(j.Skipped) < 200 { j.Skipped = append(j.Skipped, msg) } }) } // Job returns a snapshot of the latest job for a pair. func (e *Engine) Job(pairID int64) *Job { e.jobsMu.Lock() defer e.jobsMu.Unlock() j, ok := e.jobs[pairID] if !ok { return nil } cp := *j cp.Results = map[string]int{} for k, v := range j.Results { cp.Results[k] = v } cp.Skipped = append([]string{}, j.Skipped...) // never nil: the UI expects an array return &cp } func (e *Engine) CancelJob(pairID int64) bool { e.jobsMu.Lock() defer e.jobsMu.Unlock() if j, ok := e.jobs[pairID]; ok && j.State == "running" { j.cancel() return true } return false } func (e *Engine) startJob(pairID int64, kind string, run func(ctx context.Context, j *Job) error) (*Job, error) { e.jobsMu.Lock() if j, ok := e.jobs[pairID]; ok && j.State == "running" { e.jobsMu.Unlock() return nil, ErrBusy } e.nextID++ ctx, cancel := context.WithCancel(context.Background()) j := &Job{ID: e.nextID, PairID: pairID, Kind: kind, State: "running", Results: map[string]int{}, Skipped: []string{}, StartedAt: time.Now().Unix(), cancel: cancel, e: e} e.jobs[pairID] = j e.jobsMu.Unlock() go func() { defer cancel() err := run(ctx, j) j.update(func(j *Job) { j.FinishedAt = time.Now().Unix() switch { case errors.Is(err, context.Canceled): j.State, j.Error = "cancelled", "cancelled" case err != nil: j.State, j.Error = "failed", err.Error() default: j.State = "done" } }) if err != nil { e.log.Warn("job finished with error", "pair", pairID, "kind", kind, "err", err) } else { e.log.Info("job finished", "pair", pairID, "kind", kind) } }() return e.Job(pairID), nil } // ---- pair validation ---- type libs struct { pair store.Pair master fsops.Root slave fsops.Root quarantine string dev uint64 exts map[string]bool } func (l *libs) hasExt(name string) bool { return l.exts[strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".")] } func (e *Engine) withinAllowed(p string) bool { for _, r := range e.allowedRoots { if fsops.Within(r, p) { return true } } return false } // open resolves a pair's directories and re-checks every invariant. It is // called before every scan and operation so configuration drift (a remount, // a moved folder) is caught before anything is touched. func (e *Engine) open(p store.Pair) (*libs, error) { master, err := fsops.NewRoot(p.MasterRoot) if err != nil { return nil, fmt.Errorf("master library: %w", err) } slave, err := fsops.NewRoot(p.SlaveRoot) if err != nil { return nil, fmt.Errorf("slave library: %w", err) } for _, r := range []string{master.Path, slave.Path} { if !e.withinAllowed(r) { return nil, fmt.Errorf("%s is outside the allowed roots %v", r, e.allowedRoots) } } if fsops.Within(master.Path, slave.Path) || fsops.Within(slave.Path, master.Path) { return nil, errors.New("master and slave libraries must not overlap or contain each other") } mi, err := fsops.Lstat(master.Path) if err != nil { return nil, err } si, err := fsops.Lstat(slave.Path) if err != nil { return nil, err } if mi.Dev != si.Dev { return nil, fmt.Errorf("master and slave are on different filesystems (device %d vs %d): %w. "+ "In Docker, mount a single common parent folder instead of two separate volumes", mi.Dev, si.Dev, fsops.ErrCrossDevice) } q := filepath.Clean(p.QuarantineDir) if !filepath.IsAbs(q) { return nil, errors.New("quarantine directory must be absolute") } if fsops.Within(master.Path, q) { return nil, errors.New("quarantine directory must not be inside the master library") } if q == slave.Path || !e.withinAllowed(q) { return nil, errors.New("quarantine directory must be inside the allowed roots and not the slave root itself") } if err := os.MkdirAll(q, 0o775); err != nil { return nil, fmt.Errorf("quarantine directory: %w", err) } if rq, err := filepath.EvalSymlinks(q); err != nil || (rq != q && !e.withinAllowed(rq)) { return nil, errors.New("quarantine directory resolves outside the allowed roots") } qi, err := fsops.Lstat(q) if err != nil { return nil, err } if qi.Dev != mi.Dev { return nil, errors.New("quarantine directory must be on the same filesystem as the libraries") } exts := map[string]bool{} for _, x := range p.Extensions { x = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(x)), ".") if x != "" { exts[x] = true } } if len(exts) == 0 { return nil, errors.New("no file extensions configured") } return &libs{pair: p, master: master, slave: slave, quarantine: q, dev: mi.Dev, exts: exts}, nil } // NormalizePair fills defaults and validates a pair. It returns non-fatal // warnings (such as layout mismatches) for the UI. func (e *Engine) NormalizePair(p *store.Pair) ([]string, error) { p.Name = strings.TrimSpace(p.Name) if p.Name == "" { return nil, errors.New("name is required") } p.MasterRoot = filepath.Clean(strings.TrimSpace(p.MasterRoot)) p.SlaveRoot = filepath.Clean(strings.TrimSpace(p.SlaveRoot)) if strings.TrimSpace(p.QuarantineDir) == "" { p.QuarantineDir = filepath.Join(p.SlaveRoot, QuarantineDirName) } p.QuarantineDir = filepath.Clean(strings.TrimSpace(p.QuarantineDir)) if len(p.Extensions) == 0 || (len(p.Extensions) == 1 && strings.TrimSpace(p.Extensions[0]) == "") { p.Extensions = DefaultExtensions } var clean []string for _, x := range p.Extensions { x = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(x)), ".") if x != "" && !strings.ContainsAny(x, ",/") { clean = append(clean, x) } } p.Extensions = clean if p.RetentionDays < 0 || p.ScanIntervalMinutes < 0 || p.SettleMinutes < 0 { return nil, errors.New("intervals must not be negative") } if p.ApproxThreshold == 0 { p.ApproxThreshold = 85 } if p.ApproxThreshold < 60 || p.ApproxThreshold > 100 { return nil, errors.New("approximate match threshold must be between 60 and 100 percent") } if p.ScanIntervalMinutes > 0 && p.ScanIntervalMinutes < 5 { return nil, errors.New("scan interval must be 0 (manual) or at least 5 minutes") } l, err := e.open(*p) if err != nil { return nil, err } // Store resolved paths so later containment checks compare like with like. p.MasterRoot, p.SlaveRoot, p.QuarantineDir = l.master.Path, l.slave.Path, l.quarantine return layoutWarnings(l), nil } // layoutWarnings does a cheap top-level comparison of the two libraries. // Any folder layout works; what matters is that both libraries use the same // one, so matching files sit at the same relative path. func layoutWarnings(l *libs) []string { var warns []string list := func(root string) (dirs map[string]bool, files int) { dirs = map[string]bool{} ents, _ := os.ReadDir(root) for _, en := range ents { if strings.HasPrefix(en.Name(), ".") { continue } if en.IsDir() { dirs[strings.ToLower(en.Name())] = true } else if l.hasExt(en.Name()) { files++ } } return } md, _ := list(l.master.Path) sd, sf := list(l.slave.Path) if len(sd) == 0 && sf == 0 { warns = append(warns, "The slave library is empty. Use Build to pick songs from the master library to hardlink into it.") return warns } shared := 0 for d := range sd { if md[d] { shared++ } } if len(sd) > 0 && shared*2 < len(sd) { warns = append(warns, fmt.Sprintf("Only %d of %d top-level folders in the slave library also exist in the master library. "+ "Both libraries should use the same folder layout, or most files will not match.", shared, len(sd))) } return warns } // ---- helpers ---- func (e *Engine) loadPair(ctx context.Context, id int64) (*libs, error) { p, err := e.st.Pair(ctx, id) if err != nil { return nil, err } return e.open(p) } func sortedKeys(m map[string]fsops.Info) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys }