0.2.77
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user