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