Files
playlink/internal/engine/fixer.go
T
cdnutterandClaude Opus 5.5 428935dc58 Add the step-by-step fixer and library types
Each pair is a Music (Artist/Album/01 Title) or Soundtracks (Album/01 Title)
library. The new Fixer tab takes the master, then the slave, through four
reviewed steps: folder structure, album tags (optional MusicBrainz lookup;
the slave reuses the fixed master's tags), folder names and file names,
then links the two libraries. Every fix is an undoable operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:10:48 -07:00

600 lines
16 KiB
Go

package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
)
// The fixer takes each library through the same steps, the master first and
// then the slave:
//
// 1. structure: every album in its own folder at the right depth (Artist/
// Album for music, Album for soundtracks), disc folders tagged, and missing
// tags filled from folder and file names;
// 2. album tags (optional): look albums up online and review the changes;
// 3. folder names: album and artist folders renamed from the tags;
// 4. file names: songs renamed from the tags.
//
// Once both libraries are fixed they are linked. Every step is a check that
// produces a plan; nothing changes until the user applies the fixes they
// selected, and every change is an operation that can be undone.
const (
StepStructure = "structure"
StepTags = "tags"
StepFolders = "folders"
StepFiles = "files"
StepLink = "link"
)
// FixerSteps are the steps each library goes through, in order.
var FixerSteps = []string{StepStructure, StepTags, StepFolders, StepFiles}
// Kinds of fix.
const (
FixMoveFolder = "move-folder" // move or rename a folder with everything in it
FixMoveFile = "move-file" // move or rename one song
FixTags = "tags" // change one song's tags
FixAlbumTags = "album-tags" // change the tags of an album's songs (online lookup)
FixProblem = "problem" // needs a person: shown, never applied
)
type StepStatus struct {
State string `json:"state"` // "" (to do), done or skipped
At int64 `json:"at,omitempty"`
Note string `json:"note,omitempty"`
}
type FixerState struct {
Master map[string]StepStatus `json:"master"`
Slave map[string]StepStatus `json:"slave"`
Link StepStatus `json:"link"`
}
// FixerStateOf reads a pair's fixer progress.
func FixerStateOf(p store.Pair) FixerState {
var s FixerState
if len(p.FixerState) > 0 {
_ = json.Unmarshal(p.FixerState, &s)
}
if s.Master == nil {
s.Master = map[string]StepStatus{}
}
if s.Slave == nil {
s.Slave = map[string]StepStatus{}
}
return s
}
func validStep(side, step string) error {
if step == StepLink && side == "" {
return nil
}
if side != SideMaster && side != SideSlave {
return fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
for _, s := range FixerSteps {
if s == step {
return nil
}
}
return fmt.Errorf("%w: unknown step %q", ErrInvalidBulk, step)
}
// SetStep records a step as done, skipped or to do ("").
func (e *Engine) SetStep(ctx context.Context, pairID int64, side, step, state, note string) error {
if err := validStep(side, step); err != nil {
return err
}
if state != "" && state != "done" && state != "skipped" {
return fmt.Errorf("%w: state must be done, skipped or empty", ErrInvalidBulk)
}
e.fixMu.Lock()
defer e.fixMu.Unlock()
p, err := e.st.Pair(ctx, pairID)
if err != nil {
return err
}
s := FixerStateOf(p)
st := StepStatus{State: state, Note: note}
if state != "" {
st.At = time.Now().Unix()
}
switch side {
case SideMaster:
s.Master[step] = st
case SideSlave:
s.Slave[step] = st
default:
s.Link = st
}
raw, _ := json.Marshal(s)
return e.st.SetFixerState(ctx, pairID, raw)
}
// FixItem is one proposed fix (or a problem that needs a person).
type FixItem struct {
ID int `json:"id"`
Kind string `json:"kind"`
Group string `json:"group"` // album folder, for grouping
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Why string `json:"why"`
Blocked string `json:"blocked,omitempty"` // why it can't be fixed automatically
Changes map[string][2]string `json:"changes,omitempty"` // tags: field -> [old, new]
// Unsure fixes are listed but not selected by default.
Unsure bool `json:"unsure,omitempty"`
org *organizeItem // moves: the file as it was when checked
}
// FixPlan is the result of checking one step of one library.
type FixPlan struct {
ID int64 `json:"id"` // unique per check, so selections never carry over
Side string `json:"side"`
Step string `json:"step"`
CreatedAt int64 `json:"created_at"`
Songs int `json:"songs"`
Albums int `json:"albums"`
Fixable int `json:"fixable"`
Blocked int `json:"blocked"`
Notices []string `json:"notices"`
Items []FixItem `json:"-"`
AlbumFixes []*AlbumFix `json:"-"`
Applied bool `json:"applied"`
}
func (p *FixPlan) add(it FixItem) {
it.ID = len(p.Items) + 1
if it.Kind == FixProblem || it.Blocked != "" {
p.Blocked++
} else {
p.Fixable++
}
p.Items = append(p.Items, it)
}
// FixerPlan returns the latest plan of a pair and its items.
func (e *Engine) FixerPlan(pairID int64) (*FixPlan, []FixItem, []*AlbumFix) {
e.fixMu.Lock()
defer e.fixMu.Unlock()
p := e.fixPlans[pairID]
if p == nil {
return nil, nil, nil
}
return p, append([]FixItem{}, p.Items...), append([]*AlbumFix{}, p.AlbumFixes...)
}
// FixCheck asks for one step of one library to be checked.
type FixCheck struct {
Side string `json:"side"`
Step string `json:"step"`
Folder string `json:"folder"` // optional: only this folder (album tags)
// OnlyIncomplete skips albums whose tags already look complete (album tags).
OnlyIncomplete bool `json:"only_incomplete"`
}
// StartFixCheck checks a step in a background job; the plan is kept for review.
func (e *Engine) StartFixCheck(pairID int64, c FixCheck) (*Job, error) {
if err := validStep(c.Side, c.Step); err != nil {
return nil, err
}
if !e.TagEditing() {
return nil, ErrNoTagTools
}
if c.Folder != "" {
f, err := fsops.CleanRel(c.Folder)
if err != nil {
return nil, err
}
c.Folder = f
}
if c.Step == StepTags && e.meta == nil {
return nil, errors.New("online lookup is not available")
}
return e.startJob(pairID, "fix-"+c.Step, func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
plan := &FixPlan{Side: c.Side, Step: c.Step, CreatedAt: time.Now().Unix(), Notices: []string{}}
switch c.Step {
case StepStructure:
err = e.checkStructure(ctx, j, l, plan)
case StepTags:
err = e.checkAlbumTags(ctx, j, l, plan, c)
case StepFolders:
err = e.checkFolders(ctx, j, l, plan)
case StepFiles:
err = e.checkFiles(ctx, j, l, plan)
}
if err != nil {
return err
}
e.fixMu.Lock()
if e.fixPlans == nil {
e.fixPlans = map[int64]*FixPlan{}
}
e.fixSeq++
plan.ID = e.fixSeq
e.fixPlans[pairID] = plan
e.fixMu.Unlock()
j.update(func(j *Job) {
j.Message = fmt.Sprintf("%d to fix, %d need attention", plan.Fixable, plan.Blocked)
})
return nil
})
}
// FixApply selects fixes of the latest plan.
type FixApply struct {
Plan int64 `json:"plan"` // the plan the ids belong to
IDs []int `json:"ids"`
}
// StartFixApply applies the selected fixes of the latest plan. Tags are
// changed first (at the paths that were checked), then folders move, then
// files. When the step has nothing left to fix it is marked done.
func (e *Engine) StartFixApply(pairID int64, req FixApply) (*Job, error) {
e.fixMu.Lock()
plan := e.fixPlans[pairID]
e.fixMu.Unlock()
if plan == nil {
return nil, fmt.Errorf("%w: check the step first", ErrInvalidBulk)
}
if req.Plan != plan.ID {
return nil, fmt.Errorf("%w: the check was replaced by a newer one; review it and apply again", ErrInvalidBulk)
}
if plan.Applied {
return nil, fmt.Errorf("%w: the check is out of date; check again", ErrInvalidBulk)
}
want := map[int]bool{}
for _, id := range req.IDs {
want[id] = true
}
applied := 0
var tagItems, folderItems []FixItem
var moves []organizeItem
var albums []*AlbumFix
for _, it := range plan.Items {
if !want[it.ID] || it.Kind == FixProblem || it.Blocked != "" {
continue
}
applied++
switch it.Kind {
case FixTags:
tagItems = append(tagItems, it)
case FixMoveFolder:
folderItems = append(folderItems, it)
case FixMoveFile:
moves = append(moves, *it.org)
case FixAlbumTags:
for _, a := range plan.AlbumFixes {
if a.ID == it.ID {
albums = append(albums, a)
}
}
}
}
if len(tagItems)+len(folderItems)+len(moves)+len(albums) == 0 {
return nil, fmt.Errorf("%w: nothing selected", ErrInvalidBulk)
}
side, step := plan.Side, plan.Step
return e.startJob(pairID, "fix-"+step+"-apply", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
e.fixMu.Lock()
plan.Applied = true // paths and tags change: the plan is stale
e.fixMu.Unlock()
trigger := "fixer"
edited := map[string]bool{}
if n := len(tagItems) + songsIn(albums); n > 0 {
j.phase("Updating tags", int64(n))
for _, it := range tagItems {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.editTagsOne(ctx, j, l, side, it.From, newValues(it.Changes), "", false); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
edited[it.From] = true
}
j.tick()
}
for _, a := range albums {
for _, f := range a.Files {
if ctx.Err() != nil {
return ctx.Err()
}
if len(f.Changes) > 0 {
if err := e.editTagsOne(ctx, j, l, side, f.Path, newValues(f.Changes), "", false); err != nil {
j.skip(f.Path + ": " + err.Error())
}
}
j.tick()
}
j.count("albums updated")
}
}
if len(folderItems) > 0 {
j.phase("Moving folders", int64(len(folderItems)))
for _, it := range folderItems {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.moveDirOp(ctx, l, side, it.From, it.To, trigger); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
j.count("folders moved")
}
j.tick()
}
}
if len(moves) > 0 {
// A song whose tags were just rewritten is a new file: take it as it is now.
root, _ := sideRoot(l, side)
for i, m := range moves {
if edited[m.From] {
if in, err := fsops.RegularFile(filepath.Join(root.Path, m.From)); err == nil {
moves[i].ino, moves[i].size, moves[i].mtime = in.Ino, in.Size, in.MtimeNs
}
}
}
if err := e.applyOrganize(ctx, j, l, moves, trigger); err != nil {
return err
}
}
dry := e.st.DryRun(ctx)
skipped := 0
j.update(func(j *Job) {
skipped = j.Results["skipped"]
if dry {
j.Message = "Dry run: changes recorded as planned in Activity"
}
})
// Done when every fix was applied and nothing needs a person.
if !dry && skipped == 0 && applied >= plan.Fixable && plan.Blocked == 0 {
if err := e.SetStep(ctx, pairID, side, step, "done", fmt.Sprintf("%d fixed", applied)); err != nil {
e.log.Warn("fixer step not recorded", "err", err)
}
}
return nil
})
}
func songsIn(albums []*AlbumFix) int {
n := 0
for _, a := range albums {
n += len(a.Files)
}
return n
}
func newValues(changes map[string][2]string) map[string]string {
set := map[string]string{}
for k, v := range changes {
set[k] = v[1]
}
return set
}
// ---- moving folders ----
type dirMoveDetail struct {
Side string `json:"side"`
}
// moveDirOp moves a folder (with everything in it) within one library.
func (e *Engine) moveDirOp(ctx context.Context, l *libs, side, from, to, trigger string) error {
e.mu.Lock()
defer e.mu.Unlock()
root, err := sideRoot(l, side)
if err != nil {
return err
}
if why := checkDirMove(l, root, from, to); why != "" {
return errors.New(why)
}
src, _ := root.Resolve(from)
dst, _ := root.Resolve(to)
o := store.Operation{PairID: l.pair.ID, Action: store.ActMoveDir, RelNew: to, Trigger: trigger}
setSidePath(&o, side, from)
o.Detail, _ = json.Marshal(dirMoveDetail{Side: side})
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
return nil
}
err = func() error {
if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil {
return err
}
if err := renameDir(src, dst); err != nil {
pruneEmpty(filepath.Dir(dst), root.Path)
return err
}
_ = fsops.SyncDir(filepath.Dir(src))
pruneEmpty(filepath.Dir(src), root.Path)
return nil
}()
return e.finish(ctx, &o, err)
}
// checkDirMove returns why a folder can't be moved, or "".
func checkDirMove(l *libs, root fsops.Root, from, to string) string {
src, err := root.Resolve(from)
if err != nil {
return err.Error()
}
dst, err := root.Resolve(to)
if err != nil {
return err.Error()
}
if src == root.Path || dst == root.Path {
return "the library folder itself can't be moved"
}
fi, err := os.Lstat(src)
switch {
case err != nil:
return "the folder no longer exists (check again)"
case fi.Mode()&os.ModeSymlink != 0:
return "is a symlink"
case !fi.IsDir():
return "is not a folder"
case src == l.quarantine || fsops.Within(src, l.quarantine):
return "contains the quarantine folder"
case fsops.Within(src, dst):
return "can't move a folder into itself"
}
if di, err := os.Lstat(dst); err == nil && !(strings.EqualFold(from, to) && os.SameFile(fi, di)) {
return fmt.Sprintf("%q already exists; merge the two by hand", to)
}
return ""
}
// undoMoveDir moves a folder back.
func undoMoveDir(l *libs, o store.Operation) error {
var d dirMoveDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return err
}
root, err := sideRoot(l, d.Side)
if err != nil {
return err
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
cur, err := root.Resolve(o.RelNew)
if err != nil {
return err
}
old, err := root.Resolve(from)
if err != nil {
return err
}
fi, err := os.Lstat(cur)
if err != nil || !fi.IsDir() {
return fmt.Errorf("the folder %q no longer exists", o.RelNew)
}
if di, err := os.Lstat(old); err == nil && !(strings.EqualFold(o.RelNew, from) && os.SameFile(fi, di)) {
return fmt.Errorf("%q exists again: %w", from, fsops.ErrExists)
}
if err := fsops.MkdirAllLike(filepath.Dir(old)); err != nil {
return err
}
if err := renameDir(cur, old); err != nil {
return err
}
pruneEmpty(filepath.Dir(cur), root.Path)
return nil
}
// recoverMoveDir finishes a case-only folder move cut short by a crash.
func recoverMoveDir(l *libs, o store.Operation, log *slog.Logger) {
var d dirMoveDetail
if json.Unmarshal(o.Detail, &d) != nil {
return
}
root, err := sideRoot(l, d.Side)
if err != nil {
return
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
src, dst := filepath.Join(root.Path, from), filepath.Join(root.Path, o.RelNew)
if _, err := os.Lstat(src); err == nil {
return // never moved
}
if _, err := os.Lstat(dst); err == nil {
return // completed
}
dir := filepath.Dir(src)
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if en.IsDir() && strings.HasPrefix(en.Name(), fsops.TmpPrefix+"dir-") {
if err := os.Rename(filepath.Join(dir, en.Name()), dst); err == nil {
log.Warn("finished interrupted folder move", "op", o.ID, "folder", o.RelNew)
}
return
}
}
}
// ---- library layout ----
// albumInfo is one album folder of a library as found on disk.
type albumInfo struct {
Dir string // the album folder (disc folders folded in)
Songs []string // every song, including those in disc folders
Discs map[string]int // disc folder -> disc number
Loose bool // songs directly in the album folder
Nested bool // other album folders live inside this one
}
// albumsOf groups a library's songs into album folders.
func albumsOf(files map[string]fsops.Info) []*albumInfo {
byDir := map[string]*albumInfo{}
for _, rel := range sortedKeys(files) {
dir := filepath.Dir(rel)
album, disc := dir, 0
if dir != "." {
if n := discNumber(filepath.Base(dir)); n > 0 && filepath.Dir(dir) != "." {
album, disc = filepath.Dir(dir), n
}
}
a := byDir[album]
if a == nil {
a = &albumInfo{Dir: album, Discs: map[string]int{}}
byDir[album] = a
}
a.Songs = append(a.Songs, rel)
if disc > 0 {
a.Discs[dir] = disc
} else {
a.Loose = true
}
}
out := make([]*albumInfo, 0, len(byDir))
for _, a := range byDir {
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return out[i].Dir < out[j].Dir })
for _, a := range out {
for _, b := range out {
if a != b && (a.Dir == "." || strings.HasPrefix(b.Dir, a.Dir+"/")) {
a.Nested = true
break
}
}
}
return out
}
// discNumber reads "Disc 2", "CD02", "disk_3" or "Disc 1 - Title" as a disc number.
func discNumber(name string) int {
m := discNum.FindStringSubmatch(name)
if m == nil {
return 0
}
return leadingNumber(m[1])
}