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>
This commit is contained in:
1 parent
b11d0806c1
commit
428935dc58
19 files changed
+3503
-23
No files matched your search
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/cdnutter/playlink/internal/art"
|
||||
"github.com/cdnutter/playlink/internal/fsops"
|
||||
"github.com/cdnutter/playlink/internal/meta"
|
||||
"github.com/cdnutter/playlink/internal/probe"
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
"github.com/cdnutter/playlink/internal/tags"
|
||||
@@ -49,6 +50,12 @@ type Engine struct {
|
||||
artClient *art.Client
|
||||
artStore *art.Store
|
||||
|
||||
meta *meta.Client
|
||||
|
||||
fixMu sync.Mutex
|
||||
fixPlans map[int64]*FixPlan // latest fixer check per pair
|
||||
fixSeq int64
|
||||
|
||||
plansMu sync.Mutex
|
||||
plans map[int64]*organizePlan // latest "rename now" preview per pair
|
||||
|
||||
@@ -309,6 +316,13 @@ func (e *Engine) NormalizePair(p *store.Pair) ([]store.Warning, error) {
|
||||
if p.RetentionDays < 0 || p.ScanIntervalMinutes < 0 || p.SettleMinutes < 0 {
|
||||
return nil, errors.New("intervals must not be negative")
|
||||
}
|
||||
switch p.LibraryType {
|
||||
case "":
|
||||
p.LibraryType = LibraryMusic
|
||||
case LibraryMusic, LibrarySoundtracks:
|
||||
default:
|
||||
return nil, errors.New("library type must be music or soundtracks")
|
||||
}
|
||||
if err := normalizeOrganize(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/fsops"
|
||||
"github.com/cdnutter/playlink/internal/tags"
|
||||
)
|
||||
|
||||
// discNum recognizes disc folders. "Vol. 2" is left out on purpose: volumes
|
||||
// are usually albums of their own.
|
||||
var discNum = regexp.MustCompile(`(?i)^(?:disc|disk|cd|dvd|part)[\s._-]*(\d{1,2})(?:\b|$)`)
|
||||
|
||||
// junkFolder matches extra folders inside an album that only describe the
|
||||
// files ("FLAC", "MP3 320", "24bit"), whose songs belong in the album folder.
|
||||
var junkFolder = regexp.MustCompile(`(?i)^[\[(]?\s*(?:flac|mp3|aac|m4a|alac|ogg|opus|wav|lossless|lossy|hi-?res|web|` +
|
||||
`(?:mp3\s*)?(?:320|256|192|v0|v2)(?:\s*kbps)?|\d{2}\s*-?\s*bit.*|audio|tracks?|files?|songs?)\s*[\])]?$`)
|
||||
|
||||
// folderNoise is release information in folder names: "Nightmare (2010)",
|
||||
// "Nightmare [FLAC]", "2010 - Nightmare".
|
||||
var (
|
||||
folderNoise = regexp.MustCompile(`(?i)\s*[\[({]\s*(?:\d{4}(?:[-.]\d{2}){0,2}|flac|mp3|aac|alac|ogg|opus|web|cd|vinyl|lossless|` +
|
||||
`deluxe|remaster(?:ed)?|\d{2}\s*-?\s*bit[^\])}]*|\d+(?:\.\d+)?\s*k(?:hz|bps)[^\])}]*|v0|v2|320|\d{2}-\d{2,3})\s*[\])}]\s*$`)
|
||||
yearPrefix = regexp.MustCompile(`^\s*[\[(]?\d{4}[\])]?\s*[-–.]\s+`)
|
||||
)
|
||||
|
||||
// cleanFolderName turns a folder name into an album or artist name.
|
||||
func cleanFolderName(s string) string {
|
||||
for {
|
||||
t := folderNoise.ReplaceAllString(s, "")
|
||||
t = yearPrefix.ReplaceAllString(t, "")
|
||||
t = strings.TrimSpace(t)
|
||||
if t == s || t == "" {
|
||||
return s
|
||||
}
|
||||
s = t
|
||||
}
|
||||
}
|
||||
|
||||
// layoutFor returns the formats that apply at rel and the folder they are
|
||||
// relative to ("" for the library, or a folder with its own naming).
|
||||
func (c NamingConfig) layoutFor(rel string) (string, formats) {
|
||||
if rule, ok := c.folderOf(rel); ok {
|
||||
return rel[:len(rule.Folder)], rule.formats()
|
||||
}
|
||||
return "", c.formats()
|
||||
}
|
||||
|
||||
// albumDepth is how many folders deep albums sit: Artist/Album or Album.
|
||||
func (f formats) albumDepth() int {
|
||||
if f.artist == "" {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// renderComponent renders a one-folder format from a song's tags.
|
||||
func renderComponent(c NamingConfig, format string, f map[string]string) string {
|
||||
v := valuesOf(f)
|
||||
return finishComponent(tokenPat.ReplaceAllStringFunc(format, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) }))
|
||||
}
|
||||
|
||||
// renderAlbumDir is the album folder a song at rel belongs in, from its tags.
|
||||
func renderAlbumDir(c NamingConfig, rel string, f map[string]string) (string, string) {
|
||||
flat := c
|
||||
flat.TrackFormat, flat.MultiDiscFormat = "{Track Title}", "{Track Title}"
|
||||
flat.Folders = make([]FolderNaming, len(c.Folders))
|
||||
for i, r := range c.Folders {
|
||||
r.TrackFormat, r.MultiDiscFormat = "{Track Title}", "{Track Title}"
|
||||
flat.Folders[i] = r
|
||||
}
|
||||
g := map[string]string{}
|
||||
for k, v := range f {
|
||||
g[k] = v
|
||||
}
|
||||
if strings.TrimSpace(g[tags.Title]) == "" {
|
||||
g[tags.Title] = "x" // the title doesn't name folders
|
||||
}
|
||||
p, why := renderPath(flat, rel, g)
|
||||
if why != "" {
|
||||
return "", why
|
||||
}
|
||||
return filepath.Dir(p), ""
|
||||
}
|
||||
|
||||
func depthOf(rel string) int {
|
||||
if rel == "" || rel == "." {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(rel, "/") + 1
|
||||
}
|
||||
|
||||
// majority is the most common non-empty value of a field.
|
||||
func majority(fields map[string]map[string]string, songs []string, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
count := map[string]int{}
|
||||
best, bestN := "", 0
|
||||
for _, s := range songs {
|
||||
v := strings.TrimSpace(fields[s][key])
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
count[v]++
|
||||
if count[v] > bestN || (count[v] == bestN && v < best) {
|
||||
best, bestN = v, count[v]
|
||||
}
|
||||
}
|
||||
if best != "" {
|
||||
return best
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func similar(a, b string) bool {
|
||||
ka, kb := matchKey(a), matchKey(b)
|
||||
return ka != "" && kb != "" && (ka == kb || levenshteinRatio(ka, kb) >= 0.8)
|
||||
}
|
||||
|
||||
// readLibrary lists a library's songs and reads their tags.
|
||||
func (e *Engine) readLibrary(ctx context.Context, j *Job, l *libs, side string) (fsops.Root, map[string]fsops.Info, map[string]map[string]string, error) {
|
||||
root, err := sideRoot(l, side)
|
||||
if err != nil {
|
||||
return root, nil, nil, err
|
||||
}
|
||||
j.phase("Listing "+side+" files", 0)
|
||||
files := map[string]fsops.Info{}
|
||||
if _, err := walk(ctx, l, root, files); err != nil {
|
||||
return root, nil, nil, err
|
||||
}
|
||||
j.phase("Reading "+side+" tags", int64(len(files)))
|
||||
fields := make(map[string]map[string]string, len(files))
|
||||
for _, rel := range sortedKeys(files) {
|
||||
if ctx.Err() != nil {
|
||||
return root, nil, nil, ctx.Err()
|
||||
}
|
||||
f := map[string]string{}
|
||||
if _, ok := tags.FormatOf(rel); ok {
|
||||
if got, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), files[rel]); err == nil && got != nil {
|
||||
f = got
|
||||
}
|
||||
}
|
||||
fields[rel] = f
|
||||
j.tick()
|
||||
}
|
||||
return root, files, fields, nil
|
||||
}
|
||||
|
||||
func (e *Engine) moveItem(root fsops.Root, side, from, to string, in fsops.Info) *organizeItem {
|
||||
return &organizeItem{Side: side, From: from, To: to, Move: true, ino: in.Ino, size: in.Size, mtime: in.MtimeNs,
|
||||
Sidecars: sidecarsOf(root, from)}
|
||||
}
|
||||
|
||||
// ---- step 1: structure ----
|
||||
|
||||
func (e *Engine) checkStructure(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
|
||||
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
naming := NamingOf(l.pair)
|
||||
albums := albumsOf(files)
|
||||
plan.Songs, plan.Albums = len(files), len(albums)
|
||||
claimed := map[string]string{} // lower(new path) -> old path
|
||||
claim := func(from, to string) string {
|
||||
k := strings.ToLower(to)
|
||||
if c := claimed[k]; c != "" && c != from {
|
||||
return "another item gets the same name (" + c + ")"
|
||||
}
|
||||
claimed[k] = from
|
||||
return ""
|
||||
}
|
||||
// Songs that change tags here, so a move of the same song can re-read it.
|
||||
type deep struct {
|
||||
item int
|
||||
extra string
|
||||
}
|
||||
var deeps []deep
|
||||
for _, a := range albums {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
prefix, fm := naming.layoutFor(filepath.Join(a.Dir, "x"))
|
||||
rest := strings.TrimPrefix(strings.TrimPrefix(a.Dir, prefix), "/")
|
||||
if rest == "." {
|
||||
rest = ""
|
||||
}
|
||||
want, depth := fm.albumDepth(), depthOf(rest)
|
||||
target := a.Dir // where the album will be, for filling tags from names
|
||||
|
||||
// looseInto moves songs into album folders named from their tags,
|
||||
// under base (or where the naming puts them when base is "").
|
||||
looseInto := func(songs []string, base, why string) {
|
||||
for _, s := range songs {
|
||||
var dir, blocked string
|
||||
if base == "" {
|
||||
dir, blocked = renderAlbumDir(naming, s, fields[s])
|
||||
} else if strings.TrimSpace(fields[s][tags.Album]) == "" {
|
||||
blocked = "missing album tag"
|
||||
} else {
|
||||
dir = filepath.Join(base, renderComponent(naming, fm.album, fields[s]))
|
||||
}
|
||||
it := FixItem{Kind: FixMoveFile, From: s, Why: why, Group: dir}
|
||||
if blocked != "" {
|
||||
it.Blocked = blocked + ": set it in the Tags tab, then check again"
|
||||
it.Group = filepath.Dir(s)
|
||||
} else {
|
||||
it.To = filepath.Join(dir, filepath.Base(s))
|
||||
if targetTaken(root, s, it.To) {
|
||||
it.Blocked = "a different file already has that name"
|
||||
} else if c := claim(s, it.To); c != "" {
|
||||
it.Blocked = c
|
||||
} else {
|
||||
it.org = e.moveItem(root, plan.Side, s, it.To, files[s])
|
||||
}
|
||||
}
|
||||
plan.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case depth == 0:
|
||||
where := "the top of the library"
|
||||
if prefix != "" {
|
||||
where = "the top of " + prefix
|
||||
}
|
||||
looseInto(a.Songs, "", "Not in an album folder (at "+where+")")
|
||||
continue
|
||||
case depth < want:
|
||||
// Music with one folder: an album without an artist folder, or an
|
||||
// artist folder with songs directly inside it.
|
||||
album := majority(fields, a.Songs, tags.Album)
|
||||
artist := majority(fields, a.Songs, tags.AlbumArtist, tags.Artist)
|
||||
name := filepath.Base(a.Dir)
|
||||
isArtist := a.Nested || (artist != "" && similar(artist, cleanFolderName(name)) && !similar(album, cleanFolderName(name)))
|
||||
if isArtist {
|
||||
looseInto(a.Songs, a.Dir, "Songs directly in the artist folder, not in an album folder")
|
||||
continue
|
||||
}
|
||||
it := FixItem{Kind: FixMoveFolder, From: a.Dir, Why: "Album folder is not inside an artist folder"}
|
||||
if artist == "" {
|
||||
it.Blocked = "its songs have no artist tag: set the album artist (Edit album in the Tags tab), then check again"
|
||||
it.Group = a.Dir
|
||||
} else {
|
||||
f := map[string]string{tags.AlbumArtist: artist}
|
||||
it.To = filepath.Join(prefix, renderComponent(naming, fm.artist, f), name)
|
||||
it.Group = it.To
|
||||
if why := checkDirMove(l, root, it.From, it.To); why != "" {
|
||||
it.Blocked = why
|
||||
} else if c := claim(it.From, it.To); c != "" {
|
||||
it.Blocked = c
|
||||
}
|
||||
target = it.To
|
||||
}
|
||||
plan.add(it)
|
||||
case depth > want:
|
||||
comps := strings.Split(rest, "/")
|
||||
last := comps[len(comps)-1]
|
||||
if junkFolder.MatchString(last) && !a.Nested {
|
||||
parent := filepath.Dir(a.Dir)
|
||||
why := fmt.Sprintf("Extra folder %q inside the album", last)
|
||||
for _, s := range a.Songs {
|
||||
to := filepath.Join(parent, strings.TrimPrefix(s, a.Dir+"/"))
|
||||
it := FixItem{Kind: FixMoveFile, From: s, To: to, Why: why, Group: parent}
|
||||
if targetTaken(root, s, to) {
|
||||
it.Blocked = "a different file already has that name"
|
||||
} else if c := claim(s, to); c != "" {
|
||||
it.Blocked = c
|
||||
} else {
|
||||
it.org = e.moveItem(root, plan.Side, s, to, files[s])
|
||||
}
|
||||
plan.add(it)
|
||||
}
|
||||
target = parent
|
||||
break
|
||||
}
|
||||
extra := comps[:len(comps)-want]
|
||||
to := filepath.Join(prefix, filepath.Join(comps[len(comps)-want:]...))
|
||||
kind := "Album"
|
||||
if want == 1 {
|
||||
kind = "Soundtrack"
|
||||
}
|
||||
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to,
|
||||
Why: fmt.Sprintf("%s is inside extra folders (%s)", kind, strings.Join(extra, "/"))}
|
||||
switch {
|
||||
case a.Nested:
|
||||
it.Blocked = "other album folders are inside it"
|
||||
default:
|
||||
if why := checkDirMove(l, root, it.From, it.To); why != "" {
|
||||
it.Blocked = why
|
||||
} else if c := claim(it.From, it.To); c != "" {
|
||||
it.Blocked = c
|
||||
}
|
||||
}
|
||||
plan.add(it)
|
||||
deeps = append(deeps, deep{item: len(plan.Items) - 1, extra: filepath.Join(prefix, extra[0])})
|
||||
target = to
|
||||
}
|
||||
|
||||
// Disc folders and tags filled from folder and file names.
|
||||
if len(a.Discs) > 0 && a.Loose {
|
||||
plan.add(FixItem{Kind: FixProblem, Group: a.Dir, From: a.Dir, Why: "Songs are both in the album folder and in disc folders",
|
||||
Blocked: "move them into the right disc folder by hand"})
|
||||
}
|
||||
discTotal := 0
|
||||
for _, n := range a.Discs {
|
||||
discTotal = max(discTotal, n)
|
||||
}
|
||||
albumName := cleanFolderName(filepath.Base(target))
|
||||
artistName := ""
|
||||
if want == 2 && depthOf(strings.TrimPrefix(strings.TrimPrefix(target, prefix), "/")) == 2 {
|
||||
artistName = cleanFolderName(filepath.Base(filepath.Dir(target)))
|
||||
}
|
||||
for _, s := range a.Songs {
|
||||
f := fields[s]
|
||||
if _, ok := tags.FormatOf(s); !ok {
|
||||
continue
|
||||
}
|
||||
ch := map[string][2]string{}
|
||||
var why []string
|
||||
set := func(k, v string) {
|
||||
if v != "" && strings.TrimSpace(f[k]) != v {
|
||||
ch[k] = [2]string{f[k], v}
|
||||
}
|
||||
}
|
||||
if n := a.Discs[filepath.Dir(s)]; n > 0 {
|
||||
if leadingNumber(f[tags.Disc]) != n {
|
||||
set(tags.Disc, strconv.Itoa(n))
|
||||
}
|
||||
if leadingNumber(f[tags.DiscTotal]) != discTotal {
|
||||
set(tags.DiscTotal, strconv.Itoa(discTotal))
|
||||
}
|
||||
if len(ch) > 0 {
|
||||
why = append(why, "disc number from its disc folder")
|
||||
}
|
||||
}
|
||||
filled := len(ch)
|
||||
if strings.TrimSpace(f[tags.Album]) == "" {
|
||||
set(tags.Album, albumName)
|
||||
}
|
||||
if artistName != "" && strings.TrimSpace(f[tags.Artist]) == "" && strings.TrimSpace(f[tags.AlbumArtist]) == "" {
|
||||
set(tags.Artist, artistName)
|
||||
}
|
||||
stem := strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))
|
||||
if strings.TrimSpace(f[tags.Title]) == "" {
|
||||
if t := strings.TrimSpace(trackPrefix.ReplaceAllString(stem, "")); t != "" {
|
||||
set(tags.Title, t)
|
||||
} else {
|
||||
set(tags.Title, stem)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(f[tags.Track]) == "" {
|
||||
if m := trackNum.FindStringSubmatch(stem); m != nil {
|
||||
set(tags.Track, strconv.Itoa(leadingNumber(m[1])))
|
||||
}
|
||||
}
|
||||
if len(ch) > filled {
|
||||
why = append(why, "missing tags filled from the folder and file names")
|
||||
}
|
||||
if len(ch) > 0 {
|
||||
w := strings.Join(why, "; ")
|
||||
plan.add(FixItem{Kind: FixTags, From: s, Group: target, Changes: ch, Why: strings.ToUpper(w[:1]) + w[1:]})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Several albums inside the same extra folder look like a category
|
||||
// ("Video Games/…", "Rock/…") rather than a mistake: don't select those.
|
||||
byExtra := map[string]int{}
|
||||
for _, d := range deeps {
|
||||
byExtra[d.extra]++
|
||||
}
|
||||
var cats []string
|
||||
for _, d := range deeps {
|
||||
if byExtra[d.extra] >= 3 {
|
||||
plan.Items[d.item].Unsure = true
|
||||
}
|
||||
}
|
||||
for x, n := range byExtra {
|
||||
if n >= 3 {
|
||||
cats = append(cats, fmt.Sprintf("%s (%d albums)", x, n))
|
||||
}
|
||||
}
|
||||
if len(cats) > 0 {
|
||||
sort.Strings(cats)
|
||||
plan.Notices = append(plan.Notices, "Some folders hold several albums one level deeper than expected: "+strings.Join(cats, ", ")+
|
||||
". If they are categories you want to keep (like Video Games or Movies), add them under Organize → Folders with their own naming "+
|
||||
"instead of moving the albums out. Those moves are not selected.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- step 3: folder names ----
|
||||
|
||||
func (e *Engine) checkFolders(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
|
||||
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
naming := NamingOf(l.pair)
|
||||
albums := albumsOf(files)
|
||||
plan.Songs, plan.Albums = len(files), len(albums)
|
||||
claimed := map[string]string{}
|
||||
for _, a := range albums {
|
||||
prefix, _ := naming.layoutFor(filepath.Join(a.Dir, "x"))
|
||||
if a.Dir == "." || a.Dir == prefix {
|
||||
continue // loose songs: the file step puts them in album folders
|
||||
}
|
||||
targets := map[string]string{}
|
||||
var missing string
|
||||
untagged := 0
|
||||
for _, s := range a.Songs {
|
||||
t, why := renderAlbumDir(naming, s, fields[s])
|
||||
if why != "" {
|
||||
missing = why
|
||||
untagged++
|
||||
continue
|
||||
}
|
||||
targets[strings.ToLower(t)] = t
|
||||
}
|
||||
switch {
|
||||
case len(targets) == 0:
|
||||
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir, Why: "Can't name this album folder",
|
||||
Blocked: missing + " (fix it with Edit album in the Tags tab, then check again)"})
|
||||
continue
|
||||
case len(targets) > 1:
|
||||
var names []string
|
||||
for _, t := range targets {
|
||||
names = append(names, t)
|
||||
}
|
||||
sort.Strings(names)
|
||||
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir,
|
||||
Why: fmt.Sprintf("Songs belong to %d different album folders (%s)", len(names), strings.Join(names, ", ")),
|
||||
Blocked: "the file step sorts them into their own folders; or make the album tags agree with Edit album"})
|
||||
continue
|
||||
}
|
||||
var to string
|
||||
for _, t := range targets {
|
||||
to = t
|
||||
}
|
||||
if to == a.Dir {
|
||||
continue
|
||||
}
|
||||
why := "Renamed from its tags"
|
||||
if filepath.Dir(to) != filepath.Dir(a.Dir) {
|
||||
why = "Moved to the folder its tags name"
|
||||
}
|
||||
if untagged > 0 {
|
||||
why += fmt.Sprintf(" (%d songs without tags move along)", untagged)
|
||||
}
|
||||
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to, Why: why}
|
||||
switch {
|
||||
case a.Nested:
|
||||
it.Blocked = "other album folders are inside it"
|
||||
default:
|
||||
if b := checkDirMove(l, root, a.Dir, to); b != "" {
|
||||
it.Blocked = b
|
||||
if strings.Contains(b, "already exists") {
|
||||
it.Blocked = "that folder already exists; its songs are moved into it in the file step"
|
||||
}
|
||||
} else if c := claimed[strings.ToLower(to)]; c != "" {
|
||||
it.Blocked = "another folder gets the same name (" + c + ")"
|
||||
} else {
|
||||
claimed[strings.ToLower(to)] = a.Dir
|
||||
}
|
||||
}
|
||||
plan.add(it)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- step 4: file names ----
|
||||
|
||||
func (e *Engine) checkFiles(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
|
||||
op, err := e.planOrganize(ctx, j, l, []string{plan.Side})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plan.Songs = op.Scanned
|
||||
for _, it := range op.Items {
|
||||
fi := FixItem{Kind: FixMoveFile, From: it.From, To: it.To, Why: "Renamed from its tags"}
|
||||
if it.Move {
|
||||
org := it
|
||||
fi.org = &org
|
||||
fi.Group = filepath.Dir(it.To)
|
||||
if filepath.Dir(it.To) != filepath.Dir(it.From) {
|
||||
fi.Why = "Moved and renamed from its tags"
|
||||
}
|
||||
} else {
|
||||
fi.Kind, fi.Blocked, fi.Why, fi.Group = FixProblem, it.Reason, "Can't be renamed", filepath.Dir(it.From)
|
||||
}
|
||||
plan.add(fi)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/meta"
|
||||
"github.com/cdnutter/playlink/internal/tags"
|
||||
)
|
||||
|
||||
// The album tags step looks each album up and proposes corrected tags for
|
||||
// review. The master is looked up on MusicBrainz; the slave uses the fixed
|
||||
// master album when there is one (so both libraries agree), and MusicBrainz
|
||||
// otherwise.
|
||||
|
||||
// SetMeta enables online album lookups.
|
||||
func (e *Engine) SetMeta(c *meta.Client) { e.meta = c }
|
||||
|
||||
// Sources of album tags.
|
||||
const (
|
||||
SourceMusicBrainz = "musicbrainz"
|
||||
SourceMaster = "master"
|
||||
)
|
||||
|
||||
// AlbumFix is the lookup result for one album folder.
|
||||
type AlbumFix struct {
|
||||
ID int `json:"id"` // the plan item's id
|
||||
Folder string `json:"folder"`
|
||||
Artist string `json:"artist"` // what was searched for
|
||||
Album string `json:"album"`
|
||||
Source string `json:"source"`
|
||||
Release *meta.Release `json:"release,omitempty"`
|
||||
Candidates []meta.Release `json:"candidates"`
|
||||
Confidence float64 `json:"confidence"` // 0..1
|
||||
Matched int `json:"matched"` // songs matched to a track
|
||||
Files []FileFix `json:"files"`
|
||||
Shared map[string][2]string `json:"album_changes"` // changes to album-wide fields
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
songs []string
|
||||
fields map[string]map[string]string
|
||||
discs map[string]int
|
||||
}
|
||||
|
||||
// FileFix is the proposed change of one song.
|
||||
type FileFix struct {
|
||||
Path string `json:"path"`
|
||||
Track string `json:"track,omitempty"` // the matched track, "1-03 Title"
|
||||
Changes map[string][2]string `json:"changes"`
|
||||
}
|
||||
|
||||
// albumComplete reports whether an album's tags already look right: every
|
||||
// song has a title, track number, album and artist, and they agree.
|
||||
func albumComplete(songs []string, fields map[string]map[string]string) bool {
|
||||
albums, artists, tracks := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
for _, s := range songs {
|
||||
f := fields[s]
|
||||
artist := strings.TrimSpace(f[tags.AlbumArtist])
|
||||
if artist == "" {
|
||||
artist = strings.TrimSpace(f[tags.Artist])
|
||||
}
|
||||
if strings.TrimSpace(f[tags.Title]) == "" || leadingNumber(f[tags.Track]) == 0 || strings.TrimSpace(f[tags.Album]) == "" || artist == "" {
|
||||
return false
|
||||
}
|
||||
key := strconv.Itoa(leadingNumber(f[tags.Disc])) + "-" + strconv.Itoa(leadingNumber(f[tags.Track]))
|
||||
if tracks[key] {
|
||||
return false // two songs with the same number
|
||||
}
|
||||
tracks[key] = true
|
||||
albums[f[tags.Album]] = true
|
||||
artists[artist] = true
|
||||
}
|
||||
return len(albums) == 1 && len(artists) == 1
|
||||
}
|
||||
|
||||
func (e *Engine) checkAlbumTags(ctx context.Context, j *Job, l *libs, plan *FixPlan, c FixCheck) error {
|
||||
_, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
naming := NamingOf(l.pair)
|
||||
var albums []*albumInfo
|
||||
complete := 0
|
||||
for _, a := range albumsOf(files) {
|
||||
prefix, _ := naming.layoutFor(filepath.Join(a.Dir, "x"))
|
||||
if a.Dir == "." || a.Dir == prefix {
|
||||
continue // not in an album folder: the structure step handles those
|
||||
}
|
||||
if c.Folder != "" && a.Dir != c.Folder && !strings.HasPrefix(a.Dir, c.Folder+"/") {
|
||||
continue
|
||||
}
|
||||
if c.OnlyIncomplete && albumComplete(a.Songs, fields) {
|
||||
complete++
|
||||
continue
|
||||
}
|
||||
albums = append(albums, a)
|
||||
}
|
||||
plan.Songs = len(files)
|
||||
plan.Albums = len(albums)
|
||||
if complete > 0 {
|
||||
plan.Notices = append(plan.Notices, fmt.Sprintf("%d albums already have complete tags and were not looked up.", complete))
|
||||
}
|
||||
var master map[string]*masterAlbum
|
||||
if plan.Side == SideSlave {
|
||||
master, err = e.masterAlbums(ctx, j, l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
j.phase("Looking up albums", int64(len(albums)))
|
||||
correct := 0
|
||||
for _, a := range albums {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
af := &AlbumFix{Folder: a.Dir, songs: a.Songs, fields: fields, discs: map[string]int{}}
|
||||
for _, s := range a.Songs {
|
||||
af.discs[s] = a.Discs[filepath.Dir(s)]
|
||||
}
|
||||
af.Album = majority(fields, a.Songs, tags.Album)
|
||||
if af.Album == "" {
|
||||
af.Album = cleanFolderName(filepath.Base(a.Dir))
|
||||
}
|
||||
af.Artist = majority(fields, a.Songs, tags.AlbumArtist, tags.Artist)
|
||||
if m := master[fixAlbumKey(af.Artist, af.Album)]; m != nil {
|
||||
af.useMaster(m)
|
||||
} else {
|
||||
e.lookupAlbum(ctx, af, "")
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
j.tick()
|
||||
if af.Error == "" && !af.hasChanges() {
|
||||
correct++
|
||||
continue
|
||||
}
|
||||
it := FixItem{Kind: FixAlbumTags, From: a.Dir, Group: a.Dir, Why: af.summary(), Blocked: af.Error, Unsure: af.Confidence < 0.75}
|
||||
plan.add(it)
|
||||
af.ID = plan.Items[len(plan.Items)-1].ID
|
||||
plan.AlbumFixes = append(plan.AlbumFixes, af)
|
||||
}
|
||||
if correct > 0 {
|
||||
plan.Notices = append(plan.Notices, fmt.Sprintf("%d albums already match their lookup.", correct))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AlbumFix) hasChanges() bool {
|
||||
for _, f := range a.Files {
|
||||
if len(f.Changes) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *AlbumFix) summary() string {
|
||||
if a.Error != "" {
|
||||
return "No confident match"
|
||||
}
|
||||
n := 0
|
||||
for _, f := range a.Files {
|
||||
if len(f.Changes) > 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
src := "MusicBrainz"
|
||||
if a.Source == SourceMaster {
|
||||
src = "the master library"
|
||||
}
|
||||
return fmt.Sprintf("%d of %d songs change (from %s)", n, len(a.Files), src)
|
||||
}
|
||||
|
||||
func fixAlbumKey(artist, album string) string { return matchKey(artist) + "|" + matchKey(album) }
|
||||
|
||||
// ---- the master as reference (slave only) ----
|
||||
|
||||
type masterAlbum struct {
|
||||
folder string
|
||||
songs []string
|
||||
fields map[string]map[string]string
|
||||
}
|
||||
|
||||
// masterAlbums indexes the master's albums by artist and album tags.
|
||||
func (e *Engine) masterAlbums(ctx context.Context, j *Job, l *libs) (map[string]*masterAlbum, error) {
|
||||
_, files, fields, err := e.readLibrary(ctx, j, l, SideMaster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]*masterAlbum{}
|
||||
for _, a := range albumsOf(files) {
|
||||
if a.Dir == "." || !albumComplete(a.Songs, fields) {
|
||||
continue
|
||||
}
|
||||
k := fixAlbumKey(majority(fields, a.Songs, tags.AlbumArtist, tags.Artist), majority(fields, a.Songs, tags.Album))
|
||||
if out[k] == nil {
|
||||
out[k] = &masterAlbum{folder: a.Dir, songs: a.Songs, fields: fields}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// useMaster takes the tags of the same album in the master.
|
||||
func (a *AlbumFix) useMaster(m *masterAlbum) {
|
||||
a.Source = SourceMaster
|
||||
d := &meta.ReleaseDetail{Release: meta.Release{ID: "master:" + m.folder, Title: majority(m.fields, m.songs, tags.Album),
|
||||
Artist: majority(m.fields, m.songs, tags.AlbumArtist, tags.Artist), Date: majority(m.fields, m.songs, tags.Date)}}
|
||||
discs := map[int]bool{}
|
||||
for _, s := range m.songs {
|
||||
f := m.fields[s]
|
||||
disc := max(leadingNumber(f[tags.Disc]), 1)
|
||||
discs[disc] = true
|
||||
d.Tracks = append(d.Tracks, meta.Track{Disc: disc, Position: leadingNumber(f[tags.Track]), DiscTracks: leadingNumber(f[tags.TrackTotal]),
|
||||
Title: f[tags.Title], Artist: f[tags.Artist]})
|
||||
}
|
||||
d.Discs = len(discs)
|
||||
d.TrackCount = len(d.Tracks)
|
||||
d.Formats = "master: " + m.folder
|
||||
a.Candidates = []meta.Release{d.Release}
|
||||
a.apply(d, 1)
|
||||
}
|
||||
|
||||
// ---- MusicBrainz ----
|
||||
|
||||
// lookupAlbum searches MusicBrainz and applies the best release, or the one
|
||||
// given by id.
|
||||
func (e *Engine) lookupAlbum(ctx context.Context, a *AlbumFix, id string) {
|
||||
a.Source, a.Error, a.Release, a.Files, a.Matched, a.Confidence = SourceMusicBrainz, "", nil, nil, 0, 0
|
||||
if id == "" {
|
||||
res, err := e.meta.SearchReleases(ctx, a.Artist, a.Album)
|
||||
if err == nil && len(res) == 0 && a.Artist != "" {
|
||||
res, err = e.meta.SearchReleases(ctx, "", a.Album) // soundtracks: the artist is often missing or a composer
|
||||
}
|
||||
if err != nil {
|
||||
a.Error = "lookup failed: " + err.Error()
|
||||
return
|
||||
}
|
||||
scored := make([]struct {
|
||||
r meta.Release
|
||||
s float64
|
||||
}, len(res))
|
||||
for i, r := range res {
|
||||
scored[i].r, scored[i].s = r, a.releaseScore(r)
|
||||
}
|
||||
sort.SliceStable(scored, func(i, j int) bool { return scored[i].s > scored[j].s })
|
||||
a.Candidates = a.Candidates[:0]
|
||||
for i, s := range scored {
|
||||
if i == 8 {
|
||||
break
|
||||
}
|
||||
a.Candidates = append(a.Candidates, s.r)
|
||||
}
|
||||
// The search can't tell a promo single from the album it is named
|
||||
// after: compare the track lists of the best few with the songs.
|
||||
var best *meta.ReleaseDetail
|
||||
bestScore := 0.0
|
||||
for i, c := range scored {
|
||||
if i == 3 || c.s < 0.45 {
|
||||
break
|
||||
}
|
||||
d, err := e.meta.Release(ctx, c.r.ID)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
fit := a.trackFit(d)
|
||||
if score := 0.5*c.s + 0.5*fit; score > bestScore {
|
||||
best, bestScore = d, score
|
||||
}
|
||||
if fit == 1 && c.s >= 0.75 {
|
||||
break // every song is on this release
|
||||
}
|
||||
}
|
||||
if best == nil || bestScore < 0.6 {
|
||||
a.Error = "no confident match on MusicBrainz; pick a release or edit the album by hand"
|
||||
return
|
||||
}
|
||||
a.apply(best, bestScore)
|
||||
return
|
||||
}
|
||||
d, err := e.meta.Release(ctx, id)
|
||||
if err != nil {
|
||||
a.Error = "lookup failed: " + err.Error()
|
||||
return
|
||||
}
|
||||
a.apply(d, 0.5*a.releaseScore(d.Release)+0.5*a.trackFit(d))
|
||||
}
|
||||
|
||||
// releaseScore rates a search result before its track list is known (0..1).
|
||||
func (a *AlbumFix) releaseScore(r meta.Release) float64 {
|
||||
title := levenshteinRatio(matchKey(r.Title), matchKey(a.Album))
|
||||
artist := 0.6
|
||||
if a.Artist != "" {
|
||||
artist = levenshteinRatio(matchKey(r.Artist), matchKey(a.Artist))
|
||||
}
|
||||
count := 0.0
|
||||
switch n := len(a.songs); {
|
||||
case r.TrackCount == n:
|
||||
count = 1
|
||||
case r.TrackCount > n:
|
||||
count = 0.7 // a partial copy of an album is common
|
||||
}
|
||||
s := 0.45*title + 0.2*artist + 0.15*count + 0.2*typeFit(r.Type)
|
||||
if r.Status != "" && r.Status != "Official" {
|
||||
s -= 0.05
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// typeFit prefers albums and soundtracks over singles and EPs of the same name.
|
||||
func typeFit(t string) float64 {
|
||||
t = strings.ToLower(t)
|
||||
switch {
|
||||
case strings.Contains(t, "album"), strings.Contains(t, "soundtrack"):
|
||||
return 1
|
||||
case strings.Contains(t, "compilation"):
|
||||
return 0.8
|
||||
case strings.Contains(t, "ep"), t == "":
|
||||
return 0.6
|
||||
case strings.Contains(t, "single"):
|
||||
return 0.3
|
||||
}
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// trackFit is the share of the album's titled songs found on a release.
|
||||
func (a *AlbumFix) trackFit(d *meta.ReleaseDetail) float64 {
|
||||
n, hit := 0, 0
|
||||
for _, s := range a.songs {
|
||||
t := matchKey(a.fields[s][tags.Title])
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
for _, tr := range d.Tracks {
|
||||
if k := matchKey(tr.Title); k == t || levenshteinRatio(k, t) >= 0.8 {
|
||||
hit++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
if len(d.Tracks) == len(a.songs) {
|
||||
return 0.8
|
||||
}
|
||||
return 0.4
|
||||
}
|
||||
return float64(hit) / float64(n)
|
||||
}
|
||||
|
||||
// ChooseRelease switches an album of the latest plan to another release (a
|
||||
// MusicBrainz id), or to a new MusicBrainz search ("search").
|
||||
func (e *Engine) ChooseRelease(ctx context.Context, pairID int64, albumID int, id string) (*AlbumFix, error) {
|
||||
if e.meta == nil {
|
||||
return nil, errors.New("online lookup is not available")
|
||||
}
|
||||
if id != "search" && !meta.ValidID(id) {
|
||||
return nil, fmt.Errorf("%w: not a MusicBrainz release id", ErrInvalidBulk)
|
||||
}
|
||||
e.fixMu.Lock()
|
||||
plan := e.fixPlans[pairID]
|
||||
var af *AlbumFix
|
||||
if plan != nil && plan.Step == StepTags && !plan.Applied {
|
||||
for _, a := range plan.AlbumFixes {
|
||||
if a.ID == albumID {
|
||||
af = a
|
||||
}
|
||||
}
|
||||
}
|
||||
e.fixMu.Unlock()
|
||||
if af == nil {
|
||||
return nil, fmt.Errorf("%w: that album is not in the latest check", ErrInvalidBulk)
|
||||
}
|
||||
// Work on a copy so a failed or slow lookup never leaves a half-updated album.
|
||||
cp := *af
|
||||
if id == "search" {
|
||||
id = ""
|
||||
}
|
||||
e.lookupAlbum(ctx, &cp, id)
|
||||
if cp.Error != "" && cp.Release == nil && id != "" {
|
||||
return nil, errors.New(cp.Error)
|
||||
}
|
||||
e.fixMu.Lock()
|
||||
defer e.fixMu.Unlock()
|
||||
*af = cp
|
||||
for i := range plan.Items {
|
||||
if plan.Items[i].ID == albumID {
|
||||
wasBlocked := plan.Items[i].Blocked != ""
|
||||
plan.Items[i].Blocked, plan.Items[i].Why, plan.Items[i].Unsure = af.Error, af.summary(), af.Confidence < 0.75
|
||||
if wasBlocked && af.Error == "" {
|
||||
plan.Blocked--
|
||||
plan.Fixable++
|
||||
} else if !wasBlocked && af.Error != "" {
|
||||
plan.Blocked++
|
||||
plan.Fixable--
|
||||
}
|
||||
}
|
||||
}
|
||||
return af, nil
|
||||
}
|
||||
|
||||
// apply matches the album's songs to a release's tracks and works out the
|
||||
// tag changes.
|
||||
func (a *AlbumFix) apply(d *meta.ReleaseDetail, confidence float64) {
|
||||
rel := d.Release
|
||||
a.Release, a.Confidence, a.Error = &rel, confidence, ""
|
||||
match := matchTracks(a.songs, a.fields, a.discs, d.Tracks)
|
||||
a.Files, a.Matched = nil, 0
|
||||
shared := map[string][2]string{}
|
||||
for i, s := range a.songs {
|
||||
f := a.fields[s]
|
||||
want := map[string]string{tags.Album: d.Title, tags.AlbumArtist: d.Artist}
|
||||
if d.Date != "" && !(len(f[tags.Date]) >= 4 && len(d.Date) >= 4 && f[tags.Date][:4] == d.Date[:4]) {
|
||||
want[tags.Date] = d.Date
|
||||
}
|
||||
ff := FileFix{Path: s}
|
||||
if t := match[i]; t >= 0 {
|
||||
tr := d.Tracks[t]
|
||||
a.Matched++
|
||||
want[tags.Title], want[tags.Artist] = tr.Title, tr.Artist
|
||||
if tr.Position > 0 {
|
||||
want[tags.Track] = strconv.Itoa(tr.Position)
|
||||
}
|
||||
if tr.DiscTracks > 0 {
|
||||
want[tags.TrackTotal] = strconv.Itoa(tr.DiscTracks)
|
||||
}
|
||||
if d.Discs > 1 || leadingNumber(f[tags.Disc]) > 1 {
|
||||
want[tags.Disc] = strconv.Itoa(tr.Disc)
|
||||
want[tags.DiscTotal] = strconv.Itoa(d.Discs)
|
||||
}
|
||||
ff.Track = fmt.Sprintf("%d-%02d %s", tr.Disc, tr.Position, tr.Title)
|
||||
}
|
||||
ff.Changes = map[string][2]string{}
|
||||
for k, v := range want {
|
||||
if strings.TrimSpace(v) == "" || sameTag(k, f[k], v) {
|
||||
continue
|
||||
}
|
||||
ff.Changes[k] = [2]string{f[k], v}
|
||||
if k == tags.Album || k == tags.AlbumArtist || k == tags.Date {
|
||||
shared[k] = [2]string{f[k], v}
|
||||
}
|
||||
}
|
||||
a.Files = append(a.Files, ff)
|
||||
}
|
||||
a.Shared = shared
|
||||
if len(a.songs) > 0 && a.Matched*2 < len(a.songs) {
|
||||
a.Confidence = min(a.Confidence, 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
func sameTag(k, old, new string) bool {
|
||||
switch k {
|
||||
case tags.Track, tags.TrackTotal, tags.Disc, tags.DiscTotal:
|
||||
return leadingNumber(old) == leadingNumber(new)
|
||||
}
|
||||
return strings.TrimSpace(old) == strings.TrimSpace(new)
|
||||
}
|
||||
|
||||
// matchTracks pairs each song with a track of the release (-1: none). Track
|
||||
// and disc numbers are trusted when the titles mostly agree with them;
|
||||
// otherwise songs are matched by title, and failing that by file order when
|
||||
// the counts are equal.
|
||||
func matchTracks(songs []string, fields map[string]map[string]string, discs map[string]int, tracks []meta.Track) []int {
|
||||
out := make([]int, len(songs))
|
||||
for i := range out {
|
||||
out[i] = -1
|
||||
}
|
||||
titleOf := func(i int) string {
|
||||
if t := strings.TrimSpace(fields[songs[i]][tags.Title]); t != "" {
|
||||
return matchKey(t)
|
||||
}
|
||||
return normTitle(filepath.Base(songs[i]))
|
||||
}
|
||||
sim := func(i, t int) float64 { return levenshteinRatio(titleOf(i), matchKey(tracks[t].Title)) }
|
||||
|
||||
// 1. by disc and track number
|
||||
byNum := map[[2]int]int{}
|
||||
multi := false
|
||||
for t, tr := range tracks {
|
||||
byNum[[2]int{tr.Disc, tr.Position}] = t
|
||||
multi = multi || tr.Disc > 1
|
||||
}
|
||||
used := map[int]bool{}
|
||||
agree, tried := 0, 0
|
||||
for i, s := range songs {
|
||||
n := leadingNumber(fields[s][tags.Track])
|
||||
disc := leadingNumber(fields[s][tags.Disc])
|
||||
if disc == 0 {
|
||||
disc = discs[s]
|
||||
}
|
||||
if disc == 0 && !multi {
|
||||
disc = 1
|
||||
}
|
||||
if t, ok := byNum[[2]int{disc, n}]; ok && n > 0 && !used[t] {
|
||||
out[i], used[t] = t, true
|
||||
tried++
|
||||
if sim(i, t) >= 0.6 {
|
||||
agree++
|
||||
}
|
||||
}
|
||||
}
|
||||
if tried > 0 && agree*10 < tried*7 {
|
||||
for i := range out { // numbers don't fit these titles
|
||||
out[i] = -1
|
||||
}
|
||||
used = map[int]bool{}
|
||||
}
|
||||
// 2. by title, best pairs first
|
||||
type pair struct {
|
||||
i, t int
|
||||
s float64
|
||||
}
|
||||
var pairs []pair
|
||||
for i := range songs {
|
||||
if out[i] >= 0 {
|
||||
continue
|
||||
}
|
||||
for t := range tracks {
|
||||
if !used[t] {
|
||||
if s := sim(i, t); s >= 0.6 {
|
||||
pairs = append(pairs, pair{i, t, s})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(pairs, func(x, y int) bool { return pairs[x].s > pairs[y].s })
|
||||
for _, p := range pairs {
|
||||
if out[p.i] < 0 && !used[p.t] {
|
||||
out[p.i], used[p.t] = p.t, true
|
||||
}
|
||||
}
|
||||
// 3. by file order when nothing matched and the counts agree
|
||||
if len(used) == 0 && len(songs) == len(tracks) {
|
||||
order := make([]int, len(songs))
|
||||
for i := range order {
|
||||
order[i] = i
|
||||
}
|
||||
sort.Slice(order, func(x, y int) bool { return songs[order[x]] < songs[order[y]] })
|
||||
for t, i := range order {
|
||||
out[i] = t
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/meta"
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
"github.com/cdnutter/playlink/internal/tags"
|
||||
)
|
||||
|
||||
// fixCheck runs a check and returns its plan and items.
|
||||
func (ev *env) fixCheck(t *testing.T, c FixCheck) (*FixPlan, []FixItem, []*AlbumFix) {
|
||||
t.Helper()
|
||||
if _, err := ev.e.StartFixCheck(ev.pair.ID, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev.wait()
|
||||
p, items, albums := ev.e.FixerPlan(ev.pair.ID)
|
||||
if p == nil {
|
||||
t.Fatal("no plan")
|
||||
}
|
||||
return p, items, albums
|
||||
}
|
||||
|
||||
// fixAll applies every fix that isn't blocked (unsure ones included).
|
||||
func (ev *env) fixAll(t *testing.T, items []FixItem) *Job {
|
||||
t.Helper()
|
||||
var ids []int
|
||||
for _, it := range items {
|
||||
if it.Blocked == "" && it.Kind != FixProblem {
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
}
|
||||
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
|
||||
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: ids}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ev.wait()
|
||||
}
|
||||
|
||||
func byFrom(items []FixItem) map[string]FixItem {
|
||||
m := map[string]FixItem{}
|
||||
for _, it := range items {
|
||||
if _, ok := m[it.From]; !ok || it.Kind != FixTags {
|
||||
m[it.From] = it
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (ev *env) fields(t *testing.T, p string) map[string]string {
|
||||
t.Helper()
|
||||
f, err := ev.e.tagTools.Read(ctx, p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.Fields
|
||||
}
|
||||
|
||||
func TestFixStructureMusic(t *testing.T) {
|
||||
ev := tagEnv(t, false)
|
||||
s := ev.slave
|
||||
mkTagged(t, filepath.Join(s, "Loose.mp3"), "title", "Loose", "artist", "A", "album", "B")
|
||||
mkTagged(t, filepath.Join(s, "Nightmare/01 Nightmare.flac"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
||||
os.WriteFile(filepath.Join(s, "Nightmare/cover.jpg"), []byte("art"), 0o644)
|
||||
mkTagged(t, filepath.Join(s, "Band/song.mp3"), "title", "Song", "artist", "Band", "album", "Record")
|
||||
mkTagged(t, filepath.Join(s, "X/Y/FLAC/01 a.flac"), "title", "a", "artist", "X", "album", "Y")
|
||||
mkTagged(t, filepath.Join(s, "Metal/Artist2/Album2/01 s.mp3"), "title", "s", "artist", "Artist2", "album", "Album2")
|
||||
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD1/01 t.mp3"), "title", "t", "artist", "Artist3", "album", "Album3", "track", "1")
|
||||
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3"), "title", "u", "artist", "Artist3", "album", "Album3", "track", "1")
|
||||
mkTagged(t, filepath.Join(s, "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"))
|
||||
mkTagged(t, filepath.Join(s, "NoTags.mp3"))
|
||||
|
||||
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
|
||||
m := byFrom(items)
|
||||
want := map[string]string{
|
||||
"Loose.mp3": "A/B/Loose.mp3",
|
||||
"Nightmare": "Avenged Sevenfold/Nightmare",
|
||||
"Band/song.mp3": "Band/Record/song.mp3",
|
||||
"X/Y/FLAC/01 a.flac": "X/Y/01 a.flac",
|
||||
"Metal/Artist2/Album2": "Artist2/Album2",
|
||||
"Artist3/Album3/CD2/01 u.mp3": "",
|
||||
}
|
||||
for from, to := range want {
|
||||
it, ok := m[from]
|
||||
if !ok || it.To != to || it.Blocked != "" {
|
||||
t.Errorf("%s: got %+v, want to %q", from, it, to)
|
||||
}
|
||||
}
|
||||
if it := m["Artist3/Album3/CD2/01 u.mp3"]; it.Changes[tags.Disc][1] != "2" || it.Changes[tags.DiscTotal][1] != "2" {
|
||||
t.Errorf("disc tags from the disc folder: %+v", it.Changes)
|
||||
}
|
||||
fill := m["Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"].Changes
|
||||
if fill[tags.Album][1] != "Album4" || fill[tags.Artist][1] != "Artist4" || fill[tags.Title][1] != "Song Name" || fill[tags.Track][1] != "5" {
|
||||
t.Errorf("tags from names: %+v", fill)
|
||||
}
|
||||
if it := m["NoTags.mp3"]; it.Blocked == "" {
|
||||
t.Errorf("a loose song without tags can't be placed: %+v", it)
|
||||
}
|
||||
if plan.Blocked != 1 {
|
||||
t.Errorf("blocked %d", plan.Blocked)
|
||||
}
|
||||
|
||||
j := ev.fixAll(t, items)
|
||||
if j.Results["skipped"] != 0 {
|
||||
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
|
||||
}
|
||||
for _, p := range []string{"A/B/Loose.mp3", "Avenged Sevenfold/Nightmare/01 Nightmare.flac", "Avenged Sevenfold/Nightmare/cover.jpg",
|
||||
"Band/Record/song.mp3", "X/Y/01 a.flac", "Artist2/Album2/01 s.mp3", "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"} {
|
||||
if !exists(filepath.Join(s, p)) {
|
||||
t.Errorf("missing %s", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"Nightmare", "X/Y/FLAC", "Metal"} {
|
||||
if exists(filepath.Join(s, p)) {
|
||||
t.Errorf("%s should be gone", p)
|
||||
}
|
||||
}
|
||||
if f := ev.fields(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3")); f[tags.Disc] != "2" || f[tags.DiscTotal] != "2" {
|
||||
t.Errorf("disc tags not written: %+v", f)
|
||||
}
|
||||
// A folder move can be undone.
|
||||
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
|
||||
for _, o := range ops {
|
||||
if o.Action == store.ActMoveDir && o.RelNew == "Avenged Sevenfold/Nightmare" {
|
||||
if err := ev.e.Undo(ctx, o.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
waitIdle(ev)
|
||||
if !exists(filepath.Join(s, "Nightmare/cover.jpg")) || exists(filepath.Join(s, "Avenged Sevenfold")) {
|
||||
t.Fatal("undo should move the folder back and remove the empty artist folder")
|
||||
}
|
||||
// Not everything was fixable, so the step isn't marked done.
|
||||
p, _ := ev.st.Pair(ctx, ev.pair.ID)
|
||||
if FixerStateOf(p).Slave[StepStructure].State != "" {
|
||||
t.Fatal("step marked done although a song still needs attention")
|
||||
}
|
||||
}
|
||||
|
||||
func waitIdle(ev *env) {
|
||||
for i := 0; i < 500; i++ {
|
||||
if j := ev.e.Job(ev.pair.ID); j == nil || j.State != "running" {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixStructureSoundtracks(t *testing.T) {
|
||||
ev := tagEnv(t, false)
|
||||
ev.pair.LibraryType = LibrarySoundtracks
|
||||
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := ev.slave
|
||||
mkTagged(t, filepath.Join(s, "Nobuo Uematsu/Final Fantasy VII/01 Prelude.flac"), "title", "Prelude", "album", "Final Fantasy VII")
|
||||
for _, g := range []string{"Halo 3", "Super Mario Galaxy", "Portal 2"} {
|
||||
mkTagged(t, filepath.Join(s, "Video Games", g, "01 x.flac"), "title", "x", "album", g)
|
||||
}
|
||||
mkTagged(t, filepath.Join(s, "Journey/01 Nascence.flac"), "title", "Nascence", "album", "Journey")
|
||||
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
|
||||
m := byFrom(items)
|
||||
if it := m["Nobuo Uematsu/Final Fantasy VII"]; it.To != "Final Fantasy VII" || it.Unsure || !strings.Contains(it.Why, "Soundtrack") {
|
||||
t.Errorf("composer folder: %+v", it)
|
||||
}
|
||||
if it := m["Video Games/Halo 3"]; it.To != "Halo 3" || !it.Unsure {
|
||||
t.Errorf("a category folder's albums should be listed but not selected: %+v", it)
|
||||
}
|
||||
if len(plan.Notices) != 1 || !strings.Contains(plan.Notices[0], "Video Games (3 albums)") {
|
||||
t.Errorf("notices %v", plan.Notices)
|
||||
}
|
||||
if _, ok := m["Journey"]; ok {
|
||||
t.Error("a soundtrack at the top of the library is fine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixFoldersAndFiles(t *testing.T) {
|
||||
ev := tagEnv(t, false)
|
||||
m := ev.master
|
||||
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/a.mp3"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
|
||||
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/b.mp3"), "title", "Welcome to the Family", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "2")
|
||||
mkTagged(t, filepath.Join(m, "Mix/1.mp3"), "title", "One", "artist", "A", "album", "First")
|
||||
mkTagged(t, filepath.Join(m, "Mix/2.mp3"), "title", "Two", "artist", "B", "album", "Second")
|
||||
_, items, _ := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFolders})
|
||||
byF := byFrom(items)
|
||||
if it := byF["avenged sevenfold/nightmare (2010)"]; it.Kind != FixMoveFolder || it.To != "Avenged Sevenfold/Nightmare" {
|
||||
t.Fatalf("folder rename: %+v", it)
|
||||
}
|
||||
if it := byF["Mix"]; it.Kind != FixProblem || !strings.Contains(it.Why, "2 different album folders") {
|
||||
t.Fatalf("mixed folder: %+v", it)
|
||||
}
|
||||
ev.fixAll(t, items)
|
||||
if !exists(filepath.Join(m, "Avenged Sevenfold/Nightmare/a.mp3")) {
|
||||
t.Fatal("folder not renamed")
|
||||
}
|
||||
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFolders].State != "" {
|
||||
t.Fatal("a folder that needs attention keeps the step open")
|
||||
}
|
||||
_, items, _ = ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFiles})
|
||||
ev.fixAll(t, items)
|
||||
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFiles].State != "done" {
|
||||
t.Fatal("applying every fix marks the step done")
|
||||
}
|
||||
for _, p := range []string{"Avenged Sevenfold/Nightmare/01 Nightmare.mp3", "Avenged Sevenfold/Nightmare/02 Welcome to the Family.mp3", "A/First/One.mp3", "B/Second/Two.mp3"} {
|
||||
if !exists(filepath.Join(m, p)) {
|
||||
t.Errorf("missing %s", p)
|
||||
}
|
||||
}
|
||||
// Applying the same plan twice is refused: paths changed.
|
||||
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
|
||||
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: []int{1}}); err == nil {
|
||||
t.Fatal("a stale plan must not be applied again")
|
||||
}
|
||||
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID - 1, IDs: []int{1}}); err == nil {
|
||||
t.Fatal("a stale plan must not be applied again")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeMB serves one release: Avenged Sevenfold - Nightmare, 3 tracks.
|
||||
func fakeMB(t *testing.T) *meta.Client {
|
||||
const id = "85cb4213-ffc2-38b1-a256-bd6e5ce2e0af"
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, id) {
|
||||
fmt.Fprint(w, `{"id":"`+id+`","title":"Nightmare","date":"2010-07-26","status":"Official","artist-credit":[{"name":"Avenged Sevenfold"}],
|
||||
"release-group":{"primary-type":"Album"},"media":[{"position":1,"format":"CD","track-count":3,"tracks":[
|
||||
{"position":1,"title":"Nightmare","length":374000},{"position":2,"title":"Welcome to the Family","length":245000},
|
||||
{"position":3,"title":"Danger Line","length":329000}]}]}`)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(r.URL.Query().Get("query"), "Nightmare") {
|
||||
fmt.Fprint(w, `{"releases":[]}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"releases":[{"id":"`+id+`","score":100,"title":"Nightmare","status":"Official","date":"2010-07-26",
|
||||
"track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":3}]}]}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
c := meta.New("test")
|
||||
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
|
||||
return c
|
||||
}
|
||||
|
||||
func TestFixAlbumTags(t *testing.T) {
|
||||
ev := tagEnv(t, false)
|
||||
ev.e.SetMeta(fakeMB(t))
|
||||
m, s := ev.master, ev.slave
|
||||
dir := "Avenged Sevenfold/Nightmare"
|
||||
// Titles are slightly off and numbers are missing; one song has no title.
|
||||
mkTagged(t, filepath.Join(m, dir, "a.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
||||
mkTagged(t, filepath.Join(m, dir, "b.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
||||
mkTagged(t, filepath.Join(m, dir, "03 Danger Line.mp3"), "artist", "Avenged Sevenfold", "album", "Nightmare")
|
||||
mkTagged(t, filepath.Join(m, "Nobody/Unknown/x.mp3"), "title", "x", "artist", "Nobody", "album", "Unknown")
|
||||
|
||||
plan, items, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
|
||||
if len(albums) != 2 || plan.Fixable != 1 || plan.Blocked != 1 {
|
||||
t.Fatalf("plan %+v albums %d", plan, len(albums))
|
||||
}
|
||||
var nm *AlbumFix
|
||||
for _, a := range albums {
|
||||
if a.Folder == dir {
|
||||
nm = a
|
||||
}
|
||||
}
|
||||
if nm == nil || nm.Matched != 3 || nm.Release.Title != "Nightmare" || nm.Confidence < 0.9 {
|
||||
t.Fatalf("album fix %+v", nm)
|
||||
}
|
||||
ev.fixAll(t, items)
|
||||
f := ev.fields(t, filepath.Join(m, dir, "b.mp3"))
|
||||
if f[tags.Title] != "Welcome to the Family" || f[tags.Track] != "2" || f[tags.TrackTotal] != "3" || f[tags.AlbumArtist] != "Avenged Sevenfold" || f[tags.Date] != "2010-07-26" {
|
||||
t.Fatalf("tags not updated: %+v", f)
|
||||
}
|
||||
if f := ev.fields(t, filepath.Join(m, dir, "03 Danger Line.mp3")); f[tags.Title] != "Danger Line" || f[tags.Track] != "3" {
|
||||
t.Fatalf("untitled song matched by position: %+v", f)
|
||||
}
|
||||
|
||||
// The slave copy takes its tags from the fixed master, not MusicBrainz.
|
||||
mkTagged(t, filepath.Join(s, "A7X/Nightmare/x.mp3"), "title", "Welcome to the family", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
||||
_, _, albums = ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepTags})
|
||||
if len(albums) != 1 || albums[0].Source != SourceMaster || albums[0].Files[0].Changes[tags.Track][1] != "2" {
|
||||
t.Fatalf("slave from master: %+v", albums)
|
||||
}
|
||||
// Choosing MusicBrainz instead.
|
||||
a, err := ev.e.ChooseRelease(ctx, ev.pair.ID, albums[0].ID, "search")
|
||||
if err != nil || a.Source != SourceMusicBrainz || a.Matched != 1 {
|
||||
t.Fatalf("switch to MusicBrainz: %+v %v", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixerStepState(t *testing.T) {
|
||||
ev := setup(t, false)
|
||||
if err := ev.e.SetStep(ctx, ev.pair.ID, SideMaster, StepTags, "skipped", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ev.e.SetStep(ctx, ev.pair.ID, "", StepLink, "done", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := ev.st.Pair(ctx, ev.pair.ID)
|
||||
s := FixerStateOf(p)
|
||||
if s.Master[StepTags].State != "skipped" || s.Link.State != "done" || s.Slave[StepTags].State != "" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
for _, bad := range [][3]string{{"left", StepTags, "done"}, {SideMaster, "bogus", "done"}, {SideMaster, StepTags, "maybe"}} {
|
||||
if ev.e.SetStep(ctx, ev.pair.ID, bad[0], bad[1], bad[2], "") == nil {
|
||||
t.Errorf("%v should be refused", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanFolderName(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Nightmare (2010)": "Nightmare",
|
||||
"Nightmare [FLAC]": "Nightmare",
|
||||
"2010 - Nightmare": "Nightmare",
|
||||
"Nightmare (2010) [24-96]": "Nightmare",
|
||||
"Hail to the King (Deluxe)": "Hail to the King",
|
||||
"(What's the Story) Morning Glory?": "(What's the Story) Morning Glory?",
|
||||
"1989": "1989",
|
||||
} {
|
||||
if got := cleanFolderName(in); got != want {
|
||||
t.Errorf("%q: got %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
for in, want := range map[string]int{"CD2": 2, "Disc 01": 1, "disk_3": 3, "Disc 1 - The Beginning": 1, "Vol. 2": 0, "CDs": 0} {
|
||||
if got := discNumber(in); got != want {
|
||||
t.Errorf("disc %q: got %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A promo single named after the album, with exactly as many tracks as the
|
||||
// local (partial) album, must not beat the album itself.
|
||||
func TestFixAlbumTagsPrefersTheAlbum(t *testing.T) {
|
||||
ev := tagEnv(t, false)
|
||||
const promo, album = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"
|
||||
tracks := func(titles ...string) string {
|
||||
var b []string
|
||||
for i, t := range titles {
|
||||
b = append(b, fmt.Sprintf(`{"position":%d,"title":%q}`, i+1, t))
|
||||
}
|
||||
return strings.Join(b, ",")
|
||||
}
|
||||
release := map[string]string{
|
||||
promo: `{"id":"` + promo + `","title":"Nightmare","status":"Promotion","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},
|
||||
"media":[{"position":1,"tracks":[` + tracks("Nightmare (album version) (explicit)", "Nightmare (radio edit)", "Nightmare (instrumental)") + `]}]}`,
|
||||
album: `{"id":"` + album + `","title":"Nightmare","status":"Official","date":"2010-07-26","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},
|
||||
"media":[{"position":1,"tracks":[` + tracks("Nightmare", "Welcome to the Family", "Danger Line", "Buried Alive", "Natural Born Killer") + `]}]}`,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
|
||||
for id, body := range release {
|
||||
if strings.HasSuffix(r.URL.Path, id) {
|
||||
fmt.Fprint(w, body)
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Fprint(w, `{"releases":[
|
||||
{"id":"`+promo+`","score":100,"title":"Nightmare","status":"Promotion","track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},"media":[{"format":"CD-R","track-count":3}]},
|
||||
{"id":"`+album+`","score":95,"title":"Nightmare","status":"Official","track-count":5,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":5}]}]}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
c := meta.New("test")
|
||||
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
|
||||
ev.e.SetMeta(c)
|
||||
dir := filepath.Join(ev.master, "Avenged Sevenfold/Nightmare")
|
||||
mkTagged(t, filepath.Join(dir, "01.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
||||
mkTagged(t, filepath.Join(dir, "02.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
||||
mkTagged(t, filepath.Join(dir, "03.mp3"), "title", "danger line", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
||||
_, _, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
|
||||
if len(albums) != 1 || albums[0].Release == nil || albums[0].Release.ID != album || albums[0].Matched != 3 || albums[0].Confidence < 0.75 {
|
||||
t.Fatalf("want the album: %+v", albums)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -79,6 +80,22 @@ func DefaultNaming() NamingConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Library types. Music is organized Artist/Album/01 Title; soundtracks (games,
|
||||
// films) Album/01 Title, since their "artist" is often a composer or empty.
|
||||
const (
|
||||
LibraryMusic = "music"
|
||||
LibrarySoundtracks = "soundtracks"
|
||||
)
|
||||
|
||||
// DefaultNamingFor returns the recommended naming for a library type.
|
||||
func DefaultNamingFor(libraryType string) NamingConfig {
|
||||
c := DefaultNaming()
|
||||
if libraryType == LibrarySoundtracks {
|
||||
c.ArtistFolder = ""
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SoundtrackFolder is the suggested folder rule for game and film soundtracks.
|
||||
var SoundtrackFolder = FolderNaming{Folder: "Video Games", AlbumFolder: "{Album Title}",
|
||||
TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}"}
|
||||
@@ -103,7 +120,7 @@ var NamingPresets = []struct {
|
||||
// NamingOf returns a pair's naming, converting the settings of earlier
|
||||
// versions (one path pattern plus a disc style) when none is stored yet.
|
||||
func NamingOf(p store.Pair) NamingConfig {
|
||||
c := DefaultNaming()
|
||||
c := DefaultNamingFor(p.LibraryType)
|
||||
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" {
|
||||
if stored, err := parseNaming(p.OrganizeConfig); err == nil {
|
||||
return stored
|
||||
@@ -139,7 +156,7 @@ func NamingOf(p store.Pair) NamingConfig {
|
||||
// parseNaming reads stored settings. Fields missing from the JSON take the
|
||||
// defaults; an artist folder that is present but empty stays empty.
|
||||
func parseNaming(raw []byte) (NamingConfig, error) {
|
||||
c := DefaultNaming()
|
||||
c := DefaultNaming() // missing keys: only very old or hand-made settings
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return c, err
|
||||
}
|
||||
@@ -147,6 +164,17 @@ func parseNaming(raw []byte) (NamingConfig, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ChangeLibraryType switches a pair's naming to the new type's recommended
|
||||
// naming when it still uses the old type's, so custom naming is kept.
|
||||
func ChangeLibraryType(old store.Pair, p *store.Pair) {
|
||||
if old.ID == 0 || old.LibraryType == p.LibraryType {
|
||||
return
|
||||
}
|
||||
if reflect.DeepEqual(NamingOf(*p), DefaultNamingFor(old.LibraryType)) {
|
||||
p.OrganizeConfig = nil
|
||||
}
|
||||
}
|
||||
|
||||
func fill(c *NamingConfig, d NamingConfig) {
|
||||
c.ArtistFolder = strings.TrimSpace(c.ArtistFolder)
|
||||
if c.Folders == nil {
|
||||
|
||||
@@ -635,6 +635,17 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
|
||||
e.log.Info("folder cover undone", "op", o.ID)
|
||||
return nil
|
||||
}
|
||||
if o.Action == store.ActMoveDir {
|
||||
if err := undoMoveDir(l, o); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
e.log.Info("folder move undone", "op", o.ID, "folder", o.RelNew)
|
||||
_, _ = e.StartScan(o.PairID, "undo")
|
||||
return nil
|
||||
}
|
||||
if o.Action == store.ActMove {
|
||||
if err := undoMove(l, o); err != nil {
|
||||
return err
|
||||
@@ -879,6 +890,13 @@ func (e *Engine) Recover(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
for _, o := range ops {
|
||||
if o.Action == store.ActMoveDir {
|
||||
if l, err := e.loadPair(ctx, o.PairID); err == nil {
|
||||
recoverMoveDir(l, o, e.log)
|
||||
}
|
||||
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
|
||||
continue
|
||||
}
|
||||
if o.Action == store.ActMove {
|
||||
if l, err := e.loadPair(ctx, o.PairID); err == nil {
|
||||
recoverMove(l, o)
|
||||
|
||||
Reference in new issue
Block a user