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:
cdnutterandClaude Opus 5.5 committed 2026-10-08 15:36:04 -07:00
1 parent 590b8abe08
commit faebc5dfb0
17 files changed
+1086 -26

No files matched your search

+13 -3
View File
@@ -98,6 +98,15 @@ not. **Remove** asks for confirmation (and an extra acknowledgement when the fol
right before touching it, and moves it into the quarantine instead of deleting it: it can be restored from History until the retention right before touching it, and moves it into the quarantine instead of deleting it: it can be restored from History until the retention
period purges it. The library root, the quarantine and hidden folders are never listed, and dry run applies. period purges it. The library root, the quarantine and hidden folders are never listed, and dry run applies.
**Automatic cleanup** (per pair, off by default) runs the same search on the slave library after every scan and removes only folders
with nothing at all inside them. A folder holding any file, even a cover or a `.DS_Store`, is left for you in Cleanup, and folders
changed within the settle time are skipped. Removals go to the quarantine and History like manual ones; in dry run nothing is removed.
**Notifications** (Settings → Notifications) send a message to Discord, Pushover, ntfy, Gotify or any JSON webhook when a scan finds
songs that newly need review, when automatic linking replaced identical files, and when an automatic cleanup removed empty folders.
Each event can be turned off, and each service has a **Test** button. Set the optional Playlink address to get a link back to the
right page in every message.
**Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of Playlink's database (settings, library pairs, scan results and **Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of Playlink's database (settings, library pairs, scan results and
history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7 history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7
or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be
@@ -132,7 +141,8 @@ Playlink is built so that no user data is ever lost, even if it crashes or loses
replaced, `..` is rejected, and master and slave may not overlap. replaced, `..` is rejected, and master and slave may not overlap.
- **Mutations are serialized.** Only one filesystem mutation runs at a time, and only one job runs per pair. - **Mutations are serialized.** Only one filesystem mutation runs at a time, and only one job runs per pair.
- **Auto-link is conservative.** It only acts on files with the *exact* same path, and it skips files modified within the settle time - **Auto-link is conservative.** It only acts on files with the *exact* same path, and it skips files modified within the settle time
(default 10 minutes) so it never touches a download in progress. (default 10 minutes) so it never touches a download in progress. Automatic cleanup likewise only removes slave folders that are
completely empty and unchanged for the settle time, and never touches the master library.
- **Interrupted operations are cleaned up.** Operations cut short by a crash are marked *interrupted* on startup, and their staging - **Interrupted operations are cleaned up.** Operations cut short by a crash are marked *interrupted* on startup, and their staging
files are removed. files are removed.
- **The web UI requires login.** Passwords are hashed with bcrypt, sessions use `SameSite=Strict` HttpOnly cookies, and state-changing - **The web UI requires login.** Passwords are hashed with bcrypt, sessions use `SameSite=Strict` HttpOnly cookies, and state-changing
@@ -194,8 +204,8 @@ Run a scan, check the Review tab, and turn off dry run once the planned actions
pick up quarantined songs; new quarantine folders get it automatically. Turning it off removes the file again, but only if Playlink pick up quarantined songs; new quarantine folders get it automatically. Turning it off removes the file again, but only if Playlink
created it. created it.
Per-pair settings (in the UI) are file extensions, auto-link, approximate matching and its threshold, scan interval, quarantine folder, Per-pair settings (in the UI) are file extensions, auto-link, automatic cleanup, approximate matching and its threshold, scan interval,
retention and settle time. quarantine folder, retention and settle time.
## Development ## Development
+4 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/cdnutter/playlink/internal/api" "github.com/cdnutter/playlink/internal/api"
"github.com/cdnutter/playlink/internal/backup" "github.com/cdnutter/playlink/internal/backup"
"github.com/cdnutter/playlink/internal/engine" "github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/notify"
"github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store" "github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/web" "github.com/cdnutter/playlink/web"
@@ -100,6 +101,8 @@ func main() {
backups := backup.New(st, dataDir, version, log) backups := backup.New(st, dataDir, version, log)
eng.SetBeforeAction(backups.BeforeAction) eng.SetBeforeAction(backups.BeforeAction)
notifier := notify.New(st, log)
eng.SetNotify(notifier.Notify)
// Restoring a backup restarts the process (Docker's restart policy // Restoring a backup restarts the process (Docker's restart policy
// brings it back) so the database is swapped while nothing uses it. // brings it back) so the database is swapped while nothing uses it.
ctx, restart := context.WithCancel(ctx) ctx, restart := context.WithCancel(ctx)
@@ -110,7 +113,7 @@ func main() {
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: api.New(api.Config{SecureCookies: secure, Version: version, Backups: backups, Restart: restart}, st, eng, log, web.Dist()).Handler(), Handler: api.New(api.Config{SecureCookies: secure, Version: version, Backups: backups, Notifier: notifier, Restart: restart}, st, eng, log, web.Dist()).Handler(),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute, WriteTimeout: 5 * time.Minute,
+47
View File
@@ -0,0 +1,47 @@
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/cdnutter/playlink/internal/notify"
)
func (s *Server) notifyRoutes(r chi.Router) {
r.Get("/notifications", s.handleGetNotify)
r.Put("/notifications", s.handlePutNotify)
r.Post("/notifications/test", s.handleTestNotify)
}
func (s *Server) handleGetNotify(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, s.cfg.Notifier.Config(r.Context()))
}
func (s *Server) handlePutNotify(w http.ResponseWriter, r *http.Request) {
var c notify.Config
if !readJSON(w, r, &c) {
return
}
if err := s.cfg.Notifier.SetConfig(r.Context(), c); err != nil {
writeErr(w, 400, err)
return
}
writeJSON(w, 200, s.cfg.Notifier.Config(r.Context()))
}
// handleTestNotify sends a test message to a target as entered, saved or not.
func (s *Server) handleTestNotify(w http.ResponseWriter, r *http.Request) {
var body struct {
Target notify.Target `json:"target"`
BaseURL string `json:"base_url"`
}
if !readJSON(w, r, &body) {
return
}
if err := s.cfg.Notifier.Test(r.Context(), body.Target, body.BaseURL); err != nil {
writeErr(w, 400, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
+5 -1
View File
@@ -21,6 +21,7 @@ import (
"github.com/cdnutter/playlink/internal/backup" "github.com/cdnutter/playlink/internal/backup"
"github.com/cdnutter/playlink/internal/engine" "github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/notify"
"github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store" "github.com/cdnutter/playlink/internal/store"
) )
@@ -29,6 +30,7 @@ type Config struct {
SecureCookies bool SecureCookies bool
Version string Version string
Backups *backup.Manager Backups *backup.Manager
Notifier *notify.Notifier
Restart func() // stops the server so the process restarts (after a restore) Restart func() // stops the server so the process restarts (after a restore)
} }
@@ -80,6 +82,7 @@ func (s *Server) Handler() http.Handler {
r.Get("/browse", s.handleBrowse) r.Get("/browse", s.handleBrowse)
if s.cfg.Backups != nil { if s.cfg.Backups != nil {
s.backupRoutes(r) s.backupRoutes(r)
s.notifyRoutes(r)
} }
r.Get("/pairs", s.handleListPairs) r.Get("/pairs", s.handleListPairs)
@@ -344,6 +347,7 @@ type pairInput struct {
QuarantineDir string `json:"quarantine_dir"` QuarantineDir string `json:"quarantine_dir"`
Extensions []string `json:"extensions"` Extensions []string `json:"extensions"`
AutoLinkIdentical bool `json:"auto_link_identical"` AutoLinkIdentical bool `json:"auto_link_identical"`
AutoCleanupEmpty bool `json:"auto_cleanup_empty"`
ScanIntervalMinutes int `json:"scan_interval_minutes"` ScanIntervalMinutes int `json:"scan_interval_minutes"`
RetentionDays int `json:"retention_days"` RetentionDays int `json:"retention_days"`
SettleMinutes int `json:"settle_minutes"` SettleMinutes int `json:"settle_minutes"`
@@ -353,7 +357,7 @@ type pairInput struct {
func (in pairInput) apply(p *store.Pair) { func (in pairInput) apply(p *store.Pair) {
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir = in.Name, in.MasterRoot, in.SlaveRoot, in.QuarantineDir p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir = in.Name, in.MasterRoot, in.SlaveRoot, in.QuarantineDir
p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical p.Extensions, p.AutoLinkIdentical, p.AutoCleanupEmpty = in.Extensions, in.AutoLinkIdentical, in.AutoCleanupEmpty
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
} }
+137
View File
@@ -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))
}
}
+102 -7
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/notify"
"github.com/cdnutter/playlink/internal/store" "github.com/cdnutter/playlink/internal/store"
) )
@@ -224,7 +225,7 @@ func (e *Engine) StartRemoveFolders(pairID int64, rels []string) (*Job, error) {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() 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()) j.skip(rel + ": " + err.Error())
} else if e.st.DryRun(ctx) { } else if e.st.DryRun(ctx) {
j.count("planned") 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. // autoCleanup runs after a scan when the pair opts in. It only removes
func (e *Engine) removeFolder(ctx context.Context, l *libs, side, rel string) error { // 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() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
root, err := sideRoot(l, side) 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): case abs == root.Path || fsops.Within(abs, l.quarantine):
return errors.New("can't remove the library or the quarantine") return errors.New("can't remove the library or the quarantine")
} }
// Music may have arrived since the scan: check again, right before moving. // Music (or, for an empty-only removal, any file) may have arrived
var music bool // since the scan: check again, right before moving.
var music, files bool
_ = filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error { _ = 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 music = true
return filepath.SkipAll return filepath.SkipAll
} }
@@ -273,7 +365,10 @@ func (e *Engine) removeFolder(ctx context.Context, l *libs, side, rel string) er
if music { if music {
return errors.New("it contains music now; skipped") 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) setSide(&o, side, rel)
if err := e.begin(ctx, &o); err != nil { if err := e.begin(ctx, &o); err != nil {
return err return err
+36
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/notify"
"github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store" "github.com/cdnutter/playlink/internal/store"
) )
@@ -42,6 +43,7 @@ type Engine struct {
mu sync.Mutex mu sync.Mutex
beforeAction func(ctx context.Context, reason string) error beforeAction func(ctx context.Context, reason string) error
notify func(ctx context.Context, ev notify.Event)
emptyMu sync.Mutex emptyMu sync.Mutex
empty map[int64]*NoMusicScan // latest scan for folders without music, per pair 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 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 // 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 // file). It does nothing in dry run, where no files change. An error means
// the action must not run. // the action must not run.
+45 -7
View File
@@ -13,6 +13,7 @@ import (
"unicode" "unicode"
"github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/notify"
"github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store" "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))) j.phase("Comparing files", int64(len(slaveFiles)))
keep := map[string]bool{} keep := map[string]bool{}
var layout layoutStats 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) { for _, rel := range sortedKeys(slaveFiles) {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
@@ -331,6 +334,12 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
return err return err
} }
keep[rel] = true keep[rel] = true
if needsReview(m) {
review++
if pm == nil || !needsReview(*pm) {
newReview++
}
}
layout.add(m.RelSlave, m.RelMaster, m.Kind, m.Reason) layout.add(m.RelSlave, m.RelMaster, m.Kind, m.Reason)
j.count(m.Status) j.count(m.Status)
j.tick() 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)) 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 { 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 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. // 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 { func (e *Engine) autoLink(ctx context.Context, j *Job, l *libs, trigger string) error {
ids, err := e.st.IdenticalIDs(ctx, l.pair.ID) 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 { if err := e.BeforeAction(ctx, "auto-link"); err != nil {
return err 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. // linkIDs links a batch of identical matches and returns how many were
func (e *Engine) linkIDs(ctx context.Context, j *Job, pairID int64, ids []int64, trigger string, onlySettled bool) error { // 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))) j.phase("Linking identical files", int64(len(ids)))
settle := time.Duration(0) settle := time.Duration(0)
if onlySettled { 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() cutoff := time.Now().Add(-settle).UnixNano()
for _, id := range ids { for _, id := range ids {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return linked, freed, ctx.Err()
} }
m, err := e.st.Match(ctx, id) m, err := e.st.Match(ctx, id)
if err != nil { if err != nil {
@@ -419,10 +454,12 @@ func (e *Engine) linkIDs(ctx context.Context, j *Job, pairID int64, ids []int64,
j.count("planned") j.count("planned")
default: default:
j.count("linked") j.count("linked")
linked++
freed += op.Bytes
} }
j.tick() j.tick()
} }
return nil return linked, freed, nil
} }
// StartLinkIdentical links every identical match of a pair in the background. // 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" }) j.update(func(j *Job) { j.Message = "Nothing to link" })
return nil return nil
} }
return e.linkIDs(ctx, j, pairID, ids, "bulk", false) _, _, err = e.linkIDs(ctx, j, pairID, ids, "bulk", false)
return err
}) })
} }
+299
View File
@@ -0,0 +1,299 @@
// Package notify sends short messages about things that happened without
// the user (new songs to review, automatic links and cleanups) to services
// such as Discord or Pushover.
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/cdnutter/playlink/internal/store"
)
// Event kinds.
const (
EventReview = "review" // a scan found songs that newly need review
EventLinked = "linked" // identical files were linked automatically
EventCleanup = "cleanup" // an automatic cleanup removed empty folders
EventTest = "test"
)
// Target types.
const (
TypeDiscord = "discord"
TypePushover = "pushover"
TypeNtfy = "ntfy"
TypeGotify = "gotify"
TypeWebhook = "webhook"
)
// Target is one place messages are sent. Which fields are used depends on
// the type: Discord and webhooks need URL; Pushover needs Token (the app
// token) and User (the user key); ntfy needs URL (the topic URL) and takes
// an optional access Token; Gotify needs URL (the server) and Token.
type Target struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
URL string `json:"url"`
Token string `json:"token"`
User string `json:"user"`
}
// Config is the global notification setup.
type Config struct {
OnReview bool `json:"on_review"`
OnLinked bool `json:"on_linked"`
OnCleanup bool `json:"on_cleanup"`
BaseURL string `json:"base_url"` // optional: where Playlink is reachable, for links in messages
Targets []Target `json:"targets"`
}
// Event is one thing worth telling the user about.
type Event struct {
Kind string
PairID int64
Pair string
Title string
Message string
Path string // page to open, relative to the app (e.g. "#/pair/1/review")
}
const settingKey = "notifications"
const maxTargets = 10
var pushoverURL = "https://api.pushover.net/1/messages.json" // replaced in tests
type Notifier struct {
st *store.Store
log *slog.Logger
client *http.Client
}
func New(st *store.Store, log *slog.Logger) *Notifier {
return &Notifier{st: st, log: log, client: &http.Client{Timeout: 15 * time.Second}}
}
// Config returns the saved configuration, with every event on by default.
func (n *Notifier) Config(ctx context.Context) Config {
c := Config{OnReview: true, OnLinked: true, OnCleanup: true}
if raw := n.st.Setting(ctx, settingKey, ""); raw != "" {
_ = json.Unmarshal([]byte(raw), &c)
}
if c.Targets == nil {
c.Targets = []Target{}
}
return c
}
// SetConfig validates and saves the configuration.
func (n *Notifier) SetConfig(ctx context.Context, c Config) error {
if err := c.validate(); err != nil {
return err
}
raw, err := json.Marshal(c)
if err != nil {
return err
}
return n.st.SetSetting(ctx, settingKey, string(raw))
}
func (c *Config) validate() error {
if len(c.Targets) > maxTargets {
return fmt.Errorf("at most %d notification targets", maxTargets)
}
c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
if c.BaseURL != "" {
if err := httpURL(c.BaseURL); err != nil {
return fmt.Errorf("Playlink address: %w", err)
}
}
if c.Targets == nil {
c.Targets = []Target{}
}
for i := range c.Targets {
if err := c.Targets[i].validate(); err != nil {
name := c.Targets[i].Name
if name == "" {
name = c.Targets[i].Type
}
return fmt.Errorf("%s: %w", name, err)
}
}
return nil
}
func (t *Target) validate() error {
t.Name = strings.TrimSpace(t.Name)
t.URL = strings.TrimSpace(t.URL)
t.Token = strings.TrimSpace(t.Token)
t.User = strings.TrimSpace(t.User)
switch t.Type {
case TypeDiscord, TypeWebhook, TypeNtfy:
return httpURL(t.URL)
case TypeGotify:
if t.Token == "" {
return errors.New("an application token is required")
}
return httpURL(t.URL)
case TypePushover:
if t.Token == "" || t.User == "" {
return errors.New("an application token and a user key are required")
}
return nil
}
return fmt.Errorf("unknown type %q", t.Type)
}
func httpURL(s string) error {
u, err := url.Parse(s)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return errors.New("enter a full http:// or https:// address")
}
return nil
}
// Notify sends an event to every enabled target, in the background, if
// that kind of event is turned on. Failures are logged, never returned:
// a notification must not hold up or fail the work it reports on.
func (n *Notifier) Notify(ctx context.Context, ev Event) {
c := n.Config(ctx)
switch ev.Kind {
case EventReview:
if !c.OnReview {
return
}
case EventLinked:
if !c.OnLinked {
return
}
case EventCleanup:
if !c.OnCleanup {
return
}
}
for _, t := range c.Targets {
if !t.Enabled {
continue
}
go func(t Target) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := n.send(ctx, t, c.BaseURL, ev); err != nil {
n.log.Warn("notification not sent", "target", t.Name, "type", t.Type, "event", ev.Kind, "err", err)
}
}(t)
}
}
// Test sends a test message to one target and reports the result.
func (n *Notifier) Test(ctx context.Context, t Target, baseURL string) error {
if err := t.validate(); err != nil {
return err
}
return n.send(ctx, t, strings.TrimRight(strings.TrimSpace(baseURL), "/"), Event{
Kind: EventTest,
Title: "Playlink test notification",
Message: "Notifications from Playlink will arrive here.",
})
}
func (n *Notifier) send(ctx context.Context, t Target, base string, ev Event) error {
link := ""
if base != "" {
link = base + "/"
if ev.Path != "" {
link += ev.Path
}
}
title := ev.Title
if ev.Pair != "" {
title = ev.Pair + ": " + ev.Title
}
var req *http.Request
var err error
switch t.Type {
case TypeDiscord:
embed := map[string]any{"title": title, "description": ev.Message, "color": 0x8b5cf6}
if link != "" {
embed["url"] = link
}
req, err = jsonRequest(ctx, t.URL, map[string]any{"username": "Playlink", "embeds": []any{embed}})
case TypePushover:
form := url.Values{"token": {t.Token}, "user": {t.User}, "title": {title}, "message": {ev.Message}}
if link != "" {
form.Set("url", link)
form.Set("url_title", "Open Playlink")
}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, pushoverURL, strings.NewReader(form.Encode()))
if err == nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
case TypeNtfy:
req, err = http.NewRequestWithContext(ctx, http.MethodPost, t.URL, strings.NewReader(ev.Message))
if err == nil {
req.Header.Set("Title", title)
req.Header.Set("Tags", "musical_note")
if link != "" {
req.Header.Set("Click", link)
}
if t.Token != "" {
req.Header.Set("Authorization", "Bearer "+t.Token)
}
}
case TypeGotify:
body := map[string]any{"title": title, "message": ev.Message, "priority": 5}
if link != "" {
body["extras"] = map[string]any{"client::notification": map[string]any{"click": map[string]string{"url": link}}}
}
req, err = jsonRequest(ctx, strings.TrimRight(t.URL, "/")+"/message", body)
if err == nil {
req.Header.Set("X-Gotify-Key", t.Token)
}
case TypeWebhook:
req, err = jsonRequest(ctx, t.URL, map[string]any{
"event": ev.Kind, "pair_id": ev.PairID, "pair": ev.Pair, "title": ev.Title, "message": ev.Message, "url": link,
})
default:
return fmt.Errorf("unknown type %q", t.Type)
}
if err != nil {
return err
}
req.Header.Set("User-Agent", "Playlink")
res, err := n.client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(res.Body, 300))
return fmt.Errorf("%s answered %s: %s", t.Type, res.Status, strings.TrimSpace(string(msg)))
}
return nil
}
func jsonRequest(ctx context.Context, u string, body any) (*http.Request, error) {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(raw))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
}
+155
View File
@@ -0,0 +1,155 @@
package notify
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/cdnutter/playlink/internal/store"
)
type got struct {
path string
header http.Header
body string
}
func server(t *testing.T, status int) (*httptest.Server, chan got) {
t.Helper()
ch := make(chan got, 10)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
ch <- got{path: r.URL.Path, header: r.Header, body: string(b)}
w.WriteHeader(status)
_, _ = w.Write([]byte("nope"))
}))
t.Cleanup(srv.Close)
return srv, ch
}
func newNotifier(t *testing.T) *Notifier {
t.Helper()
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.DB.Close() })
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
var ev = Event{Kind: EventReview, PairID: 3, Pair: "Music", Title: "New songs to review", Message: "2 songs need review.", Path: "#/pair/3/review"}
func TestPayloads(t *testing.T) {
n := newNotifier(t)
srv, ch := server(t, 200)
pushoverURL = srv.URL + "/pushover"
ctx := context.Background()
base := "https://playlink.lan"
link := "https://playlink.lan/#/pair/3/review"
check := func(tg Target, f func(g got)) {
t.Helper()
if err := n.send(ctx, tg, base, ev); err != nil {
t.Fatalf("%s: %v", tg.Type, err)
}
f(<-ch)
}
check(Target{Type: TypeDiscord, URL: srv.URL + "/discord"}, func(g got) {
var b struct {
Embeds []struct{ Title, Description, URL string }
}
json.Unmarshal([]byte(g.body), &b)
if len(b.Embeds) != 1 || b.Embeds[0].Title != "Music: New songs to review" || b.Embeds[0].URL != link || b.Embeds[0].Description != ev.Message {
t.Fatalf("discord: %s", g.body)
}
})
check(Target{Type: TypePushover, Token: "app", User: "me"}, func(g got) {
v, _ := url.ParseQuery(g.body)
if g.path != "/pushover" || v.Get("token") != "app" || v.Get("user") != "me" || v.Get("url") != link || v.Get("message") != ev.Message {
t.Fatalf("pushover: %s %s", g.path, g.body)
}
})
check(Target{Type: TypeNtfy, URL: srv.URL + "/playlink", Token: "tk"}, func(g got) {
if g.path != "/playlink" || g.body != ev.Message || g.header.Get("Title") != "Music: New songs to review" ||
g.header.Get("Click") != link || g.header.Get("Authorization") != "Bearer tk" {
t.Fatalf("ntfy: %v %s", g.header, g.body)
}
})
check(Target{Type: TypeGotify, URL: srv.URL + "/", Token: "gk"}, func(g got) {
if g.path != "/message" || g.header.Get("X-Gotify-Key") != "gk" || !strings.Contains(g.body, `"click":{"url":"`+link+`"}`) {
t.Fatalf("gotify: %s %v %s", g.path, g.header, g.body)
}
})
check(Target{Type: TypeWebhook, URL: srv.URL + "/hook"}, func(g got) {
var b map[string]any
json.Unmarshal([]byte(g.body), &b)
if b["event"] != "review" || b["pair"] != "Music" || b["url"] != link || b["pair_id"] != float64(3) {
t.Fatalf("webhook: %s", g.body)
}
})
}
func TestErrorsAreReported(t *testing.T) {
n := newNotifier(t)
srv, _ := server(t, 401)
err := n.Test(context.Background(), Target{Type: TypeDiscord, URL: srv.URL}, "")
if err == nil || !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "nope") {
t.Fatalf("err = %v", err)
}
}
func TestConfigValidationAndEvents(t *testing.T) {
n := newNotifier(t)
ctx := context.Background()
if c := n.Config(ctx); !c.OnReview || !c.OnLinked || !c.OnCleanup || c.Targets == nil {
t.Fatalf("defaults: %+v", c)
}
for _, bad := range []Target{
{Type: TypeDiscord, URL: "discord.com/x"},
{Type: TypePushover, Token: "a"},
{Type: TypeGotify, URL: "http://g"},
{Type: "smoke-signal"},
} {
if err := n.SetConfig(ctx, Config{Targets: []Target{bad}}); err == nil {
t.Fatalf("accepted %+v", bad)
}
}
if err := n.SetConfig(ctx, Config{BaseURL: "playlink.lan"}); err == nil {
t.Fatal("accepted a base URL without a scheme")
}
// Only enabled targets, and only events that are turned on.
srv, ch := server(t, 200)
c := Config{OnReview: false, OnLinked: true, BaseURL: "http://p/ ", Targets: []Target{
{ID: "a", Type: TypeWebhook, Name: " on ", Enabled: true, URL: srv.URL + "/on"},
{ID: "b", Type: TypeWebhook, Enabled: false, URL: srv.URL + "/off"},
}}
if err := n.SetConfig(ctx, c); err != nil {
t.Fatal(err)
}
if saved := n.Config(ctx); saved.BaseURL != "http://p" || saved.Targets[0].Name != "on" {
t.Fatalf("not normalized: %+v", saved)
}
n.Notify(ctx, ev) // review is off
n.Notify(ctx, Event{Kind: EventLinked, Title: "t", Message: "m"})
select {
case g := <-ch:
if g.path != "/on" || !strings.Contains(g.body, `"event":"linked"`) {
t.Fatalf("sent %s %s", g.path, g.body)
}
case <-time.After(5 * time.Second):
t.Fatal("nothing sent")
}
select {
case g := <-ch:
t.Fatalf("unexpected second message to %s: %s", g.path, g.body)
case <-time.After(200 * time.Millisecond):
}
}
+8 -7
View File
@@ -144,6 +144,7 @@ type Pair struct {
QuarantineDir string `json:"quarantine_dir"` QuarantineDir string `json:"quarantine_dir"`
Extensions []string `json:"extensions"` Extensions []string `json:"extensions"`
AutoLinkIdentical bool `json:"auto_link_identical"` AutoLinkIdentical bool `json:"auto_link_identical"`
AutoCleanupEmpty bool `json:"auto_cleanup_empty"` // remove empty slave folders after each scan
ScanIntervalMinutes int `json:"scan_interval_minutes"` ScanIntervalMinutes int `json:"scan_interval_minutes"`
RetentionDays int `json:"retention_days"` RetentionDays int `json:"retention_days"`
SettleMinutes int `json:"settle_minutes"` SettleMinutes int `json:"settle_minutes"`
@@ -155,7 +156,7 @@ type Pair struct {
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical, const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical, auto_cleanup_empty,
scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, last_scan_at, last_scan_error, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, last_scan_at, last_scan_error,
last_scan_warnings, created_at` last_scan_warnings, created_at`
@@ -165,7 +166,7 @@ func scanPair(r scanner) (Pair, error) {
var p Pair var p Pair
var exts, warns string var exts, warns string
var last sql.NullInt64 var last sql.NullInt64
err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical, err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical, &p.AutoCleanupEmpty,
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &last, &p.LastScanError, &p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &last, &p.LastScanError,
&warns, &p.CreatedAt) &warns, &p.CreatedAt)
if err != nil { if err != nil {
@@ -208,9 +209,9 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
exts := strings.Join(p.Extensions, ",") exts := strings.Join(p.Extensions, ",")
if p.ID == 0 { if p.ID == 0 {
res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions, res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions,
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, created_at) auto_link_identical, auto_cleanup_empty, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.AutoCleanupEmpty,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, now()) p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, now())
if err != nil { if err != nil {
return err return err
@@ -219,9 +220,9 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
return nil return nil
} }
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?, _, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?,
extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?, extensions = ?, auto_link_identical = ?, auto_cleanup_empty = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?,
approx_matching = ?, approx_threshold = ? WHERE id = ?`, approx_matching = ?, approx_threshold = ? WHERE id = ?`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.AutoCleanupEmpty,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, p.ID) p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, p.ID)
return err return err
} }
+3
View File
@@ -140,6 +140,9 @@ var migrations = []string{
// 10: library type (music or soundtracks) and the step-by-step fixer's progress. // 10: library type (music or soundtracks) and the step-by-step fixer's progress.
`ALTER TABLE pairs ADD COLUMN library_type TEXT NOT NULL DEFAULT 'music'; `ALTER TABLE pairs ADD COLUMN library_type TEXT NOT NULL DEFAULT 'music';
ALTER TABLE pairs ADD COLUMN fixer_state TEXT NOT NULL DEFAULT '';`, ALTER TABLE pairs ADD COLUMN fixer_state TEXT NOT NULL DEFAULT '';`,
// 11: opt-in automatic removal of empty slave folders after each scan.
`ALTER TABLE pairs ADD COLUMN auto_cleanup_empty INTEGER NOT NULL DEFAULT 0;`,
} }
// SchemaVersion is the newest database schema this build knows. // SchemaVersion is the newest database schema this build knows.
+22
View File
@@ -69,6 +69,7 @@ export type Pair = {
quarantine_dir: string; quarantine_dir: string;
extensions: string[]; extensions: string[];
auto_link_identical: boolean; auto_link_identical: boolean;
auto_cleanup_empty: boolean;
scan_interval_minutes: number; scan_interval_minutes: number;
retention_days: number; retention_days: number;
settle_minutes: number; settle_minutes: number;
@@ -89,6 +90,7 @@ export type PairInput = Pick<
| "quarantine_dir" | "quarantine_dir"
| "extensions" | "extensions"
| "auto_link_identical" | "auto_link_identical"
| "auto_cleanup_empty"
| "scan_interval_minutes" | "scan_interval_minutes"
| "retention_days" | "retention_days"
| "settle_minutes" | "settle_minutes"
@@ -165,6 +167,26 @@ export type TreeEntry = {
linked?: number; linked?: number;
}; };
export type NotifyType = "discord" | "pushover" | "ntfy" | "gotify" | "webhook";
export type NotifyTarget = {
id: string;
type: NotifyType;
name: string;
enabled: boolean;
url: string;
token: string;
user: string;
};
export type NotifyConfig = {
on_review: boolean;
on_linked: boolean;
on_cleanup: boolean;
base_url: string;
targets: NotifyTarget[];
};
export type Page<T> = { items: T[]; total: number }; export type Page<T> = { items: T[]; total: number };
export type AlbumGroup = { export type AlbumGroup = {
+10
View File
@@ -65,6 +65,16 @@ export function Cleanup({ pair }: { pair: Pair }) {
</Button> </Button>
</div> </div>
</div> </div>
<p className="text-xs text-muted">
{pair.auto_cleanup_empty ? (
<>
<b className="text-gray-300">Automatic cleanup is on:</b> empty slave folders are removed after each scan. Folders that still hold
files always wait here for you.
</>
) : (
<>Empty slave folders can also be removed automatically after each scan. Turn it on under Edit.</>
)}
</p>
{ourJob && <JobBar job={pair.job!} />} {ourJob && <JobBar job={pair.job!} />}
</Card> </Card>
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Plus, Send, Trash2 } from "lucide-react";
import { api, type NotifyConfig, type NotifyTarget, type NotifyType } from "../api";
import { Button, Field, SettingRow, SettingsSection, Spinner, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
type FieldDef = { key: "url" | "token" | "user"; label: string; placeholder?: string; secret?: boolean };
const TYPES: Record<NotifyType, { label: string; hint: string; fields: FieldDef[] }> = {
discord: {
label: "Discord",
hint: "In Discord: Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.",
fields: [{ key: "url", label: "Webhook URL", placeholder: "https://discord.com/api/webhooks/…", secret: true }],
},
pushover: {
label: "Pushover",
hint: "Create an application at pushover.net for its token; your user key is on the Pushover dashboard.",
fields: [
{ key: "token", label: "Application token", secret: true },
{ key: "user", label: "User key", secret: true },
],
},
ntfy: {
label: "ntfy",
hint: "Subscribe to the same topic in the ntfy app. Self-hosted servers work too.",
fields: [
{ key: "url", label: "Topic URL", placeholder: "https://ntfy.sh/my-playlink" },
{ key: "token", label: "Access token (optional)", secret: true },
],
},
gotify: {
label: "Gotify",
hint: "Create an application in Gotify and copy its token.",
fields: [
{ key: "url", label: "Server URL", placeholder: "https://gotify.example.com" },
{ key: "token", label: "Application token", secret: true },
],
},
webhook: {
label: "Webhook",
hint: "Playlink POSTs JSON with event, pair_id, pair, title, message and url.",
fields: [{ key: "url", label: "URL", placeholder: "https://example.com/hooks/playlink" }],
},
};
const EVENTS: { key: "on_review" | "on_linked" | "on_cleanup"; title: string; description: string }[] = [
{ key: "on_review", title: "Songs need review", description: "A scan found songs that weren't waiting for review before." },
{ key: "on_linked", title: "Identical files were linked", description: "Automatic linking after a scan replaced byte-identical files with hardlinks." },
{ key: "on_cleanup", title: "Empty folders were cleaned up", description: "Automatic cleanup after a scan moved empty folders to the quarantine." },
];
const newID = () => Math.random().toString(36).slice(2, 10);
export function NotificationsSection() {
const qc = useQueryClient();
const toast = useToast();
const data = useQuery({ queryKey: ["notifications"], queryFn: () => api.get<NotifyConfig>("/notifications") });
const [cfg, setCfg] = useState<NotifyConfig | null>(null);
useEffect(() => {
if (data.data) setCfg(data.data);
}, [data.data]);
const saved = data.data;
const dirty = cfg && saved && JSON.stringify(cfg) !== JSON.stringify(saved);
const save = useMutation({
mutationFn: () => api.put<NotifyConfig>("/notifications", cfg),
onSuccess: () => {
toast("ok", "Notification settings saved");
qc.invalidateQueries({ queryKey: ["notifications"] });
},
onError: (e) => toast("err", e.message),
});
const test = useMutation({
mutationFn: (t: NotifyTarget) => api.post("/notifications/test", { target: t, base_url: cfg?.base_url ?? "" }),
onSuccess: (_, t) => toast("ok", `Test sent to ${t.name || TYPES[t.type].label}`),
onError: (e) => toast("err", e.message),
});
const setTarget = (id: string, patch: Partial<NotifyTarget>) =>
cfg && setCfg({ ...cfg, targets: cfg.targets.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
const add = (type: NotifyType) =>
cfg && setCfg({ ...cfg, targets: [...cfg.targets, { id: newID(), type, name: TYPES[type].label, enabled: true, url: "", token: "", user: "" }] });
return (
<SettingsSection
icon={<Bell />}
title="Notifications"
description="Get a message when something happens without you, such as after a scheduled scan."
>
{!cfg && (
<div className="px-5 py-4">
<Spinner />
</div>
)}
{cfg && (
<>
{EVENTS.map((ev) => (
<SettingRow key={ev.key} title={ev.title} description={ev.description}>
<Toggle ariaLabel={ev.title} checked={cfg[ev.key]} onChange={(v) => setCfg({ ...cfg, [ev.key]: v })} />
</SettingRow>
))}
<SettingRow title="Playlink address" description="Optional. Messages get a link back to the right page when this is set.">
<input
className={inputCls + " sm:w-72!"}
placeholder="http://tower.local:8080"
value={cfg.base_url}
onChange={(e) => setCfg({ ...cfg, base_url: e.target.value })}
/>
</SettingRow>
<div className="space-y-3 px-5 py-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted">Send to</div>
{cfg.targets.length === 0 && <p className="text-sm text-muted">No services yet. Add one below.</p>}
{cfg.targets.map((t) => {
const def = TYPES[t.type];
return (
<div key={t.id} className="space-y-3 rounded-lg border border-line bg-panel-2 p-3.5">
<div className="flex flex-wrap items-center gap-3">
<Toggle ariaLabel={`Send to ${t.name || def.label}`} checked={t.enabled} onChange={(v) => setTarget(t.id, { enabled: v })} />
<span className="rounded-md border border-line px-1.5 py-0.5 text-[11px] font-medium text-muted">{def.label}</span>
<input
className={inputCls + " h-8! min-w-0 flex-1 sm:max-w-60"}
aria-label="Name"
value={t.name}
onChange={(e) => setTarget(t.id, { name: e.target.value })}
/>
<div className="ml-auto flex gap-1.5">
<Button size="sm" onClick={() => test.mutate(t)} loading={test.isPending && test.variables?.id === t.id}>
<Send className="size-3.5" /> Test
</Button>
<Button
size="sm"
variant="ghost"
title="Remove"
onClick={() => setCfg({ ...cfg, targets: cfg.targets.filter((x) => x.id !== t.id) })}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{def.fields.map((f) => (
<Field key={f.key} label={f.label}>
<input
className={inputCls + (f.key === "url" ? " mono text-xs" : "")}
type={f.secret ? "password" : "text"}
autoComplete="off"
placeholder={f.placeholder}
value={t[f.key]}
onChange={(e) => setTarget(t.id, { [f.key]: e.target.value })}
/>
</Field>
))}
</div>
<p className="text-xs text-muted">{def.hint}</p>
</div>
);
})}
<div className="flex flex-wrap gap-2">
{(Object.keys(TYPES) as NotifyType[]).map((type) => (
<Button key={type} size="sm" variant="ghost" onClick={() => add(type)} disabled={cfg.targets.length >= 10}>
<Plus className="size-3.5" /> {TYPES[type].label}
</Button>
))}
</div>
</div>
{dirty && (
<div className="flex items-center justify-end gap-2 bg-accent/5 px-5 py-3">
<span className="mr-auto text-xs text-muted">Unsaved changes</span>
<Button size="sm" variant="ghost" onClick={() => saved && setCfg(saved)}>
Discard
</Button>
<Button size="sm" variant="primary" onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
</div>
)}
</>
)}
</SettingsSection>
);
}
+13
View File
@@ -20,6 +20,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
quarantine_dir: "", quarantine_dir: "",
extensions: [], extensions: [],
auto_link_identical: false, auto_link_identical: false,
auto_cleanup_empty: false,
scan_interval_minutes: 0, scan_interval_minutes: 0,
retention_days: 30, retention_days: 30,
settle_minutes: 10, settle_minutes: 10,
@@ -47,6 +48,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
slave_root: f.slave_root, slave_root: f.slave_root,
quarantine_dir: f.quarantine_dir, quarantine_dir: f.quarantine_dir,
auto_link_identical: f.auto_link_identical, auto_link_identical: f.auto_link_identical,
auto_cleanup_empty: f.auto_cleanup_empty,
scan_interval_minutes: f.scan_interval_minutes, scan_interval_minutes: f.scan_interval_minutes,
retention_days: f.retention_days, retention_days: f.retention_days,
settle_minutes: f.settle_minutes, settle_minutes: f.settle_minutes,
@@ -133,6 +135,17 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
Anything else always waits for manual review. Dry run still applies. Anything else always waits for manual review. Dry run still applies.
</p> </p>
</div> </div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={f.auto_cleanup_empty}
onChange={(v) => set("auto_cleanup_empty", v)}
label={<span className="font-medium">Automatically remove empty folders after each scan</span>}
/>
<p className="mt-1.5 pl-11.5 text-xs text-muted">
Only slave folders with nothing at all inside them. Folders that still hold a file, such as a cover or a playlist, are left for you in
Cleanup, and folders changed within the settle time are left alone. Removed folders go to the quarantine. Dry run still applies.
</p>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5"> <div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle <Toggle
checked={f.approx_matching} checked={f.approx_matching}
+3
View File
@@ -6,6 +6,7 @@ import { useInfo } from "../App";
import { Alert, Button, Field, SettingRow, SettingsSection, Toggle, inputCls } from "../ui"; import { Alert, Button, Field, SettingRow, SettingsSection, Toggle, inputCls } from "../ui";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { BackupsSection } from "./Backups"; import { BackupsSection } from "./Backups";
import { NotificationsSection } from "./Notifications";
// Guarantees that apply whatever the settings are. // Guarantees that apply whatever the settings are.
const ALWAYS_ON = [ const ALWAYS_ON = [
@@ -93,6 +94,8 @@ export function SettingsPage() {
</SettingRow> </SettingRow>
</SettingsSection> </SettingsSection>
<NotificationsSection />
<BackupsSection /> <BackupsSection />
<SettingsSection icon={<KeyRound />} title="Account"> <SettingsSection icon={<KeyRound />} title="Account">