The Review list can now be grouped by album (disc folders folded in), showing per-album counts of upgrades, identical files, downgrades and approximate matches. Whole albums or individual songs can be selected and linked, promoted, ignored or un-ignored in one action. Every bulk action is previewed server-side first: how many songs will be processed, which are skipped and why, and warnings with examples for downgrades, approximate matches, duration mismatches, folder moves and renames. Downgrades are excluded unless explicitly included, and replacing master files requires an acknowledgement. Link and promote run as a background job that re-plans each song and uses the normal single-item operation, so all preflight checks still apply. IDs from another pair are refused and duplicates are ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
6.3 KiB
Go
174 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|