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>
600 lines
16 KiB
Go
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])
|
|
}
|