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