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