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