package engine import ( "encoding/json" "errors" "path/filepath" "testing" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" ) // setVerdict simulates the quality comparison (tests run without ffprobe). func setVerdict(t *testing.T, ev *env, rel, better string) { t.Helper() m := ev.match(rel) m.Verdict, _ = json.Marshal(probe.Verdict{Better: better}) if err := ev.st.UpsertMatch(ctx, &m); err != nil { t.Fatal(err) } } // An album with every kind of song: the bulk plan must treat each correctly. func bulkAlbum(t *testing.T, ev *env) (album string, ids []int64) { album = "Avenged Sevenfold/Nightmare" ev.write(ev.master, album+"/01 Nightmare.flac", "same") ev.write(ev.slave, album+"/01 Nightmare.flac", "same") // identical ev.write(ev.master, album+"/02 Welcome to the Family.flac", "flac") ev.write(ev.slave, album+"/02 Welcome to the Family.flac", "mp3!!") // different, master better ev.write(ev.master, album+"/Disc 2/03 Danger Line.mp3", "lossy") ev.write(ev.slave, album+"/Disc 2/03 Danger Line.mp3", "lossless") // different, slave better ev.write(ev.slave, album+"/04 Slave Only.mp3", "only") // unmatched ev.write(ev.master, album+"/05 Ignored.flac", "a") ev.write(ev.slave, album+"/05 Ignored.flac", "b") // different, ignored ev.scan() setVerdict(t, ev, album+"/02 Welcome to the Family.flac", "master") setVerdict(t, ev, album+"/Disc 2/03 Danger Line.mp3", "slave") if err := ev.st.SetIgnored(ctx, ev.match(album+"/05 Ignored.flac").ID, true); err != nil { t.Fatal(err) } groups, err := ev.e.Groups(ctx, ev.pair.ID, store.MatchFilter{}) if err != nil { t.Fatal(err) } if len(groups) != 1 || groups[0].Folder != album || groups[0].Songs != 5 { t.Fatalf("disc folders should fold into one album group, got %+v", groups) } g := groups[0] if g.Identical != 1 || g.Review != 2 || g.Upgrades != 1 || g.Downgrades != 1 || g.Other != 2 { t.Fatalf("group counts wrong: %+v", g) } return album, g.IDs } func TestBulkLinkAlbum(t *testing.T) { ev := setup(t, false) album, ids := bulkAlbum(t, ev) p, err := ev.e.PreviewBulk(ctx, ev.pair.ID, append(ids, ids[0]), BulkOptions{Action: BulkLink}) if err != nil { t.Fatal(err) } if p.Selected != 5 || p.Albums != 1 || p.Process != 2 || p.Identical != 1 || p.Quarantined != 1 || p.Upgrades != 1 || p.BytesFreed != 4 { t.Fatalf("preview wrong: %+v", p) } if p.Skip["master copy is lower quality"] != 1 || p.Skip["no master copy"] != 1 || p.Skip["ignored"] != 1 { t.Fatalf("skip reasons wrong: %+v", p.Skip) } if _, err := ev.e.StartBulk(ev.pair.ID, ids, BulkOptions{Action: BulkLink}); err != nil { t.Fatal(err) } j := ev.wait() if j.Results["linked"] != 2 || j.Results["skipped"] != 3 { t.Fatalf("job results %+v %v", j.Results, j.Skipped) } for _, rel := range []string{"/01 Nightmare.flac", "/02 Welcome to the Family.flac"} { if !sameInode(t, filepath.Join(ev.master, album+rel), filepath.Join(ev.slave, album+rel)) { t.Fatalf("%s should be linked", rel) } } danger := album + "/Disc 2/03 Danger Line.mp3" if sameInode(t, filepath.Join(ev.master, danger), filepath.Join(ev.slave, danger)) { t.Fatal("the downgrade must be skipped without explicit permission") } // With explicit permission the downgrade is linked too. p, _ = ev.e.PreviewBulk(ctx, ev.pair.ID, ids, BulkOptions{Action: BulkLink, AllowDowngrade: true}) if p.Process != 1 || p.Downgrades != 1 || len(p.Examples["downgrades"]) != 1 { t.Fatalf("preview with downgrades: %+v", p) } if _, err := ev.e.StartBulk(ev.pair.ID, ids, BulkOptions{Action: BulkLink, AllowDowngrade: true}); err != nil { t.Fatal(err) } ev.wait() if !sameInode(t, filepath.Join(ev.master, danger), filepath.Join(ev.slave, danger)) { t.Fatal("downgrade should be linked once allowed") } if ev.read(filepath.Join(ev.slave, album+"/05 Ignored.flac")) != "b" { t.Fatal("ignored songs must never be touched") } } func TestBulkPromoteAlbum(t *testing.T) { ev := setup(t, false) album, ids := bulkAlbum(t, ev) p, err := ev.e.PreviewBulk(ctx, ev.pair.ID, ids, BulkOptions{Action: BulkPromote}) if err != nil { t.Fatal(err) } if p.Process != 1 || p.Skip["master copy is better"] != 1 || p.Skip["identical, so link instead"] != 1 || len(p.Examples["master_replaced"]) != 1 { t.Fatalf("promote preview: %+v", p) } if _, err := ev.e.StartBulk(ev.pair.ID, ids, BulkOptions{Action: BulkPromote}); err != nil { t.Fatal(err) } j := ev.wait() if j.Results["promoted"] != 1 { t.Fatalf("promote results %+v %v", j.Results, j.Skipped) } if ev.read(filepath.Join(ev.master, album+"/Disc 2/03 Danger Line.mp3")) != "lossless" { t.Fatal("the better slave copy should now be the master file") } if ev.read(filepath.Join(ev.master, album+"/02 Welcome to the Family.flac")) != "flac" { t.Fatal("a better master copy must not be replaced") } } func TestBulkIgnoreAndSafety(t *testing.T) { ev := setup(t, false) _, ids := bulkAlbum(t, ev) n, err := ev.e.ApplyIgnore(ctx, ev.pair.ID, ids, BulkOptions{Action: BulkIgnore}) if err != nil || n != 4 { t.Fatalf("ignore changed %d (%v), want 4", n, err) } n, _ = ev.e.ApplyIgnore(ctx, ev.pair.ID, ids, BulkOptions{Action: BulkUnignore}) if n != 5 { t.Fatalf("unignore changed %d, want 5", n) } // IDs from another pair are refused. other := store.Pair{Name: "other", MasterRoot: ev.master, SlaveRoot: ev.slave} if _, err := ev.e.NormalizePair(&other); err != nil { t.Fatal(err) } if err := ev.st.SavePair(ctx, &other); err != nil { t.Fatal(err) } p, _ := ev.e.PreviewBulk(ctx, other.ID, ids, BulkOptions{Action: BulkLink}) if p.Process != 0 || p.Skip["belongs to another library pair"] != 5 { t.Fatalf("cross-pair ids must be skipped: %+v", p) } if _, err := ev.e.PreviewBulk(ctx, ev.pair.ID, nil, BulkOptions{Action: BulkLink}); !errors.Is(err, ErrInvalidBulk) { t.Fatalf("empty selection: %v", err) } if _, err := ev.e.StartBulk(ev.pair.ID, ids, BulkOptions{Action: "delete"}); !errors.Is(err, ErrInvalidBulk) { t.Fatalf("unknown action: %v", err) } } func TestAlbumFolder(t *testing.T) { for in, want := range map[string]string{ "A/Album/01 x.flac": "A/Album", "A/Album/Disc 2/01 x.flac": "A/Album", "A/Album/CD1/Disc 1/x.flac": "A/Album", "x.flac": "", "Disc 1/x.flac": "", } { if got := AlbumFolder(in); got != want { t.Errorf("AlbumFolder(%q) = %q, want %q", in, got, want) } } }