Add album view and bulk actions to Review

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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-23 21:33:44 -07:00
1 parent 0ae4e85bd2
commit 1d31999793
9 files changed
+1209 -7

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
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)
}
}
}