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) } }