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

+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
}