Flag slave-only songs whose album or artist the master has

Scans now mark slave-only songs as missing from the master when the master
has their album folder or at least their artist folder. A new Missing from
master filter in Review lists them, split by album and artist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-01 12:31:39 -07:00
1 parent 206b2ab5db
commit 4bae6742a4
9 files changed
+280 -14

No files matched your search

+8 -1
View File
@@ -21,7 +21,7 @@ path on both sides. The fallbacks below catch small differences, and a scan warn
| Same title after ignoring track numbers/punctuation (`01 - Song!.flac` vs `Song.mp3`) | Status `similar` (similar title). Manual review. | | Same title after ignoring track numbers/punctuation (`01 - Song!.flac` vs `Song.mp3`) | Status `similar` (similar title). Manual review. |
| Same top-level folder and title, but a **different subfolder** | Status `similar` (same top folder, other subfolder). Manual review. | | Same top-level folder and title, but a **different subfolder** | Status `similar` (same top folder, other subfolder). Manual review. |
| *(opt-in)* Approximately the same song in another folder, e.g. a **single vs. the album version** or a **disc folder** | Status `similar` (approximate match), with the reason shown. Manual review. | | *(opt-in)* Approximately the same song in another folder, e.g. a **single vs. the album version** or a **disc folder** | Status `similar` (approximate match), with the reason shown. Manual review. |
| Only exists in the slave library | Status `unmatched`. Never touched. | | Only exists in the slave library | Status `unmatched`. Never touched. Flagged as *missing from master* when the master has the album or artist. |
| Already the same inode | Status `linked`. | | Already the same inode | Status `linked`. |
**Approximate matching** is an opt-in setting on each pair. It finds songs whose file name or folder doesn't line up exactly with the **Approximate matching** is an opt-in setting on each pair. It finds songs whose file name or folder doesn't line up exactly with the
@@ -86,6 +86,13 @@ recommended to keep (the album version, then one already linked to the master, t
**Remove** moves the selected copies into the quarantine after re-checking that each is unchanged and that another copy is still there; **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. one copy of every song is always kept, emptied folders are removed, dry run applies, and every removal can be restored from History.
**Missing from master.** The Review tab's **Missing from master** filter lists slave-only songs whose artist the master library
has, so you can complete the master. *Album in master* means the master has the song's album folder but not the song (the slave has
`Caravan Palace/Robot Face/Waterguns.mp3`, the master has `Caravan Palace/Robot Face/` without it); *only the artist in master* means the
whole album is missing. Folders are compared ignoring case, disc folders and release labels such as *Deluxe Edition*, and each row names
the master folder it found. Scans fill this in, nothing is changed, and the CSV export includes it. With approximate matching off, songs
filed under a slightly different name or release also land here.
**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 **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 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, (nothing is converted), so they play when the browser supports the format: FLAC, MP3, AAC/M4A, Ogg/Opus and WAV in current browsers,
+4
View File
@@ -63,6 +63,10 @@ func recommendation(m store.Match, v probe.Verdict) string {
return "review" return "review"
case m.Status == store.StatusError: case m.Status == store.StatusError:
return "fix error" return "fix error"
case m.Missing == "album":
return "not in master: master has the album"
case m.Missing == "artist":
return "not in master: master has the artist"
} }
return "" return ""
} }
+91
View File
@@ -0,0 +1,91 @@
package engine
import (
"fmt"
"path/filepath"
"strings"
)
// A slave-only song is "missing from the master" when the master has its
// album folder (the song was probably never added there) or at least its
// artist folder (the album is missing). Folders are compared with disc
// folders ignored and release labels such as "Deluxe Edition" stripped, so
// "Robot Face (Deluxe)" in the slave finds "Robot Face" in the master.
// Missing levels.
const (
MissingAlbum = "album"
MissingArtist = "artist"
)
// folderNorm is a folder name reduced to letters and digits, release labels removed.
func folderNorm(name string) string {
if w := words(stripRelease(name)); len(w) > 0 {
return strings.Join(w, "")
}
return strings.ToLower(name)
}
// folderKeys returns the normalized artist key and album key of a song's
// folders; the album key is empty for a song directly in the artist folder.
func folderKeys(rel string) (artist, album string) {
var parts []string
for _, p := range strings.Split(filepath.Dir(rel), string(filepath.Separator)) {
if p != "." && !discFolder.MatchString(p) {
parts = append(parts, folderNorm(p))
}
}
if len(parts) == 0 {
return "", ""
}
if len(parts) > 1 {
album = strings.Join(parts, "/")
}
return parts[0], album
}
// masterFolders indexes the master's artist and album folders by key, to
// their real spelling.
type masterFolders struct {
artists map[string]string
albums map[string]string
}
func buildMasterFolders(rels []string) masterFolders {
mf := masterFolders{artists: map[string]string{}, albums: map[string]string{}}
for _, rel := range rels {
artist, album := folderKeys(rel)
if artist == "" {
continue
}
if _, ok := mf.artists[artist]; !ok {
mf.artists[artist] = firstComponent(rel)
}
if album != "" {
if _, ok := mf.albums[album]; !ok {
d := filepath.Dir(rel)
for d != "." && discFolder.MatchString(filepath.Base(d)) {
d = filepath.Dir(d)
}
mf.albums[album] = d
}
}
}
return mf
}
// missing reports whether the master has a slave-only song's album or
// artist, with a reason naming the master's folder.
func (mf masterFolders) missing(relSlave string) (level, reason string) {
artist, album := folderKeys(relSlave)
if artist == "" {
return "", ""
}
if d, ok := mf.albums[album]; ok && album != "" {
return MissingAlbum, fmt.Sprintf("the master has the album %q but not this song", d)
}
if d, ok := mf.artists[artist]; ok {
return MissingArtist, fmt.Sprintf("the master has the artist %q but not this album", d)
}
return "", ""
}
+64
View File
@@ -0,0 +1,64 @@
package engine
import (
"strings"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func TestMissingFromMaster(t *testing.T) {
ev := setup(t, false)
m, s := ev.master, ev.slave
ev.write(m, "Caravan Palace/Robot Face/01 Lone Digger.flac", "a")
ev.write(m, "Caravan Palace/Chronologic/Disc 1/01 Miracle.flac", "b")
ev.write(s, "Caravan Palace/Robot Face/01 Lone Digger.flac", "a")
ev.write(s, "Caravan Palace/Robot Face/05 Waterguns.mp3", "song") // album in master
ev.write(s, "Caravan Palace/robot face (Deluxe Edition)/12 Bonus.mp3", "song") // same album, other spelling
ev.write(s, "Caravan Palace/Chronologic/Disc 2/03 Plume.mp3", "song") // disc folders ignored
ev.write(s, "Caravan Palace/Panic/01 Dragons.mp3", "song") // only the artist
ev.write(s, "Caravan Palace/Loose.mp3", "song") // only the artist
ev.write(s, "Unknown Artist/Album/Song.mp3", "song") // neither
ev.scan()
want := map[string]string{
"Caravan Palace/Robot Face/05 Waterguns.mp3": "album",
"Caravan Palace/robot face (Deluxe Edition)/12 Bonus.mp3": "album",
"Caravan Palace/Chronologic/Disc 2/03 Plume.mp3": "album",
"Caravan Palace/Panic/01 Dragons.mp3": "artist",
"Caravan Palace/Loose.mp3": "artist",
"Unknown Artist/Album/Song.mp3": "",
"Caravan Palace/Robot Face/01 Lone Digger.flac": "",
}
for rel, level := range want {
mt, err := ev.st.MatchBySlave(ctx, ev.pair.ID, rel)
if err != nil {
t.Fatal(rel, err)
}
if mt.Missing != level {
t.Errorf("%s: missing %q, want %q", rel, mt.Missing, level)
}
}
mt, _ := ev.st.MatchBySlave(ctx, ev.pair.ID, "Caravan Palace/Chronologic/Disc 2/03 Plume.mp3")
if !strings.Contains(mt.Reason, `"Caravan Palace/Chronologic"`) {
t.Errorf("reason should name the master's album folder: %q", mt.Reason)
}
sum, err := ev.st.Summary(ctx, ev.pair.ID)
if err != nil || sum.Missing["album"] != 3 || sum.Missing["artist"] != 2 {
t.Fatalf("summary %+v %v", sum.Missing, err)
}
if _, n, _ := ev.st.Matches(ctx, ev.pair.ID, store.MatchFilter{Status: "missing"}); n != 5 {
t.Fatalf("missing filter: %d", n)
}
if _, n, _ := ev.st.Matches(ctx, ev.pair.ID, store.MatchFilter{Status: "missing_album"}); n != 3 {
t.Fatalf("missing_album filter: %d", n)
}
// Once the master gets the song, it is no longer missing.
ev.write(m, "Caravan Palace/Robot Face/05 Waterguns.mp3", "song")
ev.scan()
if mt, _ := ev.st.MatchBySlave(ctx, ev.pair.ID, "Caravan Palace/Robot Face/05 Waterguns.mp3"); mt.Missing != "" || mt.Status == store.StatusUnmatched {
t.Fatalf("now in master: %+v", mt)
}
}
+9
View File
@@ -105,6 +105,8 @@ type masterIndex struct {
byDir map[string][]string // lower(dir) -> rels byDir map[string][]string // lower(dir) -> rels
byTitle map[string][]string // lower(top folder)|normTitle -> rels byTitle map[string][]string // lower(top folder)|normTitle -> rels
folders masterFolders // artist and album folders, for songs missing from the master
approx *approxIndex // nil unless the pair enables approximate matching approx *approxIndex // nil unless the pair enables approximate matching
threshold float64 threshold float64
verify VerifyFunc // confirms library-wide approximate matches; nil disables that scope verify VerifyFunc // confirms library-wide approximate matches; nil disables that scope
@@ -113,6 +115,7 @@ type masterIndex struct {
func buildIndex(files map[string]fsops.Info, p store.Pair) *masterIndex { func buildIndex(files map[string]fsops.Info, p store.Pair) *masterIndex {
ix := &masterIndex{files: files, lower: map[string]string{}, byDir: map[string][]string{}, byTitle: map[string][]string{}} ix := &masterIndex{files: files, lower: map[string]string{}, byDir: map[string][]string{}, byTitle: map[string][]string{}}
rels := sortedKeys(files) rels := sortedKeys(files)
ix.folders = buildMasterFolders(rels)
if p.ApproxMatching { if p.ApproxMatching {
ix.approx = buildApproxIndex(rels) ix.approx = buildApproxIndex(rels)
ix.threshold = float64(p.ApproxThreshold) / 100 ix.threshold = float64(p.ApproxThreshold) / 100
@@ -327,6 +330,9 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
pm = &p pm = &p
} }
e.classify(ctx, l, &m, masterFiles[m.RelMaster], si, pm) e.classify(ctx, l, &m, masterFiles[m.RelMaster], si, pm)
if m.Status == store.StatusUnmatched {
m.Missing, m.Reason = ix.folders.missing(rel)
}
if err := e.st.UpsertMatch(ctx, &m); err != nil { if err := e.st.UpsertMatch(ctx, &m); err != nil {
return err return err
} }
@@ -483,6 +489,9 @@ func (e *Engine) refresh(ctx context.Context, l *libs, relSlave string, hint ...
m.Kind = store.KindNone m.Kind = store.KindNone
} }
e.classify(ctx, l, &m, mi, si, pm) e.classify(ctx, l, &m, mi, si, pm)
if m.Status == store.StatusUnmatched && pm != nil && pm.Status == store.StatusUnmatched {
m.Missing, m.Reason = pm.Missing, pm.Reason // the next scan re-checks the master's folders
}
_ = e.st.UpsertMatch(ctx, &m) _ = e.st.UpsertMatch(ctx, &m)
} }
+33 -9
View File
@@ -286,20 +286,21 @@ type Match struct {
SlaveProbe json.RawMessage `json:"slave_probe,omitempty"` SlaveProbe json.RawMessage `json:"slave_probe,omitempty"`
Verdict json.RawMessage `json:"verdict,omitempty"` Verdict json.RawMessage `json:"verdict,omitempty"`
Note string `json:"note"` Note string `json:"note"`
Reason string `json:"reason"` // why an approximate match was chosen Reason string `json:"reason"` // why an approximate match was chosen, or what the master has of a missing song
Missing string `json:"missing"` // slave-only song: the master has its "album" or only its "artist"
LinkTarget string `json:"link_target"` // where linking puts the slave file (filled by the API) LinkTarget string `json:"link_target"` // where linking puts the slave file (filled by the API)
Ignored bool `json:"ignored"` Ignored bool `json:"ignored"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
const matchCols = `id, pair_id, rel_slave, rel_master, kind, status, master_ino, master_size, master_mtime, const matchCols = `id, pair_id, rel_slave, rel_master, kind, status, master_ino, master_size, master_mtime,
slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, reason, ignored, updated_at` slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, reason, missing, ignored, updated_at`
func scanMatch(r scanner) (Match, error) { func scanMatch(r scanner) (Match, error) {
var m Match var m Match
var mp, sp, v string var mp, sp, v string
err := r.Scan(&m.ID, &m.PairID, &m.RelSlave, &m.RelMaster, &m.Kind, &m.Status, &m.MasterIno, &m.MasterSize, &m.MasterMtime, err := r.Scan(&m.ID, &m.PairID, &m.RelSlave, &m.RelMaster, &m.Kind, &m.Status, &m.MasterIno, &m.MasterSize, &m.MasterMtime,
&m.SlaveIno, &m.SlaveSize, &m.SlaveMtime, &mp, &sp, &v, &m.Note, &m.Reason, &m.Ignored, &m.UpdatedAt) &m.SlaveIno, &m.SlaveSize, &m.SlaveMtime, &mp, &sp, &v, &m.Note, &m.Reason, &m.Missing, &m.Ignored, &m.UpdatedAt)
if mp != "" { if mp != "" {
m.MasterProbe = json.RawMessage(mp) m.MasterProbe = json.RawMessage(mp)
} }
@@ -348,7 +349,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), "upgrade", "missing[_album|_artist]", "ignored" or ""
Query string Query string
IDs []int64 // restrict to these matches (at most maxIDFilter) IDs []int64 // restrict to these matches (at most maxIDFilter)
Limit int Limit int
@@ -367,6 +368,11 @@ func matchWhere(pairID int64, f MatchFilter) (string, []any) {
where = append(where, "ignored = 1") where = append(where, "ignored = 1")
case "upgrade": case "upgrade":
where = append(where, "status IN ('different','similar') AND ignored = 0 AND "+upgradeCond) where = append(where, "status IN ('different','similar') AND ignored = 0 AND "+upgradeCond)
case "missing":
where = append(where, "status = 'unmatched' AND missing != '' AND ignored = 0")
case "missing_album", "missing_artist":
where = append(where, "status = 'unmatched' AND missing = ? AND ignored = 0")
args = append(args, strings.TrimPrefix(f.Status, "missing_"))
default: default:
where = append(where, "status = ? AND ignored = 0") where = append(where, "status = ? AND ignored = 0")
args = append(args, f.Status) args = append(args, f.Status)
@@ -508,15 +514,15 @@ func (s *Store) IdenticalIDs(ctx context.Context, pairID int64) ([]int64, error)
func (s *Store) UpsertMatch(ctx context.Context, m *Match) error { func (s *Store) UpsertMatch(ctx context.Context, m *Match) error {
nullable := func(r json.RawMessage) string { return string(r) } nullable := func(r json.RawMessage) string { return string(r) }
_, err := s.DB.ExecContext(ctx, `INSERT INTO matches (pair_id, rel_slave, rel_master, kind, status, master_ino, master_size, _, err := s.DB.ExecContext(ctx, `INSERT INTO matches (pair_id, rel_slave, rel_master, kind, status, master_ino, master_size,
master_mtime, slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, reason, ignored, updated_at) master_mtime, slave_ino, slave_size, slave_mtime, master_probe, slave_probe, verdict, note, reason, missing, ignored, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pair_id, rel_slave) DO UPDATE SET rel_master = excluded.rel_master, kind = excluded.kind, ON CONFLICT(pair_id, rel_slave) DO UPDATE SET rel_master = excluded.rel_master, kind = excluded.kind,
status = excluded.status, master_ino = excluded.master_ino, master_size = excluded.master_size, status = excluded.status, master_ino = excluded.master_ino, master_size = excluded.master_size,
master_mtime = excluded.master_mtime, slave_ino = excluded.slave_ino, slave_size = excluded.slave_size, master_mtime = excluded.master_mtime, slave_ino = excluded.slave_ino, slave_size = excluded.slave_size,
slave_mtime = excluded.slave_mtime, master_probe = excluded.master_probe, slave_probe = excluded.slave_probe, slave_mtime = excluded.slave_mtime, master_probe = excluded.master_probe, slave_probe = excluded.slave_probe,
verdict = excluded.verdict, note = excluded.note, reason = excluded.reason, ignored = excluded.ignored, updated_at = excluded.updated_at`, verdict = excluded.verdict, note = excluded.note, reason = excluded.reason, missing = excluded.missing, ignored = excluded.ignored, updated_at = excluded.updated_at`,
m.PairID, m.RelSlave, m.RelMaster, m.Kind, m.Status, m.MasterIno, m.MasterSize, m.MasterMtime, m.SlaveIno, m.SlaveSize, m.PairID, m.RelSlave, m.RelMaster, m.Kind, m.Status, m.MasterIno, m.MasterSize, m.MasterMtime, m.SlaveIno, m.SlaveSize,
m.SlaveMtime, nullable(m.MasterProbe), nullable(m.SlaveProbe), nullable(m.Verdict), m.Note, m.Reason, m.Ignored, now()) m.SlaveMtime, nullable(m.MasterProbe), nullable(m.SlaveProbe), nullable(m.Verdict), m.Note, m.Reason, m.Missing, m.Ignored, now())
return err return err
} }
@@ -579,12 +585,13 @@ type Summary struct {
Review int `json:"review"` Review int `json:"review"`
Upgrades int `json:"upgrades"` Upgrades int `json:"upgrades"`
Ignored int `json:"ignored"` Ignored int `json:"ignored"`
Missing map[string]int `json:"missing"` // slave-only songs by what the master has: album, artist
ReclaimableSize int64 `json:"reclaimable_bytes"` ReclaimableSize int64 `json:"reclaimable_bytes"`
LinkedSize int64 `json:"linked_bytes"` LinkedSize int64 `json:"linked_bytes"`
} }
func (s *Store) Summary(ctx context.Context, pairID int64) (Summary, error) { func (s *Store) Summary(ctx context.Context, pairID int64) (Summary, error) {
sum := Summary{Counts: map[string]int{}} sum := Summary{Counts: map[string]int{}, Missing: map[string]int{}}
rows, err := s.DB.QueryContext(ctx, `SELECT status, ignored, COUNT(*), COALESCE(SUM(slave_size), 0) FROM matches WHERE pair_id = ? GROUP BY status, ignored`, pairID) rows, err := s.DB.QueryContext(ctx, `SELECT status, ignored, COUNT(*), COALESCE(SUM(slave_size), 0) FROM matches WHERE pair_id = ? GROUP BY status, ignored`, pairID)
if err != nil { if err != nil {
return sum, err return sum, err
@@ -617,8 +624,25 @@ func (s *Store) Summary(ctx context.Context, pairID int64) (Summary, error) {
} }
err = s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM matches WHERE pair_id = ? AND ignored = 0 err = s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM matches WHERE pair_id = ? AND ignored = 0
AND status IN ('different','similar') AND `+upgradeCond, pairID).Scan(&sum.Upgrades) AND status IN ('different','similar') AND `+upgradeCond, pairID).Scan(&sum.Upgrades)
if err != nil {
return sum, err return sum, err
} }
mrows, err := s.DB.QueryContext(ctx, `SELECT missing, COUNT(*) FROM matches WHERE pair_id = ? AND ignored = 0
AND status = 'unmatched' AND missing != '' GROUP BY missing`, pairID)
if err != nil {
return sum, err
}
defer mrows.Close()
for mrows.Next() {
var k string
var n int
if err := mrows.Scan(&k, &n); err != nil {
return sum, err
}
sum.Missing[k] = n
}
return sum, mrows.Err()
}
// ---- operations ---- // ---- operations ----
+3
View File
@@ -140,6 +140,9 @@ var migrations = []string{
// 10: library type (music or soundtracks) and the step-by-step fixer's progress. // 10: library type (music or soundtracks) and the step-by-step fixer's progress.
`ALTER TABLE pairs ADD COLUMN library_type TEXT NOT NULL DEFAULT 'music'; `ALTER TABLE pairs ADD COLUMN library_type TEXT NOT NULL DEFAULT 'music';
ALTER TABLE pairs ADD COLUMN fixer_state TEXT NOT NULL DEFAULT '';`, ALTER TABLE pairs ADD COLUMN fixer_state TEXT NOT NULL DEFAULT '';`,
// 11: slave-only songs whose album or artist the master has.
`ALTER TABLE matches ADD COLUMN missing TEXT NOT NULL DEFAULT '';`,
} }
// SchemaVersion is the newest database schema this build knows. // SchemaVersion is the newest database schema this build knows.
+2
View File
@@ -57,6 +57,7 @@ export type Summary = {
review: number; review: number;
upgrades: number; upgrades: number;
ignored: number; ignored: number;
missing?: { album?: number; artist?: number };
reclaimable_bytes: number; reclaimable_bytes: number;
linked_bytes: number; linked_bytes: number;
}; };
@@ -132,6 +133,7 @@ export type Match = {
verdict?: Verdict; verdict?: Verdict;
note: string; note: string;
reason: string; reason: string;
missing: "" | "album" | "artist";
link_target: string; link_target: string;
ignored: boolean; ignored: boolean;
}; };
+66 -4
View File
@@ -1,6 +1,6 @@
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, Disc3, Download, ListMusic, ChevronDown, ChevronRight, CopyCheck, CopyMinus, EyeOff, Eye, Link2, Search, Crown } from "lucide-react"; import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, FolderSearch, 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 { 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";
@@ -13,6 +13,7 @@ const filters = [
{ id: "identical", label: "Identical" }, { id: "identical", label: "Identical" },
{ id: "linked", label: "Linked" }, { id: "linked", label: "Linked" },
{ id: "unmatched", label: "Slave only" }, { id: "unmatched", label: "Slave only" },
{ id: "missing", label: "Missing from master" },
{ id: "error", label: "Errors" }, { id: "error", label: "Errors" },
{ id: "ignored", label: "Ignored" }, { id: "ignored", label: "Ignored" },
{ id: "", label: "All" }, { id: "", label: "All" },
@@ -86,8 +87,21 @@ export function Review({ pair }: { pair: Pair }) {
}); });
const c = pair.summary.counts; const c = pair.summary.counts;
const miss = pair.summary.missing ?? {};
const count = (id: string) => const count = (id: string) =>
id === "review" ? pair.summary.review : id === "upgrade" ? pair.summary.upgrades : id === "ignored" ? pair.summary.ignored : id === "" ? undefined : c[id] ?? 0; id === "review"
? pair.summary.review
: id === "upgrade"
? pair.summary.upgrades
: id === "ignored"
? pair.summary.ignored
: id === "missing"
? (miss.album ?? 0) + (miss.artist ?? 0)
: id === ""
? undefined
: c[id] ?? 0;
// The missing filter has two levels; the chip covers all three.
const active = (id: string) => status === id || (id === "missing" && status.startsWith("missing_"));
const identical = c.identical ?? 0; const identical = c.identical ?? 0;
const running = pair.job?.state === "running"; const running = pair.job?.state === "running";
@@ -100,7 +114,7 @@ export function Review({ pair }: { pair: Pair }) {
onClick={() => setStatus(f.id)} onClick={() => setStatus(f.id)}
className={cx( className={cx(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs cursor-pointer", "flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs cursor-pointer",
status === f.id ? "border-accent bg-accent/15 text-white" : "border-line text-muted hover:text-white", active(f.id) ? "border-accent bg-accent/15 text-white" : "border-line text-muted hover:text-white",
)} )}
> >
{f.label} {f.label}
@@ -139,7 +153,7 @@ export function Review({ pair }: { pair: Pair }) {
</button> </button>
))} ))}
</div> </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) => active(f.id))?.label ?? "All"} />
</div> </div>
)} )}
</div> </div>
@@ -148,6 +162,7 @@ export function Review({ pair }: { pair: Pair }) {
<Duplicates pair={pair} /> <Duplicates pair={pair} />
) : ( ) : (
<> <>
{status.startsWith("missing") && <MissingIntro pair={pair} status={status} setStatus={setStatus} />}
{status === "identical" && identical > 0 && ( {status === "identical" && identical > 0 && (
<Card className="flex flex-wrap items-center justify-between gap-3 p-4"> <Card className="flex flex-wrap items-center justify-between gap-3 p-4">
@@ -233,6 +248,50 @@ export function Review({ pair }: { pair: Pair }) {
); );
} }
function MissingIntro({ pair, status, setStatus }: { pair: Pair; status: string; setStatus: (s: string) => void }) {
const miss = pair.summary.missing ?? {};
const levels = [
{ id: "missing", label: "All", n: (miss.album ?? 0) + (miss.artist ?? 0) },
{ id: "missing_album", label: "Album in master", n: miss.album ?? 0 },
{ id: "missing_artist", label: "Only the artist in master", n: miss.artist ?? 0 },
];
return (
<Card className="space-y-3 p-4">
<div className="flex items-start gap-3">
<FolderSearch className="mt-0.5 size-5 shrink-0 text-accent" />
<div className="text-sm">
<div className="font-medium">Songs the master library is missing</div>
<div className="mt-0.5 text-muted">
These songs are only in the slave library, but the master has their artist, and often the album too. For example, the slave has{" "}
<i>Caravan Palace/Robot Face/Waterguns</i> and the master has the <i>Caravan Palace/Robot Face</i> album without that song. Add them to
the master to complete it, then scan again to link them. Nothing here is changed automatically.
</div>
{!pair.approx_matching && (
<div className="mt-1.5 text-xs text-amber-300/90">
Approximate matching is off for this pair, so a song filed under a slightly different name or another release (a single vs. the
album) shows up here too. Turn it on in Edit to find those.
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-1 rounded-lg border border-line p-0.5 sm:w-fit">
{levels.map((l) => (
<button
key={l.id}
onClick={() => setStatus(l.id)}
className={cx(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs cursor-pointer",
status === l.id ? "bg-panel-2 text-white" : "text-muted hover:text-white",
)}
>
{l.label} <span className="tabular-nums opacity-70">{l.n}</span>
</button>
))}
</div>
</Card>
);
}
function ExportMenu({ pairId, status, query, viewCount, filterLabel }: { pairId: number; status: string; query: string; viewCount?: number; filterLabel: string }) { function ExportMenu({ pairId, status, query, viewCount, filterLabel }: { pairId: number; status: string; query: string; viewCount?: number; filterLabel: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -335,6 +394,9 @@ export function MatchRow({ m, selected, onToggle }: { m: Match; selected?: boole
<div className="flex w-28 shrink-0 flex-col items-start gap-1"> <div className="flex w-28 shrink-0 flex-col items-start gap-1">
<Badge tone={statusTone[m.status]}>{m.status}</Badge> <Badge tone={statusTone[m.status]}>{m.status}</Badge>
{m.kind !== "path" && m.kind !== "none" && <span className="text-[10px] text-muted">{kindLabel[m.kind]}</span>} {m.kind !== "path" && m.kind !== "none" && <span className="text-[10px] text-muted">{kindLabel[m.kind]}</span>}
{m.status === "unmatched" && m.missing && (
<span className="text-[10px] text-amber-300/90">{m.missing === "album" ? "album in master" : "artist in master"}</span>
)}
</div> </div>
<div className="min-w-0 flex-1" onClick={() => reviewable && setOpen(!open)}> <div className="min-w-0 flex-1" onClick={() => reviewable && setOpen(!open)}>
<PathLine m={m} /> <PathLine m={m} />