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