From 1d31999793d0099ea0f9fe451425411cb519ea9f Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 23 Sep 2026 21:33:44 -0700 Subject: [PATCH] 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 --- README.md | 7 + internal/api/server.go | 71 +++++- internal/engine/bulk.go | 384 +++++++++++++++++++++++++++++++ internal/engine/bulk_test.go | 173 ++++++++++++++ internal/engine/ops.go | 6 +- internal/store/models.go | 44 ++++ web/src/api.ts | 34 +++ web/src/pages/Bulk.tsx | 425 +++++++++++++++++++++++++++++++++++ web/src/pages/Review.tsx | 72 +++++- 9 files changed, 1209 insertions(+), 7 deletions(-) create mode 100644 internal/engine/bulk.go create mode 100644 internal/engine/bulk_test.go create mode 100644 web/src/pages/Bulk.tsx diff --git a/README.md b/README.md index c4d98e5..a270915 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,13 @@ codec-weighted bitrate (Opus and AAC count for more per kbps than MP3). You then libraries share the better file. - **Ignore.** The pair is hidden from review until you un-ignore it. +**Bulk review.** Switch the Review list from *Songs* to *Albums* to see one row per album (disc folders are folded in), with +counts of upgrades, identical files, songs where the master is lower quality and approximate matches. Tick whole albums, or expand an +album and tick individual songs, then **Link**, **Use slave copies as master**, **Ignore** or **Un-ignore** them in one go. Before +anything runs, a preview shows how many songs will be processed and which are skipped and why, with warnings and examples for +downgrades, approximate matches, duration mismatches and files that move folders. Downgrades are skipped unless you tick a box, and +replacing master files needs an explicit acknowledgement. Each song then goes through the same checks as a single action. + **Export.** The Review tab's **Export CSV** button downloads either the current view (filter and search applied) or the entire scan. Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate, bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice diff --git a/internal/api/server.go b/internal/api/server.go index a62126f..c76937f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -84,6 +84,9 @@ func (s *Server) Handler() http.Handler { r.Post("/job/cancel", s.handleCancelJob) r.Get("/matches", s.handleMatches) r.Get("/export.csv", s.handleExport) + r.Get("/groups", s.handleGroups) + r.Post("/bulk/preview", s.handleBulkPreview) + r.Post("/bulk", s.handleBulk) r.Post("/link-identical", s.handleLinkIdentical) r.Get("/tree", s.handleTree) r.Post("/build", s.handleBuild) @@ -172,7 +175,8 @@ func opErr(w http.ResponseWriter, err error) { case errors.Is(err, engine.ErrBusy), errors.Is(err, engine.ErrDowngrade), errors.Is(err, engine.ErrConfirm), errors.Is(err, fsops.ErrChanged), errors.Is(err, fsops.ErrExists): writeErr(w, 409, err) - case errors.Is(err, fsops.ErrOutsideRoot), errors.Is(err, fsops.ErrNotRegular), errors.Is(err, fsops.ErrCrossDevice): + case errors.Is(err, fsops.ErrOutsideRoot), errors.Is(err, fsops.ErrNotRegular), errors.Is(err, fsops.ErrCrossDevice), + errors.Is(err, engine.ErrInvalidBulk): writeErr(w, 400, err) default: writeErr(w, 500, err) @@ -418,7 +422,13 @@ func (s *Server) handleMatches(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) offset, _ := strconv.Atoi(q.Get("offset")) - ms, total, err := s.st.Matches(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q"), Limit: limit, Offset: offset}) + var ids []int64 + for _, part := range strings.Split(q.Get("ids"), ",") { + if n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64); err == nil && n > 0 { + ids = append(ids, n) + } + } + ms, total, err := s.st.Matches(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q"), IDs: ids, Limit: limit, Offset: offset}) if err != nil { writeErr(w, 500, err) return @@ -567,3 +577,60 @@ func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, map[string]any{"ok": true}) } + +// ---- album grouping and bulk actions ---- + +func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + q := r.URL.Query() + groups, err := s.eng.Groups(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q")}) + if err != nil { + writeErr(w, 500, err) + return + } + writeJSON(w, 200, groups) +} + +type bulkBody struct { + engine.BulkOptions + IDs []int64 `json:"ids"` +} + +func (s *Server) handleBulkPreview(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var b bulkBody + if !readJSON(w, r, &b) { + return + } + p, err := s.eng.PreviewBulk(r.Context(), id, b.IDs, b.BulkOptions) + if err != nil { + writeErr(w, 400, err) + return + } + writeJSON(w, 200, p) +} + +func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var b bulkBody + if !readJSON(w, r, &b) { + return + } + switch b.Action { + case engine.BulkIgnore, engine.BulkUnignore: + n, err := s.eng.ApplyIgnore(r.Context(), id, b.IDs, b.BulkOptions) + if err != nil { + writeErr(w, 400, err) + return + } + writeJSON(w, 200, map[string]any{"changed": n}) + default: + j, err := s.eng.StartBulk(id, b.IDs, b.BulkOptions) + if err != nil { + opErr(w, err) + return + } + s.log.Info("bulk action started", "pair", id, "action", b.Action, "songs", len(b.IDs), "allow_downgrade", b.AllowDowngrade) + writeJSON(w, 202, j) + } +} diff --git a/internal/engine/bulk.go b/internal/engine/bulk.go new file mode 100644 index 0000000..7b1bd04 --- /dev/null +++ b/internal/engine/bulk.go @@ -0,0 +1,384 @@ +package engine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/cdnutter/playlink/internal/probe" + "github.com/cdnutter/playlink/internal/store" +) + +// Bulk actions apply one decision to many matches, typically whole albums. +// Every match still goes through the same single-item code path (and its +// preflight checks); bulk only adds planning, a preview and a job. + +const ( + BulkLink = "link" + BulkPromote = "promote" + BulkIgnore = "ignore" + BulkUnignore = "unignore" +) + +// ErrInvalidBulk marks a malformed bulk request (reported as a 400). +var ErrInvalidBulk = errors.New("invalid bulk request") + +// MaxBulk caps one request so a mistaken "select all" stays reviewable. +const MaxBulk = 20000 + +type BulkOptions struct { + Action string `json:"action"` + // AllowDowngrade includes matches where the kept copy is lower quality: + // for link, the master is worse; for promote, the slave is worse. + AllowDowngrade bool `json:"allow_downgrade"` + KeepFolder bool `json:"keep_folder"` +} + +// AlbumFolder is the folder a slave file is grouped under: its directory +// with disc folders ("Disc 1", "CD2") folded into the album. +func AlbumFolder(rel string) string { + dir := filepath.Dir(rel) + for dir != "." && discFolder.MatchString(filepath.Base(dir)) { + dir = filepath.Dir(dir) + } + if dir == "." { + return "" + } + return dir +} + +func verdict(m store.Match) probe.Verdict { + var v probe.Verdict + _ = json.Unmarshal(m.Verdict, &v) + return v +} + +// planBulk decides whether a match takes part in a bulk action. It returns +// the reason when it does not. +func planBulk(pairID int64, m store.Match, opt BulkOptions) (bool, string) { + if m.PairID != pairID { + return false, "belongs to another library pair" + } + v := verdict(m) + switch opt.Action { + case BulkIgnore: + if m.Ignored { + return false, "already ignored" + } + return true, "" + case BulkUnignore: + if !m.Ignored { + return false, "not ignored" + } + return true, "" + } + if m.Ignored { + return false, "ignored" + } + switch m.Status { + case store.StatusLinked: + return false, "already linked" + case store.StatusUnmatched: + return false, "no master copy" + case store.StatusError: + return false, "has an error" + } + switch opt.Action { + case BulkLink: + if v.Better == "slave" && !opt.AllowDowngrade { + return false, "master copy is lower quality" + } + return true, "" + case BulkPromote: + if m.Status == store.StatusIdentical { + return false, "identical, so link instead" + } + if v.Better == "master" && !opt.AllowDowngrade { + return false, "master copy is better" + } + return true, "" + } + return false, "unknown action" +} + +// BulkPreview summarises a bulk action before it runs. +type BulkPreview struct { + Action string `json:"action"` + Selected int `json:"selected"` + Albums int `json:"albums"` + Process int `json:"process"` + Skip map[string]int `json:"skip"` // reason -> count + + // Among the matches that will be processed: + Identical int `json:"identical"` // linked without quarantine + Quarantined int `json:"quarantined"` // replaced file is kept in quarantine + Upgrades int `json:"upgrades"` // the copy kept is better + Downgrades int `json:"downgrades"` // the copy kept is worse (only with AllowDowngrade) + Approximate int `json:"approximate"` // approximate matches + MovesFolder int `json:"moves_folder"` // link moves the slave file into the master's folders + Renames int `json:"renames"` // file name changes + DurationMismatch int `json:"duration_mismatch"` // durations differ by more than 2 seconds + BytesFreed int64 `json:"bytes_freed"` // identical slave files replaced by links + + // A few example paths per warning, so the user can spot-check. + Examples map[string][]string `json:"examples"` +} + +func (p *BulkPreview) example(kind, path string) { + if len(p.Examples[kind]) < 3 { + p.Examples[kind] = append(p.Examples[kind], path) + } +} + +func (e *Engine) PreviewBulk(ctx context.Context, pairID int64, ids []int64, opt BulkOptions) (BulkPreview, error) { + ids = uniqueIDs(ids) + p := BulkPreview{Action: opt.Action, Selected: len(ids), Skip: map[string]int{}, Examples: map[string][]string{}} + if err := validBulk(ids, opt); err != nil { + return p, err + } + ms, err := e.st.MatchesByIDs(ctx, ids) + if err != nil { + return p, err + } + albums := map[string]bool{} + for _, m := range ms { + albums[AlbumFolder(m.RelSlave)] = true + ok, why := planBulk(pairID, m, opt) + if !ok { + p.Skip[why]++ + continue + } + p.Process++ + if opt.Action != BulkLink && opt.Action != BulkPromote { + continue + } + v := verdict(m) + kept := "master" + if opt.Action == BulkPromote { + kept = "slave" + } + switch { + case v.Better == kept: + p.Upgrades++ + case v.Better != "" && v.Better != "equal" && v.Better != "unknown": + p.Downgrades++ + p.example("downgrades", m.RelSlave) + } + if m.Kind == store.KindApprox { + p.Approximate++ + p.example("approximate", m.RelSlave) + } + if v.DurationDelta > 2 { + p.DurationMismatch++ + p.example("duration_mismatch", m.RelSlave) + } + if filepath.Base(m.RelSlave) != filepath.Base(m.RelMaster) { + p.Renames++ + } + if opt.Action == BulkLink { + if m.Status == store.StatusIdentical { + p.Identical++ + p.BytesFreed += m.SlaveSize + } else { + p.Quarantined++ + } + if target := LinkTarget(m.RelSlave, m.RelMaster, opt.KeepFolder); filepath.Dir(target) != filepath.Dir(m.RelSlave) { + p.MovesFolder++ + p.example("moves_folder", m.RelSlave+" → "+target) + } + } else { + p.Quarantined++ // every promoted master file is quarantined + p.example("master_replaced", m.RelMaster) + } + } + if missing := len(ids) - len(ms); missing > 0 { + p.Skip["no longer exists (rescan)"] += missing + } + p.Albums = len(albums) + return p, nil +} + +// uniqueIDs drops duplicates (a song selected on its own and via its album). +func uniqueIDs(ids []int64) []int64 { + seen := make(map[int64]bool, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if !seen[id] { + seen[id] = true + out = append(out, id) + } + } + return out +} + +func validBulk(ids []int64, opt BulkOptions) error { + switch opt.Action { + case BulkLink, BulkPromote, BulkIgnore, BulkUnignore: + default: + return fmt.Errorf("%w: unknown action %q", ErrInvalidBulk, opt.Action) + } + if len(ids) == 0 { + return fmt.Errorf("%w: nothing selected", ErrInvalidBulk) + } + if len(ids) > MaxBulk { + return fmt.Errorf("%w: select at most %d songs at a time", ErrInvalidBulk, MaxBulk) + } + return nil +} + +// ApplyIgnore ignores or un-ignores matches immediately (no files change). +func (e *Engine) ApplyIgnore(ctx context.Context, pairID int64, ids []int64, opt BulkOptions) (int, error) { + ids = uniqueIDs(ids) + if err := validBulk(ids, opt); err != nil { + return 0, err + } + if opt.Action != BulkIgnore && opt.Action != BulkUnignore { + return 0, fmt.Errorf("%w: not an ignore action", ErrInvalidBulk) + } + ms, err := e.st.MatchesByIDs(ctx, ids) + if err != nil { + return 0, err + } + n := 0 + for _, m := range ms { + if ok, _ := planBulk(pairID, m, opt); ok { + if err := e.st.SetIgnored(ctx, m.ID, opt.Action == BulkIgnore); err != nil { + return n, err + } + n++ + } + } + return n, nil +} + +// StartBulk links or promotes the selected matches in a background job. Each +// match is re-planned when its turn comes (the scan may have changed it) and +// then goes through the normal single-item operation. +func (e *Engine) StartBulk(pairID int64, ids []int64, opt BulkOptions) (*Job, error) { + if err := validBulk(ids, opt); err != nil { + return nil, err + } + if opt.Action != BulkLink && opt.Action != BulkPromote { + return nil, fmt.Errorf("%w: only link and promote run as a job", ErrInvalidBulk) + } + sorted := uniqueIDs(ids) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + return e.startJob(pairID, "bulk-"+opt.Action, func(ctx context.Context, j *Job) error { + verb := "Linking" + if opt.Action == BulkPromote { + verb = "Promoting slave copies" + } + j.phase(verb, int64(len(sorted))) + for _, id := range sorted { + if ctx.Err() != nil { + return ctx.Err() + } + e.bulkOne(ctx, j, pairID, id, opt) + j.tick() + } + return nil + }) +} + +func (e *Engine) bulkOne(ctx context.Context, j *Job, pairID, id int64, opt BulkOptions) { + m, err := e.st.Match(ctx, id) + if err != nil { + j.skip(fmt.Sprintf("match %d: no longer exists (rescan)", id)) + return + } + if ok, why := planBulk(pairID, m, opt); !ok { + j.skip(m.RelSlave + ": " + why) + return + } + var op store.Operation + if opt.Action == BulkLink { + op, err = e.Link(ctx, id, LinkOptions{AllowDowngrade: opt.AllowDowngrade, KeepFolder: opt.KeepFolder, Trigger: "bulk"}) + } else { + op, err = e.promote(ctx, id, "bulk") + } + switch { + case err != nil: + j.skip(m.RelSlave + ": " + strings.TrimSpace(err.Error())) + case op.DryRun: + j.count("planned") + case opt.Action == BulkLink: + j.count("linked") + default: + j.count("promoted") + } +} + +// AlbumGroup is one album folder of the slave library in the Review list. +type AlbumGroup struct { + Folder string `json:"folder"` + IDs []int64 `json:"ids"` + Songs int `json:"songs"` + Identical int `json:"identical"` + Review int `json:"review"` // different + similar + Upgrades int `json:"upgrades"` + Downgrades int `json:"downgrades"` + Approximate int `json:"approximate"` + Other int `json:"other"` // linked, unmatched, error, ignored + Bytes int64 `json:"bytes"` + // MasterFolders lists where the matched master files live (usually one). + MasterFolders []string `json:"master_folders"` +} + +// Groups returns the matches of a filter grouped by album folder. +func (e *Engine) Groups(ctx context.Context, pairID int64, f store.MatchFilter) ([]AlbumGroup, error) { + byFolder := map[string]*AlbumGroup{} + masters := map[string]map[string]bool{} + err := e.st.ExportMatches(ctx, pairID, f, func(m store.Match) error { + folder := AlbumFolder(m.RelSlave) + g := byFolder[folder] + if g == nil { + g = &AlbumGroup{Folder: folder, IDs: []int64{}} + byFolder[folder] = g + masters[folder] = map[string]bool{} + } + g.IDs = append(g.IDs, m.ID) + g.Songs++ + g.Bytes += m.SlaveSize + switch { + case m.Ignored || m.Status == store.StatusLinked || m.Status == store.StatusUnmatched || m.Status == store.StatusError: + g.Other++ + case m.Status == store.StatusIdentical: + g.Identical++ + default: + g.Review++ + switch verdict(m).Better { + case "master": + g.Upgrades++ + case "slave": + g.Downgrades++ + } + } + if m.Kind == store.KindApprox { + g.Approximate++ + } + if m.RelMaster != "" { + masters[folder][AlbumFolder(m.RelMaster)] = true + } + return nil + }) + if err != nil { + return nil, err + } + out := make([]AlbumGroup, 0, len(byFolder)) + for folder, g := range byFolder { + for mf := range masters[folder] { + g.MasterFolders = append(g.MasterFolders, mf) + } + sort.Strings(g.MasterFolders) + if g.MasterFolders == nil { + g.MasterFolders = []string{} + } + out = append(out, *g) + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Folder) < strings.ToLower(out[j].Folder) }) + return out, nil +} diff --git a/internal/engine/bulk_test.go b/internal/engine/bulk_test.go new file mode 100644 index 0000000..f8f18d5 --- /dev/null +++ b/internal/engine/bulk_test.go @@ -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) + } + } +} diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 2f36c77..d69e628 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -292,6 +292,10 @@ func (e *Engine) Promote(ctx context.Context, matchID int64, confirmed bool) (st if !confirmed { return store.Operation{}, ErrConfirm } + return e.promote(ctx, matchID, "manual") +} + +func (e *Engine) promote(ctx context.Context, matchID int64, trigger string) (store.Operation, error) { e.mu.Lock() defer e.mu.Unlock() @@ -323,7 +327,7 @@ func (e *Engine) Promote(ctx context.Context, matchID int64, confirmed bool) (st } o := store.Operation{PairID: l.pair.ID, Action: store.ActPromote, RelMaster: m.RelMaster, RelSlave: m.RelSlave, - RelNew: newMasterRel, QuarantineSide: "master", Trigger: "manual", MatchKind: m.Kind, MatchReason: m.Reason} + RelNew: newMasterRel, QuarantineSide: "master", Trigger: trigger, MatchKind: m.Kind, MatchReason: m.Reason} if err := e.begin(ctx, &o); err != nil { return o, err } diff --git a/internal/store/models.go b/internal/store/models.go index 53e77fb..497cf0c 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -310,6 +310,7 @@ func (s *Store) AllMatches(ctx context.Context, pairID int64) (map[string]Match, type MatchFilter struct { Status string // a status, "review" (different+similar), "ignored" or "" Query string + IDs []int64 // restrict to these matches (at most maxIDFilter) Limit int Offset int } @@ -335,9 +336,52 @@ func matchWhere(pairID int64, f MatchFilter) (string, []any) { q := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%" args = append(args, q, q) } + if len(f.IDs) > 0 { + ids := f.IDs + if len(ids) > maxIDFilter { + ids = ids[:maxIDFilter] + } + where = append(where, "id IN ("+strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")+")") + for _, id := range ids { + args = append(args, id) + } + } return strings.Join(where, " AND "), args } +const maxIDFilter = 500 + +// MatchesByIDs loads matches by id (any pair; callers check ownership). +func (s *Store) MatchesByIDs(ctx context.Context, ids []int64) ([]Match, error) { + out := make([]Match, 0, len(ids)) + for start := 0; start < len(ids); start += maxIDFilter { + chunk := ids[start:min(start+maxIDFilter, len(ids))] + args := make([]any, len(chunk)) + for i, id := range chunk { + args[i] = id + } + rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE id IN (`+ + strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",")+`)`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + m, err := scanMatch(rows) + if err != nil { + rows.Close() + return nil, err + } + out = append(out, m) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + } + return out, nil +} + func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Match, int, error) { w, args := matchWhere(pairID, f) var total int diff --git a/web/src/api.ts b/web/src/api.ts index 44b3206..151e48c 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -165,3 +165,37 @@ export type TreeEntry = { }; export type Page = { items: T[]; total: number }; + +export type AlbumGroup = { + folder: string; + ids: number[]; + songs: number; + identical: number; + review: number; + upgrades: number; + downgrades: number; + approximate: number; + other: number; + bytes: number; + master_folders: string[]; +}; + +export type BulkAction = "link" | "promote" | "ignore" | "unignore"; + +export type BulkPreview = { + action: BulkAction; + selected: number; + albums: number; + process: number; + skip: Record; + identical: number; + quarantined: number; + upgrades: number; + downgrades: number; + approximate: number; + moves_folder: number; + renames: number; + duration_mismatch: number; + bytes_freed: number; + examples: Record; +}; diff --git a/web/src/pages/Bulk.tsx b/web/src/pages/Bulk.tsx new file mode 100644 index 0000000..d438196 --- /dev/null +++ b/web/src/pages/Bulk.tsx @@ -0,0 +1,425 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Crown, Disc3, Eye, EyeOff, Link2, X } from "lucide-react"; +import { api, type AlbumGroup, type BulkAction, type BulkPreview, type Match, type Page, type Pair } from "../api"; +import { useInfo } from "../App"; +import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui"; +import { useToast } from "../toast"; +import { MatchRow } from "./Review"; + +export type Selection = Set; + +export function toggleIDs(sel: Selection, ids: number[], on: boolean): Selection { + const n = new Set(sel); + for (const id of ids) { + if (on) n.add(id); + else n.delete(id); + } + return n; +} + +/** A checkbox that shows the "some selected" state. */ +export function TriCheckbox({ checked, partial, onChange, label }: { checked: boolean; partial: boolean; onChange: (v: boolean) => void; label: string }) { + const ref = useRef(null); + useEffect(() => { + if (ref.current) ref.current.indeterminate = partial && !checked; + }, [partial, checked]); + return ( + onChange(e.target.checked)} + onClick={(e) => e.stopPropagation()} + /> + ); +} + +const GROUP_PAGE = 30; + +export function AlbumView({ + pair, + status, + query, + sel, + setSel, +}: { + pair: Pair; + status: string; + query: string; + sel: Selection; + setSel: (s: Selection) => void; +}) { + const [page, setPage] = useState(0); + useEffect(() => setPage(0), [status, query]); + const groups = useQuery({ + queryKey: ["matches", "groups", pair.id, status, query], + queryFn: () => api.get(`/pairs/${pair.id}/groups?status=${status}&q=${encodeURIComponent(query)}`), + }); + const all = groups.data ?? []; + const shown = all.slice(page * GROUP_PAGE, (page + 1) * GROUP_PAGE); + const shownIDs = shown.flatMap((g) => g.ids); + const pageSelected = shownIDs.length > 0 && shownIDs.every((id) => sel.has(id)); + + if (groups.isLoading) + return ( + + + + ); + if (all.length === 0) + return ( + + } title="Nothing here"> + No albums have songs matching this filter. + + + ); + + return ( + <> + + + {shown.map((g) => ( + + ))} + + {all.length > GROUP_PAGE && ( +
+ + Albums {page * GROUP_PAGE + 1}–{Math.min((page + 1) * GROUP_PAGE, all.length)} of {all.length} + +
+ + +
+
+ )} + + ); +} + +function AlbumRow({ pair, g, sel, setSel }: { pair: Pair; g: AlbumGroup; sel: Selection; setSel: (s: Selection) => void }) { + const [open, setOpen] = useState(false); + const picked = g.ids.filter((id) => sel.has(id)).length; + const parts = g.folder ? g.folder.split("/") : []; + const album = parts.length ? parts[parts.length - 1] : "(library root)"; + const parent = parts.slice(0, -1).join("/"); + const elsewhere = g.master_folders.filter((f) => f !== g.folder); + return ( +
+
0 && "bg-accent/5")} onClick={() => setOpen(!open)}> + 0} onChange={(v) => setSel(toggleIDs(sel, g.ids, v))} /> + {open ? : } +
+
+ {parent && {parent} / } + {album} +
+ {elsewhere.length > 0 && ( +
+ + master: {elsewhere.slice(0, 2).join(", ")}{elsewhere.length > 2 && ` +${elsewhere.length - 2}`} +
+ )} +
+
+ {g.upgrades > 0 && {g.upgrades} upgrade{g.upgrades > 1 && "s"}} + {g.downgrades > 0 && ( + + {g.downgrades} master lower + + )} + {g.identical > 0 && {g.identical} identical} + {g.review - g.upgrades - g.downgrades > 0 && {g.review - g.upgrades - g.downgrades} to review} + {g.approximate > 0 && {g.approximate} approximate} + {g.other > 0 && {g.other} other} +
+
+ {picked > 0 && picked < g.ids.length ? `${picked}/` : ""} + {g.songs} song{g.songs > 1 && "s"} +
{formatBytes(g.bytes)}
+
+
+ {open && } +
+ ); +} + +function AlbumSongs({ pair, ids, sel, setSel }: { pair: Pair; ids: number[]; sel: Selection; setSel: (s: Selection) => void }) { + const songs = useQuery({ + queryKey: ["matches", "album", pair.id, ids.join(",")], + queryFn: () => api.get>(`/pairs/${pair.id}/matches?ids=${ids.join(",")}&limit=500`), + }); + if (songs.isLoading) + return ( +
+ +
+ ); + return ( +
+ {songs.data?.items.map((m) => ( + setSel(toggleIDs(sel, [m.id], v))} /> + ))} +
+ ); +} + +// ---- bulk bar and confirmation ---- + +export function BulkBar({ pair, status, sel, clear }: { pair: Pair; status: string; sel: Selection; clear: () => void }) { + const [action, setAction] = useState(null); + if (sel.size === 0) return null; + const running = pair.job?.state === "running"; + return ( + <> +
+ + {sel.size} song{sel.size > 1 && "s"} selected + + +
+ {status === "ignored" ? ( + + ) : ( + <> + + + + + )} +
+
+ {action && setAction(null)} onDone={clear} />} + + ); +} + +const titles: Record = { + link: "Link selected songs to the master copies?", + promote: "Replace master files with the selected slave copies?", + ignore: "Ignore selected songs?", + unignore: "Un-ignore selected songs?", +}; + +const skipHelp: Record = { + "master copy is lower quality": "tick the box above to link these anyway", + "master copy is better": "tick the box above to replace these anyway", + "already linked": "nothing to do", + "no master copy": "slave-only songs are never touched", + ignored: "un-ignore them first", + "identical, so link instead": "promoting identical files changes nothing", +}; + +function BulkDialog({ pair, ids, action, onClose, onDone }: { pair: Pair; ids: number[]; action: BulkAction; onClose: () => void; onDone: () => void }) { + const qc = useQueryClient(); + const toast = useToast(); + const dry = useInfo().data?.dry_run ?? true; + const [allowDowngrade, setAllowDowngrade] = useState(false); + const [keepFolder, setKeepFolder] = useState(false); + const [ack, setAck] = useState(false); + const body = { ids, action, allow_downgrade: allowDowngrade, keep_folder: keepFolder }; + const preview = useQuery({ + queryKey: ["bulk-preview", pair.id, action, allowDowngrade, keepFolder, idsKey(ids)], + queryFn: () => api.post(`/pairs/${pair.id}/bulk/preview`, body), + gcTime: 0, // always preview the current state of the files + }); + const run = useMutation({ + mutationFn: () => api.post<{ changed?: number }>(`/pairs/${pair.id}/bulk`, body), + onSuccess: (res) => { + if (action === "ignore" || action === "unignore") toast("ok", `${res.changed} song${res.changed === 1 ? "" : "s"} ${action === "ignore" ? "ignored" : "un-ignored"}`); + else toast("ok", dry ? "Started (dry run): actions are recorded as planned only" : "Started. Progress is shown at the top of the page."); + qc.invalidateQueries({ queryKey: ["matches"] }); + qc.invalidateQueries({ queryKey: ["pair", pair.id] }); + onDone(); + onClose(); + }, + onError: (e) => toast("err", e.message), + }); + + const p = preview.data; + const files = action === "link" || action === "promote"; + const downgradeSkips = p ? (p.skip[action === "link" ? "master copy is lower quality" : "master copy is better"] ?? 0) : 0; + const needsAck = action === "promote" && !dry; + const verb = { link: "Link", promote: "Replace", ignore: "Ignore", unignore: "Un-ignore" }[action]; + + return ( + + {preview.isLoading && } + {preview.error && {preview.error.message}} + {p && ( +
+

+ {p.selected} song{p.selected !== 1 && "s"} selected in {p.albums} album{p.albums !== 1 && "s"}.{" "} + {p.process} will be {action === "promote" ? "promoted" : action === "link" ? "linked" : action + "d"} + {p.process < p.selected && <>, {p.selected - p.process} skipped}. +

+ + {files && p.process > 0 && ( +
    + {action === "link" && p.identical > 0 && ( +
  • + {p.identical} byte-identical: linked without a quarantine copy, freeing about {formatBytes(p.bytes_freed)}. +
  • + )} + {action === "link" && p.quarantined > 0 && ( +
  • + {p.quarantined} differ from the master copy: the slave originals go to quarantine and can be restored from History. +
  • + )} + {action === "promote" && ( +
  • + {p.quarantined} master file{p.quarantined !== 1 && "s"} will be replaced by the slave copy. The master originals go to quarantine. +
  • + )} + {p.upgrades > 0 && ( +
  • + {p.upgrades} {action === "link" ? "upgrade the slave to a better master copy" : "put a better copy into the master library"}. +
  • + )} +
+ )} + + {files && p.downgrades > 0 && ( + + {action === "link" ? "The slave has the better copy for these. It goes to quarantine." : "The master has the better copy for these. It goes to quarantine."} + + )} + {files && p.approximate > 0 && ( + + Found by approximate matching. Expand the albums and spot-check them before continuing. + + )} + {files && p.duration_mismatch > 0 && ( + + The two copies differ by more than 2 seconds, so they may be different versions or edits. + + )} + {action === "link" && p.moves_folder > 0 && ( + + Emptied folders are removed. Playlists that point at the old paths will need updating. + + )} + {files && p.renames > 0 && action === "link" && p.moves_folder === 0 && ( + + {p.renames} file name{p.renames !== 1 && "s"} will change to the master's name. Playlists that point at the old names will need updating. + + )} + + {files && ( +
+ {(downgradeSkips > 0 || allowDowngrade) && ( + + )} + {action === "link" && (p.moves_folder > 0 || keepFolder) && ( + + )} +
+ )} + + {Object.keys(p.skip).length > 0 && ( +
+
Skipped
+
    + {Object.entries(p.skip).map(([why, n]) => ( +
  • + {n} {why} + {skipHelp[why] && : {skipHelp[why]}} +
  • + ))} +
+
+ )} + + {files && dry && Dry run is on, so these are recorded as planned in Activity. No files change.} + {needsAck && p.process > 0 && ( + + )} +
+ )} +
+ + +
+
+ ); +} + +/** A short, order-independent fingerprint of a selection for cache keys. */ +function idsKey(ids: number[]) { + let h = 2166136261; + for (const id of [...ids].sort((a, b) => a - b)) h = Math.imul(h ^ id, 16777619) >>> 0; + return `${ids.length}:${h.toString(36)}`; +} + +function Warn({ title, children, examples, tone = "amber" }: { title: string; children: ReactNode; examples?: string[]; tone?: "amber" | "blue" }) { + return ( + : undefined}> +
{title}
+
{children}
+ {examples && examples.length > 0 && ( +
    + {examples.map((e) => ( +
  • + {e} +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index c4a73d1..da7a153 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from "react"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, ArrowRight, ArrowUpCircle, Download, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react"; +import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react"; import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api"; import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui"; import { useToast } from "../toast"; +import { AlbumView, BulkBar, TriCheckbox, toggleIDs, type Selection } from "./Bulk"; const filters = [ { id: "review", label: "Needs review" }, @@ -41,12 +42,30 @@ export function Review({ pair }: { pair: Pair }) { const [debounced, setDebounced] = useState(""); const [page, setPage] = useState(0); const [confirmBulk, setConfirmBulk] = useState(false); + const [view, setView] = useState<"songs" | "albums">(() => { + try { + return localStorage.getItem("playlink.reviewView") === "albums" ? "albums" : "songs"; + } catch { + return "songs"; + } + }); + const [sel, setSel] = useState(new Set()); + const chooseView = (v: "songs" | "albums") => { + setView(v); + try { + localStorage.setItem("playlink.reviewView", v); + } catch { + /* private mode: the choice just isn't remembered */ + } + }; useEffect(() => { const t = setTimeout(() => setDebounced(q), 250); return () => clearTimeout(t); }, [q]); useEffect(() => setPage(0), [status, debounced]); + // A selection only makes sense for the list it was made in. + useEffect(() => setSel(new Set()), [status, debounced, pair.id]); const list = useQuery({ queryKey: ["matches", pair.id, status, debounced, page], @@ -91,6 +110,20 @@ export function Review({ pair }: { pair: Pair }) { setQ(e.target.value)} /> +
+ {(["songs", "albums"] as const).map((v) => ( + + ))} +
f.id === status)?.label ?? "All"} /> @@ -107,7 +140,23 @@ export function Review({ pair }: { pair: Pair }) { )} + {view === "albums" ? ( + + ) : ( + <> + {list.data && list.data.items.length > 0 && ( + + )} {list.isLoading && (
@@ -118,7 +167,9 @@ export function Review({ pair }: { pair: Pair }) { {pair.last_scan_at ? "No files match this filter." : "Run a scan to compare the two libraries."} )} - {list.data?.items.map((m) => )} + {list.data?.items.map((m) => ( + setSel(toggleIDs(sel, [m.id], v))} /> + ))} {list.data && list.data.total > PAGE && ( @@ -136,6 +187,10 @@ export function Review({ pair }: { pair: Pair }) {
)} + + )} + + setSel(new Set())} /> setConfirmBulk(false)} title="Link all identical files?">

@@ -212,7 +267,7 @@ function qualityLabel(p?: ProbeInfo) { return `${p.codec.toUpperCase()} ${Math.round(p.bitrate / 1000)}k`; } -function MatchRow({ m }: { m: Match }) { +export function MatchRow({ m, selected, onToggle }: { m: Match; selected?: boolean; onToggle?: (v: boolean) => void }) { const reviewable = m.status === "different" || m.status === "similar"; const [open, setOpen] = useState(false); const qc = useQueryClient(); @@ -237,7 +292,16 @@ function MatchRow({ m }: { m: Match }) { return (

-
+
+ {onToggle && ( + onToggle(e.target.checked)} + /> + )}