Add the step-by-step fixer and library types

Each pair is a Music (Artist/Album/01 Title) or Soundtracks (Album/01 Title)
library. The new Fixer tab takes the master, then the slave, through four
reviewed steps: folder structure, album tags (optional MusicBrainz lookup;
the slave reuses the fixed master's tags), folder names and file names,
then links the two libraries. Every fix is an undoable operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 09:10:48 -07:00
1 parent b11d0806c1
commit 428935dc58
19 files changed
+3495 -15

No files matched your search

+26
View File
@@ -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`,
+2
View File
@@ -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")
}
+94
View File
@@ -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)
}
+13 -1
View File
@@ -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)
+14
View File
@@ -15,6 +15,7 @@ import (
"github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
@@ -49,6 +50,12 @@ type Engine struct {
artClient *art.Client
artStore *art.Store
meta *meta.Client
fixMu sync.Mutex
fixPlans map[int64]*FixPlan // latest fixer check per pair
fixSeq int64
plansMu sync.Mutex
plans map[int64]*organizePlan // latest "rename now" preview per pair
@@ -309,6 +316,13 @@ func (e *Engine) NormalizePair(p *store.Pair) ([]store.Warning, error) {
if p.RetentionDays < 0 || p.ScanIntervalMinutes < 0 || p.SettleMinutes < 0 {
return nil, errors.New("intervals must not be negative")
}
switch p.LibraryType {
case "":
p.LibraryType = LibraryMusic
case LibraryMusic, LibrarySoundtracks:
default:
return nil, errors.New("library type must be music or soundtracks")
}
if err := normalizeOrganize(p); err != nil {
return nil, err
}
+599
View File
@@ -0,0 +1,599 @@
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
)
// The fixer takes each library through the same steps, the master first and
// then the slave:
//
// 1. structure: every album in its own folder at the right depth (Artist/
// Album for music, Album for soundtracks), disc folders tagged, and missing
// tags filled from folder and file names;
// 2. album tags (optional): look albums up online and review the changes;
// 3. folder names: album and artist folders renamed from the tags;
// 4. file names: songs renamed from the tags.
//
// Once both libraries are fixed they are linked. Every step is a check that
// produces a plan; nothing changes until the user applies the fixes they
// selected, and every change is an operation that can be undone.
const (
StepStructure = "structure"
StepTags = "tags"
StepFolders = "folders"
StepFiles = "files"
StepLink = "link"
)
// FixerSteps are the steps each library goes through, in order.
var FixerSteps = []string{StepStructure, StepTags, StepFolders, StepFiles}
// Kinds of fix.
const (
FixMoveFolder = "move-folder" // move or rename a folder with everything in it
FixMoveFile = "move-file" // move or rename one song
FixTags = "tags" // change one song's tags
FixAlbumTags = "album-tags" // change the tags of an album's songs (online lookup)
FixProblem = "problem" // needs a person: shown, never applied
)
type StepStatus struct {
State string `json:"state"` // "" (to do), done or skipped
At int64 `json:"at,omitempty"`
Note string `json:"note,omitempty"`
}
type FixerState struct {
Master map[string]StepStatus `json:"master"`
Slave map[string]StepStatus `json:"slave"`
Link StepStatus `json:"link"`
}
// FixerStateOf reads a pair's fixer progress.
func FixerStateOf(p store.Pair) FixerState {
var s FixerState
if len(p.FixerState) > 0 {
_ = json.Unmarshal(p.FixerState, &s)
}
if s.Master == nil {
s.Master = map[string]StepStatus{}
}
if s.Slave == nil {
s.Slave = map[string]StepStatus{}
}
return s
}
func validStep(side, step string) error {
if step == StepLink && side == "" {
return nil
}
if side != SideMaster && side != SideSlave {
return fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
for _, s := range FixerSteps {
if s == step {
return nil
}
}
return fmt.Errorf("%w: unknown step %q", ErrInvalidBulk, step)
}
// SetStep records a step as done, skipped or to do ("").
func (e *Engine) SetStep(ctx context.Context, pairID int64, side, step, state, note string) error {
if err := validStep(side, step); err != nil {
return err
}
if state != "" && state != "done" && state != "skipped" {
return fmt.Errorf("%w: state must be done, skipped or empty", ErrInvalidBulk)
}
e.fixMu.Lock()
defer e.fixMu.Unlock()
p, err := e.st.Pair(ctx, pairID)
if err != nil {
return err
}
s := FixerStateOf(p)
st := StepStatus{State: state, Note: note}
if state != "" {
st.At = time.Now().Unix()
}
switch side {
case SideMaster:
s.Master[step] = st
case SideSlave:
s.Slave[step] = st
default:
s.Link = st
}
raw, _ := json.Marshal(s)
return e.st.SetFixerState(ctx, pairID, raw)
}
// FixItem is one proposed fix (or a problem that needs a person).
type FixItem struct {
ID int `json:"id"`
Kind string `json:"kind"`
Group string `json:"group"` // album folder, for grouping
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Why string `json:"why"`
Blocked string `json:"blocked,omitempty"` // why it can't be fixed automatically
Changes map[string][2]string `json:"changes,omitempty"` // tags: field -> [old, new]
// Unsure fixes are listed but not selected by default.
Unsure bool `json:"unsure,omitempty"`
org *organizeItem // moves: the file as it was when checked
}
// FixPlan is the result of checking one step of one library.
type FixPlan struct {
ID int64 `json:"id"` // unique per check, so selections never carry over
Side string `json:"side"`
Step string `json:"step"`
CreatedAt int64 `json:"created_at"`
Songs int `json:"songs"`
Albums int `json:"albums"`
Fixable int `json:"fixable"`
Blocked int `json:"blocked"`
Notices []string `json:"notices"`
Items []FixItem `json:"-"`
AlbumFixes []*AlbumFix `json:"-"`
Applied bool `json:"applied"`
}
func (p *FixPlan) add(it FixItem) {
it.ID = len(p.Items) + 1
if it.Kind == FixProblem || it.Blocked != "" {
p.Blocked++
} else {
p.Fixable++
}
p.Items = append(p.Items, it)
}
// FixerPlan returns the latest plan of a pair and its items.
func (e *Engine) FixerPlan(pairID int64) (*FixPlan, []FixItem, []*AlbumFix) {
e.fixMu.Lock()
defer e.fixMu.Unlock()
p := e.fixPlans[pairID]
if p == nil {
return nil, nil, nil
}
return p, append([]FixItem{}, p.Items...), append([]*AlbumFix{}, p.AlbumFixes...)
}
// FixCheck asks for one step of one library to be checked.
type FixCheck struct {
Side string `json:"side"`
Step string `json:"step"`
Folder string `json:"folder"` // optional: only this folder (album tags)
// OnlyIncomplete skips albums whose tags already look complete (album tags).
OnlyIncomplete bool `json:"only_incomplete"`
}
// StartFixCheck checks a step in a background job; the plan is kept for review.
func (e *Engine) StartFixCheck(pairID int64, c FixCheck) (*Job, error) {
if err := validStep(c.Side, c.Step); err != nil {
return nil, err
}
if !e.TagEditing() {
return nil, ErrNoTagTools
}
if c.Folder != "" {
f, err := fsops.CleanRel(c.Folder)
if err != nil {
return nil, err
}
c.Folder = f
}
if c.Step == StepTags && e.meta == nil {
return nil, errors.New("online lookup is not available")
}
return e.startJob(pairID, "fix-"+c.Step, func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
plan := &FixPlan{Side: c.Side, Step: c.Step, CreatedAt: time.Now().Unix(), Notices: []string{}}
switch c.Step {
case StepStructure:
err = e.checkStructure(ctx, j, l, plan)
case StepTags:
err = e.checkAlbumTags(ctx, j, l, plan, c)
case StepFolders:
err = e.checkFolders(ctx, j, l, plan)
case StepFiles:
err = e.checkFiles(ctx, j, l, plan)
}
if err != nil {
return err
}
e.fixMu.Lock()
if e.fixPlans == nil {
e.fixPlans = map[int64]*FixPlan{}
}
e.fixSeq++
plan.ID = e.fixSeq
e.fixPlans[pairID] = plan
e.fixMu.Unlock()
j.update(func(j *Job) {
j.Message = fmt.Sprintf("%d to fix, %d need attention", plan.Fixable, plan.Blocked)
})
return nil
})
}
// FixApply selects fixes of the latest plan.
type FixApply struct {
Plan int64 `json:"plan"` // the plan the ids belong to
IDs []int `json:"ids"`
}
// StartFixApply applies the selected fixes of the latest plan. Tags are
// changed first (at the paths that were checked), then folders move, then
// files. When the step has nothing left to fix it is marked done.
func (e *Engine) StartFixApply(pairID int64, req FixApply) (*Job, error) {
e.fixMu.Lock()
plan := e.fixPlans[pairID]
e.fixMu.Unlock()
if plan == nil {
return nil, fmt.Errorf("%w: check the step first", ErrInvalidBulk)
}
if req.Plan != plan.ID {
return nil, fmt.Errorf("%w: the check was replaced by a newer one; review it and apply again", ErrInvalidBulk)
}
if plan.Applied {
return nil, fmt.Errorf("%w: the check is out of date; check again", ErrInvalidBulk)
}
want := map[int]bool{}
for _, id := range req.IDs {
want[id] = true
}
applied := 0
var tagItems, folderItems []FixItem
var moves []organizeItem
var albums []*AlbumFix
for _, it := range plan.Items {
if !want[it.ID] || it.Kind == FixProblem || it.Blocked != "" {
continue
}
applied++
switch it.Kind {
case FixTags:
tagItems = append(tagItems, it)
case FixMoveFolder:
folderItems = append(folderItems, it)
case FixMoveFile:
moves = append(moves, *it.org)
case FixAlbumTags:
for _, a := range plan.AlbumFixes {
if a.ID == it.ID {
albums = append(albums, a)
}
}
}
}
if len(tagItems)+len(folderItems)+len(moves)+len(albums) == 0 {
return nil, fmt.Errorf("%w: nothing selected", ErrInvalidBulk)
}
side, step := plan.Side, plan.Step
return e.startJob(pairID, "fix-"+step+"-apply", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
e.fixMu.Lock()
plan.Applied = true // paths and tags change: the plan is stale
e.fixMu.Unlock()
trigger := "fixer"
edited := map[string]bool{}
if n := len(tagItems) + songsIn(albums); n > 0 {
j.phase("Updating tags", int64(n))
for _, it := range tagItems {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.editTagsOne(ctx, j, l, side, it.From, newValues(it.Changes), "", false); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
edited[it.From] = true
}
j.tick()
}
for _, a := range albums {
for _, f := range a.Files {
if ctx.Err() != nil {
return ctx.Err()
}
if len(f.Changes) > 0 {
if err := e.editTagsOne(ctx, j, l, side, f.Path, newValues(f.Changes), "", false); err != nil {
j.skip(f.Path + ": " + err.Error())
}
}
j.tick()
}
j.count("albums updated")
}
}
if len(folderItems) > 0 {
j.phase("Moving folders", int64(len(folderItems)))
for _, it := range folderItems {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.moveDirOp(ctx, l, side, it.From, it.To, trigger); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
j.count("folders moved")
}
j.tick()
}
}
if len(moves) > 0 {
// A song whose tags were just rewritten is a new file: take it as it is now.
root, _ := sideRoot(l, side)
for i, m := range moves {
if edited[m.From] {
if in, err := fsops.RegularFile(filepath.Join(root.Path, m.From)); err == nil {
moves[i].ino, moves[i].size, moves[i].mtime = in.Ino, in.Size, in.MtimeNs
}
}
}
if err := e.applyOrganize(ctx, j, l, moves, trigger); err != nil {
return err
}
}
dry := e.st.DryRun(ctx)
skipped := 0
j.update(func(j *Job) {
skipped = j.Results["skipped"]
if dry {
j.Message = "Dry run: changes recorded as planned in Activity"
}
})
// Done when every fix was applied and nothing needs a person.
if !dry && skipped == 0 && applied >= plan.Fixable && plan.Blocked == 0 {
if err := e.SetStep(ctx, pairID, side, step, "done", fmt.Sprintf("%d fixed", applied)); err != nil {
e.log.Warn("fixer step not recorded", "err", err)
}
}
return nil
})
}
func songsIn(albums []*AlbumFix) int {
n := 0
for _, a := range albums {
n += len(a.Files)
}
return n
}
func newValues(changes map[string][2]string) map[string]string {
set := map[string]string{}
for k, v := range changes {
set[k] = v[1]
}
return set
}
// ---- moving folders ----
type dirMoveDetail struct {
Side string `json:"side"`
}
// moveDirOp moves a folder (with everything in it) within one library.
func (e *Engine) moveDirOp(ctx context.Context, l *libs, side, from, to, trigger string) error {
e.mu.Lock()
defer e.mu.Unlock()
root, err := sideRoot(l, side)
if err != nil {
return err
}
if why := checkDirMove(l, root, from, to); why != "" {
return errors.New(why)
}
src, _ := root.Resolve(from)
dst, _ := root.Resolve(to)
o := store.Operation{PairID: l.pair.ID, Action: store.ActMoveDir, RelNew: to, Trigger: trigger}
setSidePath(&o, side, from)
o.Detail, _ = json.Marshal(dirMoveDetail{Side: side})
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
return nil
}
err = func() error {
if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil {
return err
}
if err := renameDir(src, dst); err != nil {
pruneEmpty(filepath.Dir(dst), root.Path)
return err
}
_ = fsops.SyncDir(filepath.Dir(src))
pruneEmpty(filepath.Dir(src), root.Path)
return nil
}()
return e.finish(ctx, &o, err)
}
// checkDirMove returns why a folder can't be moved, or "".
func checkDirMove(l *libs, root fsops.Root, from, to string) string {
src, err := root.Resolve(from)
if err != nil {
return err.Error()
}
dst, err := root.Resolve(to)
if err != nil {
return err.Error()
}
if src == root.Path || dst == root.Path {
return "the library folder itself can't be moved"
}
fi, err := os.Lstat(src)
switch {
case err != nil:
return "the folder no longer exists (check again)"
case fi.Mode()&os.ModeSymlink != 0:
return "is a symlink"
case !fi.IsDir():
return "is not a folder"
case src == l.quarantine || fsops.Within(src, l.quarantine):
return "contains the quarantine folder"
case fsops.Within(src, dst):
return "can't move a folder into itself"
}
if di, err := os.Lstat(dst); err == nil && !(strings.EqualFold(from, to) && os.SameFile(fi, di)) {
return fmt.Sprintf("%q already exists; merge the two by hand", to)
}
return ""
}
// undoMoveDir moves a folder back.
func undoMoveDir(l *libs, o store.Operation) error {
var d dirMoveDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return err
}
root, err := sideRoot(l, d.Side)
if err != nil {
return err
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
cur, err := root.Resolve(o.RelNew)
if err != nil {
return err
}
old, err := root.Resolve(from)
if err != nil {
return err
}
fi, err := os.Lstat(cur)
if err != nil || !fi.IsDir() {
return fmt.Errorf("the folder %q no longer exists", o.RelNew)
}
if di, err := os.Lstat(old); err == nil && !(strings.EqualFold(o.RelNew, from) && os.SameFile(fi, di)) {
return fmt.Errorf("%q exists again: %w", from, fsops.ErrExists)
}
if err := fsops.MkdirAllLike(filepath.Dir(old)); err != nil {
return err
}
if err := renameDir(cur, old); err != nil {
return err
}
pruneEmpty(filepath.Dir(cur), root.Path)
return nil
}
// recoverMoveDir finishes a case-only folder move cut short by a crash.
func recoverMoveDir(l *libs, o store.Operation, log *slog.Logger) {
var d dirMoveDetail
if json.Unmarshal(o.Detail, &d) != nil {
return
}
root, err := sideRoot(l, d.Side)
if err != nil {
return
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
src, dst := filepath.Join(root.Path, from), filepath.Join(root.Path, o.RelNew)
if _, err := os.Lstat(src); err == nil {
return // never moved
}
if _, err := os.Lstat(dst); err == nil {
return // completed
}
dir := filepath.Dir(src)
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if en.IsDir() && strings.HasPrefix(en.Name(), fsops.TmpPrefix+"dir-") {
if err := os.Rename(filepath.Join(dir, en.Name()), dst); err == nil {
log.Warn("finished interrupted folder move", "op", o.ID, "folder", o.RelNew)
}
return
}
}
}
// ---- library layout ----
// albumInfo is one album folder of a library as found on disk.
type albumInfo struct {
Dir string // the album folder (disc folders folded in)
Songs []string // every song, including those in disc folders
Discs map[string]int // disc folder -> disc number
Loose bool // songs directly in the album folder
Nested bool // other album folders live inside this one
}
// albumsOf groups a library's songs into album folders.
func albumsOf(files map[string]fsops.Info) []*albumInfo {
byDir := map[string]*albumInfo{}
for _, rel := range sortedKeys(files) {
dir := filepath.Dir(rel)
album, disc := dir, 0
if dir != "." {
if n := discNumber(filepath.Base(dir)); n > 0 && filepath.Dir(dir) != "." {
album, disc = filepath.Dir(dir), n
}
}
a := byDir[album]
if a == nil {
a = &albumInfo{Dir: album, Discs: map[string]int{}}
byDir[album] = a
}
a.Songs = append(a.Songs, rel)
if disc > 0 {
a.Discs[dir] = disc
} else {
a.Loose = true
}
}
out := make([]*albumInfo, 0, len(byDir))
for _, a := range byDir {
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return out[i].Dir < out[j].Dir })
for _, a := range out {
for _, b := range out {
if a != b && (a.Dir == "." || strings.HasPrefix(b.Dir, a.Dir+"/")) {
a.Nested = true
break
}
}
}
return out
}
// discNumber reads "Disc 2", "CD02", "disk_3" or "Disc 1 - Title" as a disc number.
func discNumber(name string) int {
m := discNum.FindStringSubmatch(name)
if m == nil {
return 0
}
return leadingNumber(m[1])
}
+500
View File
@@ -0,0 +1,500 @@
package engine
import (
"context"
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/tags"
)
// discNum recognizes disc folders. "Vol. 2" is left out on purpose: volumes
// are usually albums of their own.
var discNum = regexp.MustCompile(`(?i)^(?:disc|disk|cd|dvd|part)[\s._-]*(\d{1,2})(?:\b|$)`)
// junkFolder matches extra folders inside an album that only describe the
// files ("FLAC", "MP3 320", "24bit"), whose songs belong in the album folder.
var junkFolder = regexp.MustCompile(`(?i)^[\[(]?\s*(?:flac|mp3|aac|m4a|alac|ogg|opus|wav|lossless|lossy|hi-?res|web|` +
`(?:mp3\s*)?(?:320|256|192|v0|v2)(?:\s*kbps)?|\d{2}\s*-?\s*bit.*|audio|tracks?|files?|songs?)\s*[\])]?$`)
// folderNoise is release information in folder names: "Nightmare (2010)",
// "Nightmare [FLAC]", "2010 - Nightmare".
var (
folderNoise = regexp.MustCompile(`(?i)\s*[\[({]\s*(?:\d{4}(?:[-.]\d{2}){0,2}|flac|mp3|aac|alac|ogg|opus|web|cd|vinyl|lossless|` +
`deluxe|remaster(?:ed)?|\d{2}\s*-?\s*bit[^\])}]*|\d+(?:\.\d+)?\s*k(?:hz|bps)[^\])}]*|v0|v2|320|\d{2}-\d{2,3})\s*[\])}]\s*$`)
yearPrefix = regexp.MustCompile(`^\s*[\[(]?\d{4}[\])]?\s*[-–.]\s+`)
)
// cleanFolderName turns a folder name into an album or artist name.
func cleanFolderName(s string) string {
for {
t := folderNoise.ReplaceAllString(s, "")
t = yearPrefix.ReplaceAllString(t, "")
t = strings.TrimSpace(t)
if t == s || t == "" {
return s
}
s = t
}
}
// layoutFor returns the formats that apply at rel and the folder they are
// relative to ("" for the library, or a folder with its own naming).
func (c NamingConfig) layoutFor(rel string) (string, formats) {
if rule, ok := c.folderOf(rel); ok {
return rel[:len(rule.Folder)], rule.formats()
}
return "", c.formats()
}
// albumDepth is how many folders deep albums sit: Artist/Album or Album.
func (f formats) albumDepth() int {
if f.artist == "" {
return 1
}
return 2
}
// renderComponent renders a one-folder format from a song's tags.
func renderComponent(c NamingConfig, format string, f map[string]string) string {
v := valuesOf(f)
return finishComponent(tokenPat.ReplaceAllStringFunc(format, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) }))
}
// renderAlbumDir is the album folder a song at rel belongs in, from its tags.
func renderAlbumDir(c NamingConfig, rel string, f map[string]string) (string, string) {
flat := c
flat.TrackFormat, flat.MultiDiscFormat = "{Track Title}", "{Track Title}"
flat.Folders = make([]FolderNaming, len(c.Folders))
for i, r := range c.Folders {
r.TrackFormat, r.MultiDiscFormat = "{Track Title}", "{Track Title}"
flat.Folders[i] = r
}
g := map[string]string{}
for k, v := range f {
g[k] = v
}
if strings.TrimSpace(g[tags.Title]) == "" {
g[tags.Title] = "x" // the title doesn't name folders
}
p, why := renderPath(flat, rel, g)
if why != "" {
return "", why
}
return filepath.Dir(p), ""
}
func depthOf(rel string) int {
if rel == "" || rel == "." {
return 0
}
return strings.Count(rel, "/") + 1
}
// majority is the most common non-empty value of a field.
func majority(fields map[string]map[string]string, songs []string, keys ...string) string {
for _, key := range keys {
count := map[string]int{}
best, bestN := "", 0
for _, s := range songs {
v := strings.TrimSpace(fields[s][key])
if v == "" {
continue
}
count[v]++
if count[v] > bestN || (count[v] == bestN && v < best) {
best, bestN = v, count[v]
}
}
if best != "" {
return best
}
}
return ""
}
func similar(a, b string) bool {
ka, kb := matchKey(a), matchKey(b)
return ka != "" && kb != "" && (ka == kb || levenshteinRatio(ka, kb) >= 0.8)
}
// readLibrary lists a library's songs and reads their tags.
func (e *Engine) readLibrary(ctx context.Context, j *Job, l *libs, side string) (fsops.Root, map[string]fsops.Info, map[string]map[string]string, error) {
root, err := sideRoot(l, side)
if err != nil {
return root, nil, nil, err
}
j.phase("Listing "+side+" files", 0)
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, root, files); err != nil {
return root, nil, nil, err
}
j.phase("Reading "+side+" tags", int64(len(files)))
fields := make(map[string]map[string]string, len(files))
for _, rel := range sortedKeys(files) {
if ctx.Err() != nil {
return root, nil, nil, ctx.Err()
}
f := map[string]string{}
if _, ok := tags.FormatOf(rel); ok {
if got, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), files[rel]); err == nil && got != nil {
f = got
}
}
fields[rel] = f
j.tick()
}
return root, files, fields, nil
}
func (e *Engine) moveItem(root fsops.Root, side, from, to string, in fsops.Info) *organizeItem {
return &organizeItem{Side: side, From: from, To: to, Move: true, ino: in.Ino, size: in.Size, mtime: in.MtimeNs,
Sidecars: sidecarsOf(root, from)}
}
// ---- step 1: structure ----
func (e *Engine) checkStructure(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
albums := albumsOf(files)
plan.Songs, plan.Albums = len(files), len(albums)
claimed := map[string]string{} // lower(new path) -> old path
claim := func(from, to string) string {
k := strings.ToLower(to)
if c := claimed[k]; c != "" && c != from {
return "another item gets the same name (" + c + ")"
}
claimed[k] = from
return ""
}
// Songs that change tags here, so a move of the same song can re-read it.
type deep struct {
item int
extra string
}
var deeps []deep
for _, a := range albums {
if ctx.Err() != nil {
return ctx.Err()
}
prefix, fm := naming.layoutFor(filepath.Join(a.Dir, "x"))
rest := strings.TrimPrefix(strings.TrimPrefix(a.Dir, prefix), "/")
if rest == "." {
rest = ""
}
want, depth := fm.albumDepth(), depthOf(rest)
target := a.Dir // where the album will be, for filling tags from names
// looseInto moves songs into album folders named from their tags,
// under base (or where the naming puts them when base is "").
looseInto := func(songs []string, base, why string) {
for _, s := range songs {
var dir, blocked string
if base == "" {
dir, blocked = renderAlbumDir(naming, s, fields[s])
} else if strings.TrimSpace(fields[s][tags.Album]) == "" {
blocked = "missing album tag"
} else {
dir = filepath.Join(base, renderComponent(naming, fm.album, fields[s]))
}
it := FixItem{Kind: FixMoveFile, From: s, Why: why, Group: dir}
if blocked != "" {
it.Blocked = blocked + ": set it in the Tags tab, then check again"
it.Group = filepath.Dir(s)
} else {
it.To = filepath.Join(dir, filepath.Base(s))
if targetTaken(root, s, it.To) {
it.Blocked = "a different file already has that name"
} else if c := claim(s, it.To); c != "" {
it.Blocked = c
} else {
it.org = e.moveItem(root, plan.Side, s, it.To, files[s])
}
}
plan.add(it)
}
}
switch {
case depth == 0:
where := "the top of the library"
if prefix != "" {
where = "the top of " + prefix
}
looseInto(a.Songs, "", "Not in an album folder (at "+where+")")
continue
case depth < want:
// Music with one folder: an album without an artist folder, or an
// artist folder with songs directly inside it.
album := majority(fields, a.Songs, tags.Album)
artist := majority(fields, a.Songs, tags.AlbumArtist, tags.Artist)
name := filepath.Base(a.Dir)
isArtist := a.Nested || (artist != "" && similar(artist, cleanFolderName(name)) && !similar(album, cleanFolderName(name)))
if isArtist {
looseInto(a.Songs, a.Dir, "Songs directly in the artist folder, not in an album folder")
continue
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, Why: "Album folder is not inside an artist folder"}
if artist == "" {
it.Blocked = "its songs have no artist tag: set the album artist (Edit album in the Tags tab), then check again"
it.Group = a.Dir
} else {
f := map[string]string{tags.AlbumArtist: artist}
it.To = filepath.Join(prefix, renderComponent(naming, fm.artist, f), name)
it.Group = it.To
if why := checkDirMove(l, root, it.From, it.To); why != "" {
it.Blocked = why
} else if c := claim(it.From, it.To); c != "" {
it.Blocked = c
}
target = it.To
}
plan.add(it)
case depth > want:
comps := strings.Split(rest, "/")
last := comps[len(comps)-1]
if junkFolder.MatchString(last) && !a.Nested {
parent := filepath.Dir(a.Dir)
why := fmt.Sprintf("Extra folder %q inside the album", last)
for _, s := range a.Songs {
to := filepath.Join(parent, strings.TrimPrefix(s, a.Dir+"/"))
it := FixItem{Kind: FixMoveFile, From: s, To: to, Why: why, Group: parent}
if targetTaken(root, s, to) {
it.Blocked = "a different file already has that name"
} else if c := claim(s, to); c != "" {
it.Blocked = c
} else {
it.org = e.moveItem(root, plan.Side, s, to, files[s])
}
plan.add(it)
}
target = parent
break
}
extra := comps[:len(comps)-want]
to := filepath.Join(prefix, filepath.Join(comps[len(comps)-want:]...))
kind := "Album"
if want == 1 {
kind = "Soundtrack"
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to,
Why: fmt.Sprintf("%s is inside extra folders (%s)", kind, strings.Join(extra, "/"))}
switch {
case a.Nested:
it.Blocked = "other album folders are inside it"
default:
if why := checkDirMove(l, root, it.From, it.To); why != "" {
it.Blocked = why
} else if c := claim(it.From, it.To); c != "" {
it.Blocked = c
}
}
plan.add(it)
deeps = append(deeps, deep{item: len(plan.Items) - 1, extra: filepath.Join(prefix, extra[0])})
target = to
}
// Disc folders and tags filled from folder and file names.
if len(a.Discs) > 0 && a.Loose {
plan.add(FixItem{Kind: FixProblem, Group: a.Dir, From: a.Dir, Why: "Songs are both in the album folder and in disc folders",
Blocked: "move them into the right disc folder by hand"})
}
discTotal := 0
for _, n := range a.Discs {
discTotal = max(discTotal, n)
}
albumName := cleanFolderName(filepath.Base(target))
artistName := ""
if want == 2 && depthOf(strings.TrimPrefix(strings.TrimPrefix(target, prefix), "/")) == 2 {
artistName = cleanFolderName(filepath.Base(filepath.Dir(target)))
}
for _, s := range a.Songs {
f := fields[s]
if _, ok := tags.FormatOf(s); !ok {
continue
}
ch := map[string][2]string{}
var why []string
set := func(k, v string) {
if v != "" && strings.TrimSpace(f[k]) != v {
ch[k] = [2]string{f[k], v}
}
}
if n := a.Discs[filepath.Dir(s)]; n > 0 {
if leadingNumber(f[tags.Disc]) != n {
set(tags.Disc, strconv.Itoa(n))
}
if leadingNumber(f[tags.DiscTotal]) != discTotal {
set(tags.DiscTotal, strconv.Itoa(discTotal))
}
if len(ch) > 0 {
why = append(why, "disc number from its disc folder")
}
}
filled := len(ch)
if strings.TrimSpace(f[tags.Album]) == "" {
set(tags.Album, albumName)
}
if artistName != "" && strings.TrimSpace(f[tags.Artist]) == "" && strings.TrimSpace(f[tags.AlbumArtist]) == "" {
set(tags.Artist, artistName)
}
stem := strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))
if strings.TrimSpace(f[tags.Title]) == "" {
if t := strings.TrimSpace(trackPrefix.ReplaceAllString(stem, "")); t != "" {
set(tags.Title, t)
} else {
set(tags.Title, stem)
}
}
if strings.TrimSpace(f[tags.Track]) == "" {
if m := trackNum.FindStringSubmatch(stem); m != nil {
set(tags.Track, strconv.Itoa(leadingNumber(m[1])))
}
}
if len(ch) > filled {
why = append(why, "missing tags filled from the folder and file names")
}
if len(ch) > 0 {
w := strings.Join(why, "; ")
plan.add(FixItem{Kind: FixTags, From: s, Group: target, Changes: ch, Why: strings.ToUpper(w[:1]) + w[1:]})
}
}
}
// Several albums inside the same extra folder look like a category
// ("Video Games/…", "Rock/…") rather than a mistake: don't select those.
byExtra := map[string]int{}
for _, d := range deeps {
byExtra[d.extra]++
}
var cats []string
for _, d := range deeps {
if byExtra[d.extra] >= 3 {
plan.Items[d.item].Unsure = true
}
}
for x, n := range byExtra {
if n >= 3 {
cats = append(cats, fmt.Sprintf("%s (%d albums)", x, n))
}
}
if len(cats) > 0 {
sort.Strings(cats)
plan.Notices = append(plan.Notices, "Some folders hold several albums one level deeper than expected: "+strings.Join(cats, ", ")+
". If they are categories you want to keep (like Video Games or Movies), add them under Organize → Folders with their own naming "+
"instead of moving the albums out. Those moves are not selected.")
}
return nil
}
// ---- step 3: folder names ----
func (e *Engine) checkFolders(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
albums := albumsOf(files)
plan.Songs, plan.Albums = len(files), len(albums)
claimed := map[string]string{}
for _, a := range albums {
prefix, _ := naming.layoutFor(filepath.Join(a.Dir, "x"))
if a.Dir == "." || a.Dir == prefix {
continue // loose songs: the file step puts them in album folders
}
targets := map[string]string{}
var missing string
untagged := 0
for _, s := range a.Songs {
t, why := renderAlbumDir(naming, s, fields[s])
if why != "" {
missing = why
untagged++
continue
}
targets[strings.ToLower(t)] = t
}
switch {
case len(targets) == 0:
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir, Why: "Can't name this album folder",
Blocked: missing + " (fix it with Edit album in the Tags tab, then check again)"})
continue
case len(targets) > 1:
var names []string
for _, t := range targets {
names = append(names, t)
}
sort.Strings(names)
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir,
Why: fmt.Sprintf("Songs belong to %d different album folders (%s)", len(names), strings.Join(names, ", ")),
Blocked: "the file step sorts them into their own folders; or make the album tags agree with Edit album"})
continue
}
var to string
for _, t := range targets {
to = t
}
if to == a.Dir {
continue
}
why := "Renamed from its tags"
if filepath.Dir(to) != filepath.Dir(a.Dir) {
why = "Moved to the folder its tags name"
}
if untagged > 0 {
why += fmt.Sprintf(" (%d songs without tags move along)", untagged)
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to, Why: why}
switch {
case a.Nested:
it.Blocked = "other album folders are inside it"
default:
if b := checkDirMove(l, root, a.Dir, to); b != "" {
it.Blocked = b
if strings.Contains(b, "already exists") {
it.Blocked = "that folder already exists; its songs are moved into it in the file step"
}
} else if c := claimed[strings.ToLower(to)]; c != "" {
it.Blocked = "another folder gets the same name (" + c + ")"
} else {
claimed[strings.ToLower(to)] = a.Dir
}
}
plan.add(it)
}
return nil
}
// ---- step 4: file names ----
func (e *Engine) checkFiles(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
op, err := e.planOrganize(ctx, j, l, []string{plan.Side})
if err != nil {
return err
}
plan.Songs = op.Scanned
for _, it := range op.Items {
fi := FixItem{Kind: FixMoveFile, From: it.From, To: it.To, Why: "Renamed from its tags"}
if it.Move {
org := it
fi.org = &org
fi.Group = filepath.Dir(it.To)
if filepath.Dir(it.To) != filepath.Dir(it.From) {
fi.Why = "Moved and renamed from its tags"
}
} else {
fi.Kind, fi.Blocked, fi.Why, fi.Group = FixProblem, it.Reason, "Can't be renamed", filepath.Dir(it.From)
}
plan.add(fi)
}
return nil
}
+552
View File
@@ -0,0 +1,552 @@
package engine
import (
"context"
"errors"
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/tags"
)
// The album tags step looks each album up and proposes corrected tags for
// review. The master is looked up on MusicBrainz; the slave uses the fixed
// master album when there is one (so both libraries agree), and MusicBrainz
// otherwise.
// SetMeta enables online album lookups.
func (e *Engine) SetMeta(c *meta.Client) { e.meta = c }
// Sources of album tags.
const (
SourceMusicBrainz = "musicbrainz"
SourceMaster = "master"
)
// AlbumFix is the lookup result for one album folder.
type AlbumFix struct {
ID int `json:"id"` // the plan item's id
Folder string `json:"folder"`
Artist string `json:"artist"` // what was searched for
Album string `json:"album"`
Source string `json:"source"`
Release *meta.Release `json:"release,omitempty"`
Candidates []meta.Release `json:"candidates"`
Confidence float64 `json:"confidence"` // 0..1
Matched int `json:"matched"` // songs matched to a track
Files []FileFix `json:"files"`
Shared map[string][2]string `json:"album_changes"` // changes to album-wide fields
Error string `json:"error,omitempty"`
songs []string
fields map[string]map[string]string
discs map[string]int
}
// FileFix is the proposed change of one song.
type FileFix struct {
Path string `json:"path"`
Track string `json:"track,omitempty"` // the matched track, "1-03 Title"
Changes map[string][2]string `json:"changes"`
}
// albumComplete reports whether an album's tags already look right: every
// song has a title, track number, album and artist, and they agree.
func albumComplete(songs []string, fields map[string]map[string]string) bool {
albums, artists, tracks := map[string]bool{}, map[string]bool{}, map[string]bool{}
for _, s := range songs {
f := fields[s]
artist := strings.TrimSpace(f[tags.AlbumArtist])
if artist == "" {
artist = strings.TrimSpace(f[tags.Artist])
}
if strings.TrimSpace(f[tags.Title]) == "" || leadingNumber(f[tags.Track]) == 0 || strings.TrimSpace(f[tags.Album]) == "" || artist == "" {
return false
}
key := strconv.Itoa(leadingNumber(f[tags.Disc])) + "-" + strconv.Itoa(leadingNumber(f[tags.Track]))
if tracks[key] {
return false // two songs with the same number
}
tracks[key] = true
albums[f[tags.Album]] = true
artists[artist] = true
}
return len(albums) == 1 && len(artists) == 1
}
func (e *Engine) checkAlbumTags(ctx context.Context, j *Job, l *libs, plan *FixPlan, c FixCheck) error {
_, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
var albums []*albumInfo
complete := 0
for _, a := range albumsOf(files) {
prefix, _ := naming.layoutFor(filepath.Join(a.Dir, "x"))
if a.Dir == "." || a.Dir == prefix {
continue // not in an album folder: the structure step handles those
}
if c.Folder != "" && a.Dir != c.Folder && !strings.HasPrefix(a.Dir, c.Folder+"/") {
continue
}
if c.OnlyIncomplete && albumComplete(a.Songs, fields) {
complete++
continue
}
albums = append(albums, a)
}
plan.Songs = len(files)
plan.Albums = len(albums)
if complete > 0 {
plan.Notices = append(plan.Notices, fmt.Sprintf("%d albums already have complete tags and were not looked up.", complete))
}
var master map[string]*masterAlbum
if plan.Side == SideSlave {
master, err = e.masterAlbums(ctx, j, l)
if err != nil {
return err
}
}
j.phase("Looking up albums", int64(len(albums)))
correct := 0
for _, a := range albums {
if ctx.Err() != nil {
return ctx.Err()
}
af := &AlbumFix{Folder: a.Dir, songs: a.Songs, fields: fields, discs: map[string]int{}}
for _, s := range a.Songs {
af.discs[s] = a.Discs[filepath.Dir(s)]
}
af.Album = majority(fields, a.Songs, tags.Album)
if af.Album == "" {
af.Album = cleanFolderName(filepath.Base(a.Dir))
}
af.Artist = majority(fields, a.Songs, tags.AlbumArtist, tags.Artist)
if m := master[fixAlbumKey(af.Artist, af.Album)]; m != nil {
af.useMaster(m)
} else {
e.lookupAlbum(ctx, af, "")
}
if ctx.Err() != nil {
return ctx.Err()
}
j.tick()
if af.Error == "" && !af.hasChanges() {
correct++
continue
}
it := FixItem{Kind: FixAlbumTags, From: a.Dir, Group: a.Dir, Why: af.summary(), Blocked: af.Error, Unsure: af.Confidence < 0.75}
plan.add(it)
af.ID = plan.Items[len(plan.Items)-1].ID
plan.AlbumFixes = append(plan.AlbumFixes, af)
}
if correct > 0 {
plan.Notices = append(plan.Notices, fmt.Sprintf("%d albums already match their lookup.", correct))
}
return nil
}
func (a *AlbumFix) hasChanges() bool {
for _, f := range a.Files {
if len(f.Changes) > 0 {
return true
}
}
return false
}
func (a *AlbumFix) summary() string {
if a.Error != "" {
return "No confident match"
}
n := 0
for _, f := range a.Files {
if len(f.Changes) > 0 {
n++
}
}
src := "MusicBrainz"
if a.Source == SourceMaster {
src = "the master library"
}
return fmt.Sprintf("%d of %d songs change (from %s)", n, len(a.Files), src)
}
func fixAlbumKey(artist, album string) string { return matchKey(artist) + "|" + matchKey(album) }
// ---- the master as reference (slave only) ----
type masterAlbum struct {
folder string
songs []string
fields map[string]map[string]string
}
// masterAlbums indexes the master's albums by artist and album tags.
func (e *Engine) masterAlbums(ctx context.Context, j *Job, l *libs) (map[string]*masterAlbum, error) {
_, files, fields, err := e.readLibrary(ctx, j, l, SideMaster)
if err != nil {
return nil, err
}
out := map[string]*masterAlbum{}
for _, a := range albumsOf(files) {
if a.Dir == "." || !albumComplete(a.Songs, fields) {
continue
}
k := fixAlbumKey(majority(fields, a.Songs, tags.AlbumArtist, tags.Artist), majority(fields, a.Songs, tags.Album))
if out[k] == nil {
out[k] = &masterAlbum{folder: a.Dir, songs: a.Songs, fields: fields}
}
}
return out, nil
}
// useMaster takes the tags of the same album in the master.
func (a *AlbumFix) useMaster(m *masterAlbum) {
a.Source = SourceMaster
d := &meta.ReleaseDetail{Release: meta.Release{ID: "master:" + m.folder, Title: majority(m.fields, m.songs, tags.Album),
Artist: majority(m.fields, m.songs, tags.AlbumArtist, tags.Artist), Date: majority(m.fields, m.songs, tags.Date)}}
discs := map[int]bool{}
for _, s := range m.songs {
f := m.fields[s]
disc := max(leadingNumber(f[tags.Disc]), 1)
discs[disc] = true
d.Tracks = append(d.Tracks, meta.Track{Disc: disc, Position: leadingNumber(f[tags.Track]), DiscTracks: leadingNumber(f[tags.TrackTotal]),
Title: f[tags.Title], Artist: f[tags.Artist]})
}
d.Discs = len(discs)
d.TrackCount = len(d.Tracks)
d.Formats = "master: " + m.folder
a.Candidates = []meta.Release{d.Release}
a.apply(d, 1)
}
// ---- MusicBrainz ----
// lookupAlbum searches MusicBrainz and applies the best release, or the one
// given by id.
func (e *Engine) lookupAlbum(ctx context.Context, a *AlbumFix, id string) {
a.Source, a.Error, a.Release, a.Files, a.Matched, a.Confidence = SourceMusicBrainz, "", nil, nil, 0, 0
if id == "" {
res, err := e.meta.SearchReleases(ctx, a.Artist, a.Album)
if err == nil && len(res) == 0 && a.Artist != "" {
res, err = e.meta.SearchReleases(ctx, "", a.Album) // soundtracks: the artist is often missing or a composer
}
if err != nil {
a.Error = "lookup failed: " + err.Error()
return
}
scored := make([]struct {
r meta.Release
s float64
}, len(res))
for i, r := range res {
scored[i].r, scored[i].s = r, a.releaseScore(r)
}
sort.SliceStable(scored, func(i, j int) bool { return scored[i].s > scored[j].s })
a.Candidates = a.Candidates[:0]
for i, s := range scored {
if i == 8 {
break
}
a.Candidates = append(a.Candidates, s.r)
}
// The search can't tell a promo single from the album it is named
// after: compare the track lists of the best few with the songs.
var best *meta.ReleaseDetail
bestScore := 0.0
for i, c := range scored {
if i == 3 || c.s < 0.45 {
break
}
d, err := e.meta.Release(ctx, c.r.ID)
if err != nil {
if ctx.Err() != nil {
return
}
continue
}
fit := a.trackFit(d)
if score := 0.5*c.s + 0.5*fit; score > bestScore {
best, bestScore = d, score
}
if fit == 1 && c.s >= 0.75 {
break // every song is on this release
}
}
if best == nil || bestScore < 0.6 {
a.Error = "no confident match on MusicBrainz; pick a release or edit the album by hand"
return
}
a.apply(best, bestScore)
return
}
d, err := e.meta.Release(ctx, id)
if err != nil {
a.Error = "lookup failed: " + err.Error()
return
}
a.apply(d, 0.5*a.releaseScore(d.Release)+0.5*a.trackFit(d))
}
// releaseScore rates a search result before its track list is known (0..1).
func (a *AlbumFix) releaseScore(r meta.Release) float64 {
title := levenshteinRatio(matchKey(r.Title), matchKey(a.Album))
artist := 0.6
if a.Artist != "" {
artist = levenshteinRatio(matchKey(r.Artist), matchKey(a.Artist))
}
count := 0.0
switch n := len(a.songs); {
case r.TrackCount == n:
count = 1
case r.TrackCount > n:
count = 0.7 // a partial copy of an album is common
}
s := 0.45*title + 0.2*artist + 0.15*count + 0.2*typeFit(r.Type)
if r.Status != "" && r.Status != "Official" {
s -= 0.05
}
return s
}
// typeFit prefers albums and soundtracks over singles and EPs of the same name.
func typeFit(t string) float64 {
t = strings.ToLower(t)
switch {
case strings.Contains(t, "album"), strings.Contains(t, "soundtrack"):
return 1
case strings.Contains(t, "compilation"):
return 0.8
case strings.Contains(t, "ep"), t == "":
return 0.6
case strings.Contains(t, "single"):
return 0.3
}
return 0.5
}
// trackFit is the share of the album's titled songs found on a release.
func (a *AlbumFix) trackFit(d *meta.ReleaseDetail) float64 {
n, hit := 0, 0
for _, s := range a.songs {
t := matchKey(a.fields[s][tags.Title])
if t == "" {
continue
}
n++
for _, tr := range d.Tracks {
if k := matchKey(tr.Title); k == t || levenshteinRatio(k, t) >= 0.8 {
hit++
break
}
}
}
if n == 0 {
if len(d.Tracks) == len(a.songs) {
return 0.8
}
return 0.4
}
return float64(hit) / float64(n)
}
// ChooseRelease switches an album of the latest plan to another release (a
// MusicBrainz id), or to a new MusicBrainz search ("search").
func (e *Engine) ChooseRelease(ctx context.Context, pairID int64, albumID int, id string) (*AlbumFix, error) {
if e.meta == nil {
return nil, errors.New("online lookup is not available")
}
if id != "search" && !meta.ValidID(id) {
return nil, fmt.Errorf("%w: not a MusicBrainz release id", ErrInvalidBulk)
}
e.fixMu.Lock()
plan := e.fixPlans[pairID]
var af *AlbumFix
if plan != nil && plan.Step == StepTags && !plan.Applied {
for _, a := range plan.AlbumFixes {
if a.ID == albumID {
af = a
}
}
}
e.fixMu.Unlock()
if af == nil {
return nil, fmt.Errorf("%w: that album is not in the latest check", ErrInvalidBulk)
}
// Work on a copy so a failed or slow lookup never leaves a half-updated album.
cp := *af
if id == "search" {
id = ""
}
e.lookupAlbum(ctx, &cp, id)
if cp.Error != "" && cp.Release == nil && id != "" {
return nil, errors.New(cp.Error)
}
e.fixMu.Lock()
defer e.fixMu.Unlock()
*af = cp
for i := range plan.Items {
if plan.Items[i].ID == albumID {
wasBlocked := plan.Items[i].Blocked != ""
plan.Items[i].Blocked, plan.Items[i].Why, plan.Items[i].Unsure = af.Error, af.summary(), af.Confidence < 0.75
if wasBlocked && af.Error == "" {
plan.Blocked--
plan.Fixable++
} else if !wasBlocked && af.Error != "" {
plan.Blocked++
plan.Fixable--
}
}
}
return af, nil
}
// apply matches the album's songs to a release's tracks and works out the
// tag changes.
func (a *AlbumFix) apply(d *meta.ReleaseDetail, confidence float64) {
rel := d.Release
a.Release, a.Confidence, a.Error = &rel, confidence, ""
match := matchTracks(a.songs, a.fields, a.discs, d.Tracks)
a.Files, a.Matched = nil, 0
shared := map[string][2]string{}
for i, s := range a.songs {
f := a.fields[s]
want := map[string]string{tags.Album: d.Title, tags.AlbumArtist: d.Artist}
if d.Date != "" && !(len(f[tags.Date]) >= 4 && len(d.Date) >= 4 && f[tags.Date][:4] == d.Date[:4]) {
want[tags.Date] = d.Date
}
ff := FileFix{Path: s}
if t := match[i]; t >= 0 {
tr := d.Tracks[t]
a.Matched++
want[tags.Title], want[tags.Artist] = tr.Title, tr.Artist
if tr.Position > 0 {
want[tags.Track] = strconv.Itoa(tr.Position)
}
if tr.DiscTracks > 0 {
want[tags.TrackTotal] = strconv.Itoa(tr.DiscTracks)
}
if d.Discs > 1 || leadingNumber(f[tags.Disc]) > 1 {
want[tags.Disc] = strconv.Itoa(tr.Disc)
want[tags.DiscTotal] = strconv.Itoa(d.Discs)
}
ff.Track = fmt.Sprintf("%d-%02d %s", tr.Disc, tr.Position, tr.Title)
}
ff.Changes = map[string][2]string{}
for k, v := range want {
if strings.TrimSpace(v) == "" || sameTag(k, f[k], v) {
continue
}
ff.Changes[k] = [2]string{f[k], v}
if k == tags.Album || k == tags.AlbumArtist || k == tags.Date {
shared[k] = [2]string{f[k], v}
}
}
a.Files = append(a.Files, ff)
}
a.Shared = shared
if len(a.songs) > 0 && a.Matched*2 < len(a.songs) {
a.Confidence = min(a.Confidence, 0.5)
}
}
func sameTag(k, old, new string) bool {
switch k {
case tags.Track, tags.TrackTotal, tags.Disc, tags.DiscTotal:
return leadingNumber(old) == leadingNumber(new)
}
return strings.TrimSpace(old) == strings.TrimSpace(new)
}
// matchTracks pairs each song with a track of the release (-1: none). Track
// and disc numbers are trusted when the titles mostly agree with them;
// otherwise songs are matched by title, and failing that by file order when
// the counts are equal.
func matchTracks(songs []string, fields map[string]map[string]string, discs map[string]int, tracks []meta.Track) []int {
out := make([]int, len(songs))
for i := range out {
out[i] = -1
}
titleOf := func(i int) string {
if t := strings.TrimSpace(fields[songs[i]][tags.Title]); t != "" {
return matchKey(t)
}
return normTitle(filepath.Base(songs[i]))
}
sim := func(i, t int) float64 { return levenshteinRatio(titleOf(i), matchKey(tracks[t].Title)) }
// 1. by disc and track number
byNum := map[[2]int]int{}
multi := false
for t, tr := range tracks {
byNum[[2]int{tr.Disc, tr.Position}] = t
multi = multi || tr.Disc > 1
}
used := map[int]bool{}
agree, tried := 0, 0
for i, s := range songs {
n := leadingNumber(fields[s][tags.Track])
disc := leadingNumber(fields[s][tags.Disc])
if disc == 0 {
disc = discs[s]
}
if disc == 0 && !multi {
disc = 1
}
if t, ok := byNum[[2]int{disc, n}]; ok && n > 0 && !used[t] {
out[i], used[t] = t, true
tried++
if sim(i, t) >= 0.6 {
agree++
}
}
}
if tried > 0 && agree*10 < tried*7 {
for i := range out { // numbers don't fit these titles
out[i] = -1
}
used = map[int]bool{}
}
// 2. by title, best pairs first
type pair struct {
i, t int
s float64
}
var pairs []pair
for i := range songs {
if out[i] >= 0 {
continue
}
for t := range tracks {
if !used[t] {
if s := sim(i, t); s >= 0.6 {
pairs = append(pairs, pair{i, t, s})
}
}
}
}
sort.Slice(pairs, func(x, y int) bool { return pairs[x].s > pairs[y].s })
for _, p := range pairs {
if out[p.i] < 0 && !used[p.t] {
out[p.i], used[p.t] = p.t, true
}
}
// 3. by file order when nothing matched and the counts agree
if len(used) == 0 && len(songs) == len(tracks) {
order := make([]int, len(songs))
for i := range order {
order[i] = i
}
sort.Slice(order, func(x, y int) bool { return songs[order[x]] < songs[order[y]] })
for t, i := range order {
out[i] = t
}
}
return out
}
+384
View File
@@ -0,0 +1,384 @@
package engine
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// fixCheck runs a check and returns its plan and items.
func (ev *env) fixCheck(t *testing.T, c FixCheck) (*FixPlan, []FixItem, []*AlbumFix) {
t.Helper()
if _, err := ev.e.StartFixCheck(ev.pair.ID, c); err != nil {
t.Fatal(err)
}
ev.wait()
p, items, albums := ev.e.FixerPlan(ev.pair.ID)
if p == nil {
t.Fatal("no plan")
}
return p, items, albums
}
// fixAll applies every fix that isn't blocked (unsure ones included).
func (ev *env) fixAll(t *testing.T, items []FixItem) *Job {
t.Helper()
var ids []int
for _, it := range items {
if it.Blocked == "" && it.Kind != FixProblem {
ids = append(ids, it.ID)
}
}
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: ids}); err != nil {
t.Fatal(err)
}
return ev.wait()
}
func byFrom(items []FixItem) map[string]FixItem {
m := map[string]FixItem{}
for _, it := range items {
if _, ok := m[it.From]; !ok || it.Kind != FixTags {
m[it.From] = it
}
}
return m
}
func (ev *env) fields(t *testing.T, p string) map[string]string {
t.Helper()
f, err := ev.e.tagTools.Read(ctx, p)
if err != nil {
t.Fatal(err)
}
return f.Fields
}
func TestFixStructureMusic(t *testing.T) {
ev := tagEnv(t, false)
s := ev.slave
mkTagged(t, filepath.Join(s, "Loose.mp3"), "title", "Loose", "artist", "A", "album", "B")
mkTagged(t, filepath.Join(s, "Nightmare/01 Nightmare.flac"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
os.WriteFile(filepath.Join(s, "Nightmare/cover.jpg"), []byte("art"), 0o644)
mkTagged(t, filepath.Join(s, "Band/song.mp3"), "title", "Song", "artist", "Band", "album", "Record")
mkTagged(t, filepath.Join(s, "X/Y/FLAC/01 a.flac"), "title", "a", "artist", "X", "album", "Y")
mkTagged(t, filepath.Join(s, "Metal/Artist2/Album2/01 s.mp3"), "title", "s", "artist", "Artist2", "album", "Album2")
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD1/01 t.mp3"), "title", "t", "artist", "Artist3", "album", "Album3", "track", "1")
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3"), "title", "u", "artist", "Artist3", "album", "Album3", "track", "1")
mkTagged(t, filepath.Join(s, "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"))
mkTagged(t, filepath.Join(s, "NoTags.mp3"))
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
m := byFrom(items)
want := map[string]string{
"Loose.mp3": "A/B/Loose.mp3",
"Nightmare": "Avenged Sevenfold/Nightmare",
"Band/song.mp3": "Band/Record/song.mp3",
"X/Y/FLAC/01 a.flac": "X/Y/01 a.flac",
"Metal/Artist2/Album2": "Artist2/Album2",
"Artist3/Album3/CD2/01 u.mp3": "",
}
for from, to := range want {
it, ok := m[from]
if !ok || it.To != to || it.Blocked != "" {
t.Errorf("%s: got %+v, want to %q", from, it, to)
}
}
if it := m["Artist3/Album3/CD2/01 u.mp3"]; it.Changes[tags.Disc][1] != "2" || it.Changes[tags.DiscTotal][1] != "2" {
t.Errorf("disc tags from the disc folder: %+v", it.Changes)
}
fill := m["Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"].Changes
if fill[tags.Album][1] != "Album4" || fill[tags.Artist][1] != "Artist4" || fill[tags.Title][1] != "Song Name" || fill[tags.Track][1] != "5" {
t.Errorf("tags from names: %+v", fill)
}
if it := m["NoTags.mp3"]; it.Blocked == "" {
t.Errorf("a loose song without tags can't be placed: %+v", it)
}
if plan.Blocked != 1 {
t.Errorf("blocked %d", plan.Blocked)
}
j := ev.fixAll(t, items)
if j.Results["skipped"] != 0 {
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
}
for _, p := range []string{"A/B/Loose.mp3", "Avenged Sevenfold/Nightmare/01 Nightmare.flac", "Avenged Sevenfold/Nightmare/cover.jpg",
"Band/Record/song.mp3", "X/Y/01 a.flac", "Artist2/Album2/01 s.mp3", "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"} {
if !exists(filepath.Join(s, p)) {
t.Errorf("missing %s", p)
}
}
for _, p := range []string{"Nightmare", "X/Y/FLAC", "Metal"} {
if exists(filepath.Join(s, p)) {
t.Errorf("%s should be gone", p)
}
}
if f := ev.fields(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3")); f[tags.Disc] != "2" || f[tags.DiscTotal] != "2" {
t.Errorf("disc tags not written: %+v", f)
}
// A folder move can be undone.
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
for _, o := range ops {
if o.Action == store.ActMoveDir && o.RelNew == "Avenged Sevenfold/Nightmare" {
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
}
}
waitIdle(ev)
if !exists(filepath.Join(s, "Nightmare/cover.jpg")) || exists(filepath.Join(s, "Avenged Sevenfold")) {
t.Fatal("undo should move the folder back and remove the empty artist folder")
}
// Not everything was fixable, so the step isn't marked done.
p, _ := ev.st.Pair(ctx, ev.pair.ID)
if FixerStateOf(p).Slave[StepStructure].State != "" {
t.Fatal("step marked done although a song still needs attention")
}
}
func waitIdle(ev *env) {
for i := 0; i < 500; i++ {
if j := ev.e.Job(ev.pair.ID); j == nil || j.State != "running" {
return
}
time.Sleep(10 * time.Millisecond)
}
}
func TestFixStructureSoundtracks(t *testing.T) {
ev := tagEnv(t, false)
ev.pair.LibraryType = LibrarySoundtracks
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
s := ev.slave
mkTagged(t, filepath.Join(s, "Nobuo Uematsu/Final Fantasy VII/01 Prelude.flac"), "title", "Prelude", "album", "Final Fantasy VII")
for _, g := range []string{"Halo 3", "Super Mario Galaxy", "Portal 2"} {
mkTagged(t, filepath.Join(s, "Video Games", g, "01 x.flac"), "title", "x", "album", g)
}
mkTagged(t, filepath.Join(s, "Journey/01 Nascence.flac"), "title", "Nascence", "album", "Journey")
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
m := byFrom(items)
if it := m["Nobuo Uematsu/Final Fantasy VII"]; it.To != "Final Fantasy VII" || it.Unsure || !strings.Contains(it.Why, "Soundtrack") {
t.Errorf("composer folder: %+v", it)
}
if it := m["Video Games/Halo 3"]; it.To != "Halo 3" || !it.Unsure {
t.Errorf("a category folder's albums should be listed but not selected: %+v", it)
}
if len(plan.Notices) != 1 || !strings.Contains(plan.Notices[0], "Video Games (3 albums)") {
t.Errorf("notices %v", plan.Notices)
}
if _, ok := m["Journey"]; ok {
t.Error("a soundtrack at the top of the library is fine")
}
}
func TestFixFoldersAndFiles(t *testing.T) {
ev := tagEnv(t, false)
m := ev.master
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/a.mp3"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/b.mp3"), "title", "Welcome to the Family", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "2")
mkTagged(t, filepath.Join(m, "Mix/1.mp3"), "title", "One", "artist", "A", "album", "First")
mkTagged(t, filepath.Join(m, "Mix/2.mp3"), "title", "Two", "artist", "B", "album", "Second")
_, items, _ := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFolders})
byF := byFrom(items)
if it := byF["avenged sevenfold/nightmare (2010)"]; it.Kind != FixMoveFolder || it.To != "Avenged Sevenfold/Nightmare" {
t.Fatalf("folder rename: %+v", it)
}
if it := byF["Mix"]; it.Kind != FixProblem || !strings.Contains(it.Why, "2 different album folders") {
t.Fatalf("mixed folder: %+v", it)
}
ev.fixAll(t, items)
if !exists(filepath.Join(m, "Avenged Sevenfold/Nightmare/a.mp3")) {
t.Fatal("folder not renamed")
}
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFolders].State != "" {
t.Fatal("a folder that needs attention keeps the step open")
}
_, items, _ = ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFiles})
ev.fixAll(t, items)
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFiles].State != "done" {
t.Fatal("applying every fix marks the step done")
}
for _, p := range []string{"Avenged Sevenfold/Nightmare/01 Nightmare.mp3", "Avenged Sevenfold/Nightmare/02 Welcome to the Family.mp3", "A/First/One.mp3", "B/Second/Two.mp3"} {
if !exists(filepath.Join(m, p)) {
t.Errorf("missing %s", p)
}
}
// Applying the same plan twice is refused: paths changed.
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: []int{1}}); err == nil {
t.Fatal("a stale plan must not be applied again")
}
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID - 1, IDs: []int{1}}); err == nil {
t.Fatal("a stale plan must not be applied again")
}
}
// fakeMB serves one release: Avenged Sevenfold - Nightmare, 3 tracks.
func fakeMB(t *testing.T) *meta.Client {
const id = "85cb4213-ffc2-38b1-a256-bd6e5ce2e0af"
mux := http.NewServeMux()
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, id) {
fmt.Fprint(w, `{"id":"`+id+`","title":"Nightmare","date":"2010-07-26","status":"Official","artist-credit":[{"name":"Avenged Sevenfold"}],
"release-group":{"primary-type":"Album"},"media":[{"position":1,"format":"CD","track-count":3,"tracks":[
{"position":1,"title":"Nightmare","length":374000},{"position":2,"title":"Welcome to the Family","length":245000},
{"position":3,"title":"Danger Line","length":329000}]}]}`)
return
}
if !strings.Contains(r.URL.Query().Get("query"), "Nightmare") {
fmt.Fprint(w, `{"releases":[]}`)
return
}
fmt.Fprint(w, `{"releases":[{"id":"`+id+`","score":100,"title":"Nightmare","status":"Official","date":"2010-07-26",
"track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":3}]}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := meta.New("test")
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
return c
}
func TestFixAlbumTags(t *testing.T) {
ev := tagEnv(t, false)
ev.e.SetMeta(fakeMB(t))
m, s := ev.master, ev.slave
dir := "Avenged Sevenfold/Nightmare"
// Titles are slightly off and numbers are missing; one song has no title.
mkTagged(t, filepath.Join(m, dir, "a.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, dir, "b.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, dir, "03 Danger Line.mp3"), "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, "Nobody/Unknown/x.mp3"), "title", "x", "artist", "Nobody", "album", "Unknown")
plan, items, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
if len(albums) != 2 || plan.Fixable != 1 || plan.Blocked != 1 {
t.Fatalf("plan %+v albums %d", plan, len(albums))
}
var nm *AlbumFix
for _, a := range albums {
if a.Folder == dir {
nm = a
}
}
if nm == nil || nm.Matched != 3 || nm.Release.Title != "Nightmare" || nm.Confidence < 0.9 {
t.Fatalf("album fix %+v", nm)
}
ev.fixAll(t, items)
f := ev.fields(t, filepath.Join(m, dir, "b.mp3"))
if f[tags.Title] != "Welcome to the Family" || f[tags.Track] != "2" || f[tags.TrackTotal] != "3" || f[tags.AlbumArtist] != "Avenged Sevenfold" || f[tags.Date] != "2010-07-26" {
t.Fatalf("tags not updated: %+v", f)
}
if f := ev.fields(t, filepath.Join(m, dir, "03 Danger Line.mp3")); f[tags.Title] != "Danger Line" || f[tags.Track] != "3" {
t.Fatalf("untitled song matched by position: %+v", f)
}
// The slave copy takes its tags from the fixed master, not MusicBrainz.
mkTagged(t, filepath.Join(s, "A7X/Nightmare/x.mp3"), "title", "Welcome to the family", "artist", "Avenged Sevenfold", "album", "Nightmare")
_, _, albums = ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepTags})
if len(albums) != 1 || albums[0].Source != SourceMaster || albums[0].Files[0].Changes[tags.Track][1] != "2" {
t.Fatalf("slave from master: %+v", albums)
}
// Choosing MusicBrainz instead.
a, err := ev.e.ChooseRelease(ctx, ev.pair.ID, albums[0].ID, "search")
if err != nil || a.Source != SourceMusicBrainz || a.Matched != 1 {
t.Fatalf("switch to MusicBrainz: %+v %v", a, err)
}
}
func TestFixerStepState(t *testing.T) {
ev := setup(t, false)
if err := ev.e.SetStep(ctx, ev.pair.ID, SideMaster, StepTags, "skipped", ""); err != nil {
t.Fatal(err)
}
if err := ev.e.SetStep(ctx, ev.pair.ID, "", StepLink, "done", ""); err != nil {
t.Fatal(err)
}
p, _ := ev.st.Pair(ctx, ev.pair.ID)
s := FixerStateOf(p)
if s.Master[StepTags].State != "skipped" || s.Link.State != "done" || s.Slave[StepTags].State != "" {
t.Fatalf("%+v", s)
}
for _, bad := range [][3]string{{"left", StepTags, "done"}, {SideMaster, "bogus", "done"}, {SideMaster, StepTags, "maybe"}} {
if ev.e.SetStep(ctx, ev.pair.ID, bad[0], bad[1], bad[2], "") == nil {
t.Errorf("%v should be refused", bad)
}
}
}
func TestCleanFolderName(t *testing.T) {
for in, want := range map[string]string{
"Nightmare (2010)": "Nightmare",
"Nightmare [FLAC]": "Nightmare",
"2010 - Nightmare": "Nightmare",
"Nightmare (2010) [24-96]": "Nightmare",
"Hail to the King (Deluxe)": "Hail to the King",
"(What's the Story) Morning Glory?": "(What's the Story) Morning Glory?",
"1989": "1989",
} {
if got := cleanFolderName(in); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
for in, want := range map[string]int{"CD2": 2, "Disc 01": 1, "disk_3": 3, "Disc 1 - The Beginning": 1, "Vol. 2": 0, "CDs": 0} {
if got := discNumber(in); got != want {
t.Errorf("disc %q: got %d, want %d", in, got, want)
}
}
}
// A promo single named after the album, with exactly as many tracks as the
// local (partial) album, must not beat the album itself.
func TestFixAlbumTagsPrefersTheAlbum(t *testing.T) {
ev := tagEnv(t, false)
const promo, album = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"
tracks := func(titles ...string) string {
var b []string
for i, t := range titles {
b = append(b, fmt.Sprintf(`{"position":%d,"title":%q}`, i+1, t))
}
return strings.Join(b, ",")
}
release := map[string]string{
promo: `{"id":"` + promo + `","title":"Nightmare","status":"Promotion","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},
"media":[{"position":1,"tracks":[` + tracks("Nightmare (album version) (explicit)", "Nightmare (radio edit)", "Nightmare (instrumental)") + `]}]}`,
album: `{"id":"` + album + `","title":"Nightmare","status":"Official","date":"2010-07-26","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},
"media":[{"position":1,"tracks":[` + tracks("Nightmare", "Welcome to the Family", "Danger Line", "Buried Alive", "Natural Born Killer") + `]}]}`,
}
mux := http.NewServeMux()
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
for id, body := range release {
if strings.HasSuffix(r.URL.Path, id) {
fmt.Fprint(w, body)
return
}
}
fmt.Fprint(w, `{"releases":[
{"id":"`+promo+`","score":100,"title":"Nightmare","status":"Promotion","track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},"media":[{"format":"CD-R","track-count":3}]},
{"id":"`+album+`","score":95,"title":"Nightmare","status":"Official","track-count":5,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":5}]}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := meta.New("test")
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
ev.e.SetMeta(c)
dir := filepath.Join(ev.master, "Avenged Sevenfold/Nightmare")
mkTagged(t, filepath.Join(dir, "01.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(dir, "02.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(dir, "03.mp3"), "title", "danger line", "artist", "Avenged Sevenfold", "album", "Nightmare")
_, _, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
if len(albums) != 1 || albums[0].Release == nil || albums[0].Release.ID != album || albums[0].Matched != 3 || albums[0].Confidence < 0.75 {
t.Fatalf("want the album: %+v", albums)
}
}
+30 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"path/filepath"
"reflect"
"regexp"
"strings"
"unicode"
@@ -79,6 +80,22 @@ func DefaultNaming() NamingConfig {
}
}
// Library types. Music is organized Artist/Album/01 Title; soundtracks (games,
// films) Album/01 Title, since their "artist" is often a composer or empty.
const (
LibraryMusic = "music"
LibrarySoundtracks = "soundtracks"
)
// DefaultNamingFor returns the recommended naming for a library type.
func DefaultNamingFor(libraryType string) NamingConfig {
c := DefaultNaming()
if libraryType == LibrarySoundtracks {
c.ArtistFolder = ""
}
return c
}
// SoundtrackFolder is the suggested folder rule for game and film soundtracks.
var SoundtrackFolder = FolderNaming{Folder: "Video Games", AlbumFolder: "{Album Title}",
TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}"}
@@ -103,7 +120,7 @@ var NamingPresets = []struct {
// NamingOf returns a pair's naming, converting the settings of earlier
// versions (one path pattern plus a disc style) when none is stored yet.
func NamingOf(p store.Pair) NamingConfig {
c := DefaultNaming()
c := DefaultNamingFor(p.LibraryType)
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" {
if stored, err := parseNaming(p.OrganizeConfig); err == nil {
return stored
@@ -139,7 +156,7 @@ func NamingOf(p store.Pair) NamingConfig {
// parseNaming reads stored settings. Fields missing from the JSON take the
// defaults; an artist folder that is present but empty stays empty.
func parseNaming(raw []byte) (NamingConfig, error) {
c := DefaultNaming()
c := DefaultNaming() // missing keys: only very old or hand-made settings
if err := json.Unmarshal(raw, &c); err != nil {
return c, err
}
@@ -147,6 +164,17 @@ func parseNaming(raw []byte) (NamingConfig, error) {
return c, nil
}
// ChangeLibraryType switches a pair's naming to the new type's recommended
// naming when it still uses the old type's, so custom naming is kept.
func ChangeLibraryType(old store.Pair, p *store.Pair) {
if old.ID == 0 || old.LibraryType == p.LibraryType {
return
}
if reflect.DeepEqual(NamingOf(*p), DefaultNamingFor(old.LibraryType)) {
p.OrganizeConfig = nil
}
}
func fill(c *NamingConfig, d NamingConfig) {
c.ArtistFolder = strings.TrimSpace(c.ArtistFolder)
if c.Folders == nil {
+18
View File
@@ -635,6 +635,17 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
e.log.Info("folder cover undone", "op", o.ID)
return nil
}
if o.Action == store.ActMoveDir {
if err := undoMoveDir(l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("folder move undone", "op", o.ID, "folder", o.RelNew)
_, _ = e.StartScan(o.PairID, "undo")
return nil
}
if o.Action == store.ActMove {
if err := undoMove(l, o); err != nil {
return err
@@ -879,6 +890,13 @@ func (e *Engine) Recover(ctx context.Context) {
return
}
for _, o := range ops {
if o.Action == store.ActMoveDir {
if l, err := e.loadPair(ctx, o.PairID); err == nil {
recoverMoveDir(l, o, e.log)
}
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
continue
}
if o.Action == store.ActMove {
if l, err := e.loadPair(ctx, o.PairID); err == nil {
recoverMove(l, o)
+273
View File
@@ -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()
}
+20 -8
View File
@@ -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
}
@@ -672,6 +683,7 @@ const (
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.
+4
View File
@@ -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.
+1 -1
View File
@@ -65,7 +65,7 @@ function Shell({ username }: { username: string }) {
];
let page;
if (route[0] === "pair" && route[1]) page = <PairPage id={Number(route[1])} tab={route[2] ?? "review"} />;
if (route[0] === "pair" && route[1]) page = <PairPage id={Number(route[1])} tab={route[2] ?? "fixer"} />;
else if (route[0] === "activity") page = <ActivityPage />;
else if (route[0] === "settings") page = <SettingsPage />;
else page = <Dashboard />;
+68
View File
@@ -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<Record<FixStep, StepStatus>>; slave: Partial<Record<FixStep, StepStatus>>; 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<string, [string, string]>;
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<string, [string, string]> }[];
album_changes: Record<string, [string, string]>;
error?: string;
};
export type Fixer = { state: FixerState; steps: FixStep[]; plan: FixPlan | null; items: FixItem[]; albums: AlbumFix[] };
export type ProbeInfo = {
codec: string;
codec_long?: string;
+864
View File
@@ -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<string, string> = {
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<Sel>(() => firstOpen(pair));
const fixer = useQuery({ queryKey: ["fixer", pair.id], queryFn: () => api.get<Fixer>(`/pairs/${pair.id}/fixer`) });
if (info.data && !info.data.tag_editing)
return <Alert tone="amber">The fixer reads and writes tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
const isSel = (s: Side, st: FixStep) => sel !== "link" && sel.side === s && sel.step === st;
return (
<div className="grid gap-5 lg:grid-cols-[16rem_1fr]">
<nav className="space-y-4">
<Card className="p-3 text-xs">
<div className="text-muted">{pair.library_type === "soundtracks" ? "Soundtrack library" : "Music library"}</div>
<div className="mono mt-1 text-gray-200">
{layoutOf(pair)
.split("/")
.map((part, i) => (
<span key={i} className="whitespace-nowrap">
{i > 0 && "/"}
{part}
<wbr />
</span>
))}
</div>
<a href={`#/pair/${pair.id}/organize`} className="mt-1 inline-block text-accent hover:underline">
Change the naming
</a>
</Card>
{SIDES.map((s, si) => (
<div key={s.id}>
<div className="mb-1.5 px-1 text-xs font-medium uppercase tracking-wide text-muted">
{si + 1}. {s.title}
</div>
<div className="space-y-0.5">
{STEPS.map((st) => (
<StepLink
key={st.id}
status={pair.fixer[s.id][st.id]}
active={isSel(s.id, st.id)}
onClick={() => setSel({ side: s.id, step: st.id })}
>
{st.title}
{st.optional && <span className="ml-1 text-muted">(optional)</span>}
</StepLink>
))}
</div>
</div>
))}
<div>
<div className="mb-1.5 px-1 text-xs font-medium uppercase tracking-wide text-muted">3. Both libraries</div>
<StepLink status={pair.fixer.link} active={sel === "link"} onClick={() => setSel("link")}>
Link the libraries
</StepLink>
<a href={`#/pair/${pair.id}/review`} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-300 hover:bg-panel-2">
<ArrowRight className="size-4 text-muted" /> Review differences
{pair.summary.review > 0 && <Badge tone="amber">{pair.summary.review}</Badge>}
</a>
</div>
</nav>
<div className="min-w-0">
{sel === "link" ? (
<LinkPanel pair={pair} />
) : (
<StepPanel
key={`${sel.side}-${sel.step}`}
pair={pair}
side={sel.side}
step={sel.step}
fixer={fixer.data}
onNext={() => setSel(firstOpen(pair))}
/>
)}
</div>
</div>
);
}
function StepLink({ status, active, onClick, children }: { status?: StepStatus; active: boolean; onClick: () => void; children: ReactNode }) {
const icon =
status?.state === "done" ? (
<Check className="size-4 text-emerald-400" />
) : status?.state === "skipped" ? (
<Minus className="size-4 text-muted" />
) : (
<CircleDashed className={cx("size-4", active ? "text-accent" : "text-muted")} />
);
return (
<button
onClick={onClick}
className={cx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm cursor-pointer",
active ? "bg-accent/15 text-white" : "text-gray-300 hover:bg-panel-2",
)}
>
{icon}
<span className="min-w-0 flex-1">{children}</span>
</button>
);
}
// ---- one step of one library ----
function stepText(step: FixStep, pair: Pair, side: Side): ReactNode {
const layout = <code className="mono text-gray-200">{layoutOf(pair)}</code>;
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 <span className="mono">FLAC</span> 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 (<code className="mono text-gray-200">{pair.naming.track_format}</code>). Albums with several discs get{" "}
<span className="mono">Disc 01</span>, <span className="mono">Disc 02</span> 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 (
<Card className="space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wide text-muted">{side === "master" ? "Master library" : "Slave library"}</div>
<h2 className="flex items-center gap-2 text-lg font-semibold">
<meta.icon className="size-5 text-accent" /> {meta.title}
{meta.optional && <Badge>optional</Badge>}
{status?.state === "done" && <Badge tone="green">done {formatAgo(status.at)}</Badge>}
{status?.state === "skipped" && <Badge>skipped</Badge>}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{finished(status) ? (
<Button size="sm" variant="ghost" onClick={() => mark.mutate("")}>
<RotateCcw className="size-3.5" /> Reopen
</Button>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => mark.mutate("skipped")} title="Leave this step out">
<SkipForward className="size-3.5" /> Skip
</Button>
{plan && !plan.applied && (
<Button size="sm" onClick={() => mark.mutate("done")}>
<Check className="size-3.5" /> Mark done
</Button>
)}
</>
)}
<Button
size="sm"
variant={plan && !plan.applied ? "secondary" : "primary"}
onClick={() => check.mutate()}
disabled={running}
loading={check.isPending}
>
<Search className="size-3.5" /> {plan ? "Check again" : `Check the ${side}`}
</Button>
</div>
</div>
<p className="text-sm text-muted">{stepText(step, pair, side)}</p>
{masterOpen && (
<Alert tone="blue">
The master library isn't finished yet. Fixing the master first is recommended: the slave's album tags and links are matched against it.
</Alert>
)}
{step === "tags" && (
<div className="grid gap-3 rounded-lg border border-line bg-panel-2/40 p-3 md:grid-cols-2">
<Field label="Only this folder" hint="Optional, e.g. Avenged Sevenfold. Empty: the whole library.">
<input className={inputCls + " mono text-xs"} value={folder} placeholder="(whole library)" onChange={(e) => setFolder(e.target.value)} />
</Field>
<div className="pt-6">
<Toggle checked={onlyIncomplete} onChange={setOnlyIncomplete} label="Skip albums whose tags are already complete" />
<p className="mt-1 pl-11.5 text-xs text-muted">Complete: every song has a title, track number, album and artist, and they agree.</p>
</div>
</div>
)}
{ourJob ? (
<JobBar job={pair.job!} />
) : !plan ? (
<div className="rounded-lg border border-dashed border-line p-6 text-center text-sm text-muted">
Check the {side} library to see what needs fixing. Nothing is changed by checking.
</div>
) : plan.applied ? (
<Alert tone="green">
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."}
</Alert>
) : step === "tags" ? (
<AlbumResults pair={pair} side={side} fixer={fixer!} onApplied={refresh} />
) : (
<ItemResults pair={pair} side={side} fixer={fixer!} onApplied={refresh} />
)}
</Card>
);
}
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 (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{stats.map(([label, n, tone]) => (
<div key={label} className="rounded-lg border border-line bg-panel-2/50 px-3 py-2">
<div className={cx("text-lg font-semibold tabular-nums", tone)}>{n}</div>
<div className="text-xs text-muted">{label}</div>
</div>
))}
</div>
{p.notices.map((n, i) => (
<Alert key={i} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{n}
</Alert>
))}
</div>
);
}
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<Set<number>>(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 (
<div className="sticky bottom-0 -mx-5 -mb-5 flex flex-wrap items-center gap-3 rounded-b-xl border-t border-line bg-panel/95 px-5 py-3 backdrop-blur">
<Button variant="primary" disabled={count === 0 || pair.job?.state === "running"} loading={apply.isPending} onClick={() => apply.mutate()}>
<Check className="size-4" /> Apply {count} {count === 1 ? "fix" : "fixes"}
</Button>
<span className="text-sm text-muted">
{count} of {total} selected{dry && " · dry run: changes are only recorded"}
</span>
</div>
);
}
// ---- 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<string | null>(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<string, FixItem[]>();
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 (
<div className="space-y-4">
<PlanStats fixer={fixer} />
{plan.fixable === 0 && plan.blocked === 0 && <Alert tone="green">Nothing to fix here. Mark the step done to continue.</Alert>}
{groups.length > 0 && (
<div className="overflow-hidden rounded-lg border border-line">
<div className="flex items-center gap-2 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted">
<input
type="checkbox"
className="accent-violet-500"
checked={fixable.every((i) => sel.has(i.id))}
onChange={(e) =>
toggle(
fixable.map((i) => i.id),
e.target.checked,
)
}
/>
Select all · grouped by where they end up
</div>
<div className="divide-y divide-line">
{groups.slice(0, shown).map(([g, items]) => (
<FixGroup key={g} group={g} items={items} sel={sel} toggle={toggle} />
))}
</div>
{groups.length > shown && (
<button
className="w-full border-t border-line py-2 text-xs text-accent hover:bg-panel-2 cursor-pointer"
onClick={() => setShown(shown + GROUP_PAGE)}
>
Show more ({groups.length - shown} folders left)
</button>
)}
</div>
)}
{attention.length > 0 && (
<div>
<h3 className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-amber-200">
<AlertTriangle className="size-4" /> Needs attention ({attention.length})
</h3>
<div className="divide-y divide-amber-500/10 overflow-hidden rounded-lg border border-amber-500/25 bg-amber-500/5">
{attention.slice(0, 200).map((it) => (
<div key={it.id} className="flex flex-wrap items-start gap-x-3 gap-y-1 px-3 py-2 text-xs">
<div className="min-w-0 flex-1">
<div className="mono break-all text-gray-100">{it.from}</div>
<div className="text-amber-200/90">
{it.why}
{it.blocked && <span className="text-amber-200/60"> · {it.blocked}</span>}
</div>
</div>
{it.from && (
<Button size="sm" variant="ghost" onClick={() => setAlbum(it.kind === "move-file" ? it.group : it.group || it.from!)}>
<Pencil className="size-3.5" /> Edit album
</Button>
)}
</div>
))}
{attention.length > 200 && <div className="px-3 py-2 text-xs text-muted">and {attention.length - 200} more</div>}
</div>
</div>
)}
{fixable.length > 0 && (
<ApplyBar
pair={pair}
plan={plan.id}
count={fixable.filter((i) => sel.has(i.id)).length}
total={fixable.length}
ids={fixable.filter((i) => sel.has(i.id)).map((i) => i.id)}
onApplied={onApplied}
/>
)}
{album !== null && <AlbumEditor pairId={pair.id} side={side} folder={album} onClose={() => setAlbum(null)} />}
</div>
);
}
function FixGroup({
group,
items,
sel,
toggle,
}: {
group: string;
items: FixItem[];
sel: Set<number>;
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 (
<div>
<div className="flex items-center gap-2 px-3 py-2">
<input type="checkbox" className="accent-violet-500" checked={n === ids.length} onChange={(e) => toggle(ids, e.target.checked)} />
<button className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-sm cursor-pointer" onClick={() => setOpen(!open)}>
{open ? <ChevronDown className="size-3.5 shrink-0 text-muted" /> : <ChevronRight className="size-3.5 shrink-0 text-muted" />}
<span className="mono truncate text-gray-100">{group || "(library root)"}</span>
<span className="shrink-0 text-xs text-muted">
{items.length} {items.length === 1 ? "fix" : "fixes"}
{n !== ids.length && ` · ${n} selected`}
</span>
{items.some((i) => i.unsure) && <Badge tone="amber">check</Badge>}
</button>
</div>
{(open || single) && (
<div className="space-y-1 pb-2 pl-9 pr-3">
{items.map((it) => (
<FixRow key={it.id} it={it} checked={sel.has(it.id)} onChange={(on) => toggle([it.id], on)} />
))}
</div>
)}
</div>
);
}
function FixRow({ it, checked, onChange }: { it: FixItem; checked: boolean; onChange: (on: boolean) => void }) {
return (
<label className="flex items-start gap-2 rounded-md px-2 py-1 text-xs hover:bg-panel-2 cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<div className="min-w-0 flex-1">
{it.kind === "tags" ? (
<>
<div className="mono break-all text-gray-300">{it.from}</div>
<Changes changes={it.changes ?? {}} />
</>
) : (
<div className="flex flex-wrap items-center gap-x-1.5 mono break-all">
<span className="text-gray-400">{it.from}</span>
<ArrowRight className="size-3 shrink-0 text-muted" />
<span className="text-gray-100">{it.to}</span>
{it.kind === "move-folder" && <Badge>folder</Badge>}
</div>
)}
<div className="text-muted">
{it.why}
{it.unsure && <span className="text-amber-300"> · not selected: check it first</span>}
</div>
</div>
</label>
);
}
function Changes({ changes }: { changes: Record<string, [string, string]> }) {
const keys = Object.keys(changes).sort((a, b) => Object.keys(FIELD_LABEL).indexOf(a) - Object.keys(FIELD_LABEL).indexOf(b));
return (
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{keys.map((k) => (
<span key={k}>
<span className="text-muted">{FIELD_LABEL[k] ?? k}: </span>
{changes[k][0] ? <span className="text-red-300/80 line-through">{changes[k][0]}</span> : <span className="text-muted">(empty)</span>}
<span className="text-muted"> → </span>
<span className="text-emerald-300">{changes[k][1]}</span>
</span>
))}
</div>
);
}
// ---- 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<string | null>(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 (
<div className="space-y-4">
<PlanStats fixer={fixer} />
{fixer.albums.length === 0 && <Alert tone="green">Every album checked already matches. Mark the step done to continue.</Alert>}
<div className="space-y-2">
{ordered.slice(0, shown).map((a) => (
<AlbumCard
key={a.id}
pair={pair}
a={a}
blocked={blocked.has(a.id)}
checked={sel.has(a.id)}
onCheck={(on) => toggle([a.id], on)}
onEdit={() => setAlbum(a.folder)}
/>
))}
{ordered.length > shown && (
<Button size="sm" variant="ghost" onClick={() => setShown(shown + 40)}>
Show more ({ordered.length - shown} albums left)
</Button>
)}
</div>
{usable.length > 0 && (
<ApplyBar
pair={pair}
plan={fixer.plan!.id}
count={usable.filter((a) => sel.has(a.id)).length}
total={usable.length}
ids={usable.filter((a) => sel.has(a.id)).map((a) => a.id)}
onApplied={onApplied}
/>
)}
{album !== null && <AlbumEditor pairId={pair.id} side={side} folder={album} onClose={() => setAlbum(null)} />}
</div>
);
}
function Confidence({ c }: { c: number }) {
if (c >= 0.85) return <Badge tone="green">good match</Badge>;
if (c >= 0.6) return <Badge tone="amber">check</Badge>;
return <Badge tone="red">weak match</Badge>;
}
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<AlbumFix>(`/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 (
<div className={cx("rounded-lg border", blocked ? "border-amber-500/25 bg-amber-500/5" : "border-line")}>
<div className="flex flex-wrap items-start gap-3 px-3 py-2.5">
<input
type="checkbox"
className="mt-1 accent-violet-500"
disabled={blocked}
checked={checked && !blocked}
onChange={(e) => onCheck(e.target.checked)}
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="mono break-all text-sm text-gray-100">{a.folder}</div>
{r && !blocked ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className="text-gray-200">
{r.artist} – {r.title}
</span>
<span className="text-muted">
{[r.date, r.country, r.formats, r.track_count ? `${r.track_count} tracks` : ""].filter(Boolean).join(" · ")}
</span>
{a.source === "master" ? <Badge tone="violet">from master</Badge> : <Badge>MusicBrainz</Badge>}
<Confidence c={a.confidence} />
<span className="text-muted">
{a.matched} of {a.files.length} songs matched · {changed} change
</span>
</div>
) : (
<div className="text-xs text-amber-200">{a.error || "No confident match"}</div>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{a.source === "master" ? (
<Button size="sm" variant="ghost" loading={choose.isPending} onClick={() => choose.mutate("search")}>
<Search className="size-3.5" /> Use MusicBrainz
</Button>
) : (
a.candidates.length > 0 && (
<select
className={inputCls + " h-8 w-56 text-xs"}
value={r?.id ?? ""}
disabled={choose.isPending}
onChange={(e) => e.target.value && choose.mutate(e.target.value)}
title="Pick another release"
>
<option value="">{r ? "Other releases…" : "Pick a release…"}</option>
{a.candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.artist} – {c.title} ({[c.date?.slice(0, 4), c.country, c.formats, `${c.track_count} tr`].filter(Boolean).join(", ")})
</option>
))}
</select>
)
)}
{choose.isPending && <Spinner className="size-4" />}
<Button size="sm" variant="ghost" onClick={onEdit} title="Edit the album's tags by hand">
<Pencil className="size-3.5" /> Edit album
</Button>
{!blocked && (
<Button size="sm" variant="ghost" onClick={() => setOpen(!open)}>
{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Changes
</Button>
)}
</div>
</div>
{open && !blocked && (
<div className="border-t border-line px-3 py-2">
{Object.keys(a.album_changes).length > 0 && (
<div className="mb-2 text-xs">
<span className="mr-2 text-muted">Every song:</span>
<Changes changes={a.album_changes} />
</div>
)}
<table className="w-full text-xs">
<thead className="text-left text-muted">
<tr>
<th className="py-1 pr-3 font-normal">File</th>
<th className="py-1 pr-3 font-normal">Matched track</th>
<th className="py-1 font-normal">Changes</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{a.files.map((f) => {
const own = Object.fromEntries(Object.entries(f.changes).filter(([k]) => !(k in a.album_changes)));
return (
<tr key={f.path} className="align-top">
<td className="mono break-all py-1 pr-3 text-gray-300">{f.path.slice(a.folder.length + 1)}</td>
<td className="py-1 pr-3 text-gray-300">{f.track ?? <span className="text-amber-300">not matched</span>}</td>
<td className="py-1">{Object.keys(own).length ? <Changes changes={own} /> : <span className="text-muted">—</span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
// ---- 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) => (
<div className="rounded-lg border border-line bg-panel-2/50 px-3 py-2">
<div className={cx("text-lg font-semibold tabular-nums", tone)}>{n ?? 0}</div>
<div className="text-xs text-muted">{label}</div>
</div>
);
return (
<Card className="space-y-5 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-wide text-muted">Both libraries</div>
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Link2 className="size-5 text-accent" /> Link the libraries
{pair.fixer.link?.state === "done" && <Badge tone="green">done {formatAgo(pair.fixer.link.at)}</Badge>}
</h2>
</div>
{pair.fixer.link?.state === "done" ? (
<Button size="sm" variant="ghost" onClick={() => mark.mutate("")}>
<RotateCcw className="size-3.5" /> Reopen
</Button>
) : (
<Button size="sm" onClick={() => mark.mutate("done")}>
<Check className="size-3.5" /> Mark done
</Button>
)}
</div>
<p className="text-sm text-muted">
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.
</p>
{open.length > 0 && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
Not finished yet: {open.join(", ")}. Linking works anyway, but songs in folders that are still named differently won't match.
</Alert>
)}
<section className="space-y-2">
<h3 className="text-sm font-medium">1. Scan both libraries</h3>
<div className="flex flex-wrap items-center gap-3 text-sm">
<ScanButton pair={pair} />
<span className="text-muted">Last scan {formatAgo(pair.last_scan_at)}</span>
</div>
{running && pair.job && <JobBar job={pair.job} />}
</section>
{pair.last_scan_warnings.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium">2. Folders named differently</h3>
<WarningList warnings={pair.last_scan_warnings} pair={pair} />
</section>
)}
<section className="space-y-2">
<h3 className="text-sm font-medium">{pair.last_scan_warnings.length > 0 ? "3" : "2"}. Link identical songs</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{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)}
</div>
{confirm ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-3 py-2 text-sm">
Replace {c.identical ?? 0} slave songs with hardlinks to the identical master songs? Each is compared byte by byte first.
<Button size="sm" variant="primary" loading={link.isPending} onClick={() => link.mutate()}>
Link them
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="primary" disabled={!c.identical || running} onClick={() => setConfirm(true)}>
<Link2 className="size-4" /> Link {c.identical ?? 0} identical songs
</Button>
)}
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium">{pair.last_scan_warnings.length > 0 ? "4" : "3"}. Review the differences</h3>
<p className="text-sm text-muted">Upgrades, other formats and approximate matches are reviewed one album or song at a time.</p>
<a href={`#/pair/${pair.id}/review`}>
<Button>
Open Review <ArrowRight className="size-4" />
{pair.summary.review > 0 && <Badge tone="amber">{pair.summary.review}</Badge>}
</Button>
</a>
</section>
</Card>
);
}
+29 -3
View File
@@ -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<PairInput>(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 extends keyof PairInput>(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;
<Field label="Name">
<input className={inputCls} value={f.name} onChange={(e) => set("name", e.target.value)} />
</Field>
<Field label="Library type" hint="Sets the recommended folder layout; you can change the naming later in Organize.">
<div className="grid gap-2 sm:grid-cols-2">
{LIBRARY_TYPES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => set("library_type", t.id)}
className={cx(
"rounded-lg border p-3 text-left text-sm cursor-pointer",
f.library_type === t.id ? "border-accent bg-accent/10" : "border-line bg-panel-2 hover:border-accent/60",
)}
>
<div className="font-medium">{t.label}</div>
<div className="mono mt-0.5 text-xs text-gray-300">{t.layout}</div>
<div className="mt-1 text-xs text-muted">{t.hint}</div>
</button>
))}
</div>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<PathField
label="Master library (keeps the files)"
+4
View File
@@ -12,6 +12,7 @@ import { OpsTable } from "./Activity";
import { WarningList } from "./Warnings";
import { TagsTab } from "./Tags";
import { OrganizeTab } from "./Organize";
import { FixerTab } from "./Fixer";
export function usePair(id: number) {
return useQuery({
@@ -41,6 +42,7 @@ function useJobFinished(pair: Pair | undefined) {
qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["files"] });
qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
const j = pair.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`);
@@ -123,6 +125,7 @@ function LastJob({ job }: { job: Job }) {
}
const tabs = [
{ id: "fixer", label: "Fixer" },
{ id: "review", label: "Review" },
{ id: "build", label: "Build" },
{ id: "tags", label: "Tags" },
@@ -205,6 +208,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
))}
</div>
{tab === "fixer" && <FixerTab pair={pair} />}
{tab === "review" && <Review pair={pair} />}
{tab === "build" && <Build pair={pair} />}
{tab === "tags" && <TagsTab pair={pair} />}