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:
cdnutterandClaude Opus 5.5 committed 2026-09-30 09:10:48 -07:00
1 parent b11d0806c1
commit 428935dc58
19 files changed
+3503 -23

No files matched your search

+14
View File
@@ -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
}
+599
View File
@@ -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])
}
+500
View File
@@ -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
}
+552
View File
@@ -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
}
+384
View File
@@ -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)
}
}
+30 -2
View File
@@ -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 {
+18
View File
@@ -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)