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:
1 parent
590b8abe08
commit
faebc5dfb0
17 files changed
+1086
-26
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user