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
+ {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.>
+ )}
+ No services yet. Add one below. {def.hint}
+ 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. +
+