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

+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):
}
}