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