From faebc5dfb0228b93be2f1f033b80401c76c4a4dc Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 8 Oct 2026 15:36:04 -0700 Subject: [PATCH] Notifications, and automatic cleanup of empty folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 16 +- cmd/playlink/main.go | 5 +- internal/api/notify.go | 47 +++++ internal/api/server.go | 6 +- internal/engine/autocleanup_test.go | 137 +++++++++++++ internal/engine/emptydirs.go | 109 +++++++++- internal/engine/engine.go | 36 ++++ internal/engine/scan.go | 52 ++++- internal/notify/notify.go | 299 ++++++++++++++++++++++++++++ internal/notify/notify_test.go | 155 ++++++++++++++ internal/store/models.go | 15 +- internal/store/store.go | 3 + web/src/api.ts | 22 ++ web/src/pages/Cleanup.tsx | 10 + web/src/pages/Notifications.tsx | 184 +++++++++++++++++ web/src/pages/PairForm.tsx | 13 ++ web/src/pages/Settings.tsx | 3 + 17 files changed, 1086 insertions(+), 26 deletions(-) create mode 100644 internal/api/notify.go create mode 100644 internal/engine/autocleanup_test.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_test.go create mode 100644 web/src/pages/Notifications.tsx diff --git a/README.md b/README.md index e8fd3ad..dde3f8e 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,15 @@ not. **Remove** asks for confirmation (and an extra acknowledgement when the fol right before touching it, and moves it into the quarantine instead of deleting it: it can be restored from History until the retention period purges it. The library root, the quarantine and hidden folders are never listed, and dry run applies. +**Automatic cleanup** (per pair, off by default) runs the same search on the slave library after every scan and removes only folders +with nothing at all inside them. A folder holding any file, even a cover or a `.DS_Store`, is left for you in Cleanup, and folders +changed within the settle time are skipped. Removals go to the quarantine and History like manual ones; in dry run nothing is removed. + +**Notifications** (Settings → Notifications) send a message to Discord, Pushover, ntfy, Gotify or any JSON webhook when a scan finds +songs that newly need review, when automatic linking replaced identical files, and when an automatic cleanup removed empty folders. +Each event can be turned off, and each service has a **Test** button. Set the optional Playlink address to get a link back to the +right page in every message. + **Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of Playlink's database (settings, library pairs, scan results and history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7 or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be @@ -132,7 +141,8 @@ Playlink is built so that no user data is ever lost, even if it crashes or loses replaced, `..` is rejected, and master and slave may not overlap. - **Mutations are serialized.** Only one filesystem mutation runs at a time, and only one job runs per pair. - **Auto-link is conservative.** It only acts on files with the *exact* same path, and it skips files modified within the settle time - (default 10 minutes) so it never touches a download in progress. + (default 10 minutes) so it never touches a download in progress. Automatic cleanup likewise only removes slave folders that are + completely empty and unchanged for the settle time, and never touches the master library. - **Interrupted operations are cleaned up.** Operations cut short by a crash are marked *interrupted* on startup, and their staging files are removed. - **The web UI requires login.** Passwords are hashed with bcrypt, sessions use `SameSite=Strict` HttpOnly cookies, and state-changing @@ -194,8 +204,8 @@ Run a scan, check the Review tab, and turn off dry run once the planned actions pick up quarantined songs; new quarantine folders get it automatically. Turning it off removes the file again, but only if Playlink created it. -Per-pair settings (in the UI) are file extensions, auto-link, approximate matching and its threshold, scan interval, quarantine folder, -retention and settle time. +Per-pair settings (in the UI) are file extensions, auto-link, automatic cleanup, approximate matching and its threshold, scan interval, +quarantine folder, retention and settle time. ## Development diff --git a/cmd/playlink/main.go b/cmd/playlink/main.go index fd15704..906240e 100644 --- a/cmd/playlink/main.go +++ b/cmd/playlink/main.go @@ -17,6 +17,7 @@ import ( "github.com/cdnutter/playlink/internal/api" "github.com/cdnutter/playlink/internal/backup" "github.com/cdnutter/playlink/internal/engine" + "github.com/cdnutter/playlink/internal/notify" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" "github.com/cdnutter/playlink/web" @@ -100,6 +101,8 @@ func main() { backups := backup.New(st, dataDir, version, log) eng.SetBeforeAction(backups.BeforeAction) + notifier := notify.New(st, log) + eng.SetNotify(notifier.Notify) // Restoring a backup restarts the process (Docker's restart policy // brings it back) so the database is swapped while nothing uses it. ctx, restart := context.WithCancel(ctx) @@ -110,7 +113,7 @@ func main() { srv := &http.Server{ Addr: addr, - Handler: api.New(api.Config{SecureCookies: secure, Version: version, Backups: backups, Restart: restart}, st, eng, log, web.Dist()).Handler(), + Handler: api.New(api.Config{SecureCookies: secure, Version: version, Backups: backups, Notifier: notifier, Restart: restart}, st, eng, log, web.Dist()).Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, diff --git a/internal/api/notify.go b/internal/api/notify.go new file mode 100644 index 0000000..905c4b1 --- /dev/null +++ b/internal/api/notify.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/cdnutter/playlink/internal/notify" +) + +func (s *Server) notifyRoutes(r chi.Router) { + r.Get("/notifications", s.handleGetNotify) + r.Put("/notifications", s.handlePutNotify) + r.Post("/notifications/test", s.handleTestNotify) +} + +func (s *Server) handleGetNotify(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, s.cfg.Notifier.Config(r.Context())) +} + +func (s *Server) handlePutNotify(w http.ResponseWriter, r *http.Request) { + var c notify.Config + if !readJSON(w, r, &c) { + return + } + if err := s.cfg.Notifier.SetConfig(r.Context(), c); err != nil { + writeErr(w, 400, err) + return + } + writeJSON(w, 200, s.cfg.Notifier.Config(r.Context())) +} + +// handleTestNotify sends a test message to a target as entered, saved or not. +func (s *Server) handleTestNotify(w http.ResponseWriter, r *http.Request) { + var body struct { + Target notify.Target `json:"target"` + BaseURL string `json:"base_url"` + } + if !readJSON(w, r, &body) { + return + } + if err := s.cfg.Notifier.Test(r.Context(), body.Target, body.BaseURL); err != nil { + writeErr(w, 400, err) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 0e37040..09c4de6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -21,6 +21,7 @@ import ( "github.com/cdnutter/playlink/internal/backup" "github.com/cdnutter/playlink/internal/engine" + "github.com/cdnutter/playlink/internal/notify" "github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/store" ) @@ -29,6 +30,7 @@ type Config struct { SecureCookies bool Version string Backups *backup.Manager + Notifier *notify.Notifier Restart func() // stops the server so the process restarts (after a restore) } @@ -80,6 +82,7 @@ func (s *Server) Handler() http.Handler { r.Get("/browse", s.handleBrowse) if s.cfg.Backups != nil { s.backupRoutes(r) + s.notifyRoutes(r) } r.Get("/pairs", s.handleListPairs) @@ -344,6 +347,7 @@ type pairInput struct { QuarantineDir string `json:"quarantine_dir"` Extensions []string `json:"extensions"` AutoLinkIdentical bool `json:"auto_link_identical"` + AutoCleanupEmpty bool `json:"auto_cleanup_empty"` ScanIntervalMinutes int `json:"scan_interval_minutes"` RetentionDays int `json:"retention_days"` SettleMinutes int `json:"settle_minutes"` @@ -353,7 +357,7 @@ type pairInput struct { func (in pairInput) apply(p *store.Pair) { p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir = in.Name, in.MasterRoot, in.SlaveRoot, in.QuarantineDir - p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical + p.Extensions, p.AutoLinkIdentical, p.AutoCleanupEmpty = in.Extensions, in.AutoLinkIdentical, in.AutoCleanupEmpty p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold } diff --git a/internal/engine/autocleanup_test.go b/internal/engine/autocleanup_test.go new file mode 100644 index 0000000..8383c6f --- /dev/null +++ b/internal/engine/autocleanup_test.go @@ -0,0 +1,137 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/cdnutter/playlink/internal/notify" + "github.com/cdnutter/playlink/internal/store" +) + +// events records what the engine would notify about. +type events struct { + mu sync.Mutex + got []notify.Event +} + +func (ev *env) captureEvents() *events { + r := &events{} + ev.e.SetNotify(func(_ context.Context, e notify.Event) { + r.mu.Lock() + r.got = append(r.got, e) + r.mu.Unlock() + }) + return r +} + +func (r *events) take() []notify.Event { + r.mu.Lock() + defer r.mu.Unlock() + out := r.got + r.got = nil + return out +} + +func kinds(es []notify.Event) string { + var k []string + for _, e := range es { + k = append(k, e.Kind) + } + return strings.Join(k, ",") +} + +func (ev *env) automate(t *testing.T, settle int) { + t.Helper() + ev.pair.AutoLinkIdentical, ev.pair.AutoCleanupEmpty, ev.pair.SettleMinutes = true, true, settle + if err := ev.st.SavePair(ctx, &ev.pair); err != nil { + t.Fatal(err) + } +} + +func TestAutoCleanupRemovesOnlyEmptyFolders(t *testing.T) { + ev := setup(t, false) + ev.automate(t, 0) + rec := ev.captureEvents() + ev.write(ev.master, "A/Same/1 Song.flac", "same") + ev.write(ev.slave, "A/Same/1 Song.flac", "same") + ev.write(ev.master, "A/Diff/1 Song.flac", "master") + ev.write(ev.slave, "A/Diff/1 Song.flac", "slave!") + os.MkdirAll(filepath.Join(ev.slave, "B/Empty/Disc 1"), 0o755) + ev.write(ev.slave, "C/Art/cover.jpg", "art") + ev.write(ev.slave, "D/.DS_Store", "x") // any file at all keeps the folder + + ev.scan() + if exists(filepath.Join(ev.slave, "B")) { + t.Fatal("empty folder B should have been removed") + } + for _, keep := range []string{"C/Art/cover.jpg", "D/.DS_Store", "A/Same/1 Song.flac"} { + if !exists(filepath.Join(ev.slave, keep)) { + t.Fatalf("%s should be kept", keep) + } + } + es := rec.take() + if kinds(es) != "review,linked,cleanup" { + t.Fatalf("events = %s", kinds(es)) + } + if es[0].Message != "1 song needs review." || es[0].PairID != ev.pair.ID { + t.Fatalf("review event: %+v", es[0]) + } + if !strings.Contains(es[1].Message, "1 identical file") { + t.Fatalf("linked event: %+v", es[1]) + } + if !strings.Contains(es[2].Message, "1 empty folder") || !strings.Contains(es[2].Message, "2 folders without music still hold files") { + t.Fatalf("cleanup event: %+v", es[2]) + } + // Removed folders are quarantined and listed in History like manual ones. + ops, _, err := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID, Limit: 100}) + if err != nil { + t.Fatal(err) + } + var found bool + for _, o := range ops { + if o.Action == "remove-folder" && o.RelSlave == "B" && strings.HasPrefix(o.Trigger, "auto:") && exists(o.QuarantinePath) { + found = true + } + } + if !found { + t.Fatalf("no quarantined auto removal of B in %+v", ops) + } + + // Nothing new: a second scan stays quiet. + ev.scan() + if es := rec.take(); len(es) != 0 { + t.Fatalf("second scan notified: %s", kinds(es)) + } +} + +func TestAutoCleanupWaitsForSettleAndDryRun(t *testing.T) { + ev := setup(t, false) + ev.automate(t, 10) + rec := ev.captureEvents() + os.MkdirAll(filepath.Join(ev.slave, "Fresh"), 0o755) + ev.write(ev.slave, "A/1 Song.flac", "x") + ev.scan() + if !exists(filepath.Join(ev.slave, "Fresh")) { + t.Fatal("a folder changed within the settle time must be left alone") + } + if es := rec.take(); len(es) != 0 { + t.Fatalf("events = %s", kinds(es)) + } + + dry := setup(t, true) + dry.automate(t, 0) + rec = dry.captureEvents() + os.MkdirAll(filepath.Join(dry.slave, "Empty"), 0o755) + dry.write(dry.slave, "A/1 Song.flac", "x") + dry.scan() + if !exists(filepath.Join(dry.slave, "Empty")) { + t.Fatal("dry run removed a folder") + } + if es := rec.take(); len(es) != 0 { + t.Fatalf("dry run notified: %s", kinds(es)) + } +} diff --git a/internal/engine/emptydirs.go b/internal/engine/emptydirs.go index 1d6fa45..b0fc219 100644 --- a/internal/engine/emptydirs.go +++ b/internal/engine/emptydirs.go @@ -12,6 +12,7 @@ import ( "time" "github.com/cdnutter/playlink/internal/fsops" + "github.com/cdnutter/playlink/internal/notify" "github.com/cdnutter/playlink/internal/store" ) @@ -224,7 +225,7 @@ func (e *Engine) StartRemoveFolders(pairID int64, rels []string) (*Job, error) { if ctx.Err() != nil { return ctx.Err() } - if err := e.removeFolder(ctx, l, side, rel); err != nil { + if err := e.removeFolder(ctx, l, side, rel, "manual", false); err != nil { j.skip(rel + ": " + err.Error()) } else if e.st.DryRun(ctx) { j.count("planned") @@ -240,8 +241,90 @@ func (e *Engine) StartRemoveFolders(pairID int64, rels []string) (*Job, error) { }) } -// removeFolder moves one folder without music into the quarantine. -func (e *Engine) removeFolder(ctx context.Context, l *libs, side, rel string) error { +// autoCleanup runs after a scan when the pair opts in. It only removes +// slave folders with nothing in them at all (or only empty subfolders); +// folders holding any file, such as a cover or a playlist, are left for the +// user in Cleanup. Folders changed within the settle time are left too, as +// a download may be about to fill them. +func (e *Engine) autoCleanup(ctx context.Context, j *Job, l *libs, trigger string) error { + j.phase("Looking for empty folders", 0) + found, err := findNoMusic(ctx, l, l.slave) + if err != nil { + return err + } + cutoff := time.Now().Add(-time.Duration(l.pair.SettleMinutes) * time.Minute) + var empty []string + withFiles := 0 + for _, f := range found { + switch { + case f.Files > 0: + withFiles++ + case changedSince(filepath.Join(l.slave.Path, f.Rel), cutoff): + j.skip(f.Rel + ": changed recently, waiting for it to settle") + default: + empty = append(empty, f.Rel) + } + } + if len(empty) == 0 { + return nil + } + if e.st.DryRun(ctx) { + j.update(func(j *Job) { + j.Message += fmt.Sprintf(". Dry run is on: %s would be removed.", plural(len(empty), "empty folder", "empty folders")) + }) + return nil + } + if err := e.BeforeAction(ctx, "auto-cleanup"); err != nil { + return err + } + j.phase("Removing empty folders", int64(len(empty))) + removed := 0 + for _, rel := range empty { + if ctx.Err() != nil { + break + } + if err := e.removeFolder(ctx, l, SideSlave, rel, "auto:"+trigger, true); err != nil { + j.skip(rel + ": " + err.Error()) + } else { + j.count("empty folders removed") + removed++ + } + j.tick() + } + if removed > 0 { + e.emptyMu.Lock() + delete(e.empty, l.pair.ID) // a listed folder may be gone now + e.emptyMu.Unlock() + msg := fmt.Sprintf("Moved %s from the slave library to the quarantine.", plural(removed, "empty folder", "empty folders")) + if withFiles > 0 { + msg += fmt.Sprintf(" %s without music still hold files and were left for you in Cleanup.", plural(withFiles, "folder", "folders")) + } + e.emit(ctx, l, notify.Event{Kind: notify.EventCleanup, Title: "Empty folders cleaned up", Message: msg, + Path: fmt.Sprintf("#/pair/%d/cleanup", l.pair.ID)}) + } + return ctx.Err() +} + +// changedSince reports whether a folder or any folder inside it was +// modified after t. +func changedSince(abs string, t time.Time) bool { + changed := false + _ = filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil + } + if fi, err := d.Info(); err == nil && fi.ModTime().After(t) { + changed = true + return filepath.SkipAll + } + return nil + }) + return changed +} + +// removeFolder moves one folder without music into the quarantine. With +// emptyOnly, it refuses a folder that holds any file. +func (e *Engine) removeFolder(ctx context.Context, l *libs, side, rel, trigger string, emptyOnly bool) error { e.mu.Lock() defer e.mu.Unlock() root, err := sideRoot(l, side) @@ -261,10 +344,19 @@ func (e *Engine) removeFolder(ctx context.Context, l *libs, side, rel string) er case abs == root.Path || fsops.Within(abs, l.quarantine): return errors.New("can't remove the library or the quarantine") } - // Music may have arrived since the scan: check again, right before moving. - var music bool + // Music (or, for an empty-only removal, any file) may have arrived + // since the scan: check again, right before moving. + var music, files bool _ = filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error { - if err == nil && !d.IsDir() && d.Type().IsRegular() && l.isMusic(d.Name()) { + if err != nil { + files = true // can't see inside: never treat it as empty + return nil + } + if d.IsDir() { + return nil + } + files = true + if d.Type().IsRegular() && l.isMusic(d.Name()) { music = true return filepath.SkipAll } @@ -273,7 +365,10 @@ func (e *Engine) removeFolder(ctx context.Context, l *libs, side, rel string) er if music { return errors.New("it contains music now; skipped") } - o := store.Operation{PairID: l.pair.ID, Action: store.ActRemoveFolder, QuarantineSide: side, Trigger: "manual"} + if emptyOnly && files { + return errors.New("it contains files now; skipped") + } + o := store.Operation{PairID: l.pair.ID, Action: store.ActRemoveFolder, QuarantineSide: side, Trigger: trigger} setSide(&o, side, rel) if err := e.begin(ctx, &o); err != nil { return err diff --git a/internal/engine/engine.go b/internal/engine/engine.go index c95f7b6..ddee9d6 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -14,6 +14,7 @@ import ( "time" "github.com/cdnutter/playlink/internal/fsops" + "github.com/cdnutter/playlink/internal/notify" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" ) @@ -42,6 +43,7 @@ type Engine struct { mu sync.Mutex beforeAction func(ctx context.Context, reason string) error + notify func(ctx context.Context, ev notify.Event) emptyMu sync.Mutex empty map[int64]*NoMusicScan // latest scan for folders without music, per pair @@ -366,6 +368,40 @@ func (e *Engine) SetBeforeAction(f func(ctx context.Context, reason string) erro e.beforeAction = f } +// SetNotify installs the hook that tells the user about things that +// happened without them (see the notify package). +func (e *Engine) SetNotify(f func(ctx context.Context, ev notify.Event)) { + e.notify = f +} + +func (e *Engine) emit(ctx context.Context, l *libs, ev notify.Event) { + if e.notify == nil { + return + } + ev.PairID, ev.Pair = l.pair.ID, l.pair.Name + e.notify(ctx, ev) +} + +func plural(n int, one, many string) string { + if n == 1 { + return "1 " + one + } + return fmt.Sprintf("%d %s", n, many) +} + +func formatBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) +} + // BeforeAction runs the pre-action backup, once per user action (not per // file). It does nothing in dry run, where no files change. An error means // the action must not run. diff --git a/internal/engine/scan.go b/internal/engine/scan.go index 2c6ec15..09137f7 100644 --- a/internal/engine/scan.go +++ b/internal/engine/scan.go @@ -13,6 +13,7 @@ import ( "unicode" "github.com/cdnutter/playlink/internal/fsops" + "github.com/cdnutter/playlink/internal/notify" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" ) @@ -315,6 +316,8 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string) j.phase("Comparing files", int64(len(slaveFiles))) keep := map[string]bool{} var layout layoutStats + // Songs waiting for review, and how many of them weren't before this scan. + review, newReview := 0, 0 for _, rel := range sortedKeys(slaveFiles) { if ctx.Err() != nil { return ctx.Err() @@ -331,6 +334,12 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string) return err } keep[rel] = true + if needsReview(m) { + review++ + if pm == nil || !needsReview(*pm) { + newReview++ + } + } layout.add(m.RelSlave, m.RelMaster, m.Kind, m.Reason) j.count(m.Status) j.tick() @@ -358,12 +367,31 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string) j.Message = fmt.Sprintf("Scanned %d master and %d slave files in %s", ms.files, ss.files, time.Since(started).Round(time.Second)) }) + if newReview > 0 { + msg := plural(newReview, "song needs", "songs need") + " review." + if review > newReview { + msg = fmt.Sprintf("%s %d are waiting in total.", msg, review) + } + e.emit(ctx, l, notify.Event{Kind: notify.EventReview, Title: "New songs to review", Message: msg, + Path: fmt.Sprintf("#/pair/%d/review?status=review", l.pair.ID)}) + } + if l.pair.AutoLinkIdentical { - return e.autoLink(ctx, j, l, trigger) + if err := e.autoLink(ctx, j, l, trigger); err != nil { + return err + } + } + if l.pair.AutoCleanupEmpty { + return e.autoCleanup(ctx, j, l, trigger) } return nil } +// needsReview matches the Review page's "Needs review" filter. +func needsReview(m store.Match) bool { + return (m.Status == store.StatusDifferent || m.Status == store.StatusSimilar) && !m.Ignored +} + // autoLink links settled, byte-identical, same-path files after a scan. func (e *Engine) autoLink(ctx context.Context, j *Job, l *libs, trigger string) error { ids, err := e.st.IdenticalIDs(ctx, l.pair.ID) @@ -379,11 +407,18 @@ func (e *Engine) autoLink(ctx context.Context, j *Job, l *libs, trigger string) if err := e.BeforeAction(ctx, "auto-link"); err != nil { return err } - return e.linkIDs(ctx, j, l.pair.ID, ids, "auto:"+trigger, true) + linked, freed, err := e.linkIDs(ctx, j, l.pair.ID, ids, "auto:"+trigger, true) + if linked > 0 { + e.emit(ctx, l, notify.Event{Kind: notify.EventLinked, Title: "Identical files linked", + Message: fmt.Sprintf("Replaced %s with hardlinks to the master, freeing %s.", plural(linked, "identical file", "identical files"), formatBytes(freed)), + Path: "#/activity"}) + } + return err } -// linkIDs links a batch of identical matches. -func (e *Engine) linkIDs(ctx context.Context, j *Job, pairID int64, ids []int64, trigger string, onlySettled bool) error { +// linkIDs links a batch of identical matches and returns how many were +// linked and the space that freed. +func (e *Engine) linkIDs(ctx context.Context, j *Job, pairID int64, ids []int64, trigger string, onlySettled bool) (linked int, freed int64, err error) { j.phase("Linking identical files", int64(len(ids))) settle := time.Duration(0) if onlySettled { @@ -394,7 +429,7 @@ func (e *Engine) linkIDs(ctx context.Context, j *Job, pairID int64, ids []int64, cutoff := time.Now().Add(-settle).UnixNano() for _, id := range ids { if ctx.Err() != nil { - return ctx.Err() + return linked, freed, ctx.Err() } m, err := e.st.Match(ctx, id) if err != nil { @@ -419,10 +454,12 @@ func (e *Engine) linkIDs(ctx context.Context, j *Job, pairID int64, ids []int64, j.count("planned") default: j.count("linked") + linked++ + freed += op.Bytes } j.tick() } - return nil + return linked, freed, nil } // StartLinkIdentical links every identical match of a pair in the background. @@ -436,7 +473,8 @@ func (e *Engine) StartLinkIdentical(pairID int64) (*Job, error) { j.update(func(j *Job) { j.Message = "Nothing to link" }) return nil } - return e.linkIDs(ctx, j, pairID, ids, "bulk", false) + _, _, err = e.linkIDs(ctx, j, pairID, ids, "bulk", false) + return err }) } diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..7d06efe --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,299 @@ +// Package notify sends short messages about things that happened without +// the user (new songs to review, automatic links and cleanups) to services +// such as Discord or Pushover. +package notify + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/cdnutter/playlink/internal/store" +) + +// Event kinds. +const ( + EventReview = "review" // a scan found songs that newly need review + EventLinked = "linked" // identical files were linked automatically + EventCleanup = "cleanup" // an automatic cleanup removed empty folders + EventTest = "test" +) + +// Target types. +const ( + TypeDiscord = "discord" + TypePushover = "pushover" + TypeNtfy = "ntfy" + TypeGotify = "gotify" + TypeWebhook = "webhook" +) + +// Target is one place messages are sent. Which fields are used depends on +// the type: Discord and webhooks need URL; Pushover needs Token (the app +// token) and User (the user key); ntfy needs URL (the topic URL) and takes +// an optional access Token; Gotify needs URL (the server) and Token. +type Target struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + URL string `json:"url"` + Token string `json:"token"` + User string `json:"user"` +} + +// Config is the global notification setup. +type Config struct { + OnReview bool `json:"on_review"` + OnLinked bool `json:"on_linked"` + OnCleanup bool `json:"on_cleanup"` + BaseURL string `json:"base_url"` // optional: where Playlink is reachable, for links in messages + Targets []Target `json:"targets"` +} + +// Event is one thing worth telling the user about. +type Event struct { + Kind string + PairID int64 + Pair string + Title string + Message string + Path string // page to open, relative to the app (e.g. "#/pair/1/review") +} + +const settingKey = "notifications" + +const maxTargets = 10 + +var pushoverURL = "https://api.pushover.net/1/messages.json" // replaced in tests + +type Notifier struct { + st *store.Store + log *slog.Logger + client *http.Client +} + +func New(st *store.Store, log *slog.Logger) *Notifier { + return &Notifier{st: st, log: log, client: &http.Client{Timeout: 15 * time.Second}} +} + +// Config returns the saved configuration, with every event on by default. +func (n *Notifier) Config(ctx context.Context) Config { + c := Config{OnReview: true, OnLinked: true, OnCleanup: true} + if raw := n.st.Setting(ctx, settingKey, ""); raw != "" { + _ = json.Unmarshal([]byte(raw), &c) + } + if c.Targets == nil { + c.Targets = []Target{} + } + return c +} + +// SetConfig validates and saves the configuration. +func (n *Notifier) SetConfig(ctx context.Context, c Config) error { + if err := c.validate(); err != nil { + return err + } + raw, err := json.Marshal(c) + if err != nil { + return err + } + return n.st.SetSetting(ctx, settingKey, string(raw)) +} + +func (c *Config) validate() error { + if len(c.Targets) > maxTargets { + return fmt.Errorf("at most %d notification targets", maxTargets) + } + c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") + if c.BaseURL != "" { + if err := httpURL(c.BaseURL); err != nil { + return fmt.Errorf("Playlink address: %w", err) + } + } + if c.Targets == nil { + c.Targets = []Target{} + } + for i := range c.Targets { + if err := c.Targets[i].validate(); err != nil { + name := c.Targets[i].Name + if name == "" { + name = c.Targets[i].Type + } + return fmt.Errorf("%s: %w", name, err) + } + } + return nil +} + +func (t *Target) validate() error { + t.Name = strings.TrimSpace(t.Name) + t.URL = strings.TrimSpace(t.URL) + t.Token = strings.TrimSpace(t.Token) + t.User = strings.TrimSpace(t.User) + switch t.Type { + case TypeDiscord, TypeWebhook, TypeNtfy: + return httpURL(t.URL) + case TypeGotify: + if t.Token == "" { + return errors.New("an application token is required") + } + return httpURL(t.URL) + case TypePushover: + if t.Token == "" || t.User == "" { + return errors.New("an application token and a user key are required") + } + return nil + } + return fmt.Errorf("unknown type %q", t.Type) +} + +func httpURL(s string) error { + u, err := url.Parse(s) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return errors.New("enter a full http:// or https:// address") + } + return nil +} + +// Notify sends an event to every enabled target, in the background, if +// that kind of event is turned on. Failures are logged, never returned: +// a notification must not hold up or fail the work it reports on. +func (n *Notifier) Notify(ctx context.Context, ev Event) { + c := n.Config(ctx) + switch ev.Kind { + case EventReview: + if !c.OnReview { + return + } + case EventLinked: + if !c.OnLinked { + return + } + case EventCleanup: + if !c.OnCleanup { + return + } + } + for _, t := range c.Targets { + if !t.Enabled { + continue + } + go func(t Target) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := n.send(ctx, t, c.BaseURL, ev); err != nil { + n.log.Warn("notification not sent", "target", t.Name, "type", t.Type, "event", ev.Kind, "err", err) + } + }(t) + } +} + +// Test sends a test message to one target and reports the result. +func (n *Notifier) Test(ctx context.Context, t Target, baseURL string) error { + if err := t.validate(); err != nil { + return err + } + return n.send(ctx, t, strings.TrimRight(strings.TrimSpace(baseURL), "/"), Event{ + Kind: EventTest, + Title: "Playlink test notification", + Message: "Notifications from Playlink will arrive here.", + }) +} + +func (n *Notifier) send(ctx context.Context, t Target, base string, ev Event) error { + link := "" + if base != "" { + link = base + "/" + if ev.Path != "" { + link += ev.Path + } + } + title := ev.Title + if ev.Pair != "" { + title = ev.Pair + ": " + ev.Title + } + + var req *http.Request + var err error + switch t.Type { + case TypeDiscord: + embed := map[string]any{"title": title, "description": ev.Message, "color": 0x8b5cf6} + if link != "" { + embed["url"] = link + } + req, err = jsonRequest(ctx, t.URL, map[string]any{"username": "Playlink", "embeds": []any{embed}}) + case TypePushover: + form := url.Values{"token": {t.Token}, "user": {t.User}, "title": {title}, "message": {ev.Message}} + if link != "" { + form.Set("url", link) + form.Set("url_title", "Open Playlink") + } + req, err = http.NewRequestWithContext(ctx, http.MethodPost, pushoverURL, strings.NewReader(form.Encode())) + if err == nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + case TypeNtfy: + req, err = http.NewRequestWithContext(ctx, http.MethodPost, t.URL, strings.NewReader(ev.Message)) + if err == nil { + req.Header.Set("Title", title) + req.Header.Set("Tags", "musical_note") + if link != "" { + req.Header.Set("Click", link) + } + if t.Token != "" { + req.Header.Set("Authorization", "Bearer "+t.Token) + } + } + case TypeGotify: + body := map[string]any{"title": title, "message": ev.Message, "priority": 5} + if link != "" { + body["extras"] = map[string]any{"client::notification": map[string]any{"click": map[string]string{"url": link}}} + } + req, err = jsonRequest(ctx, strings.TrimRight(t.URL, "/")+"/message", body) + if err == nil { + req.Header.Set("X-Gotify-Key", t.Token) + } + case TypeWebhook: + req, err = jsonRequest(ctx, t.URL, map[string]any{ + "event": ev.Kind, "pair_id": ev.PairID, "pair": ev.Pair, "title": ev.Title, "message": ev.Message, "url": link, + }) + default: + return fmt.Errorf("unknown type %q", t.Type) + } + if err != nil { + return err + } + req.Header.Set("User-Agent", "Playlink") + res, err := n.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= 300 { + msg, _ := io.ReadAll(io.LimitReader(res.Body, 300)) + return fmt.Errorf("%s answered %s: %s", t.Type, res.Status, strings.TrimSpace(string(msg))) + } + return nil +} + +func jsonRequest(ctx context.Context, u string, body any) (*http.Request, error) { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return req, nil +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..aebce5f --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,155 @@ +package notify + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/cdnutter/playlink/internal/store" +) + +type got struct { + path string + header http.Header + body string +} + +func server(t *testing.T, status int) (*httptest.Server, chan got) { + t.Helper() + ch := make(chan got, 10) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + ch <- got{path: r.URL.Path, header: r.Header, body: string(b)} + w.WriteHeader(status) + _, _ = w.Write([]byte("nope")) + })) + t.Cleanup(srv.Close) + return srv, ch +} + +func newNotifier(t *testing.T) *Notifier { + t.Helper() + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.DB.Close() }) + return New(st, slog.New(slog.NewTextHandler(io.Discard, nil))) +} + +var ev = Event{Kind: EventReview, PairID: 3, Pair: "Music", Title: "New songs to review", Message: "2 songs need review.", Path: "#/pair/3/review"} + +func TestPayloads(t *testing.T) { + n := newNotifier(t) + srv, ch := server(t, 200) + pushoverURL = srv.URL + "/pushover" + ctx := context.Background() + base := "https://playlink.lan" + link := "https://playlink.lan/#/pair/3/review" + + check := func(tg Target, f func(g got)) { + t.Helper() + if err := n.send(ctx, tg, base, ev); err != nil { + t.Fatalf("%s: %v", tg.Type, err) + } + f(<-ch) + } + check(Target{Type: TypeDiscord, URL: srv.URL + "/discord"}, func(g got) { + var b struct { + Embeds []struct{ Title, Description, URL string } + } + json.Unmarshal([]byte(g.body), &b) + if len(b.Embeds) != 1 || b.Embeds[0].Title != "Music: New songs to review" || b.Embeds[0].URL != link || b.Embeds[0].Description != ev.Message { + t.Fatalf("discord: %s", g.body) + } + }) + check(Target{Type: TypePushover, Token: "app", User: "me"}, func(g got) { + v, _ := url.ParseQuery(g.body) + if g.path != "/pushover" || v.Get("token") != "app" || v.Get("user") != "me" || v.Get("url") != link || v.Get("message") != ev.Message { + t.Fatalf("pushover: %s %s", g.path, g.body) + } + }) + check(Target{Type: TypeNtfy, URL: srv.URL + "/playlink", Token: "tk"}, func(g got) { + if g.path != "/playlink" || g.body != ev.Message || g.header.Get("Title") != "Music: New songs to review" || + g.header.Get("Click") != link || g.header.Get("Authorization") != "Bearer tk" { + t.Fatalf("ntfy: %v %s", g.header, g.body) + } + }) + check(Target{Type: TypeGotify, URL: srv.URL + "/", Token: "gk"}, func(g got) { + if g.path != "/message" || g.header.Get("X-Gotify-Key") != "gk" || !strings.Contains(g.body, `"click":{"url":"`+link+`"}`) { + t.Fatalf("gotify: %s %v %s", g.path, g.header, g.body) + } + }) + check(Target{Type: TypeWebhook, URL: srv.URL + "/hook"}, func(g got) { + var b map[string]any + json.Unmarshal([]byte(g.body), &b) + if b["event"] != "review" || b["pair"] != "Music" || b["url"] != link || b["pair_id"] != float64(3) { + t.Fatalf("webhook: %s", g.body) + } + }) +} + +func TestErrorsAreReported(t *testing.T) { + n := newNotifier(t) + srv, _ := server(t, 401) + err := n.Test(context.Background(), Target{Type: TypeDiscord, URL: srv.URL}, "") + if err == nil || !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "nope") { + t.Fatalf("err = %v", err) + } +} + +func TestConfigValidationAndEvents(t *testing.T) { + n := newNotifier(t) + ctx := context.Background() + if c := n.Config(ctx); !c.OnReview || !c.OnLinked || !c.OnCleanup || c.Targets == nil { + t.Fatalf("defaults: %+v", c) + } + for _, bad := range []Target{ + {Type: TypeDiscord, URL: "discord.com/x"}, + {Type: TypePushover, Token: "a"}, + {Type: TypeGotify, URL: "http://g"}, + {Type: "smoke-signal"}, + } { + if err := n.SetConfig(ctx, Config{Targets: []Target{bad}}); err == nil { + t.Fatalf("accepted %+v", bad) + } + } + if err := n.SetConfig(ctx, Config{BaseURL: "playlink.lan"}); err == nil { + t.Fatal("accepted a base URL without a scheme") + } + + // Only enabled targets, and only events that are turned on. + srv, ch := server(t, 200) + c := Config{OnReview: false, OnLinked: true, BaseURL: "http://p/ ", Targets: []Target{ + {ID: "a", Type: TypeWebhook, Name: " on ", Enabled: true, URL: srv.URL + "/on"}, + {ID: "b", Type: TypeWebhook, Enabled: false, URL: srv.URL + "/off"}, + }} + if err := n.SetConfig(ctx, c); err != nil { + t.Fatal(err) + } + if saved := n.Config(ctx); saved.BaseURL != "http://p" || saved.Targets[0].Name != "on" { + t.Fatalf("not normalized: %+v", saved) + } + n.Notify(ctx, ev) // review is off + n.Notify(ctx, Event{Kind: EventLinked, Title: "t", Message: "m"}) + select { + case g := <-ch: + if g.path != "/on" || !strings.Contains(g.body, `"event":"linked"`) { + t.Fatalf("sent %s %s", g.path, g.body) + } + case <-time.After(5 * time.Second): + t.Fatal("nothing sent") + } + select { + case g := <-ch: + t.Fatalf("unexpected second message to %s: %s", g.path, g.body) + case <-time.After(200 * time.Millisecond): + } +} diff --git a/internal/store/models.go b/internal/store/models.go index 0c3d978..a0b203d 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -144,6 +144,7 @@ type Pair struct { QuarantineDir string `json:"quarantine_dir"` Extensions []string `json:"extensions"` AutoLinkIdentical bool `json:"auto_link_identical"` + AutoCleanupEmpty bool `json:"auto_cleanup_empty"` // remove empty slave folders after each scan ScanIntervalMinutes int `json:"scan_interval_minutes"` RetentionDays int `json:"retention_days"` SettleMinutes int `json:"settle_minutes"` @@ -155,7 +156,7 @@ type Pair struct { CreatedAt int64 `json:"created_at"` } -const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical, +const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical, auto_cleanup_empty, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, last_scan_at, last_scan_error, last_scan_warnings, created_at` @@ -165,7 +166,7 @@ func scanPair(r scanner) (Pair, error) { var p Pair var exts, warns string var last sql.NullInt64 - err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical, + err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical, &p.AutoCleanupEmpty, &p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &last, &p.LastScanError, &warns, &p.CreatedAt) if err != nil { @@ -208,9 +209,9 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error { exts := strings.Join(p.Extensions, ",") if p.ID == 0 { res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions, - auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, + auto_link_identical, auto_cleanup_empty, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.AutoCleanupEmpty, p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, now()) if err != nil { return err @@ -219,9 +220,9 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error { return nil } _, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?, - extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?, + extensions = ?, auto_link_identical = ?, auto_cleanup_empty = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?, approx_matching = ?, approx_threshold = ? WHERE id = ?`, - p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, + p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.AutoCleanupEmpty, p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, p.ID) return err } diff --git a/internal/store/store.go b/internal/store/store.go index 0f4a0ea..fd2fb15 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -140,6 +140,9 @@ var migrations = []string{ // 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 fixer_state TEXT NOT NULL DEFAULT '';`, + + // 11: opt-in automatic removal of empty slave folders after each scan. + `ALTER TABLE pairs ADD COLUMN auto_cleanup_empty INTEGER NOT NULL DEFAULT 0;`, } // SchemaVersion is the newest database schema this build knows. diff --git a/web/src/api.ts b/web/src/api.ts index a549a15..5f4e67e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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 = { items: T[]; total: number }; export type AlbumGroup = { diff --git a/web/src/pages/Cleanup.tsx b/web/src/pages/Cleanup.tsx index 3a77882..05ed319 100644 --- a/web/src/pages/Cleanup.tsx +++ b/web/src/pages/Cleanup.tsx @@ -65,6 +65,16 @@ export function Cleanup({ pair }: { pair: Pair }) { +

+ {pair.auto_cleanup_empty ? ( + <> + Automatic cleanup is on: 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. + )} +

{ourJob && } diff --git a/web/src/pages/Notifications.tsx b/web/src/pages/Notifications.tsx new file mode 100644 index 0000000..eaab3b7 --- /dev/null +++ b/web/src/pages/Notifications.tsx @@ -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 = { + 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("/notifications") }); + const [cfg, setCfg] = useState(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("/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) => + 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 ( + } + title="Notifications" + description="Get a message when something happens without you, such as after a scheduled scan." + > + {!cfg && ( +
+ +
+ )} + {cfg && ( + <> + {EVENTS.map((ev) => ( + + setCfg({ ...cfg, [ev.key]: v })} /> + + ))} + + setCfg({ ...cfg, base_url: e.target.value })} + /> + + +
+
Send to
+ {cfg.targets.length === 0 &&

No services yet. Add one below.

} + {cfg.targets.map((t) => { + const def = TYPES[t.type]; + return ( +
+
+ setTarget(t.id, { enabled: v })} /> + {def.label} + setTarget(t.id, { name: e.target.value })} + /> +
+ + +
+
+
+ {def.fields.map((f) => ( + + setTarget(t.id, { [f.key]: e.target.value })} + /> + + ))} +
+

{def.hint}

+
+ ); + })} +
+ {(Object.keys(TYPES) as NotifyType[]).map((type) => ( + + ))} +
+
+ + {dirty && ( +
+ Unsaved changes + + +
+ )} + + )} +
+ ); +} diff --git a/web/src/pages/PairForm.tsx b/web/src/pages/PairForm.tsx index 509c494..d5fe2ef 100644 --- a/web/src/pages/PairForm.tsx +++ b/web/src/pages/PairForm.tsx @@ -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.

+
+ set("auto_cleanup_empty", v)} + label={Automatically remove empty folders after each scan} + /> +

+ 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. +

+
+ + } title="Account">