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
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user