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
+281 -15

No files matched your search

+2
View File
@@ -57,6 +57,7 @@ export type Summary = {
review: number;
upgrades: number;
ignored: number;
missing?: { album?: number; artist?: number };
reclaimable_bytes: number;
linked_bytes: number;
};
@@ -132,6 +133,7 @@ export type Match = {
verdict?: Verdict;
note: string;
reason: string;
missing: "" | "album" | "artist";
link_target: string;
ignored: boolean;
};
+66 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
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 { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui";
import { useToast } from "../toast";
@@ -13,6 +13,7 @@ const filters = [
{ id: "identical", label: "Identical" },
{ id: "linked", label: "Linked" },
{ id: "unmatched", label: "Slave only" },
{ id: "missing", label: "Missing from master" },
{ id: "error", label: "Errors" },
{ id: "ignored", label: "Ignored" },
{ id: "", label: "All" },
@@ -86,8 +87,21 @@ export function Review({ pair }: { pair: Pair }) {
});
const c = pair.summary.counts;
const miss = pair.summary.missing ?? {};
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 running = pair.job?.state === "running";
@@ -100,7 +114,7 @@ export function Review({ pair }: { pair: Pair }) {
onClick={() => setStatus(f.id)}
className={cx(
"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}
@@ -139,7 +153,7 @@ export function Review({ pair }: { pair: Pair }) {
</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) => active(f.id))?.label ?? "All"} />
</div>
)}
</div>
@@ -148,6 +162,7 @@ export function Review({ pair }: { pair: Pair }) {
<Duplicates pair={pair} />
) : (
<>
{status.startsWith("missing") && <MissingIntro pair={pair} status={status} setStatus={setStatus} />}
{status === "identical" && identical > 0 && (
<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 }) {
const [open, setOpen] = useState(false);
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">
<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.status === "unmatched" && m.missing && (
<span className="text-[10px] text-amber-300/90">{m.missing === "album" ? "album in master" : "artist in master"}</span>
)}
</div>
<div className="min-w-0 flex-1" onClick={() => reviewable && setOpen(!open)}>
<PathLine m={m} />