164 lines
6.5 KiB
Go
164 lines
6.5 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/notify"
|
|
)
|
|
|
|
// The gateway's notification providers, and the two helpers every producer now calls.
|
|
//
|
|
// internal/notify owns the audit trail and knows nothing about Memby's channels; this file
|
|
// is the other half — what "in-app" and "broadcast" actually mean here. A feature says
|
|
// *what* it wants said; these decide how it is carried and what to report about it.
|
|
//
|
|
// The sources below name the service that decided to notify. They are stored and the
|
|
// console filters on them, so they are constants rather than string literals typed at each
|
|
// call site: a source spelled two ways is two rows in a dropdown for one feature.
|
|
const (
|
|
notifySourceSonarrLifecycle = "sonarr-lifecycle"
|
|
notifySourceShowReturn = "show-return-scan"
|
|
notifySourceAutoFollow = "auto-follow"
|
|
notifySourceWatchTime = "watch-time-digest"
|
|
notifySourceLibraryIngest = "library-ingest"
|
|
notifySourceLibrarySync = "library-sync"
|
|
notifySourceDeployment = "deployment"
|
|
notifySourceEmbyHealth = "emby-health"
|
|
notifySourceIntegrations = "integrations"
|
|
)
|
|
|
|
// registerNotifiers installs the gateway's delivery providers on the notification service.
|
|
// Called once from New, so every producer can assume the channels it uses exist.
|
|
func (s *Server) registerNotifiers() {
|
|
if s.notify == nil {
|
|
return
|
|
}
|
|
s.notify.Register(
|
|
notify.DelivererFunc{Name: notify.ChannelInApp, Fn: s.deliverInApp},
|
|
notify.DelivererFunc{Name: notify.ChannelBroadcast, Fn: s.deliverBroadcast},
|
|
)
|
|
}
|
|
|
|
// deliverInApp writes a notification into one viewer's own list.
|
|
//
|
|
// The three answers it can give are all real and all worth recording separately. A row was
|
|
// written: sent. A row with that source key was already there: skipped, because the
|
|
// producers here are deliberately re-run — the watch-time digest fires hourly and re-sends
|
|
// the same weekly key all evening so a gateway that was off still delivers — and every one
|
|
// of those catch-up passes would otherwise read as a summary somebody never got. And the
|
|
// write failed: failed, with the database's own words, which is the only thing that would
|
|
// explain a viewer's empty list.
|
|
func (s *Server) deliverInApp(ctx context.Context, n notify.Notification) notify.Outcome {
|
|
if s.store == nil {
|
|
return notify.Failed(errors.New("no database"))
|
|
}
|
|
if n.UserID == "" {
|
|
return notify.Failed(errors.New("an in-app notification needs a recipient"))
|
|
}
|
|
inserted, err := s.store.UpsertNotification(
|
|
ctx, n.UserID, n.SourceKey, n.Kind, n.ItemID, n.Title, n.Body, n.EventAt)
|
|
if err != nil {
|
|
return notify.Failed(err)
|
|
}
|
|
if !inserted {
|
|
return notify.Skipped("already in this viewer's list")
|
|
}
|
|
return notify.Sent()
|
|
}
|
|
|
|
// broadcastWindow travels with a broadcast notification: how long the alert stays on offer
|
|
// to televisions that were switched off when it happened.
|
|
const broadcastWindowKey = "windowSeconds"
|
|
|
|
// deliverBroadcast publishes a service alert to every signed-in television.
|
|
//
|
|
// The alert itself is carried in the notification's metadata rather than in its fields,
|
|
// because a clientAlert is a wire type with an id, a kind and an image tag that
|
|
// notify.Notification has no business modelling. broadcast() below is the only thing that
|
|
// builds one of these, so the round trip is contained.
|
|
func (s *Server) deliverBroadcast(ctx context.Context, n notify.Notification) notify.Outcome {
|
|
alert, ok := n.Metadata[broadcastAlertKey].(clientAlert)
|
|
if !ok {
|
|
return notify.Failed(errors.New("no alert to publish"))
|
|
}
|
|
window, _ := n.Metadata[broadcastWindowKey].(time.Duration)
|
|
if window <= 0 {
|
|
// An operator has this kind of news switched off. Deliberately a recorded skip
|
|
// rather than silence: "the window is zero" is the answer to why nobody was told,
|
|
// and it is not one anybody would find by reading the code.
|
|
return notify.Skipped("this alert window is switched off")
|
|
}
|
|
if s.cache == nil {
|
|
return notify.Failed(errors.New("no cache to publish alerts through"))
|
|
}
|
|
if err := s.publishAlertNow(ctx, alert, window); err != nil {
|
|
return notify.Failed(err)
|
|
}
|
|
return notify.Sent()
|
|
}
|
|
|
|
// broadcastAlertKey is the metadata slot the clientAlert rides in. It is stripped before
|
|
// the record is written — the alert's fields are already the record's title and body, and
|
|
// storing the whole struct again would put a second copy of every banner in the log.
|
|
const broadcastAlertKey = "alert"
|
|
|
|
// broadcast is what every service-alert producer calls in place of publishAlert.
|
|
//
|
|
// It is the one place a clientAlert becomes a notification, so the console's row for a
|
|
// banner says the same thing the television's bar said, with no producer having to
|
|
// describe its news twice.
|
|
func (s *Server) broadcast(
|
|
ctx context.Context, source string, alert clientAlert, window time.Duration,
|
|
) {
|
|
outcome := s.notify.Send(ctx, notify.Notification{
|
|
Channel: notify.ChannelBroadcast,
|
|
Kind: alert.Kind,
|
|
Source: source,
|
|
Title: alert.Title,
|
|
Body: alert.Message,
|
|
ItemID: alert.ItemID,
|
|
SourceKey: alert.ID,
|
|
EventAt: alertEventTime(alert),
|
|
Metadata: map[string]any{
|
|
broadcastAlertKey: alert,
|
|
broadcastWindowKey: window,
|
|
"label": alert.Label,
|
|
},
|
|
})
|
|
if outcome.Err != nil {
|
|
s.loggerFor(ctx).Warn("service alert not published",
|
|
"kind", alert.Kind, "id", alert.ID, "error", outcome.Err)
|
|
}
|
|
}
|
|
|
|
func alertEventTime(alert clientAlert) *time.Time {
|
|
when, err := time.Parse(time.RFC3339, alert.AiredAt)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &when
|
|
}
|
|
|
|
// notifyUser is what every per-viewer producer calls in place of store.UpsertNotification.
|
|
//
|
|
// It returns whether the notification actually reached the viewer's list, which is what
|
|
// the callers' own counters mean: the Sonarr scan reporting "14 notifications" must not
|
|
// count fourteen repeats of one it had already sent.
|
|
func (s *Server) notifyUser(ctx context.Context, n notify.Notification) bool {
|
|
n.Channel = notify.ChannelInApp
|
|
return s.notify.Send(ctx, n).Status == notify.StatusSent
|
|
}
|
|
|
|
// declineUser records a notification a viewer's own preferences refused.
|
|
//
|
|
// This is the half a per-feature audit trail always misses, and it is the reason the page
|
|
// is worth having: "I never got the weekly summary" and "you have weekly summaries turned
|
|
// off" look identical from the outside, and only a recorded skip tells them apart. It is
|
|
// never delivered, so it goes through Log rather than Send.
|
|
func (s *Server) declineUser(ctx context.Context, n notify.Notification, reason string) {
|
|
n.Channel = notify.ChannelInApp
|
|
s.notify.Log(ctx, n, notify.Skipped(reason), 0)
|
|
}
|