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
+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