Notifications, and automatic cleanup of empty folders

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

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

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

No files matched your search

+47
View File
@@ -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})
}
+5 -1
View File
@@ -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
}