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