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

+384
View File
@@ -0,0 +1,384 @@
package engine
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// fixCheck runs a check and returns its plan and items.
func (ev *env) fixCheck(t *testing.T, c FixCheck) (*FixPlan, []FixItem, []*AlbumFix) {
t.Helper()
if _, err := ev.e.StartFixCheck(ev.pair.ID, c); err != nil {
t.Fatal(err)
}
ev.wait()
p, items, albums := ev.e.FixerPlan(ev.pair.ID)
if p == nil {
t.Fatal("no plan")
}
return p, items, albums
}
// fixAll applies every fix that isn't blocked (unsure ones included).
func (ev *env) fixAll(t *testing.T, items []FixItem) *Job {
t.Helper()
var ids []int
for _, it := range items {
if it.Blocked == "" && it.Kind != FixProblem {
ids = append(ids, it.ID)
}
}
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: ids}); err != nil {
t.Fatal(err)
}
return ev.wait()
}
func byFrom(items []FixItem) map[string]FixItem {
m := map[string]FixItem{}
for _, it := range items {
if _, ok := m[it.From]; !ok || it.Kind != FixTags {
m[it.From] = it
}
}
return m
}
func (ev *env) fields(t *testing.T, p string) map[string]string {
t.Helper()
f, err := ev.e.tagTools.Read(ctx, p)
if err != nil {
t.Fatal(err)
}
return f.Fields
}
func TestFixStructureMusic(t *testing.T) {
ev := tagEnv(t, false)
s := ev.slave
mkTagged(t, filepath.Join(s, "Loose.mp3"), "title", "Loose", "artist", "A", "album", "B")
mkTagged(t, filepath.Join(s, "Nightmare/01 Nightmare.flac"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
os.WriteFile(filepath.Join(s, "Nightmare/cover.jpg"), []byte("art"), 0o644)
mkTagged(t, filepath.Join(s, "Band/song.mp3"), "title", "Song", "artist", "Band", "album", "Record")
mkTagged(t, filepath.Join(s, "X/Y/FLAC/01 a.flac"), "title", "a", "artist", "X", "album", "Y")
mkTagged(t, filepath.Join(s, "Metal/Artist2/Album2/01 s.mp3"), "title", "s", "artist", "Artist2", "album", "Album2")
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD1/01 t.mp3"), "title", "t", "artist", "Artist3", "album", "Album3", "track", "1")
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3"), "title", "u", "artist", "Artist3", "album", "Album3", "track", "1")
mkTagged(t, filepath.Join(s, "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"))
mkTagged(t, filepath.Join(s, "NoTags.mp3"))
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
m := byFrom(items)
want := map[string]string{
"Loose.mp3": "A/B/Loose.mp3",
"Nightmare": "Avenged Sevenfold/Nightmare",
"Band/song.mp3": "Band/Record/song.mp3",
"X/Y/FLAC/01 a.flac": "X/Y/01 a.flac",
"Metal/Artist2/Album2": "Artist2/Album2",
"Artist3/Album3/CD2/01 u.mp3": "",
}
for from, to := range want {
it, ok := m[from]
if !ok || it.To != to || it.Blocked != "" {
t.Errorf("%s: got %+v, want to %q", from, it, to)
}
}
if it := m["Artist3/Album3/CD2/01 u.mp3"]; it.Changes[tags.Disc][1] != "2" || it.Changes[tags.DiscTotal][1] != "2" {
t.Errorf("disc tags from the disc folder: %+v", it.Changes)
}
fill := m["Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"].Changes
if fill[tags.Album][1] != "Album4" || fill[tags.Artist][1] != "Artist4" || fill[tags.Title][1] != "Song Name" || fill[tags.Track][1] != "5" {
t.Errorf("tags from names: %+v", fill)
}
if it := m["NoTags.mp3"]; it.Blocked == "" {
t.Errorf("a loose song without tags can't be placed: %+v", it)
}
if plan.Blocked != 1 {
t.Errorf("blocked %d", plan.Blocked)
}
j := ev.fixAll(t, items)
if j.Results["skipped"] != 0 {
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
}
for _, p := range []string{"A/B/Loose.mp3", "Avenged Sevenfold/Nightmare/01 Nightmare.flac", "Avenged Sevenfold/Nightmare/cover.jpg",
"Band/Record/song.mp3", "X/Y/01 a.flac", "Artist2/Album2/01 s.mp3", "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"} {
if !exists(filepath.Join(s, p)) {
t.Errorf("missing %s", p)
}
}
for _, p := range []string{"Nightmare", "X/Y/FLAC", "Metal"} {
if exists(filepath.Join(s, p)) {
t.Errorf("%s should be gone", p)
}
}
if f := ev.fields(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3")); f[tags.Disc] != "2" || f[tags.DiscTotal] != "2" {
t.Errorf("disc tags not written: %+v", f)
}
// A folder move can be undone.
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
for _, o := range ops {
if o.Action == store.ActMoveDir && o.RelNew == "Avenged Sevenfold/Nightmare" {
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
}
}
waitIdle(ev)
if !exists(filepath.Join(s, "Nightmare/cover.jpg")) || exists(filepath.Join(s, "Avenged Sevenfold")) {
t.Fatal("undo should move the folder back and remove the empty artist folder")
}
// Not everything was fixable, so the step isn't marked done.
p, _ := ev.st.Pair(ctx, ev.pair.ID)
if FixerStateOf(p).Slave[StepStructure].State != "" {
t.Fatal("step marked done although a song still needs attention")
}
}
func waitIdle(ev *env) {
for i := 0; i < 500; i++ {
if j := ev.e.Job(ev.pair.ID); j == nil || j.State != "running" {
return
}
time.Sleep(10 * time.Millisecond)
}
}
func TestFixStructureSoundtracks(t *testing.T) {
ev := tagEnv(t, false)
ev.pair.LibraryType = LibrarySoundtracks
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
s := ev.slave
mkTagged(t, filepath.Join(s, "Nobuo Uematsu/Final Fantasy VII/01 Prelude.flac"), "title", "Prelude", "album", "Final Fantasy VII")
for _, g := range []string{"Halo 3", "Super Mario Galaxy", "Portal 2"} {
mkTagged(t, filepath.Join(s, "Video Games", g, "01 x.flac"), "title", "x", "album", g)
}
mkTagged(t, filepath.Join(s, "Journey/01 Nascence.flac"), "title", "Nascence", "album", "Journey")
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
m := byFrom(items)
if it := m["Nobuo Uematsu/Final Fantasy VII"]; it.To != "Final Fantasy VII" || it.Unsure || !strings.Contains(it.Why, "Soundtrack") {
t.Errorf("composer folder: %+v", it)
}
if it := m["Video Games/Halo 3"]; it.To != "Halo 3" || !it.Unsure {
t.Errorf("a category folder's albums should be listed but not selected: %+v", it)
}
if len(plan.Notices) != 1 || !strings.Contains(plan.Notices[0], "Video Games (3 albums)") {
t.Errorf("notices %v", plan.Notices)
}
if _, ok := m["Journey"]; ok {
t.Error("a soundtrack at the top of the library is fine")
}
}
func TestFixFoldersAndFiles(t *testing.T) {
ev := tagEnv(t, false)
m := ev.master
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/a.mp3"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/b.mp3"), "title", "Welcome to the Family", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "2")
mkTagged(t, filepath.Join(m, "Mix/1.mp3"), "title", "One", "artist", "A", "album", "First")
mkTagged(t, filepath.Join(m, "Mix/2.mp3"), "title", "Two", "artist", "B", "album", "Second")
_, items, _ := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFolders})
byF := byFrom(items)
if it := byF["avenged sevenfold/nightmare (2010)"]; it.Kind != FixMoveFolder || it.To != "Avenged Sevenfold/Nightmare" {
t.Fatalf("folder rename: %+v", it)
}
if it := byF["Mix"]; it.Kind != FixProblem || !strings.Contains(it.Why, "2 different album folders") {
t.Fatalf("mixed folder: %+v", it)
}
ev.fixAll(t, items)
if !exists(filepath.Join(m, "Avenged Sevenfold/Nightmare/a.mp3")) {
t.Fatal("folder not renamed")
}
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFolders].State != "" {
t.Fatal("a folder that needs attention keeps the step open")
}
_, items, _ = ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFiles})
ev.fixAll(t, items)
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFiles].State != "done" {
t.Fatal("applying every fix marks the step done")
}
for _, p := range []string{"Avenged Sevenfold/Nightmare/01 Nightmare.mp3", "Avenged Sevenfold/Nightmare/02 Welcome to the Family.mp3", "A/First/One.mp3", "B/Second/Two.mp3"} {
if !exists(filepath.Join(m, p)) {
t.Errorf("missing %s", p)
}
}
// Applying the same plan twice is refused: paths changed.
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: []int{1}}); err == nil {
t.Fatal("a stale plan must not be applied again")
}
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID - 1, IDs: []int{1}}); err == nil {
t.Fatal("a stale plan must not be applied again")
}
}
// fakeMB serves one release: Avenged Sevenfold - Nightmare, 3 tracks.
func fakeMB(t *testing.T) *meta.Client {
const id = "85cb4213-ffc2-38b1-a256-bd6e5ce2e0af"
mux := http.NewServeMux()
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, id) {
fmt.Fprint(w, `{"id":"`+id+`","title":"Nightmare","date":"2010-07-26","status":"Official","artist-credit":[{"name":"Avenged Sevenfold"}],
"release-group":{"primary-type":"Album"},"media":[{"position":1,"format":"CD","track-count":3,"tracks":[
{"position":1,"title":"Nightmare","length":374000},{"position":2,"title":"Welcome to the Family","length":245000},
{"position":3,"title":"Danger Line","length":329000}]}]}`)
return
}
if !strings.Contains(r.URL.Query().Get("query"), "Nightmare") {
fmt.Fprint(w, `{"releases":[]}`)
return
}
fmt.Fprint(w, `{"releases":[{"id":"`+id+`","score":100,"title":"Nightmare","status":"Official","date":"2010-07-26",
"track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":3}]}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := meta.New("test")
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
return c
}
func TestFixAlbumTags(t *testing.T) {
ev := tagEnv(t, false)
ev.e.SetMeta(fakeMB(t))
m, s := ev.master, ev.slave
dir := "Avenged Sevenfold/Nightmare"
// Titles are slightly off and numbers are missing; one song has no title.
mkTagged(t, filepath.Join(m, dir, "a.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, dir, "b.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, dir, "03 Danger Line.mp3"), "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, "Nobody/Unknown/x.mp3"), "title", "x", "artist", "Nobody", "album", "Unknown")
plan, items, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
if len(albums) != 2 || plan.Fixable != 1 || plan.Blocked != 1 {
t.Fatalf("plan %+v albums %d", plan, len(albums))
}
var nm *AlbumFix
for _, a := range albums {
if a.Folder == dir {
nm = a
}
}
if nm == nil || nm.Matched != 3 || nm.Release.Title != "Nightmare" || nm.Confidence < 0.9 {
t.Fatalf("album fix %+v", nm)
}
ev.fixAll(t, items)
f := ev.fields(t, filepath.Join(m, dir, "b.mp3"))
if f[tags.Title] != "Welcome to the Family" || f[tags.Track] != "2" || f[tags.TrackTotal] != "3" || f[tags.AlbumArtist] != "Avenged Sevenfold" || f[tags.Date] != "2010-07-26" {
t.Fatalf("tags not updated: %+v", f)
}
if f := ev.fields(t, filepath.Join(m, dir, "03 Danger Line.mp3")); f[tags.Title] != "Danger Line" || f[tags.Track] != "3" {
t.Fatalf("untitled song matched by position: %+v", f)
}
// The slave copy takes its tags from the fixed master, not MusicBrainz.
mkTagged(t, filepath.Join(s, "A7X/Nightmare/x.mp3"), "title", "Welcome to the family", "artist", "Avenged Sevenfold", "album", "Nightmare")
_, _, albums = ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepTags})
if len(albums) != 1 || albums[0].Source != SourceMaster || albums[0].Files[0].Changes[tags.Track][1] != "2" {
t.Fatalf("slave from master: %+v", albums)
}
// Choosing MusicBrainz instead.
a, err := ev.e.ChooseRelease(ctx, ev.pair.ID, albums[0].ID, "search")
if err != nil || a.Source != SourceMusicBrainz || a.Matched != 1 {
t.Fatalf("switch to MusicBrainz: %+v %v", a, err)
}
}
func TestFixerStepState(t *testing.T) {
ev := setup(t, false)
if err := ev.e.SetStep(ctx, ev.pair.ID, SideMaster, StepTags, "skipped", ""); err != nil {
t.Fatal(err)
}
if err := ev.e.SetStep(ctx, ev.pair.ID, "", StepLink, "done", ""); err != nil {
t.Fatal(err)
}
p, _ := ev.st.Pair(ctx, ev.pair.ID)
s := FixerStateOf(p)
if s.Master[StepTags].State != "skipped" || s.Link.State != "done" || s.Slave[StepTags].State != "" {
t.Fatalf("%+v", s)
}
for _, bad := range [][3]string{{"left", StepTags, "done"}, {SideMaster, "bogus", "done"}, {SideMaster, StepTags, "maybe"}} {
if ev.e.SetStep(ctx, ev.pair.ID, bad[0], bad[1], bad[2], "") == nil {
t.Errorf("%v should be refused", bad)
}
}
}
func TestCleanFolderName(t *testing.T) {
for in, want := range map[string]string{
"Nightmare (2010)": "Nightmare",
"Nightmare [FLAC]": "Nightmare",
"2010 - Nightmare": "Nightmare",
"Nightmare (2010) [24-96]": "Nightmare",
"Hail to the King (Deluxe)": "Hail to the King",
"(What's the Story) Morning Glory?": "(What's the Story) Morning Glory?",
"1989": "1989",
} {
if got := cleanFolderName(in); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
for in, want := range map[string]int{"CD2": 2, "Disc 01": 1, "disk_3": 3, "Disc 1 - The Beginning": 1, "Vol. 2": 0, "CDs": 0} {
if got := discNumber(in); got != want {
t.Errorf("disc %q: got %d, want %d", in, got, want)
}
}
}
// A promo single named after the album, with exactly as many tracks as the
// local (partial) album, must not beat the album itself.
func TestFixAlbumTagsPrefersTheAlbum(t *testing.T) {
ev := tagEnv(t, false)
const promo, album = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"
tracks := func(titles ...string) string {
var b []string
for i, t := range titles {
b = append(b, fmt.Sprintf(`{"position":%d,"title":%q}`, i+1, t))
}
return strings.Join(b, ",")
}
release := map[string]string{
promo: `{"id":"` + promo + `","title":"Nightmare","status":"Promotion","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},
"media":[{"position":1,"tracks":[` + tracks("Nightmare (album version) (explicit)", "Nightmare (radio edit)", "Nightmare (instrumental)") + `]}]}`,
album: `{"id":"` + album + `","title":"Nightmare","status":"Official","date":"2010-07-26","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},
"media":[{"position":1,"tracks":[` + tracks("Nightmare", "Welcome to the Family", "Danger Line", "Buried Alive", "Natural Born Killer") + `]}]}`,
}
mux := http.NewServeMux()
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
for id, body := range release {
if strings.HasSuffix(r.URL.Path, id) {
fmt.Fprint(w, body)
return
}
}
fmt.Fprint(w, `{"releases":[
{"id":"`+promo+`","score":100,"title":"Nightmare","status":"Promotion","track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},"media":[{"format":"CD-R","track-count":3}]},
{"id":"`+album+`","score":95,"title":"Nightmare","status":"Official","track-count":5,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":5}]}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := meta.New("test")
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
ev.e.SetMeta(c)
dir := filepath.Join(ev.master, "Avenged Sevenfold/Nightmare")
mkTagged(t, filepath.Join(dir, "01.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(dir, "02.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(dir, "03.mp3"), "title", "danger line", "artist", "Avenged Sevenfold", "album", "Nightmare")
_, _, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
if len(albums) != 1 || albums[0].Release == nil || albums[0].Release.ID != album || albums[0].Matched != 3 || albums[0].Confidence < 0.75 {
t.Fatalf("want the album: %+v", albums)
}
}