Add finding and removing folders without music

The new Cleanup tab lists the top-most folders in either library that
contain no music file (per the pair's extensions). Removing them needs
confirmation, plus an explicit acknowledgement when they still hold
files; each folder is re-checked for music and moved into the
quarantine (undoable from History, purged after the retention period).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 12:24:47 -07:00
1 parent e0617047f7
commit 30fab3a208
11 files changed
+760 -8

No files matched your search

+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";
action: "link" | "promote" | "build" | "remove" | "rename" | "remove-folder";
rel_master: string;
rel_slave: string;
rel_new: string;
+13 -6
View File
@@ -37,6 +37,7 @@ const actionLabel: Record<Operation["action"], string> = {
build: "Built into slave",
remove: "Removed from slave",
rename: "Renamed slave folder",
"remove-folder": "Removed folder without music",
};
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
@@ -83,20 +84,23 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
</Empty>
)}
{items.map((o) => {
const canUndo = o.status === "done" && !o.undone_at && !o.purged_at;
// History may hold actions of removed features: shown, never undone.
const known = o.action in actionLabel;
const canUndo = known && o.status === "done" && !o.undone_at && !o.purged_at;
const inQuarantine = !!o.quarantine_path && !o.purged_at && !o.undone_at && (o.status === "done" || o.status === "interrupted");
return (
<div key={o.id} className="flex flex-wrap items-center gap-3 border-b border-line px-4 py-2.5 last:border-0">
<div className="w-40 shrink-0">
<div className="text-sm">{actionLabel[o.action]}</div>
<div className="text-sm">{actionLabel[o.action] ?? o.action}</div>
<div className="text-[11px] text-muted">
{formatAgo(o.created_at)} · {o.trigger}
{!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="truncate mono text-[13px]" title={o.rel_slave}>
{o.rel_slave}
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
{o.action === "remove-folder" && <span className="mr-1.5 text-muted">{o.quarantine_side}:</span>}
{o.rel_slave || o.rel_master}
</div>
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
@@ -104,7 +108,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
{o.error && <div className="text-xs text-red-300">{o.error}</div>}
{inQuarantine && (
<div className="truncate text-xs text-amber-300/80 mono" title={o.quarantine_path}>
{o.quarantine_side} original kept in quarantine
{o.action === "remove-folder" ? "folder" : `${o.quarantine_side} original`} kept in quarantine
</div>
)}
</div>
@@ -145,7 +149,8 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
<p className="text-sm text-gray-300">
{confirm.kind === "purge" ? (
<>
The quarantined {confirm.op.quarantine_side} original of <code className="mono break-all">{confirm.op.rel_slave}</code> will be deleted.{" "}
The quarantined {confirm.op.action === "remove-folder" ? "folder" : `${confirm.op.quarantine_side} original of`}{" "}
<code className="mono break-all">{confirm.op.rel_slave || confirm.op.rel_master}</code> will be deleted.{" "}
<b>This cannot be undone.</b>
</>
) : (
@@ -180,5 +185,7 @@ function undoText(o: Operation) {
return "The hardlink is recreated in the slave library.";
case "rename":
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.`;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, FolderX, Search } from "lucide-react";
import { api, type Pair } from "../api";
import { Alert, Badge, Button, Card, Modal, Toggle, cx, formatAgo, formatBytes, inputCls } from "../ui";
import { useToast } from "../toast";
import { JobBar } from "./PairPage";
type Side = "slave" | "master";
type NoMusicFolder = { rel: string; files: number; folders: number; bytes: number; sample: string[] | null };
type NoMusicScan = { side: Side; created_at: number; folders: NoMusicFolder[] };
export function Cleanup({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const [side, setSide] = useState<Side>("slave");
const [sel, setSel] = useState<Set<string>>(new Set());
const [confirm, setConfirm] = useState(false);
const data = useQuery({
queryKey: ["empty-folders", pair.id],
queryFn: () => api.get<{ scan: NoMusicScan | null; dry_run: boolean }>(`/pairs/${pair.id}/empty-folders`),
});
const scan = data.data?.scan ?? null;
const running = pair.job?.state === "running";
const ourJob = running && pair.job!.kind.includes("empty-folders");
const refresh = () => {
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["empty-folders", pair.id] });
};
// Empty folders are selected by default; folders holding files are not.
useEffect(() => {
setSel(new Set((scan?.folders ?? []).filter((f) => f.files === 0).map((f) => f.rel)));
}, [scan?.created_at, scan?.side]); // eslint-disable-line react-hooks/exhaustive-deps
const find = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/empty-folders/scan`, { side }),
onSuccess: refresh,
onError: (e) => toast("err", e.message),
});
const folders = scan?.folders ?? [];
const chosen = folders.filter((f) => sel.has(f.rel));
const allOn = folders.length > 0 && chosen.length === folders.length;
return (
<div className="space-y-4">
<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">
<FolderX className="size-4 text-accent" /> Folders without music
</h2>
<p className="mt-1 text-sm text-muted">
Finds folders with no music file anywhere inside them (music = this pair's file extensions), such as album folders left empty after
linking, or ones holding only a cover or a playlist. Nothing is removed without your confirmation, and removed folders go to the
quarantine, so they can be restored from History.
</p>
</div>
<div className="flex items-center gap-2">
<select className={inputCls + " h-9 w-auto"} value={side} onChange={(e) => setSide(e.target.value as Side)}>
<option value="slave">Slave library</option>
<option value="master">Master library</option>
</select>
<Button variant="primary" onClick={() => find.mutate()} disabled={running} loading={find.isPending}>
<Search className="size-4" /> Find folders
</Button>
</div>
</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">
<span className="font-medium">{folders.length}</span> folders without music in the <b>{scan.side}</b> library
<span className="text-muted"> · checked {formatAgo(scan.created_at)}</span>
</div>
{folders.length > 0 && (
<Button variant="danger" disabled={chosen.length === 0 || running} onClick={() => setConfirm(true)}>
<FolderX className="size-4" /> Remove {chosen.length} {chosen.length === 1 ? "folder" : "folders"}…
</Button>
)}
</div>
{folders.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted">Every folder in the {scan.side} library contains music.</div>
) : (
<>
<label className="flex items-center gap-2 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted cursor-pointer">
<input
type="checkbox"
className="accent-violet-500"
checked={allOn}
onChange={() => setSel(allOn ? new Set() : new Set(folders.map((f) => f.rel)))}
/>
Select all · empty folders are selected to start with; folders that still hold files are not
</label>
<div className="max-h-[60vh] divide-y divide-line overflow-y-auto">
{folders.map((f) => (
<label key={f.rel} className="flex items-start gap-3 px-4 py-2 hover:bg-panel-2 cursor-pointer">
<input
type="checkbox"
className="mt-1 accent-violet-500"
checked={sel.has(f.rel)}
onChange={() =>
setSel((s) => {
const n = new Set(s);
n.has(f.rel) ? n.delete(f.rel) : n.add(f.rel);
return n;
})
}
/>
<div className="min-w-0 flex-1">
<div className="mono break-all text-sm text-gray-100">{f.rel}</div>
{f.files > 0 && (
<div className="mono truncate text-xs text-muted" title={(f.sample ?? []).join("\n")}>
{(f.sample ?? []).join(", ")}
{f.files > (f.sample?.length ?? 0) && ` and ${f.files - (f.sample?.length ?? 0)} more`}
</div>
)}
</div>
{f.files === 0 ? (
<Badge>empty</Badge>
) : (
<Badge tone="amber">
{f.files} {f.files === 1 ? "file" : "files"} · {formatBytes(f.bytes)}
</Badge>
)}
</label>
))}
</div>
</>
)}
</Card>
)}
{confirm && scan && (
<ConfirmRemove
pair={pair}
side={scan.side}
folders={chosen}
dry={data.data?.dry_run ?? true}
onClose={() => setConfirm(false)}
onDone={() => {
setConfirm(false);
refresh();
}}
/>
)}
</div>
);
}
function ConfirmRemove({
pair,
side,
folders,
dry,
onClose,
onDone,
}: {
pair: Pair;
side: Side;
folders: NoMusicFolder[];
dry: boolean;
onClose: () => void;
onDone: () => void;
}) {
const toast = useToast();
const withFiles = folders.filter((f) => f.files > 0);
const files = withFiles.reduce((n, f) => n + f.files, 0);
const bytes = withFiles.reduce((n, f) => n + f.bytes, 0);
const [checked, setChecked] = useState(withFiles.length === 0);
const remove = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/empty-folders/remove`, { folders: folders.map((f) => f.rel) }),
onSuccess: onDone,
onError: (e) => toast("err", e.message),
});
return (
<Modal open onClose={onClose} title={`Remove ${folders.length} ${folders.length === 1 ? "folder" : "folders"}?`}>
<div className="space-y-4 text-sm">
<p className="text-gray-300">
These folders in the <b>{side}</b> library are moved into the quarantine. Each one is checked for music again right before it moves, and
skipped if music appeared. 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"}.
</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">
{folders.map((f) => (
<div key={f.rel} className={cx("truncate", f.files > 0 && "text-amber-200")}>
{f.rel}
{f.files > 0 && ` (${f.files} ${f.files === 1 ? "file" : "files"})`}
</div>
))}
</div>
{withFiles.length > 0 && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
<div className="space-y-2">
<div>
{withFiles.length} of these folders still hold files that aren't music ({files} files, {formatBytes(bytes)}), such as covers, lyrics or
playlists. They move to the quarantine together with their folder.
</div>
<Toggle checked={checked} onChange={setChecked} label="I've checked these folders and want them removed" />
</div>
</Alert>
)}
{dry && <Alert tone="blue">Dry run is on: the removals are only recorded in Activity, and no folder moves.</Alert>}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="danger" disabled={!checked} loading={remove.isPending} onClick={() => remove.mutate()}>
<FolderX className="size-4" /> Remove {folders.length} {folders.length === 1 ? "folder" : "folders"}
</Button>
</div>
</div>
</Modal>
);
}
+4
View File
@@ -10,6 +10,7 @@ import { Review } from "./Review";
import { Build } from "./Build";
import { OpsTable } from "./Activity";
import { WarningList } from "./Warnings";
import { Cleanup } from "./Cleanup";
export function usePair(id: number) {
return useQuery({
@@ -38,6 +39,7 @@ function useJobFinished(pair: Pair | undefined) {
qc.invalidateQueries({ queryKey: ["tree"] });
qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["empty-folders"] });
const j = pair.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`);
@@ -122,6 +124,7 @@ function LastJob({ job }: { job: Job }) {
const tabs = [
{ id: "review", label: "Review" },
{ id: "build", label: "Build" },
{ id: "cleanup", label: "Cleanup" },
{ id: "history", label: "History" },
];
@@ -202,6 +205,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
{tab === "review" && <Review pair={pair} />}
{tab === "build" && <Build pair={pair} />}
{tab === "cleanup" && <Cleanup pair={pair} />}
{tab === "history" && <OpsTable pairId={pair.id} />}
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />