Go backend (chi, SQLite) with an embedded React/TypeScript UI that finds songs present in both a host and a slave music library and replaces the slave copy with a hardlink to the host copy. - Path-first matching with format/title/album-folder fallbacks - Auto-linking of byte-identical files, re-verified with cmp before linking - Manual review with ffprobe quality comparison, downgrade guard and promote-to-host override - Build mode to populate an empty slave library from the host - Dry run by default, atomic stage+rename replacement, quarantine with retention, undo, path confinement and authenticated UI - Dockerfile and compose file for Unraid Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
353 lines
10 KiB
Go
353 lines
10 KiB
Go
// 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 host copy is lower quality than the slave copy; confirm the downgrade to continue")
|
|
ErrConfirm = errors.New("this action modifies the host 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
|
|
host 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) {
|
|
host, err := fsops.NewRoot(p.HostRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("host library: %w", err)
|
|
}
|
|
slave, err := fsops.NewRoot(p.SlaveRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("slave library: %w", err)
|
|
}
|
|
for _, r := range []string{host.Path, slave.Path} {
|
|
if !e.withinAllowed(r) {
|
|
return nil, fmt.Errorf("%s is outside the allowed roots %v", r, e.allowedRoots)
|
|
}
|
|
}
|
|
if fsops.Within(host.Path, slave.Path) || fsops.Within(slave.Path, host.Path) {
|
|
return nil, errors.New("host and slave libraries must not overlap or contain each other")
|
|
}
|
|
hi, err := fsops.Lstat(host.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
si, err := fsops.Lstat(slave.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hi.Dev != si.Dev {
|
|
return nil, fmt.Errorf("host and slave are on different filesystems (device %d vs %d): %w. "+
|
|
"In Docker, mount a single common parent folder instead of two separate volumes", hi.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(host.Path, q) {
|
|
return nil, errors.New("quarantine directory must not be inside the host 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 != hi.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, host: host, slave: slave, quarantine: q, dev: hi.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.HostRoot = filepath.Clean(strings.TrimSpace(p.HostRoot))
|
|
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.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.HostRoot, p.SlaveRoot, p.QuarantineDir = l.host.Path, l.slave.Path, l.quarantine
|
|
return layoutWarnings(l), nil
|
|
}
|
|
|
|
// layoutWarnings does a cheap top-level comparison of the two libraries.
|
|
func layoutWarnings(l *libs) []string {
|
|
var warns []string
|
|
list := func(root string) (dirs map[string]bool, looseFiles 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()) {
|
|
looseFiles++
|
|
}
|
|
}
|
|
return
|
|
}
|
|
hd, hl := list(l.host.Path)
|
|
sd, sl := list(l.slave.Path)
|
|
if len(sd) == 0 && sl == 0 {
|
|
warns = append(warns, "The slave library is empty. Use Build to pick songs from the host library to hardlink into it.")
|
|
return warns
|
|
}
|
|
if hl > 0 || sl > 0 {
|
|
warns = append(warns, fmt.Sprintf("Found audio files directly in a library root (host: %d, slave: %d). Both libraries should use Artist/Album/Title.ext.", hl, sl))
|
|
}
|
|
shared := 0
|
|
for d := range sd {
|
|
if hd[d] {
|
|
shared++
|
|
}
|
|
}
|
|
if len(sd) > 0 && shared*2 < len(sd) {
|
|
warns = append(warns, fmt.Sprintf("Only %d of %d artist folders in the slave library also exist in the host library. "+
|
|
"The two libraries should share the same Artist/Album/Title.ext 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
|
|
}
|