Files
cdnutterandClaude Opus 5.5 faebc5dfb0 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>
2026-10-08 15:36:04 -07:00

432 lines
12 KiB
Go

package engine
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/notify"
"github.com/cdnutter/playlink/internal/store"
)
// Folders without music are what linking, renames and manual clean-ups leave
// behind: empty album folders, or ones holding only a cover or a playlist.
// A scan lists them; removing them needs the user's confirmation and moves
// each folder into the quarantine (never deletes it), so it can be undone
// until the retention period purges it.
// Library sides.
const (
SideMaster = "master"
SideSlave = "slave"
)
func sideRoot(l *libs, side string) (fsops.Root, error) {
switch side {
case SideMaster:
return l.master, nil
case SideSlave:
return l.slave, nil
}
return fsops.Root{}, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
// NoMusicFolder is a folder with no music file anywhere inside it.
type NoMusicFolder struct {
Rel string `json:"rel"`
Files int `json:"files"` // other files inside (covers, playlists, ...)
Folders int `json:"folders"` // subfolders inside
Bytes int64 `json:"bytes"`
Sample []string `json:"sample"` // a few of the files, relative to the folder
}
// NoMusicScan is the result of the latest scan for folders without music.
type NoMusicScan struct {
Side string `json:"side"`
CreatedAt int64 `json:"created_at"`
Folders []NoMusicFolder `json:"folders"`
}
const noMusicSample = 5
// isMusic reports whether a file counts as music: one of the pair's
// extensions, and not a macOS "._" metadata file.
func (l *libs) isMusic(name string) bool {
return l.hasExt(name) && !strings.HasPrefix(name, "._")
}
// findNoMusic lists the top-most folders under a library root that contain
// no music. The root itself, the quarantine and anything containing it, and
// hidden folders are never listed.
func findNoMusic(ctx context.Context, l *libs, root fsops.Root) ([]NoMusicFolder, error) {
type dirInfo struct {
music bool
files, folders int
bytes int64
sample []string
}
dirs := map[string]*dirInfo{}
var order []string
err := filepath.WalkDir(root.Path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
if p == root.Path {
return err
}
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
if p == l.quarantine {
return filepath.SkipDir
}
dirs[p] = &dirInfo{}
order = append(order, p)
// Count the folder in every ancestor.
for a := filepath.Dir(p); p != root.Path && fsops.Within(root.Path, a); a = filepath.Dir(a) {
if di := dirs[a]; di != nil {
di.folders++
}
if a == root.Path {
break
}
}
return nil
}
// Files (and symlinks, which are never followed) count towards every
// ancestor; music marks every ancestor as having music.
music := d.Type().IsRegular() && l.isMusic(d.Name())
var size int64
if fi, err := d.Info(); err == nil {
size = fi.Size()
}
for a := filepath.Dir(p); fsops.Within(root.Path, a); a = filepath.Dir(a) {
di := dirs[a]
if di == nil {
break
}
if music {
di.music = true
} else {
di.files++
di.bytes += size
if len(di.sample) < noMusicSample {
if rel, err := filepath.Rel(a, p); err == nil {
di.sample = append(di.sample, rel)
}
}
}
if a == root.Path {
break
}
}
return nil
})
if err != nil {
return nil, err
}
var out []NoMusicFolder
for _, p := range order {
di := dirs[p]
if p == root.Path || di.music || strings.HasPrefix(filepath.Base(p), ".") || fsops.Within(p, l.quarantine) {
continue
}
// Only the top-most folder: its parent must have music (or be the root).
if parent := dirs[filepath.Dir(p)]; filepath.Dir(p) != root.Path && parent != nil && !parent.music {
continue
}
rel, err := root.Rel(p)
if err != nil {
continue
}
out = append(out, NoMusicFolder{Rel: rel, Files: di.files, Folders: di.folders, Bytes: di.bytes, Sample: di.sample})
}
sort.Slice(out, func(i, j int) bool { return out[i].Rel < out[j].Rel })
if out == nil {
out = []NoMusicFolder{}
}
return out, nil
}
// StartNoMusicScan looks for folders without music in one library.
func (e *Engine) StartNoMusicScan(pairID int64, side string) (*Job, error) {
if side != SideMaster && side != SideSlave {
return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
return e.startJob(pairID, "find-empty-folders", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
root, _ := sideRoot(l, side)
j.phase("Looking for folders without music ("+side+")", 0)
found, err := findNoMusic(ctx, l, root)
if err != nil {
return err
}
e.emptyMu.Lock()
if e.empty == nil {
e.empty = map[int64]*NoMusicScan{}
}
e.empty[pairID] = &NoMusicScan{Side: side, CreatedAt: time.Now().Unix(), Folders: found}
e.emptyMu.Unlock()
j.update(func(j *Job) { j.Message = fmt.Sprintf("%d folders without music in the %s library", len(found), side) })
return nil
})
}
// NoMusicResult returns the latest scan for folders without music.
func (e *Engine) NoMusicResult(pairID int64) *NoMusicScan {
e.emptyMu.Lock()
defer e.emptyMu.Unlock()
return e.empty[pairID]
}
// StartRemoveFolders moves the chosen folders of the latest scan into the
// quarantine. Only folders that scan listed can be chosen, so a request can
// never name arbitrary paths, and each is checked again for music first.
func (e *Engine) StartRemoveFolders(pairID int64, rels []string) (*Job, error) {
e.emptyMu.Lock()
scan := e.empty[pairID]
e.emptyMu.Unlock()
if scan == nil {
return nil, fmt.Errorf("%w: look for folders without music first", ErrInvalidBulk)
}
listed := map[string]bool{}
for _, f := range scan.Folders {
listed[f.Rel] = true
}
var chosen []string
for _, r := range rels {
if !listed[r] {
return nil, fmt.Errorf("%w: %q is not in the latest scan", ErrInvalidBulk, r)
}
chosen = append(chosen, r)
}
if len(chosen) == 0 {
return nil, fmt.Errorf("%w: no folders selected", ErrInvalidBulk)
}
side := scan.Side
return e.startJob(pairID, "remove-empty-folders", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
j.phase("Moving folders to the quarantine", int64(len(chosen)))
for _, rel := range chosen {
if ctx.Err() != nil {
return ctx.Err()
}
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")
} else {
j.count("removed")
}
j.tick()
}
e.emptyMu.Lock()
delete(e.empty, pairID) // stale now
e.emptyMu.Unlock()
return nil
})
}
// 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)
if err != nil {
return err
}
abs, err := root.Resolve(rel)
if err != nil {
return err
}
fi, err := os.Lstat(abs)
switch {
case err != nil:
return errors.New("the folder no longer exists")
case fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir():
return errors.New("is not a folder")
case abs == root.Path || fsops.Within(abs, l.quarantine):
return errors.New("can't remove the library or the quarantine")
}
// 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 {
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
}
return nil
})
if music {
return errors.New("it contains music now; skipped")
}
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
}
if o.DryRun {
return nil
}
q := quarantinePath(l, o.ID, side, rel)
err = func() error {
// Recorded first, so the folder is always findable from History.
if _, err := e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, o.ID); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(q), 0o775); err != nil {
return err
}
if err := os.Rename(abs, q); err != nil {
return err
}
return fsops.SyncDir(filepath.Dir(abs))
}()
return e.finish(ctx, &o, err)
}
func setSide(o *store.Operation, side, rel string) {
if side == SideMaster {
o.RelMaster = rel
} else {
o.RelSlave = rel
}
}
// undoRemoveFolder moves a folder back from the quarantine.
func undoRemoveFolder(l *libs, o store.Operation) error {
root, err := sideRoot(l, o.QuarantineSide)
if err != nil {
return err
}
rel := o.RelSlave
if o.QuarantineSide == SideMaster {
rel = o.RelMaster
}
dst, err := root.Resolve(rel)
if err != nil {
return err
}
if !fsops.Within(l.quarantine, filepath.Clean(o.QuarantinePath)) {
return fsops.ErrOutsideRoot
}
if fi, err := os.Lstat(o.QuarantinePath); err != nil || !fi.IsDir() {
return errors.New("the folder is no longer in the quarantine")
}
if _, err := os.Lstat(dst); err == nil {
return fmt.Errorf("%q exists again: %w", rel, fsops.ErrExists)
}
if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil {
return err
}
return os.Rename(o.QuarantinePath, dst)
}