Find and remove duplicate songs within the slave library

A new Duplicates filter in Review finds songs filed more than once in the
slave library, such as a single next to its album version, and recommends
which copy to keep. Removed copies go to the quarantine and can be restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 19:44:03 -07:00
1 parent 491d679233
commit 206b2ab5db
13 files changed
+1122 -10

No files matched your search

+10
View File
@@ -76,6 +76,16 @@ Each row has the match status and type, a recommendation, both paths, where link
bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice
(UTF-8, and cells a spreadsheet would treat as formulas are escaped).
**Duplicates within the slave library.** The Review tab's **Duplicates** filter is separate from master/slave matching: it looks
only inside the slave library for songs filed more than once, such as a single next to its album version, a compilation track, or one
song in two formats. **Find duplicates** groups songs in the same top-level folder by title, using the same rules as approximate matching
(track numbers, disc folders and release labels such as *Single Version* or *Remastered* are ignored; *Live*, *Remix*, *Acoustic* and
similar versions are kept apart). With ffprobe, copies whose artist tags disagree are kept apart too, and each copy shows its quality and
length, with a warning when lengths differ by more than 10 seconds. Each song has a play button to compare the copies. One copy is
recommended to keep (the album version, then one already linked to the master, then the better quality) and the others start selected.
**Remove** moves the selected copies into the quarantine after re-checking that each is unchanged and that another copy is still there;
one copy of every song is always kept, emptied folders are removed, dry run applies, and every removal can be restored from History.
**Listening in build mode.** Every song in the Build tab has a play button. A mini-player along the bottom of the page plays the
folder in order, with seeking, previous/next, volume and space to pause. Songs are streamed from the master library as they are
(nothing is converted), so they play when the browser supports the format: FLAC, MP3, AAC/M4A, Ogg/Opus and WAV in current browsers,
+35
View File
@@ -105,6 +105,9 @@ func (s *Server) Handler() http.Handler {
r.Get("/empty-folders", s.handleEmptyFolders)
r.Post("/empty-folders/scan", s.handleEmptyFolderScan)
r.Post("/empty-folders/remove", s.handleRemoveEmptyFolders)
r.Get("/duplicates", s.handleDupes)
r.Post("/duplicates/scan", s.handleDupeScan)
r.Post("/duplicates/remove", s.handleRemoveDupes)
r.Post("/build", s.handleBuild)
r.Post("/remove", s.handleRemove)
})
@@ -797,6 +800,38 @@ var audioTypes = map[string]string{
}
// handleListen streams a song with range support, so the browser can seek.
func (s *Server) handleDupes(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
writeJSON(w, 200, map[string]any{"scan": s.eng.DupeResult(id), "dry_run": s.st.DryRun(r.Context())})
}
func (s *Server) handleDupeScan(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
j, err := s.eng.StartDupeScan(id)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
func (s *Server) handleRemoveDupes(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Files []string `json:"files"` // must come from the latest search
}
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "remove duplicate songs") {
return
}
j, err := s.eng.StartRemoveDupes(id, body.Files)
if err != nil {
opErr(w, err)
return
}
s.log.Info("removing duplicate songs", "pair", id, "files", len(body.Files))
writeJSON(w, 202, j)
}
func (s *Server) handleListen(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
q := r.URL.Query()
+519
View File
@@ -0,0 +1,519 @@
package engine
import (
"context"
"errors"
"fmt"
"io/fs"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
)
// Duplicates are the same song filed more than once in the slave library,
// typically a single next to the album version ("Song - Single/Song.mp3" and
// "Album/03 Song.flac"), or one song in two formats. They are found by the
// same title rules as approximate matching (track numbers and release labels
// such as "Single Version" ignored, live/remix/acoustic versions kept apart)
// within one top-level folder. One copy of each group is recommended to keep;
// removing the others needs the user's confirmation and moves them into the
// quarantine, so they can be restored from History.
// DupeFile is one copy of a duplicated song.
type DupeFile struct {
Rel string `json:"rel"`
Size int64 `json:"size"`
Release string `json:"release"` // album, single or compilation (from the folder name)
Linked bool `json:"linked"` // a hardlink of a master file: it takes no extra space
Probe *probe.Info `json:"probe,omitempty"`
Keep bool `json:"keep"` // the copy recommended to keep
ino uint64
mtimeNs int64
tracks int // songs in its album folder
}
// DupeGroup is one song found more than once.
type DupeGroup struct {
ID string `json:"id"`
Title string `json:"title"`
Files []DupeFile `json:"files"`
Notes []string `json:"notes,omitempty"`
}
// DupeScan is the result of the latest duplicate search.
type DupeScan struct {
CreatedAt int64 `json:"created_at"`
Files int `json:"files"` // slave files checked
Groups []DupeGroup `json:"groups"`
Probed bool `json:"probed"` // codec details and artist tags were available
}
// dupeKey groups songs: top-level folder (normally the artist), the cleaned
// title and its version words.
func dupeKey(rel string, ti titleInfo) string {
if ti.norm == "" {
return ""
}
vs := make([]string, 0, len(ti.versions))
for v := range ti.versions {
vs = append(vs, v)
}
sort.Strings(vs)
return strings.ToLower(firstComponent(rel)) + "|" + ti.norm + "|" + strings.Join(vs, ",")
}
func releaseOf(ti titleInfo) string {
switch {
case ti.single:
return "single"
case ti.compilation:
return "compilation"
}
return "album"
}
// normArtist reduces an artist tag to letters and digits.
func normArtist(s string) string {
return strings.Join(words(s), "")
}
// sameArtist treats "Artist" and "Artist feat. Someone" as one artist.
func sameArtist(a, b string) bool {
return a == b || strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
}
// splitByArtist keeps files whose artist tags disagree apart, so a folder
// layout like Genre/Artist/Song doesn't group two artists' songs with the
// same title. Untagged files join the tagged ones only when there is no
// doubt which artist they belong to.
func splitByArtist(files []DupeFile) [][]DupeFile {
var clusters [][]DupeFile
var reps []string
var untagged []DupeFile
for _, f := range files {
a := ""
if f.Probe != nil && f.Probe.Error == "" {
a = normArtist(f.Probe.Artist)
}
if a == "" {
untagged = append(untagged, f)
continue
}
placed := false
for i, r := range reps {
if sameArtist(a, r) {
clusters[i] = append(clusters[i], f)
placed = true
break
}
}
if !placed {
reps = append(reps, a)
clusters = append(clusters, []DupeFile{f})
}
}
switch len(clusters) {
case 0:
return [][]DupeFile{untagged}
case 1:
clusters[0] = append(clusters[0], untagged...)
}
return clusters
}
// keepBetter reports whether a is the better copy to keep than b: an album
// release over a single or compilation, then one already linked to the
// master (it takes no extra space), then the better quality, then the fuller
// album, then the better format.
func keepBetter(a, b DupeFile) bool {
if ra, rb := a.Release == "album", b.Release == "album"; ra != rb {
return ra
}
if a.Release != b.Release {
return a.Release == "single" // a single over a compilation
}
if a.Linked != b.Linked {
return a.Linked
}
if a.Probe != nil && b.Probe != nil {
sa, sb := probe.Score(*a.Probe), probe.Score(*b.Probe)
if sa > 0 && sb > 0 && math.Abs(sa-sb) > 0.05*math.Max(sa, sb) {
return sa > sb
}
}
if a.tracks != b.tracks {
return a.tracks > b.tracks
}
if ra, rb := extRank(a.Rel), extRank(b.Rel); ra != rb {
return ra < rb
}
return a.Rel < b.Rel
}
func minSec(sec float64) string {
s := int(sec + 0.5)
return fmt.Sprintf("%d:%02d", s/60, s%60)
}
// findDupes groups the slave files that are the same song. linked holds the
// slave paths that are hardlinks of a master file; probeFn (optional)
// supplies codec details and tags for the files in a group, and only
// (optional) limits the search to those files.
func findDupes(ctx context.Context, files map[string]fsops.Info, linked, only map[string]bool, probeFn func(rel string) *probe.Info) ([]DupeGroup, error) {
byKey := map[string][]string{}
titles := map[string]titleInfo{}
tracks := map[string]int{}
for rel := range files {
tracks[albumKey(rel)]++ // album sizes always count every file
if only != nil && !only[rel] {
continue
}
ti := parseTitle(rel)
titles[rel] = ti
if k := dupeKey(rel, ti); k != "" {
byKey[k] = append(byKey[k], rel)
}
}
keys := make([]string, 0, len(byKey))
for k, rels := range byKey {
if len(rels) > 1 {
keys = append(keys, k)
}
}
sort.Strings(keys)
var groups []DupeGroup
for _, k := range keys {
if ctx.Err() != nil {
return nil, ctx.Err()
}
var copies []DupeFile
for _, rel := range byKey[k] {
in := files[rel]
f := DupeFile{Rel: rel, Size: in.Size, Release: releaseOf(titles[rel]), Linked: linked[rel],
ino: in.Ino, mtimeNs: in.MtimeNs, tracks: tracks[albumKey(rel)]}
if probeFn != nil {
f.Probe = probeFn(rel)
}
copies = append(copies, f)
}
for _, c := range splitByArtist(copies) {
if len(c) < 2 {
continue
}
sort.Slice(c, func(i, j int) bool { return keepBetter(c[i], c[j]) })
c[0].Keep = true
g := DupeGroup{Files: c}
name := filepath.Base(c[0].Rel)
g.Title = strings.TrimSpace(trackNum.ReplaceAllString(strings.TrimSuffix(name, filepath.Ext(name)), ""))
if g.Title == "" {
g.Title = name
}
if top := firstComponent(c[0].Rel); top != "" {
g.Title = top + " – " + g.Title
}
lo, hi := math.Inf(1), 0.0
for _, f := range c {
if f.Probe != nil && f.Probe.Duration > 0 {
lo, hi = math.Min(lo, f.Probe.Duration), math.Max(hi, f.Probe.Duration)
}
}
if hi-lo > 10 {
g.Notes = append(g.Notes, fmt.Sprintf("Lengths differ by %s: one may be a radio edit or a different version. Listen before removing.", minSec(hi-lo)))
}
same := true
for _, f := range c[1:] {
same = same && f.ino == c[0].ino
}
if same {
g.Notes = append(g.Notes, "All copies are the same file (hardlinks), so removing one frees no space, only the extra entry in your media server.")
}
groups = append(groups, g)
}
}
for i := range groups {
groups[i].ID = groups[i].Files[0].Rel
}
sort.Slice(groups, func(i, j int) bool { return strings.ToLower(groups[i].Title) < strings.ToLower(groups[j].Title) })
if groups == nil {
groups = []DupeGroup{}
}
return groups, nil
}
// StartDupeScan looks for songs that are in the slave library more than once.
func (e *Engine) StartDupeScan(pairID int64) (*Job, error) {
return e.startJob(pairID, "find-duplicates", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
j.phase("Listing the slave library", 0)
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, l.slave, files); err != nil {
return err
}
linked := map[string]bool{}
if ms, err := e.st.AllMatches(ctx, pairID); err == nil {
for rel, m := range ms {
// Only trust the scan's verdict for the file it saw.
if in, ok := files[rel]; ok && m.Status == store.StatusLinked && m.SlaveIno == in.Ino {
linked[rel] = true
}
}
}
// Probe only files that share a title with another: a first pass
// without tags finds them.
cands, err := findDupes(ctx, files, linked, nil, nil)
if err != nil {
return err
}
var probeFn func(string) *probe.Info
if e.prober.Bin != "" {
n := 0
for _, g := range cands {
n += len(g.Files)
}
j.phase("Reading tags and codec details", int64(n))
probes := map[string]*probe.Info{}
for _, g := range cands {
for _, f := range g.Files {
if ctx.Err() != nil {
return ctx.Err()
}
p := e.prober.Probe(ctx, filepath.Join(l.slave.Path, f.Rel))
probes[f.Rel] = &p
j.tick()
}
}
probeFn = func(rel string) *probe.Info { return probes[rel] }
only := map[string]bool{}
for rel := range probes {
only[rel] = true
}
// Again with tags: artists that differ are told apart.
if cands, err = findDupes(ctx, files, linked, only, probeFn); err != nil {
return err
}
}
e.dupeMu.Lock()
if e.dupes == nil {
e.dupes = map[int64]*DupeScan{}
}
e.dupes[pairID] = &DupeScan{CreatedAt: time.Now().Unix(), Files: len(files), Groups: cands, Probed: probeFn != nil}
e.dupeMu.Unlock()
extra := 0
for _, g := range cands {
extra += len(g.Files) - 1
}
j.update(func(j *Job) {
j.Message = fmt.Sprintf("%d songs are in the slave library more than once (%d extra copies)", len(cands), extra)
})
return nil
})
}
// DupeResult returns the latest duplicate search.
func (e *Engine) DupeResult(pairID int64) *DupeScan {
e.dupeMu.Lock()
defer e.dupeMu.Unlock()
return e.dupes[pairID]
}
// StartRemoveDupes moves the chosen copies of the latest duplicate search into
// the quarantine. Only files that search listed can be chosen, and at least
// one copy of every song must be kept.
func (e *Engine) StartRemoveDupes(pairID int64, rels []string) (*Job, error) {
e.dupeMu.Lock()
scan := e.dupes[pairID]
e.dupeMu.Unlock()
if scan == nil {
return nil, fmt.Errorf("%w: look for duplicates first", ErrInvalidBulk)
}
type entry struct {
f DupeFile
group int
}
listed := map[string]entry{}
for gi, g := range scan.Groups {
for _, f := range g.Files {
listed[f.Rel] = entry{f, gi}
}
}
chosen := map[string]bool{}
var order []entry
for _, r := range rels {
en, ok := listed[r]
if !ok {
return nil, fmt.Errorf("%w: %q is not in the latest search", ErrInvalidBulk, r)
}
if !chosen[r] {
chosen[r] = true
order = append(order, en)
}
}
if len(order) == 0 {
return nil, fmt.Errorf("%w: no files selected", ErrInvalidBulk)
}
keepers := map[int][]string{}
for gi, g := range scan.Groups {
for _, f := range g.Files {
if !chosen[f.Rel] {
keepers[gi] = append(keepers[gi], f.Rel)
}
}
}
for _, en := range order {
if len(keepers[en.group]) == 0 {
return nil, fmt.Errorf("%w: every copy of %q is selected; keep at least one", ErrInvalidBulk, scan.Groups[en.group].Title)
}
}
return e.startJob(pairID, "remove-duplicates", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
j.phase("Moving duplicates to the quarantine", int64(len(order)))
removed := map[string]bool{}
for _, en := range order {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.removeDupe(ctx, l, en.f, keepers[en.group]); err != nil {
j.skip(en.f.Rel + ": " + err.Error())
} else if e.st.DryRun(ctx) {
j.count("planned")
} else {
j.count("removed")
removed[en.f.Rel] = true
}
j.tick()
}
e.dropFromDupeScan(pairID, scan, removed)
return nil
})
}
// dropFromDupeScan takes removed files out of the search result so the list
// stays current without searching again.
func (e *Engine) dropFromDupeScan(pairID int64, scan *DupeScan, removed map[string]bool) {
if len(removed) == 0 {
return
}
next := &DupeScan{CreatedAt: scan.CreatedAt, Files: scan.Files - len(removed), Probed: scan.Probed, Groups: []DupeGroup{}}
for _, g := range scan.Groups {
var left []DupeFile
for _, f := range g.Files {
if !removed[f.Rel] {
left = append(left, f)
}
}
if len(left) > 1 {
g.Files = left
next.Groups = append(next.Groups, g)
}
}
e.dupeMu.Lock()
if e.dupes[pairID] == scan {
e.dupes[pairID] = next
}
e.dupeMu.Unlock()
}
// removeDupe moves one copy of a song into the quarantine, after checking it
// is still the file that was found and that a copy being kept still exists.
func (e *Engine) removeDupe(ctx context.Context, l *libs, f DupeFile, keepers []string) error {
e.mu.Lock()
defer e.mu.Unlock()
abs, err := l.slave.Resolve(f.Rel)
if err != nil {
return err
}
si, err := fsops.RegularFile(abs)
if err != nil {
return err
}
if si.Ino != f.ino || !si.Unchanged(f.Size, f.mtimeNs) {
return fsops.ErrChanged
}
kept := false
for _, k := range keepers {
if p, err := l.slave.Resolve(k); err == nil {
if _, err := fsops.RegularFile(p); err == nil {
kept = true
break
}
}
}
if !kept {
return errors.New("no other copy of this song is left in the slave library; skipped")
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActRemoveDupe, RelSlave: f.Rel, QuarantineSide: SideSlave, Trigger: "manual"}
// Remember the master pairing so an undo restores it.
if m, err := e.st.MatchBySlave(ctx, l.pair.ID, f.Rel); err == nil && m.Kind != store.KindNone {
o.RelMaster, o.MatchKind, o.MatchReason = m.RelMaster, m.Kind, m.Reason
}
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
return nil
}
q := quarantinePath(l, o.ID, SideSlave, f.Rel)
err = func() error {
// Recorded first, so the file is always findable from History.
if _, err := e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, o.ID); err != nil {
return err
}
if err := quarantine(abs, si, q); err != nil {
return err
}
if err := removeOriginal(abs, si, q); err != nil {
if stillQuarantined(abs, q) {
dropQuarantine(l, q, si)
}
return err
}
return nil
}()
if err = e.finish(ctx, &o, err); err != nil {
return err
}
pruneEmpty(filepath.Dir(abs), l.slave.Path)
e.refresh(ctx, l, f.Rel)
return nil
}
// undoRemoveDupe puts a removed duplicate back from the quarantine.
func (e *Engine) undoRemoveDupe(ctx context.Context, l *libs, o store.Operation) error {
dst, err := l.slave.Resolve(o.RelSlave)
if err != nil {
return err
}
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
if _, err := os.Lstat(dst); err == nil {
return fmt.Errorf("%q exists again: %w", o.RelSlave, fsops.ErrExists)
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil {
return err
}
if err := fsops.LinkNew(o.QuarantinePath, qi, dst); err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
e.refresh(ctx, l, o.RelSlave, store.Match{RelMaster: o.RelMaster, Kind: o.MatchKind, Reason: o.MatchReason})
return nil
}
+178
View File
@@ -0,0 +1,178 @@
package engine
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
)
func (ev *env) dupes(t *testing.T) *DupeScan {
t.Helper()
if _, err := ev.e.StartDupeScan(ev.pair.ID); err != nil {
t.Fatal(err)
}
ev.wait()
s := ev.e.DupeResult(ev.pair.ID)
if s == nil {
t.Fatal("no search result")
}
return s
}
func groupRels(g DupeGroup) string {
var out []string
for _, f := range g.Files {
out = append(out, f.Rel)
}
return strings.Join(out, ",")
}
func TestFindDuplicates(t *testing.T) {
ev := setup(t, false)
s := ev.slave
// A single next to the album version: the album copy is kept.
ev.write(s, "A7X/Hail to the King - Single/Hail to the King.mp3", "single")
ev.write(s, "A7X/Hail to the King/01 Hail to the King.flac", "album")
ev.write(s, "A7X/Hail to the King/02 Shepherd of Fire.flac", "album")
// Release labels and disc folders don't matter; a live version is a different song.
ev.write(s, "A7X/City of Evil/Disc 1/03 Bat Country.flac", "album")
ev.write(s, "A7X/Greatest Hits/07 Bat Country (Remastered).mp3", "comp")
ev.write(s, "A7X/Live in the LBC/05 Bat Country.flac", "live")
ev.write(s, "A7X/Singles/Nightmare (Live).mp3", "live single")
ev.write(s, "A7X/Nightmare/01 Nightmare.flac", "album")
// The same title by another artist is not a duplicate.
ev.write(s, "Other/Album/Nightmare.flac", "other")
// One song in two formats in one folder.
ev.write(s, "Band/Album/Song.mp3", "mp3")
ev.write(s, "Band/Album/Song.flac", "flac")
scan := ev.dupes(t)
var got []string
for _, g := range scan.Groups {
got = append(got, groupRels(g))
if !g.Files[0].Keep || g.Files[1].Keep {
t.Errorf("%s: only the first copy should be kept", g.Title)
}
}
want := []string{
"A7X/City of Evil/Disc 1/03 Bat Country.flac,A7X/Greatest Hits/07 Bat Country (Remastered).mp3",
"A7X/Hail to the King/01 Hail to the King.flac,A7X/Hail to the King - Single/Hail to the King.mp3",
"Band/Album/Song.flac,Band/Album/Song.mp3",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("got\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
if scan.Groups[1].Files[1].Release != "single" || scan.Groups[0].Files[1].Release != "compilation" {
t.Fatalf("releases: %+v", scan.Groups)
}
if scan.Groups[1].Title != "A7X – Hail to the King" {
t.Fatalf("title %q", scan.Groups[1].Title)
}
}
func TestDuplicatesSplitByArtistTag(t *testing.T) {
files := map[string]fsops.Info{
"Rock/Song.mp3": {Ino: 1},
"Rock/Album/Song.mp3": {Ino: 2},
"Rock/Other/Song.mp3": {Ino: 3},
}
artist := map[string]string{"Rock/Song.mp3": "Band", "Rock/Album/Song.mp3": "Band feat. Guest", "Rock/Other/Song.mp3": "Someone Else"}
probeFn := func(rel string) *probe.Info { return &probe.Info{Codec: "mp3", Artist: artist[rel]} }
groups, err := findDupes(ctx, files, nil, nil, probeFn)
if err != nil {
t.Fatal(err)
}
if len(groups) != 1 || len(groups[0].Files) != 2 || strings.Contains(groupRels(groups[0]), "Other") {
t.Fatalf("got %+v", groups)
}
}
func TestRemoveDuplicates(t *testing.T) {
ev := setup(t, false)
s := ev.slave
ev.write(ev.master, "A/Album/01 Song.flac", "album")
keep := ev.write(s, "A/Album/01 Song.flac", "album")
single := ev.write(s, "A/Song - Single/Song.mp3", "single")
ev.write(s, "B/X/Tune.mp3", "1")
ev.write(s, "B/Y/Tune.mp3", "2")
ev.scan()
scan := ev.dupes(t)
if len(scan.Groups) != 2 {
t.Fatalf("groups %+v", scan.Groups)
}
if _, err := ev.e.StartRemoveDupes(ev.pair.ID, []string{"A/Album/02 Nope.flac"}); err == nil {
t.Fatal("a file that wasn't listed must be refused")
}
if _, err := ev.e.StartRemoveDupes(ev.pair.ID, []string{"B/X/Tune.mp3", "B/Y/Tune.mp3"}); err == nil {
t.Fatal("removing every copy must be refused")
}
// A file changed since the search is skipped.
ev.write(s, "B/Y/Tune.mp3", "changed!")
if _, err := ev.e.StartRemoveDupes(ev.pair.ID, []string{"A/Song - Single/Song.mp3", "B/Y/Tune.mp3"}); err != nil {
t.Fatal(err)
}
j := ev.wait()
if j.Results["removed"] != 1 || len(j.Skipped) != 1 || !strings.Contains(j.Skipped[0], "changed") {
t.Fatalf("results %+v skipped %v", j.Results, j.Skipped)
}
if exists(single) || exists(filepath.Dir(single)) || !exists(keep) {
t.Fatal("the single (and its emptied folder) should be gone, the album copy kept")
}
if g := ev.e.DupeResult(ev.pair.ID).Groups; len(g) != 1 || !strings.HasPrefix(g[0].Files[0].Rel, "B/") {
t.Fatalf("the removed song should leave the list: %+v", g)
}
if _, err := ev.st.MatchBySlave(ctx, ev.pair.ID, "A/Song - Single/Song.mp3"); err == nil {
t.Fatal("the scan result for the removed file should be gone")
}
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
var op store.Operation
for _, o := range ops {
if o.Action == store.ActRemoveDupe {
op = o
}
}
if op.ID == 0 || ev.read(op.QuarantinePath) != "single" {
t.Fatalf("the single should be in the quarantine: %+v", op)
}
if err := ev.e.Undo(ctx, op.ID); err != nil {
t.Fatal(err)
}
if ev.read(single) != "single" || exists(op.QuarantinePath) {
t.Fatal("undo should move the file back")
}
if _, err := ev.st.MatchBySlave(ctx, ev.pair.ID, "A/Song - Single/Song.mp3"); err != nil {
t.Fatal("undo should restore the scan result")
}
}
func TestRemoveDuplicatesKeepsACopy(t *testing.T) {
ev := setup(t, false)
ev.write(ev.slave, "A/X/Song.mp3", "1")
ev.write(ev.slave, "A/Y/Song.mp3", "2")
ev.dupes(t)
// The copy being kept disappeared after the search.
os.Remove(filepath.Join(ev.slave, "A/X/Song.mp3"))
ev.e.StartRemoveDupes(ev.pair.ID, []string{"A/Y/Song.mp3"})
j := ev.wait()
if j.Results["removed"] != 0 || !exists(filepath.Join(ev.slave, "A/Y/Song.mp3")) {
t.Fatalf("the last copy must never be removed: %+v %v", j.Results, j.Skipped)
}
}
func TestRemoveDuplicatesDryRun(t *testing.T) {
ev := setup(t, true)
ev.write(ev.slave, "A/X/Song.mp3", "1")
p := ev.write(ev.slave, "A/Y/Song.mp3", "2")
ev.dupes(t)
ev.e.StartRemoveDupes(ev.pair.ID, []string{"A/Y/Song.mp3"})
if j := ev.wait(); j.Results["planned"] != 1 || !exists(p) {
t.Fatalf("dry run must only record: %+v", j.Results)
}
}
+3
View File
@@ -46,6 +46,9 @@ type Engine struct {
emptyMu sync.Mutex
empty map[int64]*NoMusicScan // latest scan for folders without music, per pair
dupeMu sync.Mutex
dupes map[int64]*DupeScan // latest duplicate search, per pair
jobsMu sync.Mutex
jobs map[int64]*Job
nextID int64
+11 -1
View File
@@ -622,7 +622,7 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
return errors.New("the quarantined original was already purged")
}
switch o.Action {
case store.ActLink, store.ActPromote, store.ActBuild, store.ActRemove, store.ActRename, store.ActRemoveFolder:
case store.ActLink, store.ActPromote, store.ActBuild, store.ActRemove, store.ActRename, store.ActRemoveFolder, store.ActRemoveDupe:
default:
// History can hold operations of features that were removed (tag
// edits, file moves); undoing them as a link would be wrong.
@@ -643,6 +643,16 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
pruneEmpty(filepath.Dir(o.QuarantinePath), l.quarantine)
return nil
}
if o.Action == store.ActRemoveDupe {
if err := e.undoRemoveDupe(ctx, l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("duplicate restored from quarantine", "op", o.ID, "slave", o.RelSlave)
return nil
}
if o.Action == store.ActRename {
if err := undoRename(l, o); err != nil {
return err
+1
View File
@@ -655,6 +655,7 @@ const (
ActRename = "rename" // slave folder renamed to the master's spelling
ActRemoveFolder = "remove-folder" // folder without music moved to the quarantine
ActRemoveDupe = "remove-duplicate" // extra copy of a song in the slave moved to the quarantine
)
// Operation statuses.
+1 -1
View File
@@ -139,7 +139,7 @@ export type Match = {
export type Operation = {
id: number;
pair_id: number;
action: "link" | "promote" | "build" | "remove" | "rename" | "remove-folder";
action: "link" | "promote" | "build" | "remove" | "rename" | "remove-folder" | "remove-duplicate";
rel_master: string;
rel_slave: string;
rel_new: string;
+5
View File
@@ -6,6 +6,7 @@ import {
ChevronLeft,
ChevronRight,
CircleAlert,
CopyMinus,
Crown,
FolderPen,
FolderPlus,
@@ -65,6 +66,7 @@ const actionLabel: Record<Operation["action"], string> = {
remove: "Removed from slave",
rename: "Renamed slave folder",
"remove-folder": "Removed folder without music",
"remove-duplicate": "Removed duplicate song",
};
const actionIcon: Record<Operation["action"], typeof Link2> = {
@@ -74,6 +76,7 @@ const actionIcon: Record<Operation["action"], typeof Link2> = {
remove: Unlink,
rename: FolderPen,
"remove-folder": FolderX,
"remove-duplicate": CopyMinus,
};
// Only exceptions get a badge: "done" is the normal case.
@@ -306,5 +309,7 @@ function undoText(o: Operation) {
return `The slave folder is renamed back from "${o.rel_new}" to "${o.rel_slave}", and the library is rescanned.`;
case "remove-folder":
return `The folder "${o.rel_slave || o.rel_master}" is moved back from the quarantine into the ${o.quarantine_side} library, with everything that was in it.`;
case "remove-duplicate":
return `The song "${o.rel_slave}" is moved back from the quarantine into the slave library.`;
}
}
+327
View File
@@ -0,0 +1,327 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, AudioLines, CopyMinus, Link2, Play, Search, Star } from "lucide-react";
import { api, type Pair, type ProbeInfo } from "../api";
import { Alert, Badge, Button, Card, Modal, cx, formatAgo, formatBytes, formatDuration, inputCls } from "../ui";
import { useToast } from "../toast";
import { JobBar } from "./PairPage";
import { MiniPlayer, type Track } from "./Player";
type DupeFile = { rel: string; size: number; release: "album" | "single" | "compilation"; linked: boolean; probe?: ProbeInfo; keep: boolean };
type DupeGroup = { id: string; title: string; files: DupeFile[]; notes?: string[] | null };
type DupeScan = { created_at: number; files: number; groups: DupeGroup[]; probed: boolean };
const SHOW = 100;
function quality(p?: ProbeInfo) {
if (!p || p.error) return null;
if (p.lossless) return `${p.codec.toUpperCase()} ${p.bit_depth || "?"}/${(p.sample_rate / 1000).toFixed(1)}`;
return `${p.codec.toUpperCase()} ${Math.round(p.bitrate / 1000)}k`;
}
function split(rel: string) {
const i = rel.lastIndexOf("/");
return i >= 0 ? [rel.slice(0, i + 1), rel.slice(i + 1)] : ["", rel];
}
/**
* Songs that are in the slave library more than once, such as a single next
* to the album version. Separate from master/slave matching: this only looks
* inside the slave library.
*/
export function Duplicates({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const [sel, setSel] = useState<Set<string>>(new Set());
const [q, setQ] = useState("");
const [show, setShow] = useState(SHOW);
const [confirm, setConfirm] = useState(false);
const [player, setPlayer] = useState<{ queue: Track[]; index: number } | null>(null);
const nowPlaying = player ? player.queue[player.index].rel : null;
const data = useQuery({
queryKey: ["duplicates", pair.id],
queryFn: () => api.get<{ scan: DupeScan | null; dry_run: boolean }>(`/pairs/${pair.id}/duplicates`),
});
const scan = data.data?.scan ?? null;
const running = pair.job?.state === "running";
const ourJob = running && pair.job!.kind.includes("duplicates");
const refresh = () => {
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["duplicates", pair.id] });
};
// Every copy except the recommended one starts selected.
useEffect(() => {
setSel(new Set((scan?.groups ?? []).flatMap((g) => g.files.filter((f) => !f.keep).map((f) => f.rel))));
}, [scan?.created_at]); // eslint-disable-line react-hooks/exhaustive-deps
// A removal shrinks the list: drop selections of files that are gone.
useEffect(() => {
if (!scan) return;
const listed = new Set(scan.groups.flatMap((g) => g.files.map((f) => f.rel)));
setSel((s) => new Set([...s].filter((r) => listed.has(r))));
}, [scan]);
const find = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/duplicates/scan`),
onSuccess: refresh,
onError: (e) => toast("err", e.message),
});
const groups = scan?.groups ?? [];
const needle = q.trim().toLowerCase();
const shown = useMemo(
() => (needle ? groups.filter((g) => g.title.toLowerCase().includes(needle) || g.files.some((f) => f.rel.toLowerCase().includes(needle))) : groups),
[groups, needle],
);
const chosen = groups.flatMap((g) => g.files.filter((f) => sel.has(f.rel)));
const extra = groups.reduce((n, g) => n + g.files.length - 1, 0);
const toggle = (g: DupeGroup, rel: string) =>
setSel((s) => {
const n = new Set(s);
if (n.has(rel)) n.delete(rel);
else if (g.files.filter((f) => f.rel !== rel).some((f) => !n.has(f.rel))) n.add(rel); // always keep one copy
return n;
});
const play = (g: DupeGroup, rel: string) => {
const queue = g.files.map((f) => ({ rel: f.rel, name: split(f.rel)[1], side: "slave" as const }));
setPlayer({ queue, index: queue.findIndex((t) => t.rel === rel) });
};
return (
<div className={cx("space-y-4", player && "pb-28")}>
<Card className="space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="max-w-2xl">
<h2 className="flex items-center gap-2 font-semibold">
<CopyMinus className="size-4 text-accent" /> Duplicate songs in the slave library
</h2>
<p className="mt-1 text-sm text-muted">
Finds songs that are in the slave library more than once, such as a single next to its album version, a compilation track, or one
song in two formats. This only looks inside the slave library and is separate from matching against the master. Live, remix,
acoustic and other versions are kept apart. Removed copies go to the quarantine and can be restored from History.
</p>
</div>
<Button variant="primary" onClick={() => find.mutate()} disabled={running} loading={find.isPending}>
<Search className="size-4" /> Find duplicates
</Button>
</div>
{ourJob && <JobBar job={pair.job!} />}
</Card>
{scan && !ourJob && (
<Card className="overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3">
<div className="text-sm">
{groups.length === 0 ? (
"No duplicates"
) : (
<>
<span className="font-medium">{groups.length}</span> {groups.length === 1 ? "song is" : "songs are"} in the slave library more than
once ({extra} extra {extra === 1 ? "copy" : "copies"})
</>
)}
<span className="text-muted">
{" "}
· {scan.files} songs checked {formatAgo(scan.created_at)}
</span>
</div>
{groups.length > 0 && (
<div className="flex w-full items-center gap-2 sm:w-auto">
<div className="relative flex-1 sm:w-56">
<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)} />
</div>
<Button variant="danger" disabled={chosen.length === 0 || running} onClick={() => setConfirm(true)}>
<CopyMinus className="size-4" /> Remove {chosen.length}…
</Button>
</div>
)}
</div>
{!scan.probed && groups.length > 0 && (
<div className="border-b border-line px-4 py-2 text-xs text-amber-300/90">
ffprobe isn't available, so artist tags, lengths and quality weren't compared. Check each group before removing.
</div>
)}
{groups.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted">Every song is in the slave library once.</div>
) : (
<>
<div className="border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
<Star className="mb-0.5 inline size-3 fill-current text-emerald-300" /> marks the copy recommended to keep: the album version first, then one
already linked to the master, then the better quality. The others start selected; one copy of every song is always kept.
</div>
<div className="divide-y divide-line">
{shown.slice(0, show).map((g) => (
<div key={g.id} className="px-4 py-3">
<div className="mb-1.5 flex flex-wrap items-baseline gap-x-2 text-sm font-medium text-gray-100">
{g.title}
<span className="text-xs font-normal text-muted">{g.files.length} copies</span>
</div>
{g.notes?.map((n) => (
<div key={n} className="mb-1.5 flex items-start gap-1.5 text-xs text-amber-300/90">
<AlertTriangle className="mt-px size-3.5 shrink-0" /> {n}
</div>
))}
<div className="space-y-0.5">
{g.files.map((f) => {
const [dir, base] = split(f.rel);
const on = sel.has(f.rel);
const lastKept = !on && g.files.every((o) => o.rel === f.rel || sel.has(o.rel));
const playing = nowPlaying === f.rel;
return (
<div key={f.rel} className={cx("flex items-center gap-3 rounded-md px-2 py-1.5", on ? "bg-red-500/5" : "hover:bg-panel-2/60")}>
<input
type="checkbox"
className="shrink-0 cursor-pointer disabled:cursor-not-allowed"
aria-label={`Remove ${f.rel}`}
checked={on}
disabled={lastKept}
title={lastKept ? "One copy is always kept" : on ? "Will be removed" : "Select to remove"}
onChange={() => toggle(g, f.rel)}
/>
<button
className={cx(
"grid size-6 shrink-0 place-items-center rounded-full cursor-pointer",
playing ? "brand-gradient text-on-accent" : "text-muted hover:bg-accent/15 hover:text-accent",
)}
title={playing ? "Playing" : "Listen"}
onClick={() => play(g, f.rel)}
>
{playing ? <AudioLines className="size-3.5" /> : <Play className="ml-px size-3 fill-current" />}
</button>
<div className={cx("min-w-0 flex-1 break-words text-sm sm:truncate", on && "text-gray-400 line-through decoration-red-400/50")} title={f.rel}>
<span className="text-muted">{dir}</span>
<span className={cx(playing ? "font-medium text-accent" : "text-gray-100")}>{base}</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{f.keep && (
<Badge tone="green">
<Star className="size-3 fill-current" /> keep
</Badge>
)}
{f.release !== "album" && <Badge tone="amber">{f.release}</Badge>}
{f.linked && (
<Badge tone="accent">
<Link2 className="size-3" /> linked
</Badge>
)}
</div>
<div className="hidden w-24 shrink-0 text-right text-xs text-muted md:block">{quality(f.probe) ?? ""}</div>
<div className="hidden w-12 shrink-0 text-right text-xs tabular-nums text-muted sm:block">
{f.probe?.duration ? formatDuration(f.probe.duration) : ""}
</div>
<div className="hidden w-16 shrink-0 text-right text-xs tabular-nums text-muted sm:block">{formatBytes(f.size)}</div>
</div>
);
})}
</div>
</div>
))}
</div>
{shown.length === 0 && <div className="px-4 py-8 text-center text-sm text-muted">No duplicates match this filter.</div>}
{shown.length > show && (
<div className="border-t border-line px-4 py-3 text-center">
<Button size="sm" onClick={() => setShow(show + SHOW)}>
Show more ({shown.length - show} left)
</Button>
</div>
)}
</>
)}
</Card>
)}
{player && (
<MiniPlayer pairId={pair.id} queue={player.queue} index={player.index} onIndex={(index) => setPlayer({ ...player, index })} onClose={() => setPlayer(null)} />
)}
{confirm && scan && (
<ConfirmRemove
pair={pair}
groups={groups}
files={chosen}
dry={data.data?.dry_run ?? true}
onClose={() => setConfirm(false)}
onDone={() => {
setConfirm(false);
setPlayer(null);
refresh();
}}
/>
)}
</div>
);
}
function ConfirmRemove({
pair,
groups,
files,
dry,
onClose,
onDone,
}: {
pair: Pair;
groups: DupeGroup[];
files: DupeFile[];
dry: boolean;
onClose: () => void;
onDone: () => void;
}) {
const toast = useToast();
const chosen = new Set(files.map((f) => f.rel));
// Groups where the recommended copy is the one being removed, or whose lengths differ.
const overridden = groups.filter((g) => g.files.some((f) => f.keep && chosen.has(f.rel))).length;
const lengths = groups.filter((g) => g.files.some((f) => chosen.has(f.rel)) && (g.notes ?? []).some((n) => n.startsWith("Lengths"))).length;
const bytes = files.filter((f) => !f.linked).reduce((n, f) => n + f.size, 0);
const remove = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/duplicates/remove`, { files: files.map((f) => f.rel) }),
onSuccess: onDone,
onError: (e) => toast("err", e.message),
});
const n = files.length;
return (
<Modal open onClose={onClose} title={`Remove ${n} duplicate ${n === 1 ? "copy" : "copies"}?`}>
<div className="space-y-4 text-sm">
<p className="text-gray-300">
These files are moved from the slave library into the quarantine. Each is checked right before it moves: it's skipped if it changed since
the search or if no other copy of the song is left. Emptied folders are removed. You can restore them from History
{pair.retention_days > 0 ? `; they are deleted for good after ${pair.retention_days} days` : "; they stay in the quarantine until you purge them"}
{bytes > 0 && <>, which frees up to {formatBytes(bytes)}</>}.
</p>
<div className="max-h-48 overflow-y-auto rounded-lg border border-line bg-bg/60 px-3 py-2 mono text-xs text-gray-300">
{files.map((f) => (
<div key={f.rel} className="truncate" title={f.rel}>
{f.rel}
</div>
))}
</div>
{(overridden > 0 || lengths > 0) && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{overridden > 0 && (
<div>
In {overridden} {overridden === 1 ? "song" : "songs"}, you're removing the copy recommended to keep.
</div>
)}
{lengths > 0 && (
<div>
{lengths} {lengths === 1 ? "song has" : "songs have"} copies of different lengths, which may be different edits.
</div>
)}
</Alert>
)}
<p className="text-xs text-muted">Playlists that point at the removed files will need updating.</p>
{dry && <Alert tone="blue">Dry run is on: the removals are only recorded in Activity, and no file moves.</Alert>}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="danger" loading={remove.isPending} onClick={() => remove.mutate()}>
<CopyMinus className="size-4" /> Remove {n} {n === 1 ? "copy" : "copies"}
</Button>
</div>
</div>
</Modal>
);
}
+1
View File
@@ -40,6 +40,7 @@ function useJobFinished(pair: Pair | undefined) {
qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["empty-folders"] });
qc.invalidateQueries({ queryKey: ["duplicates"] });
const j = pair.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`);
+7 -7
View File
@@ -2,11 +2,11 @@ import { useEffect, useRef, useState } from "react";
import { CircleAlert, Disc3, Pause, Play, SkipBack, SkipForward, Volume1, Volume2, VolumeX, X } from "lucide-react";
import { cx } from "../ui";
/** A song the player can play: its path in the master library and a display name. */
export type Track = { rel: string; name: string };
/** A song the player can play: its path in a library (the master unless side says otherwise) and a display name. */
export type Track = { rel: string; name: string; side?: "master" | "slave" };
export function listenURL(pairId: number, rel: string) {
return `/api/pairs/${pairId}/listen?side=master&path=${encodeURIComponent(rel)}`;
export function listenURL(pairId: number, rel: string, side: "master" | "slave" = "master") {
return `/api/pairs/${pairId}/listen?side=${side}&path=${encodeURIComponent(rel)}`;
}
function clock(sec: number) {
@@ -59,9 +59,9 @@ export function MiniPlayer({
setError(null);
setTime(0);
setDuration(0);
a.src = listenURL(pairId, track.rel);
a.src = listenURL(pairId, track.rel, track.side);
a.play().catch(() => {}); // a refused autoplay just leaves it paused
}, [pairId, track.rel]);
}, [pairId, track.rel, track.side]);
useEffect(() => {
if (audio.current) audio.current.volume = volume;
@@ -126,7 +126,7 @@ export function MiniPlayer({
</span>
) : (
<>
{folder || "master"} · {index + 1} of {queue.length}
{folder || track.side || "master"} · {index + 1} of {queue.length}
</>
)}
</div>
+24 -1
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, ChevronDown, ChevronRight, CopyCheck, CopyMinus, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui";
import { useToast } from "../toast";
import { AlbumView, BulkBar, TriCheckbox, toggleIDs, type Selection } from "./Bulk";
import { Duplicates } from "./Duplicates";
const filters = [
{ id: "review", label: "Needs review" },
@@ -72,6 +73,7 @@ export function Review({ pair }: { pair: Pair }) {
queryFn: () =>
api.get<Page<Match>>(`/pairs/${pair.id}/matches?status=${status}&q=${encodeURIComponent(debounced)}&limit=${PAGE}&offset=${page * PAGE}`),
placeholderData: keepPreviousData,
enabled: status !== "duplicates",
});
const bulk = useMutation({
@@ -105,6 +107,19 @@ export function Review({ pair }: { pair: Pair }) {
{count(f.id) !== undefined && <span className="tabular-nums opacity-70">{count(f.id)}</span>}
</button>
))}
{/* Not a match status: duplicates within the slave library. */}
<span className="mx-1 h-4 w-px bg-line" aria-hidden />
<button
onClick={() => setStatus("duplicates")}
title="Songs that are in the slave library more than once, such as a single and its album version"
className={cx(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs cursor-pointer",
status === "duplicates" ? "border-accent bg-accent/15 text-white" : "border-line text-muted hover:text-white",
)}
>
<CopyMinus className="size-3.5" /> Duplicates
</button>
{status !== "duplicates" && (
<div className="ml-auto flex w-full items-center gap-2 sm:w-auto">
<div className="relative flex-1 sm:w-64">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted" />
@@ -126,8 +141,14 @@ export function Review({ pair }: { pair: Pair }) {
</div>
<ExportMenu pairId={pair.id} status={status} query={debounced} viewCount={list.data?.total} filterLabel={filters.find((f) => f.id === status)?.label ?? "All"} />
</div>
)}
</div>
{status === "duplicates" ? (
<Duplicates pair={pair} />
) : (
<>
{status === "identical" && identical > 0 && (
<Card className="flex flex-wrap items-center justify-between gap-3 p-4">
<div className="text-sm">
@@ -191,6 +212,8 @@ export function Review({ pair }: { pair: Pair }) {
)}
<BulkBar pair={pair} status={status} sel={sel} clear={() => setSel(new Set())} />
</>
)}
<Modal open={confirmBulk} onClose={() => setConfirmBulk(false)} title="Link all identical files?">
<p className="text-sm text-gray-300">