From 428935dc583d9a8f01294b2076e59af6cf310607 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 30 Sep 2026 09:10:48 -0700 Subject: [PATCH] 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 --- README.md | 26 + cmd/playlink/main.go | 2 + internal/api/fixer.go | 94 ++++ internal/api/server.go | 14 +- internal/engine/engine.go | 14 + internal/engine/fixer.go | 599 +++++++++++++++++++++++ internal/engine/fixer_steps.go | 500 +++++++++++++++++++ internal/engine/fixer_tags.go | 552 +++++++++++++++++++++ internal/engine/fixer_test.go | 384 +++++++++++++++ internal/engine/naming.go | 32 +- internal/engine/ops.go | 18 + internal/meta/meta.go | 273 +++++++++++ internal/store/models.go | 44 +- internal/store/store.go | 4 + web/src/App.tsx | 2 +- web/src/api.ts | 68 +++ web/src/pages/Fixer.tsx | 864 +++++++++++++++++++++++++++++++++ web/src/pages/PairForm.tsx | 32 +- web/src/pages/PairPage.tsx | 4 + 19 files changed, 3503 insertions(+), 23 deletions(-) create mode 100644 internal/api/fixer.go create mode 100644 internal/engine/fixer.go create mode 100644 internal/engine/fixer_steps.go create mode 100644 internal/engine/fixer_tags.go create mode 100644 internal/engine/fixer_test.go create mode 100644 internal/meta/meta.go create mode 100644 web/src/pages/Fixer.tsx diff --git a/README.md b/README.md index 9a56502..27d39b9 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,32 @@ up disk space only once, while both libraries keep looking exactly the same to P It ships as a single Go binary with an embedded React web UI and SQLite state, packaged as a small Docker image, in the style of [qui](https://github.com/autobrr/qui). +## Workflow: fix, organize, then link + +Each library pair has a **type**: **Music** (recommended layout `Artist/Album/01 Title.flac`) or **Soundtracks** for video game and film +soundtracks (`Album/01 Title.flac`, named by game or film). The type sets the default naming; the *Organize* tab can change it. + +The **Fixer** tab takes each library through the same steps, the **master first, then the slave**, and then links the two: + +1. **Folder structure.** Every album in its own folder at the right depth. Songs outside an album folder move into one named from their + tags; extra folders around an album (a composer folder around a soundtrack, `Metal/Artist/Album`, a `FLAC` folder inside an album) are + removed; songs in disc folders (`CD2`, `Disc 2`) get their disc number; missing titles, track numbers, albums and artists are filled in + from the folder and file names (`Album (2010) [FLAC]` → `Album`). When several albums sit in the same extra folder it may be a category + you want to keep (`Video Games/…`), so those moves are listed but not selected, with a hint to give that folder its own naming. +2. **Album tags** (optional). Each album is looked up on MusicBrainz: the best few releases are compared with your songs' titles (so a promo + single never beats the album it is named after), songs are matched to tracks by number or title, and the corrected titles, track and + disc numbers, album artist and date are shown per album for review, with a confidence rating. You can pick another release or edit the + album by hand. In the slave, albums that are also in the (fixed) master take the master's tags instead, so both libraries match. + MusicBrainz allows about one request a second, so large libraries take a while; limit it to a folder or skip albums whose tags are already + complete. +3. **Folder names.** Artist and album folders are renamed from the tags. Whole album folders move, so covers and booklets stay with them. +4. **File names.** Songs are renamed from the tags; multi-disc albums get `Disc 01`, `Disc 02` folders. +5. **Link the libraries.** Scan, fix any folders still named differently, link the identical songs, then open **Review** for the rest. + +Every step is a check first: nothing changes until you apply the fixes you selected, each fix is an operation in History that can be undone, +and dry run applies. Problems that need a person (a song without an album tag, a folder with songs from several albums) are listed with an +**Edit album** button. A step is marked done when everything in it was fixed; you can also mark it done or skip it yourself. + ## What it does Files are matched by their **path relative to each library root**. Any folder layout works (`Artist/Album/Title.ext`, diff --git a/cmd/playlink/main.go b/cmd/playlink/main.go index d4e649e..5e8a126 100644 --- a/cmd/playlink/main.go +++ b/cmd/playlink/main.go @@ -18,6 +18,7 @@ import ( "github.com/cdnutter/playlink/internal/art" "github.com/cdnutter/playlink/internal/backup" "github.com/cdnutter/playlink/internal/engine" + "github.com/cdnutter/playlink/internal/meta" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" "github.com/cdnutter/playlink/internal/tags" @@ -101,6 +102,7 @@ func main() { if ff, err := exec.LookPath(env("PLAYLINK_FFMPEG", "ffmpeg")); err == nil && ffprobe != "" { eng.SetTagTools(tags.Tools{FFmpeg: ff, FFprobe: ffprobe}) eng.SetArt(art.New(version), &art.Store{Dir: filepath.Join(dataDir, "art-cache"), FFmpeg: ff}) + eng.SetMeta(meta.New(version)) } else { log.Warn("ffmpeg not found; tag editing disabled") } diff --git a/internal/api/fixer.go b/internal/api/fixer.go new file mode 100644 index 0000000..e228b04 --- /dev/null +++ b/internal/api/fixer.go @@ -0,0 +1,94 @@ +package api + +import ( + "net/http" + + "github.com/cdnutter/playlink/internal/engine" +) + +// ---- step-by-step fixer ---- + +func (s *Server) handleFixer(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + p, err := s.st.Pair(r.Context(), id) + if err != nil { + opErr(w, err) + return + } + out := map[string]any{"state": engine.FixerStateOf(p), "steps": engine.FixerSteps, "plan": nil, + "items": []engine.FixItem{}, "albums": []*engine.AlbumFix{}} + if plan, items, albums := s.eng.FixerPlan(id); plan != nil { + out["plan"], out["items"], out["albums"] = plan, items, albums + } + writeJSON(w, 200, out) +} + +func (s *Server) handleFixCheck(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var body engine.FixCheck + if !readJSON(w, r, &body) { + return + } + j, err := s.eng.StartFixCheck(id, body) + if err != nil { + opErr(w, err) + return + } + writeJSON(w, 202, j) +} + +func (s *Server) handleFixApply(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var body engine.FixApply + if !readJSON(w, r, &body) { + return + } + if !s.preAction(w, r, id, "fixer") { + return + } + j, err := s.eng.StartFixApply(id, body) + if err != nil { + opErr(w, err) + return + } + s.log.Info("fixer apply started", "pair", id, "selected", len(body.IDs)) + writeJSON(w, 202, j) +} + +func (s *Server) handleFixStep(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var body struct { + Side string `json:"side"` // master, slave, or "" for the link step + Step string `json:"step"` + State string `json:"state"` // done, skipped or "" (to do) + } + if !readJSON(w, r, &body) { + return + } + if err := s.eng.SetStep(r.Context(), id, body.Side, body.Step, body.State, ""); err != nil { + opErr(w, err) + return + } + s.handleFixer(w, r) +} + +func (s *Server) handleFixRelease(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var body struct { + Album int `json:"album"` + Release string `json:"release"` // a MusicBrainz release id, or "search" + } + if !readJSON(w, r, &body) { + return + } + if j := s.eng.Job(id); j != nil && j.State == "running" { + writeErr(w, 409, engine.ErrBusy) + return + } + a, err := s.eng.ChooseRelease(r.Context(), id, body.Album, body.Release) + if err != nil { + opErr(w, err) + return + } + writeJSON(w, 200, a) +} diff --git a/internal/api/server.go b/internal/api/server.go index 8cc09b1..3dada31 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -122,6 +122,11 @@ func (s *Server) Handler() http.Handler { r.Get("/renames", s.handleRenamePreview) r.Post("/renames", s.handleRenames) r.Post("/link-identical", s.handleLinkIdentical) + r.Get("/fixer", s.handleFixer) + r.Post("/fixer/check", s.handleFixCheck) + r.Post("/fixer/apply", s.handleFixApply) + r.Put("/fixer/step", s.handleFixStep) + r.Post("/fixer/release", s.handleFixRelease) r.Get("/tree", s.handleTree) r.Post("/build", s.handleBuild) r.Post("/remove", s.handleRemove) @@ -322,13 +327,14 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { type pairView struct { store.Pair Naming engine.NamingConfig `json:"naming"` + Fixer engine.FixerState `json:"fixer"` Summary store.Summary `json:"summary"` Job *engine.Job `json:"job"` } func (s *Server) view(r *http.Request, p store.Pair) pairView { sum, _ := s.st.Summary(r.Context(), p.ID) - return pairView{Pair: p, Naming: engine.NamingOf(p), Summary: sum, Job: s.eng.Job(p.ID)} + return pairView{Pair: p, Naming: engine.NamingOf(p), Fixer: engine.FixerStateOf(p), Summary: sum, Job: s.eng.Job(p.ID)} } func (s *Server) handleListPairs(w http.ResponseWriter, r *http.Request) { @@ -367,6 +373,7 @@ type pairInput struct { ApproxMatching bool `json:"approx_matching"` ApproxThreshold int `json:"approx_threshold"` AutoOrganize string `json:"auto_organize"` + LibraryType string `json:"library_type"` // Naming is optional: when absent, the pair keeps its current naming. Naming *engine.NamingConfig `json:"naming,omitempty"` } @@ -377,6 +384,9 @@ func (in pairInput) apply(p *store.Pair) { p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold p.AutoOrganize = in.AutoOrganize + if in.LibraryType != "" { + p.LibraryType = in.LibraryType + } if in.Naming != nil { p.OrganizeConfig, _ = json.Marshal(in.Naming) } @@ -420,6 +430,8 @@ func (s *Server) handleSavePair(w http.ResponseWriter, r *http.Request) { } } in.apply(&p) + engine.ChangeLibraryType(old, &p) + engine.ChangeLibraryType(old, &p) warns, err := s.eng.NormalizePair(&p) if err != nil { writeErr(w, 400, err) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 0646715..be37142 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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 } diff --git a/internal/engine/fixer.go b/internal/engine/fixer.go new file mode 100644 index 0000000..7286167 --- /dev/null +++ b/internal/engine/fixer.go @@ -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]) +} diff --git a/internal/engine/fixer_steps.go b/internal/engine/fixer_steps.go new file mode 100644 index 0000000..7dd7962 --- /dev/null +++ b/internal/engine/fixer_steps.go @@ -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 +} diff --git a/internal/engine/fixer_tags.go b/internal/engine/fixer_tags.go new file mode 100644 index 0000000..1c14b31 --- /dev/null +++ b/internal/engine/fixer_tags.go @@ -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 +} diff --git a/internal/engine/fixer_test.go b/internal/engine/fixer_test.go new file mode 100644 index 0000000..346dffc --- /dev/null +++ b/internal/engine/fixer_test.go @@ -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) + } +} diff --git a/internal/engine/naming.go b/internal/engine/naming.go index de3c09b..86ee93d 100644 --- a/internal/engine/naming.go +++ b/internal/engine/naming.go @@ -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 { diff --git a/internal/engine/ops.go b/internal/engine/ops.go index fbb7f3a..0d612f3 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -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) diff --git a/internal/meta/meta.go b/internal/meta/meta.go new file mode 100644 index 0000000..6a9b73b --- /dev/null +++ b/internal/meta/meta.go @@ -0,0 +1,273 @@ +// Package meta looks up album information (track lists, titles, dates) on +// MusicBrainz for the fixer's album tags step. +package meta + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/cdnutter/playlink/internal/art" +) + +// Client talks to MusicBrainz. Requests are spaced out to respect its limit +// of one request per second. +type Client struct { + HTTP *http.Client + UserAgent string + BaseURL string + Gap time.Duration + + mu sync.Mutex + next time.Time +} + +func New(version string) *Client { + return &Client{ + HTTP: art.SafeHTTPClient(20 * time.Second), + UserAgent: "playlink/" + version + " (self-hosted music library tool)", + BaseURL: "https://musicbrainz.org", + Gap: 1100 * time.Millisecond, + } +} + +// Release is one search result. +type Release struct { + ID string `json:"id"` + Title string `json:"title"` + Artist string `json:"artist"` + Date string `json:"date"` + Country string `json:"country"` + Status string `json:"status"` + Type string `json:"type"` // Album, Single, EP, Soundtrack, ... + Disambiguation string `json:"disambiguation,omitempty"` + Formats string `json:"formats"` // "2×CD", "Digital Media" + TrackCount int `json:"track_count"` + Discs int `json:"discs"` + Score int `json:"score"` +} + +// Track is one track of a release. +type Track struct { + Disc int `json:"disc"` + Position int `json:"position"` + DiscTracks int `json:"disc_tracks"` + Title string `json:"title"` + Artist string `json:"artist"` + LengthMs int `json:"length_ms"` +} + +// ReleaseDetail is a release with its track list. +type ReleaseDetail struct { + Release + Tracks []Track `json:"tracks"` +} + +var mbid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// ValidID reports whether s is a MusicBrainz id. +func ValidID(s string) bool { return mbid.MatchString(s) } + +type artistCredit []struct { + Name string `json:"name"` + JoinPhrase string `json:"joinphrase"` +} + +func (a artistCredit) String() string { + var b strings.Builder + for _, c := range a { + b.WriteString(c.Name + c.JoinPhrase) + } + return strings.TrimSpace(b.String()) +} + +type mbRelease struct { + ID string `json:"id"` + Score int `json:"score"` + Title string `json:"title"` + Status string `json:"status"` + Date string `json:"date"` + Country string `json:"country"` + Disambiguation string `json:"disambiguation"` + TrackCount int `json:"track-count"` + ArtistCredit artistCredit `json:"artist-credit"` + ReleaseGroup struct { + PrimaryType string `json:"primary-type"` + SecondaryTypes []string `json:"secondary-types"` + } `json:"release-group"` + Media []struct { + Position int `json:"position"` + Format string `json:"format"` + TrackCount int `json:"track-count"` + Tracks []struct { + Position int `json:"position"` + Title string `json:"title"` + Length int `json:"length"` + ArtistCredit artistCredit `json:"artist-credit"` + } `json:"tracks"` + } `json:"media"` +} + +func (r mbRelease) summary() Release { + out := Release{ID: r.ID, Title: r.Title, Artist: r.ArtistCredit.String(), Date: r.Date, Country: r.Country, + Status: r.Status, Disambiguation: r.Disambiguation, TrackCount: r.TrackCount, Discs: len(r.Media), Score: r.Score} + out.Type = r.ReleaseGroup.PrimaryType + if len(r.ReleaseGroup.SecondaryTypes) > 0 { + out.Type = strings.TrimSpace(out.Type + " " + strings.Join(r.ReleaseGroup.SecondaryTypes, " ")) + } + formats := map[string]int{} + var order []string + total := 0 + for _, m := range r.Media { + f := m.Format + if f == "" { + f = "Unknown" + } + if formats[f] == 0 { + order = append(order, f) + } + formats[f]++ + total += m.TrackCount + } + var parts []string + for _, f := range order { + if formats[f] > 1 { + parts = append(parts, fmt.Sprintf("%d×%s", formats[f], f)) + } else { + parts = append(parts, f) + } + } + out.Formats = strings.Join(parts, " + ") + if out.TrackCount == 0 { + out.TrackCount = total + } + return out +} + +func quote(s string) string { + // Lucene syntax: escape quotes and backslashes inside a phrase. + s = strings.ReplaceAll(s, `\`, `\\`) + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` +} + +// SearchReleases finds releases by album title and (optionally) artist. +func (c *Client) SearchReleases(ctx context.Context, artist, album string) ([]Release, error) { + if strings.TrimSpace(album) == "" { + return nil, errors.New("no album title to search for") + } + q := "release:" + quote(album) + if strings.TrimSpace(artist) != "" { + q += " AND artist:" + quote(artist) + } + var d struct { + Releases []mbRelease `json:"releases"` + } + u := c.BaseURL + "/ws/2/release/?" + url.Values{"query": {q}, "fmt": {"json"}, "limit": {"15"}}.Encode() + if err := c.get(ctx, u, &d); err != nil { + return nil, err + } + out := make([]Release, 0, len(d.Releases)) + for _, r := range d.Releases { + out = append(out, r.summary()) + } + return out, nil +} + +// Release fetches a release with its track list. +func (c *Client) Release(ctx context.Context, id string) (*ReleaseDetail, error) { + if !ValidID(id) { + return nil, errors.New("not a MusicBrainz release id") + } + var r mbRelease + u := c.BaseURL + "/ws/2/release/" + id + "?" + url.Values{"inc": {"recordings artist-credits release-groups"}, "fmt": {"json"}}.Encode() + if err := c.get(ctx, u, &r); err != nil { + return nil, err + } + d := &ReleaseDetail{Release: r.summary()} + for _, m := range r.Media { + for _, t := range m.Tracks { + artist := t.ArtistCredit.String() + if artist == "" { + artist = d.Artist + } + d.Tracks = append(d.Tracks, Track{Disc: m.Position, Position: t.Position, DiscTracks: len(m.Tracks), + Title: t.Title, Artist: artist, LengthMs: t.Length}) + } + } + return d, nil +} + +// get fetches JSON, waiting for its turn and retrying once when MusicBrainz +// says it is busy. +func (c *Client) get(ctx context.Context, u string, v any) error { + for attempt := 0; ; attempt++ { + if err := c.wait(ctx); err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("Accept", "application/json") + res, err := c.HTTP.Do(req) + if err != nil { + return err + } + body, err := io.ReadAll(io.LimitReader(res.Body, 8<<20)) + res.Body.Close() + if err != nil { + return err + } + if res.StatusCode == http.StatusServiceUnavailable && attempt < 2 { + c.backoff(res.Header.Get("Retry-After")) + continue + } + if res.StatusCode != http.StatusOK { + return fmt.Errorf("MusicBrainz: %s", res.Status) + } + return json.Unmarshal(body, v) + } +} + +func (c *Client) wait(ctx context.Context) error { + c.mu.Lock() + now := time.Now() + at := c.next + if at.Before(now) { + at = now + } + c.next = at.Add(c.Gap) + c.mu.Unlock() + if d := time.Until(at); d > 0 { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + } + } + return nil +} + +func (c *Client) backoff(retryAfter string) { + d := 2 * time.Second + if n, err := strconv.Atoi(retryAfter); err == nil && n > 0 && n < 30 { + d = time.Duration(n) * time.Second + } + c.mu.Lock() + if t := time.Now().Add(d); t.After(c.next) { + c.next = t + } + c.mu.Unlock() +} diff --git a/internal/store/models.go b/internal/store/models.go index 5a4d677..90db3d1 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -154,6 +154,8 @@ type Pair struct { AutoOrganize string `json:"auto_organize"` // off, slave, master or both OrganizeDiscDigits int `json:"-"` // superseded by OrganizeConfig; read to convert old settings OrganizeConfig json.RawMessage `json:"-"` // naming settings (engine.NamingConfig) + LibraryType string `json:"library_type"` // music or soundtracks + FixerState json.RawMessage `json:"-"` // step-by-step fixer progress (engine.FixerState) LastScanAt *int64 `json:"last_scan_at"` LastScanError string `json:"last_scan_error"` LastScanWarnings []Warning `json:"last_scan_warnings"` @@ -163,18 +165,18 @@ type Pair struct { const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, organize_template, organize_multidisc, auto_organize, organize_disc_digits, organize_config, last_scan_at, last_scan_error, - last_scan_warnings, created_at` + last_scan_warnings, created_at, library_type, fixer_state` type scanner interface{ Scan(...any) error } func scanPair(r scanner) (Pair, error) { var p Pair - var exts, warns, orgCfg string + var exts, warns, orgCfg, fixer string var last sql.NullInt64 err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical, &p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &p.OrganizeTemplate, &p.OrganizeMultiDisc, &p.AutoOrganize, &p.OrganizeDiscDigits, &orgCfg, &last, &p.LastScanError, - &warns, &p.CreatedAt) + &warns, &p.CreatedAt, &p.LibraryType, &fixer) if err != nil { return p, err } @@ -186,6 +188,9 @@ func scanPair(r scanner) (Pair, error) { if orgCfg != "" { p.OrganizeConfig = json.RawMessage(orgCfg) } + if fixer != "" { + p.FixerState = json.RawMessage(fixer) + } return p, nil } @@ -219,11 +224,11 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error { if p.ID == 0 { res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, - organize_template, organize_multidisc, auto_organize, organize_disc_digits, organize_config, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + organize_template, organize_multidisc, auto_organize, organize_disc_digits, organize_config, library_type, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, - p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), now()) + p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), p.LibraryType, now()) if err != nil { return err } @@ -233,10 +238,16 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error { _, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?, extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?, approx_matching = ?, approx_threshold = ?, organize_template = ?, organize_multidisc = ?, auto_organize = ?, - organize_disc_digits = ?, organize_config = ? WHERE id = ?`, + organize_disc_digits = ?, organize_config = ?, library_type = ? WHERE id = ?`, p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, - p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), p.ID) + p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), p.LibraryType, p.ID) + return err +} + +// SetFixerState stores the fixer's progress for a pair. +func (s *Store) SetFixerState(ctx context.Context, id int64, raw []byte) error { + _, err := s.DB.ExecContext(ctx, `UPDATE pairs SET fixer_state = ? WHERE id = ?`, string(raw), id) return err } @@ -664,14 +675,15 @@ type Operation struct { // Operation actions. const ( - ActLink = "link" // slave replaced by hardlink of master - ActPromote = "promote" // master replaced by slave's file, then linked - ActBuild = "build" // master file linked into the slave (fresh library) - ActRemove = "remove" // slave hardlink removed (master copy kept) - ActRename = "rename" // slave folder renamed to the master's spelling - ActTags = "tags" // tags of a file edited - ActMove = "move" // file renamed/moved by the organizer - ActCover = "cover" // album folder image written + ActLink = "link" // slave replaced by hardlink of master + ActPromote = "promote" // master replaced by slave's file, then linked + ActBuild = "build" // master file linked into the slave (fresh library) + ActRemove = "remove" // slave hardlink removed (master copy kept) + ActRename = "rename" // slave folder renamed to the master's spelling + ActTags = "tags" // tags of a file edited + ActMove = "move" // file renamed/moved by the organizer + ActCover = "cover" // album folder image written + ActMoveDir = "move-dir" // folder moved by the fixer (structure or folder names) ) // Operation statuses. diff --git a/internal/store/store.go b/internal/store/store.go index 447d61e..d10016e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -132,6 +132,10 @@ var migrations = []string{ // 9: Lidarr-style naming (artist/album folder and track formats) as JSON. `ALTER TABLE pairs ADD COLUMN organize_config TEXT NOT NULL DEFAULT '';`, + + // 10: library type (music or soundtracks) and the step-by-step fixer's progress. + `ALTER TABLE pairs ADD COLUMN library_type TEXT NOT NULL DEFAULT 'music'; + ALTER TABLE pairs ADD COLUMN fixer_state TEXT NOT NULL DEFAULT '';`, } // SchemaVersion is the newest database schema this build knows. diff --git a/web/src/App.tsx b/web/src/App.tsx index f63c761..9b64400 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -65,7 +65,7 @@ function Shell({ username }: { username: string }) { ]; let page; - if (route[0] === "pair" && route[1]) page = ; + if (route[0] === "pair" && route[1]) page = ; else if (route[0] === "activity") page = ; else if (route[0] === "settings") page = ; else page = ; diff --git a/web/src/api.ts b/web/src/api.ts index 2efb146..a4ce75c 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -78,7 +78,9 @@ export type Pair = { approx_matching: boolean; approx_threshold: number; auto_organize: "off" | "slave" | "master" | "both"; + library_type: LibraryType; naming: Naming; + fixer: FixerState; last_scan_at: number | null; last_scan_error: string; last_scan_warnings: ScanWarning[]; @@ -100,8 +102,74 @@ export type PairInput = Pick< | "approx_matching" | "approx_threshold" | "auto_organize" + | "library_type" >; +export type LibraryType = "music" | "soundtracks"; + +// ---- step-by-step fixer ---- + +export type FixStep = "structure" | "tags" | "folders" | "files"; +export type StepStatus = { state: "" | "done" | "skipped"; at?: number; note?: string }; +export type FixerState = { master: Partial>; slave: Partial>; link: StepStatus }; + +export type FixPlan = { + id: number; + side: Side; + step: FixStep; + created_at: number; + songs: number; + albums: number; + fixable: number; + blocked: number; + notices: string[]; + applied: boolean; +}; + +export type FixItem = { + id: number; + kind: "move-folder" | "move-file" | "tags" | "album-tags" | "problem"; + group: string; + from?: string; + to?: string; + why: string; + blocked?: string; + changes?: Record; + unsure?: boolean; +}; + +export type MbRelease = { + id: string; + title: string; + artist: string; + date: string; + country: string; + status: string; + type: string; + disambiguation?: string; + formats: string; + track_count: number; + discs: number; + score: number; +}; + +export type AlbumFix = { + id: number; + folder: string; + artist: string; + album: string; + source: "musicbrainz" | "master"; + release?: MbRelease; + candidates: MbRelease[]; + confidence: number; + matched: number; + files: { path: string; track?: string; changes: Record }[]; + album_changes: Record; + error?: string; +}; + +export type Fixer = { state: FixerState; steps: FixStep[]; plan: FixPlan | null; items: FixItem[]; albums: AlbumFix[] }; + export type ProbeInfo = { codec: string; codec_long?: string; diff --git a/web/src/pages/Fixer.tsx b/web/src/pages/Fixer.tsx new file mode 100644 index 0000000..230e32d --- /dev/null +++ b/web/src/pages/Fixer.tsx @@ -0,0 +1,864 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowRight, + Check, + ChevronDown, + ChevronRight, + CircleDashed, + FileAudio, + FolderInput, + FolderTree, + Link2, + Minus, + Pencil, + RotateCcw, + Search, + SkipForward, + Tags as TagsIcon, +} from "lucide-react"; +import { api, type AlbumFix, type FixItem, type FixStep, type Fixer, type Pair, type Side, type StepStatus } from "../api"; +import { Alert, Badge, Button, Card, Field, Spinner, Toggle, cx, formatAgo, inputCls } from "../ui"; +import { useToast } from "../toast"; +import { useInfo } from "../App"; +import { JobBar, ScanButton } from "./PairPage"; +import { WarningList } from "./Warnings"; +import { AlbumEditor } from "./Album"; + +// The fixer takes each library through the same steps, master first, then +// links the two. Each step is checked, reviewed and applied here. + +type Sel = { side: Side; step: FixStep } | "link"; + +const STEPS: { id: FixStep; title: string; icon: typeof FolderTree; optional?: boolean }[] = [ + { id: "structure", title: "Folder structure", icon: FolderTree }, + { id: "tags", title: "Album tags", icon: TagsIcon, optional: true }, + { id: "folders", title: "Folder names", icon: FolderInput }, + { id: "files", title: "File names", icon: FileAudio }, +]; + +const SIDES: { id: Side; title: string }[] = [ + { id: "master", title: "Master library" }, + { id: "slave", title: "Slave library" }, +]; + +const FIELD_LABEL: Record = { + title: "Title", + artist: "Artist", + album: "Album", + album_artist: "Album artist", + track: "Track", + track_total: "Tracks", + disc: "Disc", + disc_total: "Discs", + date: "Date", + genre: "Genre", +}; + +const finished = (s?: StepStatus) => s?.state === "done" || s?.state === "skipped"; + +/** layoutOf shows where albums go, e.g. "{Artist Name}/{Album Title}/{track:00} {Track Title}". */ +function layoutOf(pair: Pair) { + const n = pair.naming; + return [n.artist_folder, n.album_folder, n.track_format].filter(Boolean).join("/"); +} + +function firstOpen(pair: Pair): Sel { + for (const s of SIDES) for (const st of STEPS) if (!finished(pair.fixer[s.id][st.id])) return { side: s.id, step: st.id }; + return "link"; +} + +export function FixerTab({ pair }: { pair: Pair }) { + const info = useInfo(); + const [sel, setSel] = useState(() => firstOpen(pair)); + const fixer = useQuery({ queryKey: ["fixer", pair.id], queryFn: () => api.get(`/pairs/${pair.id}/fixer`) }); + if (info.data && !info.data.tag_editing) + return The fixer reads and writes tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.; + const isSel = (s: Side, st: FixStep) => sel !== "link" && sel.side === s && sel.step === st; + + return ( +
+ +
+ {sel === "link" ? ( + + ) : ( + setSel(firstOpen(pair))} + /> + )} +
+
+ ); +} + +function StepLink({ status, active, onClick, children }: { status?: StepStatus; active: boolean; onClick: () => void; children: ReactNode }) { + const icon = + status?.state === "done" ? ( + + ) : status?.state === "skipped" ? ( + + ) : ( + + ); + return ( + + ); +} + +// ---- one step of one library ---- + +function stepText(step: FixStep, pair: Pair, side: Side): ReactNode { + const layout = {layoutOf(pair)}; + switch (step) { + case "structure": + return ( + <> + Puts every album in its own folder at the right depth for {layout}. Songs outside an album folder are moved into one named from their tags; + extra folders around an album (a composer folder around a soundtrack, a FLAC folder inside an album) are + removed; songs in disc folders get their disc number; and missing titles, track numbers, albums and artists are filled in from the folder + and file names. Names themselves are fixed in the next steps. + + ); + case "tags": + return ( + <> + Looks each album up on MusicBrainz and shows corrected titles, track and disc numbers, album artist and date for you to review. + {side === "slave" && " Albums that are also in the master take the master's tags instead, so both libraries match."} Nothing changes until + you apply the albums you selected. This step is optional and can take a while: MusicBrainz allows about one lookup a second. + + ); + case "folders": + return ( + <> + Renames artist and album folders from the tags, following {layout}. Whole album folders move, so covers, booklets and other files stay with + their album. + + ); + case "files": + return ( + <> + Renames each song from its tags ({pair.naming.track_format}). Albums with several discs get{" "} + Disc 01, Disc 02 folders. Lyrics files move with their song. + + ); + } +} + +function StepPanel({ pair, side, step, fixer, onNext }: { pair: Pair; side: Side; step: FixStep; fixer?: Fixer; onNext: () => void }) { + const qc = useQueryClient(); + const toast = useToast(); + const [folder, setFolder] = useState(""); + const [onlyIncomplete, setOnlyIncomplete] = useState(true); + const meta = STEPS.find((s) => s.id === step)!; + const status = pair.fixer[side][step]; + const plan = fixer?.plan && fixer.plan.side === side && fixer.plan.step === step ? fixer.plan : null; + const running = pair.job?.state === "running"; + const ourJob = running && pair.job!.kind.startsWith("fix-"); + const refresh = () => { + qc.invalidateQueries({ queryKey: ["pair", pair.id] }); + qc.invalidateQueries({ queryKey: ["fixer", pair.id] }); + }; + const check = useMutation({ + mutationFn: () => api.post(`/pairs/${pair.id}/fixer/check`, { side, step, folder: folder.trim(), only_incomplete: onlyIncomplete }), + onSuccess: refresh, + onError: (e) => toast("err", e.message), + }); + const mark = useMutation({ + mutationFn: (state: StepStatus["state"]) => api.put(`/pairs/${pair.id}/fixer/step`, { side, step, state }), + onSuccess: (_, state) => { + refresh(); + if (state) setTimeout(onNext, 300); + }, + onError: (e) => toast("err", e.message), + }); + const masterOpen = side === "slave" && STEPS.some((s) => !finished(pair.fixer.master[s.id])); + + return ( + +
+
+
{side === "master" ? "Master library" : "Slave library"}
+

+ {meta.title} + {meta.optional && optional} + {status?.state === "done" && done {formatAgo(status.at)}} + {status?.state === "skipped" && skipped} +

+
+
+ {finished(status) ? ( + + ) : ( + <> + + {plan && !plan.applied && ( + + )} + + )} + +
+
+

{stepText(step, pair, side)}

+ {masterOpen && ( + + The master library isn't finished yet. Fixing the master first is recommended: the slave's album tags and links are matched against it. + + )} + {step === "tags" && ( +
+ + setFolder(e.target.value)} /> + +
+ +

Complete: every song has a title, track number, album and artist, and they agree.

+
+
+ )} + + {ourJob ? ( + + ) : !plan ? ( +
+ Check the {side} library to see what needs fixing. Nothing is changed by checking. +
+ ) : plan.applied ? ( + + Fixes applied{pair.job?.results?.skipped ? `, ${pair.job.results.skipped} skipped (see above)` : ""}. Every change is in History and can be + undone. {finished(status) ? "This step is done." : "Check again to see what's left, or mark the step done."} + + ) : step === "tags" ? ( + + ) : ( + + )} +
+ ); +} + +function PlanStats({ fixer }: { fixer: Fixer }) { + const p = fixer.plan!; + const stats: [string, number, string?][] = [ + ["songs", p.songs], + ["albums", p.albums], + ["to fix", p.fixable, "text-accent"], + ["need attention", p.blocked, p.blocked ? "text-amber-300" : undefined], + ]; + return ( +
+
+ {stats.map(([label, n, tone]) => ( +
+
{n}
+
{label}
+
+ ))} +
+ {p.notices.map((n, i) => ( + }> + {n} + + ))} +
+ ); +} + +function useSelection(fixer: Fixer) { + const initial = () => new Set(fixer.items.filter((i) => !i.blocked && i.kind !== "problem" && !i.unsure).map((i) => i.id)); + const [sel, setSel] = useState>(initial); + useEffect(() => setSel(initial()), [fixer.plan?.id]); // eslint-disable-line react-hooks/exhaustive-deps + const toggle = (ids: number[], on: boolean) => + setSel((s) => { + const n = new Set(s); + ids.forEach((id) => (on ? n.add(id) : n.delete(id))); + return n; + }); + return { sel, toggle }; +} + +function ApplyBar({ + pair, + plan, + count, + total, + ids, + onApplied, +}: { + pair: Pair; + plan: number; + count: number; + total: number; + ids: number[]; + onApplied: () => void; +}) { + const toast = useToast(); + const dry = useInfo().data?.dry_run ?? true; + const apply = useMutation({ + mutationFn: () => api.post(`/pairs/${pair.id}/fixer/apply`, { plan, ids }), + onSuccess: onApplied, + onError: (e) => toast("err", e.message), + }); + return ( +
+ + + {count} of {total} selected{dry && " · dry run: changes are only recorded"} + +
+ ); +} + +// ---- moves and tag fixes ---- + +const GROUP_PAGE = 60; + +function ItemResults({ pair, side, fixer, onApplied }: { pair: Pair; side: Side; fixer: Fixer; onApplied: () => void }) { + const { sel, toggle } = useSelection(fixer); + const [shown, setShown] = useState(GROUP_PAGE); + const [album, setAlbum] = useState(null); + const fixable = fixer.items.filter((i) => !i.blocked && i.kind !== "problem"); + const attention = fixer.items.filter((i) => i.blocked || i.kind === "problem"); + const groups = useMemo(() => { + const m = new Map(); + for (const it of fixable) m.set(it.group, [...(m.get(it.group) ?? []), it]); + return [...m.entries()]; + }, [fixer.items]); // eslint-disable-line react-hooks/exhaustive-deps + const plan = fixer.plan!; + + return ( +
+ + {plan.fixable === 0 && plan.blocked === 0 && Nothing to fix here. Mark the step done to continue.} + {groups.length > 0 && ( +
+
+ sel.has(i.id))} + onChange={(e) => + toggle( + fixable.map((i) => i.id), + e.target.checked, + ) + } + /> + Select all · grouped by where they end up +
+
+ {groups.slice(0, shown).map(([g, items]) => ( + + ))} +
+ {groups.length > shown && ( + + )} +
+ )} + {attention.length > 0 && ( +
+

+ Needs attention ({attention.length}) +

+
+ {attention.slice(0, 200).map((it) => ( +
+
+
{it.from}
+
+ {it.why} + {it.blocked && · {it.blocked}} +
+
+ {it.from && ( + + )} +
+ ))} + {attention.length > 200 &&
and {attention.length - 200} more
} +
+
+ )} + {fixable.length > 0 && ( + sel.has(i.id)).length} + total={fixable.length} + ids={fixable.filter((i) => sel.has(i.id)).map((i) => i.id)} + onApplied={onApplied} + /> + )} + {album !== null && setAlbum(null)} />} +
+ ); +} + +function FixGroup({ + group, + items, + sel, + toggle, +}: { + group: string; + items: FixItem[]; + sel: Set; + toggle: (ids: number[], on: boolean) => void; +}) { + const [open, setOpen] = useState(items.length <= 3); + const ids = items.map((i) => i.id); + const n = ids.filter((id) => sel.has(id)).length; + const single = items.length === 1 ? items[0] : null; + return ( +
+
+ toggle(ids, e.target.checked)} /> + +
+ {(open || single) && ( +
+ {items.map((it) => ( + toggle([it.id], on)} /> + ))} +
+ )} +
+ ); +} + +function FixRow({ it, checked, onChange }: { it: FixItem; checked: boolean; onChange: (on: boolean) => void }) { + return ( + + ); +} + +function Changes({ changes }: { changes: Record }) { + const keys = Object.keys(changes).sort((a, b) => Object.keys(FIELD_LABEL).indexOf(a) - Object.keys(FIELD_LABEL).indexOf(b)); + return ( +
+ {keys.map((k) => ( + + {FIELD_LABEL[k] ?? k}: + {changes[k][0] ? {changes[k][0]} : (empty)} + → + {changes[k][1]} + + ))} +
+ ); +} + +// ---- album tags (online lookup) ---- + +function AlbumResults({ pair, side, fixer, onApplied }: { pair: Pair; side: Side; fixer: Fixer; onApplied: () => void }) { + const { sel, toggle } = useSelection(fixer); + const [shown, setShown] = useState(40); + const [album, setAlbum] = useState(null); + const blocked = new Set(fixer.items.filter((i) => i.blocked).map((i) => i.id)); + const ordered = [...fixer.albums].sort((a, b) => Number(blocked.has(a.id)) - Number(blocked.has(b.id)) || a.folder.localeCompare(b.folder)); + const usable = fixer.albums.filter((a) => !blocked.has(a.id)); + return ( +
+ + {fixer.albums.length === 0 && Every album checked already matches. Mark the step done to continue.} +
+ {ordered.slice(0, shown).map((a) => ( + toggle([a.id], on)} + onEdit={() => setAlbum(a.folder)} + /> + ))} + {ordered.length > shown && ( + + )} +
+ {usable.length > 0 && ( + sel.has(a.id)).length} + total={usable.length} + ids={usable.filter((a) => sel.has(a.id)).map((a) => a.id)} + onApplied={onApplied} + /> + )} + {album !== null && setAlbum(null)} />} +
+ ); +} + +function Confidence({ c }: { c: number }) { + if (c >= 0.85) return good match; + if (c >= 0.6) return check; + return weak match; +} + +function AlbumCard({ + pair, + a, + blocked, + checked, + onCheck, + onEdit, +}: { + pair: Pair; + a: AlbumFix; + blocked: boolean; + checked: boolean; + onCheck: (on: boolean) => void; + onEdit: () => void; +}) { + const qc = useQueryClient(); + const toast = useToast(); + const [open, setOpen] = useState(false); + const choose = useMutation({ + mutationFn: (release: string) => api.post(`/pairs/${pair.id}/fixer/release`, { album: a.id, release }), + onSuccess: (res) => { + qc.invalidateQueries({ queryKey: ["fixer", pair.id] }); + if (!res.error) onCheck(true); + }, + onError: (e) => toast("err", e.message), + }); + const changed = a.files.filter((f) => Object.keys(f.changes).length > 0).length; + const r = a.release; + return ( +
+
+ onCheck(e.target.checked)} + /> +
+
{a.folder}
+ {r && !blocked ? ( +
+ + {r.artist} – {r.title} + + + {[r.date, r.country, r.formats, r.track_count ? `${r.track_count} tracks` : ""].filter(Boolean).join(" · ")} + + {a.source === "master" ? from master : MusicBrainz} + + + {a.matched} of {a.files.length} songs matched · {changed} change + +
+ ) : ( +
{a.error || "No confident match"}
+ )} +
+
+ {a.source === "master" ? ( + + ) : ( + a.candidates.length > 0 && ( + + ) + )} + {choose.isPending && } + + {!blocked && ( + + )} +
+
+ {open && !blocked && ( +
+ {Object.keys(a.album_changes).length > 0 && ( +
+ Every song: + +
+ )} + + + + + + + + + + {a.files.map((f) => { + const own = Object.fromEntries(Object.entries(f.changes).filter(([k]) => !(k in a.album_changes))); + return ( + + + + + + ); + })} + +
FileMatched trackChanges
{f.path.slice(a.folder.length + 1)}{f.track ?? not matched}{Object.keys(own).length ? : —}
+
+ )} +
+ ); +} + +// ---- linking ---- + +function LinkPanel({ pair }: { pair: Pair }) { + const qc = useQueryClient(); + const toast = useToast(); + const [confirm, setConfirm] = useState(false); + const c = pair.summary.counts; + const open = SIDES.flatMap((s) => STEPS.filter((st) => !finished(pair.fixer[s.id][st.id])).map((st) => `${s.title}: ${st.title}`)); + const refresh = () => { + qc.invalidateQueries({ queryKey: ["pair", pair.id] }); + qc.invalidateQueries({ queryKey: ["pairs"] }); + }; + const link = useMutation({ + mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`), + onSuccess: () => { + setConfirm(false); + refresh(); + }, + onError: (e) => toast("err", e.message), + }); + const mark = useMutation({ + mutationFn: (state: StepStatus["state"]) => api.put(`/pairs/${pair.id}/fixer/step`, { side: "", step: "link", state }), + onSuccess: refresh, + onError: (e) => toast("err", e.message), + }); + const running = pair.job?.state === "running"; + const stat = (label: string, n: number | undefined, tone?: string) => ( +
+
{n ?? 0}
+
{label}
+
+ ); + + return ( + +
+
+
Both libraries
+

+ Link the libraries + {pair.fixer.link?.state === "done" && done {formatAgo(pair.fixer.link.at)}} +

+
+ {pair.fixer.link?.state === "done" ? ( + + ) : ( + + )} +
+

+ With both libraries organized the same way, songs match by path. Identical songs are replaced in the slave with a hardlink to the master, so + they take up space only once. Songs that differ (another quality, other tags) are left for you in Review. +

+ {open.length > 0 && ( + }> + Not finished yet: {open.join(", ")}. Linking works anyway, but songs in folders that are still named differently won't match. + + )} + +
+

1. Scan both libraries

+
+ + Last scan {formatAgo(pair.last_scan_at)} +
+ {running && pair.job && } +
+ + {pair.last_scan_warnings.length > 0 && ( +
+

2. Folders named differently

+ +
+ )} + +
+

{pair.last_scan_warnings.length > 0 ? "3" : "2"}. Link identical songs

+
+ {stat("already linked", c.linked, "text-emerald-300")} + {stat("identical", c.identical, "text-accent")} + {stat("different", (c.different ?? 0) + (c.similar ?? 0), "text-amber-300")} + {stat("only in slave", c.unmatched)} + {stat("errors", c.error, c.error ? "text-red-300" : undefined)} +
+ {confirm ? ( +
+ Replace {c.identical ?? 0} slave songs with hardlinks to the identical master songs? Each is compared byte by byte first. + + +
+ ) : ( + + )} +
+ +
+

{pair.last_scan_warnings.length > 0 ? "4" : "3"}. Review the differences

+

Upgrades, other formats and approximate matches are reviewed one album or song at a time.

+ + + +
+
+ ); +} diff --git a/web/src/pages/PairForm.tsx b/web/src/pages/PairForm.tsx index 7f45de5..887807d 100644 --- a/web/src/pages/PairForm.tsx +++ b/web/src/pages/PairForm.tsx @@ -1,12 +1,17 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronLeft, Folder, FolderOpen } from "lucide-react"; -import { api, type Pair, type PairInput, type ScanWarning } from "../api"; +import { api, type LibraryType, type Pair, type PairInput, type ScanWarning } from "../api"; import { WarningList } from "./Warnings"; import { useInfo } from "../App"; -import { Alert, Button, Field, Modal, Toggle, inputCls } from "../ui"; +import { Alert, Button, Field, Modal, Toggle, cx, inputCls } from "../ui"; import { useToast } from "../toast"; +export const LIBRARY_TYPES: { id: LibraryType; label: string; layout: string; hint: string }[] = [ + { id: "music", label: "Music", layout: "Artist/Album/01 Title.flac", hint: "Albums by artist." }, + { id: "soundtracks", label: "Soundtracks", layout: "Album/01 Title.flac", hint: "Video game and film soundtracks, named by game or film." }, +]; + type Validation = { ok: boolean; error?: string; warnings: ScanWarning[] }; export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) { @@ -26,6 +31,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; approx_matching: false, approx_threshold: 85, auto_organize: "off", + library_type: "music", }; const [f, setF] = useState(pair ?? blank); const [exts, setExts] = useState((pair?.extensions ?? []).join(", ")); @@ -55,6 +61,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; approx_threshold: f.approx_threshold, // Edited on the Organize tab; sent unchanged so saving here keeps it. auto_organize: f.auto_organize, + library_type: f.library_type, extensions: exts.split(",").map((s) => s.trim()).filter(Boolean), }); const set = (k: K, v: PairInput[K]) => { @@ -71,7 +78,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onSuccess: () => { qc.invalidateQueries({ queryKey: ["pairs"] }); qc.invalidateQueries({ queryKey: ["pair"] }); - toast("ok", pair ? "Library pair updated" : "Library pair created. Run a scan to find matches."); + toast("ok", pair ? "Library pair updated" : "Library pair created. Start with the Fixer, master library first."); onClose(); }, }); @@ -82,6 +89,25 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; set("name", e.target.value)} /> + +
+ {LIBRARY_TYPES.map((t) => ( + + ))} +
+
+ {tab === "fixer" && } {tab === "review" && } {tab === "build" && } {tab === "tags" && }