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:
1 parent
0ae4e85bd2
commit
1d31999793
9 files changed
+1209
-7
No files matched your search
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
|||||||
Reference in new issue
Block a user