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

+7
View File
@@ -57,6 +57,13 @@ codec-weighted bitrate (Opus and AAC count for more per kbps than MP3). You then
libraries share the better file. libraries share the better file.
- **Ignore.** The pair is hidden from review until you un-ignore it. - **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. **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, 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 bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice
+69 -2
View File
@@ -84,6 +84,9 @@ func (s *Server) Handler() http.Handler {
r.Post("/job/cancel", s.handleCancelJob) r.Post("/job/cancel", s.handleCancelJob)
r.Get("/matches", s.handleMatches) r.Get("/matches", s.handleMatches)
r.Get("/export.csv", s.handleExport) 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.Post("/link-identical", s.handleLinkIdentical)
r.Get("/tree", s.handleTree) r.Get("/tree", s.handleTree)
r.Post("/build", s.handleBuild) 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), 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): errors.Is(err, fsops.ErrChanged), errors.Is(err, fsops.ErrExists):
writeErr(w, 409, err) 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) writeErr(w, 400, err)
default: default:
writeErr(w, 500, err) writeErr(w, 500, err)
@@ -418,7 +422,13 @@ func (s *Server) handleMatches(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit")) limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset")) 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 { if err != nil {
writeErr(w, 500, err) writeErr(w, 500, err)
return return
@@ -567,3 +577,60 @@ func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, 200, map[string]any{"ok": true}) 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)
}
}
+384
View File
@@ -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
}
+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)
}
}
}
+5 -1
View File
@@ -292,6 +292,10 @@ func (e *Engine) Promote(ctx context.Context, matchID int64, confirmed bool) (st
if !confirmed { if !confirmed {
return store.Operation{}, ErrConfirm 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() e.mu.Lock()
defer e.mu.Unlock() 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, 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 { if err := e.begin(ctx, &o); err != nil {
return o, err return o, err
} }
+44
View File
@@ -310,6 +310,7 @@ func (s *Store) AllMatches(ctx context.Context, pairID int64) (map[string]Match,
type MatchFilter struct { type MatchFilter struct {
Status string // a status, "review" (different+similar), "ignored" or "" Status string // a status, "review" (different+similar), "ignored" or ""
Query string Query string
IDs []int64 // restrict to these matches (at most maxIDFilter)
Limit int Limit int
Offset int Offset int
} }
@@ -335,9 +336,52 @@ func matchWhere(pairID int64, f MatchFilter) (string, []any) {
q := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%" q := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%"
args = append(args, q, q) 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 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) { func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Match, int, error) {
w, args := matchWhere(pairID, f) w, args := matchWhere(pairID, f)
var total int var total int
+34
View File
@@ -165,3 +165,37 @@ export type TreeEntry = {
}; };
export type Page<T> = { items: T[]; total: number }; export type Page<T> = { 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<string, number>;
identical: number;
quarantined: number;
upgrades: number;
downgrades: number;
approximate: number;
moves_folder: number;
renames: number;
duration_mismatch: number;
bytes_freed: number;
examples: Record<string, string[] | undefined>;
};
+425
View File
@@ -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<number>;
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<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = partial && !checked;
}, [partial, checked]);
return (
<input
ref={ref}
type="checkbox"
aria-label={label}
className="size-4 shrink-0 accent-violet-500 cursor-pointer"
checked={checked}
onChange={(e) => 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<AlbumGroup[]>(`/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 (
<Card className="p-8">
<Spinner />
</Card>
);
if (all.length === 0)
return (
<Card>
<Empty icon={<Disc3 className="size-8" />} title="Nothing here">
No albums have songs matching this filter.
</Empty>
</Card>
);
return (
<>
<Card className="overflow-hidden">
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
<TriCheckbox
label="Select all albums on this page"
checked={pageSelected}
partial={shownIDs.some((id) => sel.has(id))}
onChange={(v) => setSel(toggleIDs(sel, shownIDs, v))}
/>
Select all {shown.length} albums on this page
{all.length > shown.length && (
<button className="ml-2 text-accent hover:underline cursor-pointer" onClick={() => setSel(toggleIDs(sel, all.flatMap((g) => g.ids), true))}>
or all {all.length} albums ({all.reduce((n, g) => n + g.songs, 0)} songs)
</button>
)}
</label>
{shown.map((g) => (
<AlbumRow key={g.folder} pair={pair} g={g} sel={sel} setSel={setSel} />
))}
</Card>
{all.length > GROUP_PAGE && (
<div className="flex items-center justify-between text-sm text-muted">
<span>
Albums {page * GROUP_PAGE + 1}–{Math.min((page + 1) * GROUP_PAGE, all.length)} of {all.length}
</span>
<div className="flex gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Previous
</Button>
<Button size="sm" disabled={(page + 1) * GROUP_PAGE >= all.length} onClick={() => setPage(page + 1)}>
Next
</Button>
</div>
</div>
)}
</>
);
}
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 (
<div className="border-b border-line last:border-0">
<div className={cx("flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:bg-panel-2/40", picked > 0 && "bg-accent/5")} onClick={() => setOpen(!open)}>
<TriCheckbox label={`Select ${album}`} checked={picked === g.ids.length} partial={picked > 0} onChange={(v) => setSel(toggleIDs(sel, g.ids, v))} />
{open ? <ChevronDown className="size-4 shrink-0 text-muted" /> : <ChevronRight className="size-4 shrink-0 text-muted" />}
<div className="min-w-0 flex-1">
<div className="truncate text-sm">
{parent && <span className="text-muted">{parent} / </span>}
<span className="font-medium">{album}</span>
</div>
{elsewhere.length > 0 && (
<div className="flex min-w-0 items-center gap-1 text-[11px] text-muted" title={elsewhere.join("\n")}>
<ArrowRight className="size-3 shrink-0" />
<span className="truncate">master: {elsewhere.slice(0, 2).join(", ")}{elsewhere.length > 2 && ` +${elsewhere.length - 2}`}</span>
</div>
)}
</div>
<div className="hidden flex-wrap justify-end gap-1 sm:flex">
{g.upgrades > 0 && <Badge tone="green">{g.upgrades} upgrade{g.upgrades > 1 && "s"}</Badge>}
{g.downgrades > 0 && (
<Badge tone="amber">
<AlertTriangle className="size-3" /> {g.downgrades} master lower
</Badge>
)}
{g.identical > 0 && <Badge tone="blue">{g.identical} identical</Badge>}
{g.review - g.upgrades - g.downgrades > 0 && <Badge tone="violet">{g.review - g.upgrades - g.downgrades} to review</Badge>}
{g.approximate > 0 && <Badge>{g.approximate} approximate</Badge>}
{g.other > 0 && <Badge>{g.other} other</Badge>}
</div>
<div className="w-24 shrink-0 text-right text-xs tabular-nums text-muted">
{picked > 0 && picked < g.ids.length ? `${picked}/` : ""}
{g.songs} song{g.songs > 1 && "s"}
<div>{formatBytes(g.bytes)}</div>
</div>
</div>
{open && <AlbumSongs pair={pair} ids={g.ids} sel={sel} setSel={setSel} />}
</div>
);
}
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<Page<Match>>(`/pairs/${pair.id}/matches?ids=${ids.join(",")}&limit=500`),
});
if (songs.isLoading)
return (
<div className="px-12 py-3">
<Spinner className="size-4" />
</div>
);
return (
<div className="border-t border-line bg-bg/40 pl-6">
{songs.data?.items.map((m) => (
<MatchRow key={m.id} m={m} selected={sel.has(m.id)} onToggle={(v) => setSel(toggleIDs(sel, [m.id], v))} />
))}
</div>
);
}
// ---- bulk bar and confirmation ----
export function BulkBar({ pair, status, sel, clear }: { pair: Pair; status: string; sel: Selection; clear: () => void }) {
const [action, setAction] = useState<BulkAction | null>(null);
if (sel.size === 0) return null;
const running = pair.job?.state === "running";
return (
<>
<div className="sticky bottom-4 z-30 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-panel/95 px-4 py-3 shadow-2xl backdrop-blur">
<span className="text-sm">
<b className="tabular-nums">{sel.size}</b> song{sel.size > 1 && "s"} selected
</span>
<button className="text-xs text-muted hover:text-white cursor-pointer" onClick={clear}>
<X className="inline size-3" /> clear
</button>
<div className="ml-auto flex flex-wrap gap-2">
{status === "ignored" ? (
<Button size="sm" onClick={() => setAction("unignore")}>
<Eye className="size-3.5" /> Un-ignore
</Button>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setAction("ignore")}>
<EyeOff className="size-3.5" /> Ignore
</Button>
<Button size="sm" onClick={() => setAction("promote")} disabled={running}>
<Crown className="size-3.5" /> Use slave copies as master…
</Button>
<Button size="sm" variant="primary" onClick={() => setAction("link")} disabled={running}>
<Link2 className="size-3.5" /> Link to master copies…
</Button>
</>
)}
</div>
</div>
{action && <BulkDialog pair={pair} ids={[...sel]} action={action} onClose={() => setAction(null)} onDone={clear} />}
</>
);
}
const titles: Record<BulkAction, string> = {
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<string, string> = {
"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<BulkPreview>(`/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 (
<Modal open onClose={onClose} title={titles[action]} wide>
{preview.isLoading && <Spinner />}
{preview.error && <Alert tone="red">{preview.error.message}</Alert>}
{p && (
<div className="space-y-3 text-sm">
<p className="text-gray-300">
<b>{p.selected}</b> song{p.selected !== 1 && "s"} selected in <b>{p.albums}</b> album{p.albums !== 1 && "s"}.{" "}
<b className="text-white">{p.process}</b> will be {action === "promote" ? "promoted" : action === "link" ? "linked" : action + "d"}
{p.process < p.selected && <>, {p.selected - p.process} skipped</>}.
</p>
{files && p.process > 0 && (
<ul className="space-y-1 rounded-lg border border-line bg-panel-2 p-3 text-xs">
{action === "link" && p.identical > 0 && (
<li>
<b>{p.identical}</b> byte-identical: linked without a quarantine copy, freeing about <b>{formatBytes(p.bytes_freed)}</b>.
</li>
)}
{action === "link" && p.quarantined > 0 && (
<li>
<b>{p.quarantined}</b> differ from the master copy: the slave originals go to quarantine and can be restored from History.
</li>
)}
{action === "promote" && (
<li>
<b>{p.quarantined}</b> master file{p.quarantined !== 1 && "s"} will be replaced by the slave copy. The master originals go to quarantine.
</li>
)}
{p.upgrades > 0 && (
<li className="text-emerald-300">
<b>{p.upgrades}</b> {action === "link" ? "upgrade the slave to a better master copy" : "put a better copy into the master library"}.
</li>
)}
</ul>
)}
{files && p.downgrades > 0 && (
<Warn title={`${p.downgrades} will keep the lower-quality copy`} examples={p.examples.downgrades}>
{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."}
</Warn>
)}
{files && p.approximate > 0 && (
<Warn title={`${p.approximate} approximate match${p.approximate !== 1 ? "es" : ""}`} examples={p.examples.approximate}>
Found by approximate matching. Expand the albums and spot-check them before continuing.
</Warn>
)}
{files && p.duration_mismatch > 0 && (
<Warn title={`${p.duration_mismatch} with different durations`} examples={p.examples.duration_mismatch}>
The two copies differ by more than 2 seconds, so they may be different versions or edits.
</Warn>
)}
{action === "link" && p.moves_folder > 0 && (
<Warn tone="blue" title={`${p.moves_folder} will move into the master's folders`} examples={p.examples.moves_folder}>
Emptied folders are removed. Playlists that point at the old paths will need updating.
</Warn>
)}
{files && p.renames > 0 && action === "link" && p.moves_folder === 0 && (
<Alert tone="blue">
<b>{p.renames}</b> file name{p.renames !== 1 && "s"} will change to the master's name. Playlists that point at the old names will need updating.
</Alert>
)}
{files && (
<div className="space-y-2">
{(downgradeSkips > 0 || allowDowngrade) && (
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-amber-500" checked={allowDowngrade} onChange={(e) => setAllowDowngrade(e.target.checked)} />
<span>
Also {action === "link" ? "link" : "promote"} the {downgradeSkips || p.downgrades} song{(downgradeSkips || p.downgrades) !== 1 && "s"} where the{" "}
{action === "link" ? "master" : "slave"} copy is <b>lower quality</b>
</span>
</label>
)}
{action === "link" && (p.moves_folder > 0 || keepFolder) && (
<label className="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" className="accent-violet-500" checked={keepFolder} onChange={(e) => setKeepFolder(e.target.checked)} />
Keep songs in their current slave folders instead
</label>
)}
</div>
)}
{Object.keys(p.skip).length > 0 && (
<div className="text-xs text-muted">
<div className="mb-1 font-medium text-gray-300">Skipped</div>
<ul className="space-y-0.5">
{Object.entries(p.skip).map(([why, n]) => (
<li key={why}>
<b className="tabular-nums text-gray-300">{n}</b> {why}
{skipHelp[why] && <span className="text-gray-500">: {skipHelp[why]}</span>}
</li>
))}
</ul>
</div>
)}
{files && dry && <Alert tone="blue">Dry run is on, so these are recorded as planned in Activity. No files change.</Alert>}
{needsAck && p.process > 0 && (
<label className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-100 cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-amber-500" checked={ack} onChange={(e) => setAck(e.target.checked)} />
<span>
I understand this <b>changes the master library</b>: {p.process} master file{p.process !== 1 && "s"} will be replaced (originals kept in
quarantine).
</span>
</label>
)}
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
variant={action === "promote" ? "warn" : "primary"}
disabled={!p || p.process === 0 || (needsAck && !ack)}
loading={run.isPending}
onClick={() => run.mutate()}
>
{verb} {p?.process ?? ""} song{p?.process !== 1 && "s"}
</Button>
</div>
</Modal>
);
}
/** 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 (
<Alert tone={tone} icon={tone === "amber" ? <AlertTriangle className="mt-0.5 size-4 shrink-0" /> : undefined}>
<div className="font-medium">{title}</div>
<div className="text-xs opacity-90">{children}</div>
{examples && examples.length > 0 && (
<ul className="mt-1 space-y-0.5 text-[11px] opacity-80">
{examples.map((e) => (
<li key={e} className="mono break-all">
{e}
</li>
))}
</ul>
)}
</Alert>
);
}
+68 -4
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { 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 { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { AlbumView, BulkBar, TriCheckbox, toggleIDs, type Selection } from "./Bulk";
const filters = [ const filters = [
{ id: "review", label: "Needs review" }, { id: "review", label: "Needs review" },
@@ -41,12 +42,30 @@ export function Review({ pair }: { pair: Pair }) {
const [debounced, setDebounced] = useState(""); const [debounced, setDebounced] = useState("");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [confirmBulk, setConfirmBulk] = useState(false); 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<Selection>(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(() => { useEffect(() => {
const t = setTimeout(() => setDebounced(q), 250); const t = setTimeout(() => setDebounced(q), 250);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [q]); }, [q]);
useEffect(() => setPage(0), [status, debounced]); 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({ const list = useQuery({
queryKey: ["matches", pair.id, status, debounced, page], queryKey: ["matches", pair.id, status, debounced, page],
@@ -91,6 +110,20 @@ export function Review({ pair }: { pair: Pair }) {
<Search className="absolute left-2.5 top-2.5 size-4 text-muted" /> <Search className="absolute left-2.5 top-2.5 size-4 text-muted" />
<input className={inputCls + " pl-8"} placeholder="Filter by path…" value={q} onChange={(e) => setQ(e.target.value)} /> <input className={inputCls + " pl-8"} placeholder="Filter by path…" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="flex rounded-lg border border-line p-0.5" role="tablist" aria-label="Group by">
{(["songs", "albums"] as const).map((v) => (
<button
key={v}
role="tab"
aria-selected={view === v}
onClick={() => chooseView(v)}
className={cx("flex items-center gap-1 rounded-md px-2.5 py-1 text-xs capitalize cursor-pointer", view === v ? "bg-panel-2 text-white" : "text-muted hover:text-white")}
>
{v === "songs" ? <ListMusic className="size-3.5" /> : <Disc3 className="size-3.5" />}
{v}
</button>
))}
</div>
<ExportMenu pairId={pair.id} status={status} query={debounced} viewCount={list.data?.total} filterLabel={filters.find((f) => f.id === status)?.label ?? "All"} /> <ExportMenu pairId={pair.id} status={status} query={debounced} viewCount={list.data?.total} filterLabel={filters.find((f) => f.id === status)?.label ?? "All"} />
</div> </div>
</div> </div>
@@ -107,7 +140,23 @@ export function Review({ pair }: { pair: Pair }) {
</Card> </Card>
)} )}
{view === "albums" ? (
<AlbumView pair={pair} status={status} query={debounced} sel={sel} setSel={setSel} />
) : (
<>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
{list.data && list.data.items.length > 0 && (
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
<TriCheckbox
label="Select all songs on this page"
checked={list.data.items.every((m) => sel.has(m.id))}
partial={list.data.items.some((m) => sel.has(m.id))}
onChange={(v) => setSel(toggleIDs(sel, list.data!.items.map((m) => m.id), v))}
/>
Select all {list.data.items.length} songs on this page
<span className="text-gray-600">· switch to Albums to select whole albums</span>
</label>
)}
{list.isLoading && ( {list.isLoading && (
<div className="p-8"> <div className="p-8">
<Spinner /> <Spinner />
@@ -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."} {pair.last_scan_at ? "No files match this filter." : "Run a scan to compare the two libraries."}
</Empty> </Empty>
)} )}
{list.data?.items.map((m) => <MatchRow key={m.id} m={m} />)} {list.data?.items.map((m) => (
<MatchRow key={m.id} m={m} selected={sel.has(m.id)} onToggle={(v) => setSel(toggleIDs(sel, [m.id], v))} />
))}
</Card> </Card>
{list.data && list.data.total > PAGE && ( {list.data && list.data.total > PAGE && (
@@ -136,6 +187,10 @@ export function Review({ pair }: { pair: Pair }) {
</div> </div>
</div> </div>
)} )}
</>
)}
<BulkBar pair={pair} status={status} sel={sel} clear={() => setSel(new Set())} />
<Modal open={confirmBulk} onClose={() => setConfirmBulk(false)} title="Link all identical files?"> <Modal open={confirmBulk} onClose={() => setConfirmBulk(false)} title="Link all identical files?">
<p className="text-sm text-gray-300"> <p className="text-sm text-gray-300">
@@ -212,7 +267,7 @@ function qualityLabel(p?: ProbeInfo) {
return `${p.codec.toUpperCase()} ${Math.round(p.bitrate / 1000)}k`; 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 reviewable = m.status === "different" || m.status === "similar";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const qc = useQueryClient(); const qc = useQueryClient();
@@ -237,7 +292,16 @@ function MatchRow({ m }: { m: Match }) {
return ( return (
<div className="border-b border-line last:border-0"> <div className="border-b border-line last:border-0">
<div className="flex items-center gap-3 px-4 py-2.5"> <div className={cx("flex items-center gap-3 px-4 py-2.5", selected && "bg-accent/5")}>
{onToggle && (
<input
type="checkbox"
aria-label={`Select ${m.rel_slave}`}
className="size-4 shrink-0 accent-violet-500 cursor-pointer"
checked={!!selected}
onChange={(e) => onToggle(e.target.checked)}
/>
)}
<button <button
className={cx("text-muted", reviewable ? "cursor-pointer hover:text-white" : "invisible")} className={cx("text-muted", reviewable ? "cursor-pointer hover:text-white" : "invisible")}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}