Add the step-by-step fixer and library types

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

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

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
package api
import (
"net/http"
"github.com/cdnutter/playlink/internal/engine"
)
// ---- step-by-step fixer ----
func (s *Server) handleFixer(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
p, err := s.st.Pair(r.Context(), id)
if err != nil {
opErr(w, err)
return
}
out := map[string]any{"state": engine.FixerStateOf(p), "steps": engine.FixerSteps, "plan": nil,
"items": []engine.FixItem{}, "albums": []*engine.AlbumFix{}}
if plan, items, albums := s.eng.FixerPlan(id); plan != nil {
out["plan"], out["items"], out["albums"] = plan, items, albums
}
writeJSON(w, 200, out)
}
func (s *Server) handleFixCheck(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body engine.FixCheck
if !readJSON(w, r, &body) {
return
}
j, err := s.eng.StartFixCheck(id, body)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
func (s *Server) handleFixApply(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body engine.FixApply
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "fixer") {
return
}
j, err := s.eng.StartFixApply(id, body)
if err != nil {
opErr(w, err)
return
}
s.log.Info("fixer apply started", "pair", id, "selected", len(body.IDs))
writeJSON(w, 202, j)
}
func (s *Server) handleFixStep(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Side string `json:"side"` // master, slave, or "" for the link step
Step string `json:"step"`
State string `json:"state"` // done, skipped or "" (to do)
}
if !readJSON(w, r, &body) {
return
}
if err := s.eng.SetStep(r.Context(), id, body.Side, body.Step, body.State, ""); err != nil {
opErr(w, err)
return
}
s.handleFixer(w, r)
}
func (s *Server) handleFixRelease(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Album int `json:"album"`
Release string `json:"release"` // a MusicBrainz release id, or "search"
}
if !readJSON(w, r, &body) {
return
}
if j := s.eng.Job(id); j != nil && j.State == "running" {
writeErr(w, 409, engine.ErrBusy)
return
}
a, err := s.eng.ChooseRelease(r.Context(), id, body.Album, body.Release)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, a)
}
+13 -1
View File
@@ -122,6 +122,11 @@ func (s *Server) Handler() http.Handler {
r.Get("/renames", s.handleRenamePreview)
r.Post("/renames", s.handleRenames)
r.Post("/link-identical", s.handleLinkIdentical)
r.Get("/fixer", s.handleFixer)
r.Post("/fixer/check", s.handleFixCheck)
r.Post("/fixer/apply", s.handleFixApply)
r.Put("/fixer/step", s.handleFixStep)
r.Post("/fixer/release", s.handleFixRelease)
r.Get("/tree", s.handleTree)
r.Post("/build", s.handleBuild)
r.Post("/remove", s.handleRemove)
@@ -322,13 +327,14 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
type pairView struct {
store.Pair
Naming engine.NamingConfig `json:"naming"`
Fixer engine.FixerState `json:"fixer"`
Summary store.Summary `json:"summary"`
Job *engine.Job `json:"job"`
}
func (s *Server) view(r *http.Request, p store.Pair) pairView {
sum, _ := s.st.Summary(r.Context(), p.ID)
return pairView{Pair: p, Naming: engine.NamingOf(p), Summary: sum, Job: s.eng.Job(p.ID)}
return pairView{Pair: p, Naming: engine.NamingOf(p), Fixer: engine.FixerStateOf(p), Summary: sum, Job: s.eng.Job(p.ID)}
}
func (s *Server) handleListPairs(w http.ResponseWriter, r *http.Request) {
@@ -367,6 +373,7 @@ type pairInput struct {
ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"`
AutoOrganize string `json:"auto_organize"`
LibraryType string `json:"library_type"`
// Naming is optional: when absent, the pair keeps its current naming.
Naming *engine.NamingConfig `json:"naming,omitempty"`
}
@@ -377,6 +384,9 @@ func (in pairInput) apply(p *store.Pair) {
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
p.AutoOrganize = in.AutoOrganize
if in.LibraryType != "" {
p.LibraryType = in.LibraryType
}
if in.Naming != nil {
p.OrganizeConfig, _ = json.Marshal(in.Naming)
}
@@ -420,6 +430,8 @@ func (s *Server) handleSavePair(w http.ResponseWriter, r *http.Request) {
}
}
in.apply(&p)
engine.ChangeLibraryType(old, &p)
engine.ChangeLibraryType(old, &p)
warns, err := s.eng.NormalizePair(&p)
if err != nil {
writeErr(w, 400, err)