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>
385 lines
11 KiB
Go
385 lines
11 KiB
Go
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
|
|
}
|