Notifications, and automatic cleanup of empty folders

Settings → Notifications sends a message to Discord, Pushover, ntfy,
Gotify or a JSON webhook when a scan finds songs that newly need
review, when auto-link replaced identical files, and when an automatic
cleanup ran. Each event can be turned off and each service tested; an
optional Playlink address adds a link back to the right page.

A new per-pair option removes empty slave folders after each scan. Only
folders with no file at all inside are removed (into the quarantine,
recorded in History); folders holding any file wait in Cleanup, folders
changed within the settle time are skipped, and dry run removes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-08 15:36:04 -07:00
1 parent 590b8abe08
commit faebc5dfb0
17 files changed
+1086 -26

No files matched your search

+22
View File
@@ -69,6 +69,7 @@ export type Pair = {
quarantine_dir: string;
extensions: string[];
auto_link_identical: boolean;
auto_cleanup_empty: boolean;
scan_interval_minutes: number;
retention_days: number;
settle_minutes: number;
@@ -89,6 +90,7 @@ export type PairInput = Pick<
| "quarantine_dir"
| "extensions"
| "auto_link_identical"
| "auto_cleanup_empty"
| "scan_interval_minutes"
| "retention_days"
| "settle_minutes"
@@ -165,6 +167,26 @@ export type TreeEntry = {
linked?: number;
};
export type NotifyType = "discord" | "pushover" | "ntfy" | "gotify" | "webhook";
export type NotifyTarget = {
id: string;
type: NotifyType;
name: string;
enabled: boolean;
url: string;
token: string;
user: string;
};
export type NotifyConfig = {
on_review: boolean;
on_linked: boolean;
on_cleanup: boolean;
base_url: string;
targets: NotifyTarget[];
};
export type Page<T> = { items: T[]; total: number };
export type AlbumGroup = {
+10
View File
@@ -65,6 +65,16 @@ export function Cleanup({ pair }: { pair: Pair }) {
</Button>
</div>
</div>
<p className="text-xs text-muted">
{pair.auto_cleanup_empty ? (
<>
<b className="text-gray-300">Automatic cleanup is on:</b> empty slave folders are removed after each scan. Folders that still hold
files always wait here for you.
</>
) : (
<>Empty slave folders can also be removed automatically after each scan. Turn it on under Edit.</>
)}
</p>
{ourJob && <JobBar job={pair.job!} />}
</Card>
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Plus, Send, Trash2 } from "lucide-react";
import { api, type NotifyConfig, type NotifyTarget, type NotifyType } from "../api";
import { Button, Field, SettingRow, SettingsSection, Spinner, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
type FieldDef = { key: "url" | "token" | "user"; label: string; placeholder?: string; secret?: boolean };
const TYPES: Record<NotifyType, { label: string; hint: string; fields: FieldDef[] }> = {
discord: {
label: "Discord",
hint: "In Discord: Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.",
fields: [{ key: "url", label: "Webhook URL", placeholder: "https://discord.com/api/webhooks/…", secret: true }],
},
pushover: {
label: "Pushover",
hint: "Create an application at pushover.net for its token; your user key is on the Pushover dashboard.",
fields: [
{ key: "token", label: "Application token", secret: true },
{ key: "user", label: "User key", secret: true },
],
},
ntfy: {
label: "ntfy",
hint: "Subscribe to the same topic in the ntfy app. Self-hosted servers work too.",
fields: [
{ key: "url", label: "Topic URL", placeholder: "https://ntfy.sh/my-playlink" },
{ key: "token", label: "Access token (optional)", secret: true },
],
},
gotify: {
label: "Gotify",
hint: "Create an application in Gotify and copy its token.",
fields: [
{ key: "url", label: "Server URL", placeholder: "https://gotify.example.com" },
{ key: "token", label: "Application token", secret: true },
],
},
webhook: {
label: "Webhook",
hint: "Playlink POSTs JSON with event, pair_id, pair, title, message and url.",
fields: [{ key: "url", label: "URL", placeholder: "https://example.com/hooks/playlink" }],
},
};
const EVENTS: { key: "on_review" | "on_linked" | "on_cleanup"; title: string; description: string }[] = [
{ key: "on_review", title: "Songs need review", description: "A scan found songs that weren't waiting for review before." },
{ key: "on_linked", title: "Identical files were linked", description: "Automatic linking after a scan replaced byte-identical files with hardlinks." },
{ key: "on_cleanup", title: "Empty folders were cleaned up", description: "Automatic cleanup after a scan moved empty folders to the quarantine." },
];
const newID = () => Math.random().toString(36).slice(2, 10);
export function NotificationsSection() {
const qc = useQueryClient();
const toast = useToast();
const data = useQuery({ queryKey: ["notifications"], queryFn: () => api.get<NotifyConfig>("/notifications") });
const [cfg, setCfg] = useState<NotifyConfig | null>(null);
useEffect(() => {
if (data.data) setCfg(data.data);
}, [data.data]);
const saved = data.data;
const dirty = cfg && saved && JSON.stringify(cfg) !== JSON.stringify(saved);
const save = useMutation({
mutationFn: () => api.put<NotifyConfig>("/notifications", cfg),
onSuccess: () => {
toast("ok", "Notification settings saved");
qc.invalidateQueries({ queryKey: ["notifications"] });
},
onError: (e) => toast("err", e.message),
});
const test = useMutation({
mutationFn: (t: NotifyTarget) => api.post("/notifications/test", { target: t, base_url: cfg?.base_url ?? "" }),
onSuccess: (_, t) => toast("ok", `Test sent to ${t.name || TYPES[t.type].label}`),
onError: (e) => toast("err", e.message),
});
const setTarget = (id: string, patch: Partial<NotifyTarget>) =>
cfg && setCfg({ ...cfg, targets: cfg.targets.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
const add = (type: NotifyType) =>
cfg && setCfg({ ...cfg, targets: [...cfg.targets, { id: newID(), type, name: TYPES[type].label, enabled: true, url: "", token: "", user: "" }] });
return (
<SettingsSection
icon={<Bell />}
title="Notifications"
description="Get a message when something happens without you, such as after a scheduled scan."
>
{!cfg && (
<div className="px-5 py-4">
<Spinner />
</div>
)}
{cfg && (
<>
{EVENTS.map((ev) => (
<SettingRow key={ev.key} title={ev.title} description={ev.description}>
<Toggle ariaLabel={ev.title} checked={cfg[ev.key]} onChange={(v) => setCfg({ ...cfg, [ev.key]: v })} />
</SettingRow>
))}
<SettingRow title="Playlink address" description="Optional. Messages get a link back to the right page when this is set.">
<input
className={inputCls + " sm:w-72!"}
placeholder="http://tower.local:8080"
value={cfg.base_url}
onChange={(e) => setCfg({ ...cfg, base_url: e.target.value })}
/>
</SettingRow>
<div className="space-y-3 px-5 py-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted">Send to</div>
{cfg.targets.length === 0 && <p className="text-sm text-muted">No services yet. Add one below.</p>}
{cfg.targets.map((t) => {
const def = TYPES[t.type];
return (
<div key={t.id} className="space-y-3 rounded-lg border border-line bg-panel-2 p-3.5">
<div className="flex flex-wrap items-center gap-3">
<Toggle ariaLabel={`Send to ${t.name || def.label}`} checked={t.enabled} onChange={(v) => setTarget(t.id, { enabled: v })} />
<span className="rounded-md border border-line px-1.5 py-0.5 text-[11px] font-medium text-muted">{def.label}</span>
<input
className={inputCls + " h-8! min-w-0 flex-1 sm:max-w-60"}
aria-label="Name"
value={t.name}
onChange={(e) => setTarget(t.id, { name: e.target.value })}
/>
<div className="ml-auto flex gap-1.5">
<Button size="sm" onClick={() => test.mutate(t)} loading={test.isPending && test.variables?.id === t.id}>
<Send className="size-3.5" /> Test
</Button>
<Button
size="sm"
variant="ghost"
title="Remove"
onClick={() => setCfg({ ...cfg, targets: cfg.targets.filter((x) => x.id !== t.id) })}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{def.fields.map((f) => (
<Field key={f.key} label={f.label}>
<input
className={inputCls + (f.key === "url" ? " mono text-xs" : "")}
type={f.secret ? "password" : "text"}
autoComplete="off"
placeholder={f.placeholder}
value={t[f.key]}
onChange={(e) => setTarget(t.id, { [f.key]: e.target.value })}
/>
</Field>
))}
</div>
<p className="text-xs text-muted">{def.hint}</p>
</div>
);
})}
<div className="flex flex-wrap gap-2">
{(Object.keys(TYPES) as NotifyType[]).map((type) => (
<Button key={type} size="sm" variant="ghost" onClick={() => add(type)} disabled={cfg.targets.length >= 10}>
<Plus className="size-3.5" /> {TYPES[type].label}
</Button>
))}
</div>
</div>
{dirty && (
<div className="flex items-center justify-end gap-2 bg-accent/5 px-5 py-3">
<span className="mr-auto text-xs text-muted">Unsaved changes</span>
<Button size="sm" variant="ghost" onClick={() => saved && setCfg(saved)}>
Discard
</Button>
<Button size="sm" variant="primary" onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
</div>
)}
</>
)}
</SettingsSection>
);
}
+13
View File
@@ -20,6 +20,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
quarantine_dir: "",
extensions: [],
auto_link_identical: false,
auto_cleanup_empty: false,
scan_interval_minutes: 0,
retention_days: 30,
settle_minutes: 10,
@@ -47,6 +48,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
slave_root: f.slave_root,
quarantine_dir: f.quarantine_dir,
auto_link_identical: f.auto_link_identical,
auto_cleanup_empty: f.auto_cleanup_empty,
scan_interval_minutes: f.scan_interval_minutes,
retention_days: f.retention_days,
settle_minutes: f.settle_minutes,
@@ -133,6 +135,17 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
Anything else always waits for manual review. Dry run still applies.
</p>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={f.auto_cleanup_empty}
onChange={(v) => set("auto_cleanup_empty", v)}
label={<span className="font-medium">Automatically remove empty folders after each scan</span>}
/>
<p className="mt-1.5 pl-11.5 text-xs text-muted">
Only slave folders with nothing at all inside them. Folders that still hold a file, such as a cover or a playlist, are left for you in
Cleanup, and folders changed within the settle time are left alone. Removed folders go to the quarantine. Dry run still applies.
</p>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={f.approx_matching}
+3
View File
@@ -6,6 +6,7 @@ import { useInfo } from "../App";
import { Alert, Button, Field, SettingRow, SettingsSection, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
import { BackupsSection } from "./Backups";
import { NotificationsSection } from "./Notifications";
// Guarantees that apply whatever the settings are.
const ALWAYS_ON = [
@@ -93,6 +94,8 @@ export function SettingsPage() {
</SettingRow>
</SettingsSection>
<NotificationsSection />
<BackupsSection />
<SettingsSection icon={<KeyRound />} title="Account">