0.2.77
This commit is contained in:
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/library"
|
||||
"github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
@@ -262,7 +263,12 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
// takes all three: a handler that could not publish would have to check for nil at
|
||||
// every call site, which is exactly how an event comes to be silently dropped.
|
||||
adminBus := adminevents.New(st, log)
|
||||
dispatcher := integrations.New(st, log, adminBus)
|
||||
// The notification service is built before both the dispatcher and the server, because
|
||||
// both write into it: the dispatcher records what it posted to Discord, and the server
|
||||
// registers the in-app and broadcast providers on it. The store is its recorder, which
|
||||
// is the whole audit trail.
|
||||
notifier := notify.New(st, log)
|
||||
dispatcher := integrations.New(st, log, adminBus, notifier)
|
||||
adminBus.AddSink(dispatcher)
|
||||
sched := scheduler.New(st, log, adminBus)
|
||||
|
||||
@@ -286,6 +292,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
AdminEvents: adminBus,
|
||||
Scheduler: sched,
|
||||
Integrations: dispatcher,
|
||||
Notify: notifier,
|
||||
LogLevel: logLevel,
|
||||
})
|
||||
if err := server.LoadQuietTime(ctx); err != nil {
|
||||
|
||||
@@ -88,6 +88,11 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("GET /admin/api/logins/devices", s.adminAuth(s.handleAdminLoginDevices))
|
||||
mux.Handle("GET /admin/api/logins/devices/{deviceID}", s.adminAuth(s.handleAdminDeviceDetail))
|
||||
|
||||
// The outbound notification history. Distinct from the feed below it: that is the
|
||||
// operator's own activity bell, this is the record of what Memby sent to viewers and
|
||||
// to external services.
|
||||
mux.Handle("GET /admin/api/notification-log", s.adminAuth(s.handleAdminNotificationLog))
|
||||
|
||||
// The administrative feed behind the notification bell.
|
||||
mux.Handle("GET /admin/api/notifications", s.adminAuth(s.handleAdminNotifications))
|
||||
mux.Handle("POST /admin/api/notifications/read", s.adminAuth(s.handleAdminNotificationsRead))
|
||||
|
||||
@@ -32,9 +32,13 @@ type adminOnboardingPreferences struct {
|
||||
}
|
||||
|
||||
type adminMembyAccount struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Initials string `json:"initials"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Initials string `json:"initials"`
|
||||
// ShortName is the friendly name the launcher greets this person by, and is blank far
|
||||
// more often than not — the directory reads it as "their account name" rather than as
|
||||
// something missing.
|
||||
ShortName string `json:"shortName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Devices []store.MembyDevice `json:"devices"`
|
||||
@@ -153,8 +157,9 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
result = append(result, adminMembyAccount{
|
||||
WatchTime: summariseWatchTime(watched, matchedWatchTime),
|
||||
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
||||
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
|
||||
ShortName: stringPreference(accountSettings.Preferences, "shortName"),
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Themes: nonNilStrings(themes[account.ID]),
|
||||
Notifications: notificationPrefs,
|
||||
Recommendations: adminOnboardingPreferences{
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The console's window on the outbound notification log.
|
||||
//
|
||||
// One route rather than three, unlike the sign-in history: an operator arrives here with a
|
||||
// *question* — "did the weekly summary go out", "why did nobody get told about that
|
||||
// import" — and every part of the answer is the same filtered window. Splitting the table
|
||||
// from its totals would mean two requests that could disagree with each other while a
|
||||
// filter was being typed.
|
||||
const (
|
||||
// notificationPageLimit caps one page. Large enough that the ordinary answer needs no
|
||||
// paging, small enough that a household with a busy week does not send a megabyte.
|
||||
notificationPageLimit = 100
|
||||
// notificationWindowDays is the widest window the page offers, derived from the
|
||||
// retention period rather than written down: PruneNotificationLog removes anything
|
||||
// older, so a page offering 180 days would draw a flat line for half of it.
|
||||
notificationWindowDays = int(store.NotificationRetention / (24 * time.Hour))
|
||||
)
|
||||
|
||||
type adminNotificationLogResponse struct {
|
||||
Entries []store.NotificationLogEntry `json:"entries"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Totals store.NotificationLogTotals `json:"totals"`
|
||||
Days []store.NotificationLogDay `json:"days"`
|
||||
Facets store.NotificationFacets `json:"facets"`
|
||||
Users []store.KnownUser `json:"users"`
|
||||
Retention int `json:"retentionDays"`
|
||||
}
|
||||
|
||||
// notificationLogFilter reads the console's question off the query string.
|
||||
//
|
||||
// Every list filter is comma-separated and multi-valued, because the useful questions are
|
||||
// plural: "everything that failed or was skipped", "both digest kinds". A single-valued
|
||||
// filter would make the common troubleshooting question take two passes.
|
||||
func notificationLogFilter(r *http.Request) store.NotificationLogFilter {
|
||||
query := r.URL.Query()
|
||||
filter := store.NotificationLogFilter{
|
||||
UserID: strings.TrimSpace(query.Get("user")),
|
||||
Kinds: splitCSV(query.Get("kind")),
|
||||
Channels: splitCSV(query.Get("channel")),
|
||||
Statuses: splitCSV(query.Get("status")),
|
||||
Sources: splitCSV(query.Get("source")),
|
||||
Query: strings.TrimSpace(query.Get("q")),
|
||||
Limit: queryInt(r, "limit", notificationPageLimit, 500),
|
||||
Offset: queryInt(r, "offset", 0, 100000),
|
||||
}
|
||||
filter.From, filter.To = notificationWindow(r)
|
||||
return filter
|
||||
}
|
||||
|
||||
// notificationWindow resolves the date range.
|
||||
//
|
||||
// An explicit `from` wins over the day window, the rule the sign-in history follows: an
|
||||
// operator who typed a date meant it, and silently narrowing it to the last week would
|
||||
// answer a question they did not ask. `to` is read as the *end* of the day named, because
|
||||
// somebody filtering "to the 12th" means through the 12th, not up to midnight at its start.
|
||||
func notificationWindow(r *http.Request) (time.Time, time.Time) {
|
||||
query := r.URL.Query()
|
||||
from := parseDay(query.Get("from"))
|
||||
to := parseDay(query.Get("to"))
|
||||
if !to.IsZero() {
|
||||
to = to.AddDate(0, 0, 1)
|
||||
}
|
||||
if from.IsZero() {
|
||||
days := queryInt(r, "days", 7, notificationWindowDays)
|
||||
if days > 0 {
|
||||
from = time.Now().UTC().AddDate(0, 0, -days)
|
||||
}
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
|
||||
func parseDay(raw string) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
day, err := time.Parse("2006-01-02", raw)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
// handleAdminNotificationLog answers the Notifications page.
|
||||
//
|
||||
// The log is the page and everything else is decoration, which is why only its failure is
|
||||
// a 500: a facet list or a name lookup that will not answer costs a dropdown, and an
|
||||
// operator reading this page after something went wrong must still get the rows.
|
||||
func (s *Server) handleAdminNotificationLog(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
filter := notificationLogFilter(r)
|
||||
|
||||
page, err := s.store.NotificationLog(ctx, filter)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("notification log read failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read the notification history")
|
||||
return
|
||||
}
|
||||
|
||||
response := adminNotificationLogResponse{
|
||||
Entries: page.Entries, Total: page.Total, Limit: page.Limit, Offset: page.Offset,
|
||||
Days: []store.NotificationLogDay{}, Users: []store.KnownUser{},
|
||||
Retention: notificationWindowDays,
|
||||
}
|
||||
if totals, err := s.store.NotificationLogTotals(ctx, filter); err == nil {
|
||||
response.Totals = totals
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("notification totals unavailable", "error", err)
|
||||
}
|
||||
if days, err := s.store.NotificationLogDays(ctx, filter); err == nil {
|
||||
response.Days = days
|
||||
}
|
||||
// Facets are computed over the whole retention window rather than the current filter,
|
||||
// so narrowing the table never removes the option that would widen it again.
|
||||
since := time.Now().UTC().Add(-store.NotificationRetention)
|
||||
if facets, err := s.store.NotificationLogFacets(ctx, since); err == nil {
|
||||
response.Facets = facets
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("notification facets unavailable", "error", err)
|
||||
}
|
||||
if users, err := s.store.KnownUsers(ctx); err == nil {
|
||||
response.Users = users
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
@@ -99,9 +99,25 @@ type storedAlert struct {
|
||||
// Failures are logged and swallowed. A missed banner is not worth failing the thing that
|
||||
// produced it — an import, a library sync, a health probe — none of which the viewer
|
||||
// would want retried for the sake of a notice.
|
||||
//
|
||||
// Producers no longer call this directly: they call Server.broadcast, which carries the
|
||||
// same alert through the notification service so it lands in the audit trail beside every
|
||||
// other thing Memby sent. This remains the delivery half of that path.
|
||||
func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window time.Duration) {
|
||||
if err := s.publishAlertNow(ctx, alert, window); err != nil {
|
||||
s.loggerFor(ctx).Warn("alert publish failed", "id", alert.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishAlertNow is publishAlert with the failure returned rather than swallowed.
|
||||
//
|
||||
// The notification log needs the error — a banner nobody received is exactly the row an
|
||||
// operator opens this feature to find — and swallowing it here would leave the audit trail
|
||||
// reporting a success the cache never gave. Everything above still treats the answer as
|
||||
// advisory; nothing retries on it.
|
||||
func (s *Server) publishAlertNow(ctx context.Context, alert clientAlert, window time.Duration) error {
|
||||
if window <= 0 || alert.ID == "" || s.cache == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
// Read-modify-write on one key, so producers running on their own schedules need
|
||||
// serialising against each other. They are rare enough that a mutex is the whole
|
||||
@@ -113,14 +129,14 @@ func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window tim
|
||||
stored := appendAlert(s.storedAlerts(ctx), alert, now.Add(window), now)
|
||||
body, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("alert encode failed", "error", err)
|
||||
return
|
||||
return fmt.Errorf("alert encode failed: %w", err)
|
||||
}
|
||||
// The key's own TTL is a floor sweep for a gateway that stops producing events; the
|
||||
// per-entry expiry is what actually decides what a client sees.
|
||||
if err := s.cache.Set(ctx, publishedAlertsCacheKey, body, window*2); err != nil {
|
||||
s.loggerFor(ctx).Warn("alert store failed", "error", err)
|
||||
return fmt.Errorf("alert store failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appendAlert is the pure half of publishing: prune what has expired, replace any earlier
|
||||
|
||||
@@ -34,18 +34,18 @@ type analyticsRequest struct {
|
||||
}
|
||||
|
||||
type journeyEventPayload struct {
|
||||
UserID string `json:"userId"`
|
||||
JourneyID string `json:"journeyId"`
|
||||
Sequence int `json:"sequence"`
|
||||
Category string `json:"category"`
|
||||
Action string `json:"action"`
|
||||
Screen string `json:"screen"`
|
||||
Feature string `json:"feature"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
ItemID string `json:"itemId"`
|
||||
ItemName string `json:"itemName"`
|
||||
ItemType string `json:"itemType"`
|
||||
UserID string `json:"userId"`
|
||||
JourneyID string `json:"journeyId"`
|
||||
Sequence int `json:"sequence"`
|
||||
Category string `json:"category"`
|
||||
Action string `json:"action"`
|
||||
Screen string `json:"screen"`
|
||||
Feature string `json:"feature"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
ItemID string `json:"itemId"`
|
||||
ItemName string `json:"itemName"`
|
||||
ItemType string `json:"itemType"`
|
||||
// The Emby play session a playback step belongs to. Validated like every other
|
||||
// controlled field: it is Emby's string rather than ours, and an event carrying one this
|
||||
// cannot read is dropped whole, so the television sanitises it before sending.
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/integrations"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
@@ -71,7 +72,11 @@ type Server struct {
|
||||
adminEvents *adminevents.Bus
|
||||
scheduler *scheduler.Scheduler
|
||||
integrations *integrations.Dispatcher
|
||||
sonarrMu sync.Mutex
|
||||
// notify is the one door every outbound notification leaves through, and the only
|
||||
// thing that writes the notification log. Producers never call a delivery provider
|
||||
// directly any more — see internal/api/notifications.go.
|
||||
notify *notify.Service
|
||||
sonarrMu sync.Mutex
|
||||
// sonarrSeriesMu guards the catalogue cache separately from the calendar's, so an add
|
||||
// to My Shows never waits behind a launcher rebuilding the schedule row.
|
||||
sonarrSeriesMu sync.Mutex
|
||||
@@ -143,6 +148,10 @@ type Deps struct {
|
||||
AdminEvents *adminevents.Bus
|
||||
Scheduler *scheduler.Scheduler
|
||||
Integrations *integrations.Dispatcher
|
||||
// Notify is optional. A server built without one still delivers every notification —
|
||||
// Send falls through to the providers regardless — it simply records nothing, which is
|
||||
// what every unit test in this package wants.
|
||||
Notify *notify.Service
|
||||
// LogLevel is the live level of the process's own logger, so the console can turn
|
||||
// debug on and watch the thing it turned it on for. Nil is allowed and means the
|
||||
// level is fixed at whatever the container was started with.
|
||||
@@ -150,7 +159,7 @@ type Deps struct {
|
||||
}
|
||||
|
||||
func New(cfg config.Config, deps Deps) *Server {
|
||||
return &Server{
|
||||
server := &Server{
|
||||
cfg: cfg,
|
||||
emby: deps.Emby,
|
||||
store: deps.Store,
|
||||
@@ -172,9 +181,16 @@ func New(cfg config.Config, deps Deps) *Server {
|
||||
scheduler: deps.Scheduler,
|
||||
integrations: deps.Integrations,
|
||||
|
||||
notify: deps.Notify,
|
||||
|
||||
logLevel: deps.LogLevel,
|
||||
deployedLogLevel: deployedLevel(deps.LogLevel),
|
||||
}
|
||||
// The providers are installed here rather than by the caller so a producer can assume
|
||||
// the channels it uses exist: a channel with no provider is a configuration fault the
|
||||
// audit trail would faithfully record on every single notification.
|
||||
server.registerNotifiers()
|
||||
return server
|
||||
}
|
||||
|
||||
func deployedLevel(level *slog.LevelVar) slog.Level {
|
||||
@@ -292,6 +308,7 @@ func (s *Server) Routes() http.Handler {
|
||||
// token arrives in the query string, the way artwork's does, because a media player
|
||||
// fetching a sidecar sends none of Memby's headers.
|
||||
v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle))
|
||||
v1.Handle("GET /v1/radarr/movies/{id}", s.authed(s.handleRadarrMovie))
|
||||
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
|
||||
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
|
||||
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
|
||||
|
||||
@@ -142,7 +142,7 @@ var featureCatalogue = []featureDefinition{
|
||||
Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications",
|
||||
Description: "Tell each viewer how long they watched this week and this month, on " +
|
||||
"Sunday evening, with a summary of the month just gone once it ends. Read from " +
|
||||
"Tracearr; a household running none never sends one.",
|
||||
"Tracearr; a server running none never sends one.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1,
|
||||
Recovery: "Server-enforced; takes effect before the next summary is due.",
|
||||
},
|
||||
|
||||
@@ -51,6 +51,22 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
|
||||
},
|
||||
})
|
||||
|
||||
// The outbound notification history, which is a different table from the one above:
|
||||
// that prunes the operator's activity feed, this prunes the record of what Memby sent
|
||||
// to viewers and to external services.
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "notification-log-retention",
|
||||
Name: "Notification history retention",
|
||||
Group: "Housekeeping",
|
||||
Description: fmt.Sprintf("Removes outbound notification records older than %d days.",
|
||||
int(store.NotificationRetention/(24*time.Hour))),
|
||||
Interval: 24 * time.Hour,
|
||||
Run: func(ctx context.Context) (string, error) {
|
||||
removed, err := s.store.PruneNotificationLog(ctx, store.NotificationRetention)
|
||||
return countDetail(removed, "notification record"), err
|
||||
},
|
||||
})
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "device-activity-cleanup",
|
||||
Name: "Device activity cleanup",
|
||||
|
||||
@@ -57,10 +57,14 @@ func (s *Server) AnnounceLibraryIngest(ctx context.Context, result library.Inges
|
||||
// episode. Neither is a title somebody can watch, and the episode that follows is.
|
||||
}
|
||||
|
||||
// The zero-window case is deliberately not short-circuited here any more. An operator who
|
||||
// has switched movie import banners off is a reason nobody was told, and the notification
|
||||
// log is where that answer belongs — deliverBroadcast records it as a skip rather than the
|
||||
// producer returning in silence.
|
||||
func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) {
|
||||
window := s.radarrAlertWindow()
|
||||
title := strings.TrimSpace(result.Name)
|
||||
if window <= 0 || title == "" || result.ItemID == "" {
|
||||
if title == "" || result.ItemID == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
@@ -68,7 +72,7 @@ func (s *Server) announceImportedMovie(ctx context.Context, result library.Inges
|
||||
if result.Year > 0 {
|
||||
name = fmt.Sprintf("%s (%d)", title, result.Year)
|
||||
}
|
||||
s.publishAlert(ctx, clientAlert{
|
||||
s.broadcast(ctx, notifySourceLibraryIngest, clientAlert{
|
||||
// Keyed on the Emby item, so a repeated delivery of one import is one banner while
|
||||
// a film deleted and re-imported is news again. Clients dedupe on this id forever.
|
||||
ID: "ingest:movie:" + result.ItemID,
|
||||
@@ -90,7 +94,7 @@ func (s *Server) announceImportedEpisode(ctx context.Context, result library.Ing
|
||||
// as it does the "aired, coming soon" one, without touching films.
|
||||
window := s.sonarrAlertWindow()
|
||||
series := strings.TrimSpace(result.SeriesName)
|
||||
if window <= 0 || series == "" || result.ItemID == "" {
|
||||
if series == "" || result.ItemID == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
@@ -98,7 +102,7 @@ func (s *Server) announceImportedEpisode(ctx context.Context, result library.Ing
|
||||
seasonRunKey(series, result.Season), result.ItemID,
|
||||
episodeSummary(result), now, ingestRunWindow,
|
||||
)
|
||||
s.publishAlert(ctx, clientAlert{
|
||||
s.broadcast(ctx, notifySourceLibraryIngest, clientAlert{
|
||||
// The run's *first* episode anchors the id, so every later arrival in the same
|
||||
// season pack replaces one banner rather than stacking another — and next week's
|
||||
// episode, arriving after the window has closed, starts a run of its own and is
|
||||
|
||||
@@ -84,7 +84,7 @@ func buildSonarrRowForTest(t *testing.T, episode sonarr.Episode, now time.Time)
|
||||
|
||||
func buildRadarrRowForTest(t *testing.T, movie radarr.Movie, now time.Time) radarrScheduleItem {
|
||||
t.Helper()
|
||||
row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC)
|
||||
row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRadarrRow: %v", err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -213,10 +214,18 @@ func (s *Server) syncReturnNotifications(
|
||||
message = show.Title + " returns next week."
|
||||
}
|
||||
sourceKey := "show-return:" + show.ItemID + ":" + series.NextAiring.UTC().Format("2006-01-02")
|
||||
_ = s.store.UpsertNotification(
|
||||
r.Context(), sess.EmbyUserID, sourceKey, "show-return", show.ItemID,
|
||||
"New episode coming", message, series.NextAiring,
|
||||
)
|
||||
s.notifyUser(r.Context(), notify.Notification{
|
||||
Kind: "show-return",
|
||||
Source: notifySourceShowReturn,
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
Title: "New episode coming",
|
||||
Body: message,
|
||||
ItemID: show.ItemID,
|
||||
SourceKey: sourceKey,
|
||||
EventAt: series.NextAiring,
|
||||
Metadata: map[string]any{"show": show.Title, "leadDays": prefs.LeadDays},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +240,12 @@ func (s *Server) handleNotificationAction(
|
||||
switch r.PathValue("action") {
|
||||
case "read":
|
||||
err = s.store.MarkNotificationRead(r.Context(), sess.EmbyUserID, id)
|
||||
// Marking a notification back to new is the viewer's own action, where "read" is set by
|
||||
// the page merely focusing a row. That is why the two are separate routes rather than one
|
||||
// carrying a boolean: an automatic mark and a deliberate one are different events, and only
|
||||
// this one is ever a decision somebody made with the remote.
|
||||
case "unread":
|
||||
err = s.store.MarkNotificationUnread(r.Context(), sess.EmbyUserID, id)
|
||||
case "dismiss":
|
||||
err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id)
|
||||
default:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -950,15 +951,30 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
|
||||
return ""
|
||||
}
|
||||
if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) {
|
||||
_ = s.store.UpsertNotification(
|
||||
ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow",
|
||||
episode.SeriesID, "Added to My Shows",
|
||||
seriesItem.Name+" was added because you started watching it and it is still continuing.", nil,
|
||||
)
|
||||
return seriesItem.Name
|
||||
notification := notify.Notification{
|
||||
Kind: "auto-follow",
|
||||
Source: notifySourceAutoFollow,
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
Title: "Added to My Shows",
|
||||
Body: seriesItem.Name + " was added because you started watching it and it is still continuing.",
|
||||
ItemID: episode.SeriesID,
|
||||
SourceKey: "auto-follow:" + episode.SeriesID,
|
||||
Metadata: map[string]any{"series": seriesItem.Name},
|
||||
}
|
||||
return ""
|
||||
// The show is followed either way — that is the feature — and only the *notice* is
|
||||
// conditional. Recording the refusal is what separates "Memby quietly followed this for
|
||||
// you" from a bug, which from the viewer's side look the same.
|
||||
if !prefs.Enabled {
|
||||
s.declineUser(ctx, notification, "this viewer has notifications switched off")
|
||||
return ""
|
||||
}
|
||||
if !s.featureEnabled(ctx, featureMyShowsNotification) {
|
||||
s.declineUser(ctx, notification, "the My Shows notification feature is switched off")
|
||||
return ""
|
||||
}
|
||||
s.notifyUser(ctx, notification)
|
||||
return seriesItem.Name
|
||||
}
|
||||
|
||||
func max64(v, floor int64) int64 {
|
||||
|
||||
@@ -61,8 +61,16 @@ type preferenceDefinition struct {
|
||||
// number reads as minutes, which is what the first one to exist happened to be.
|
||||
Unit string `json:"unit,omitempty"`
|
||||
MaxLength int `json:"maxLength,omitempty"`
|
||||
AdminOnly bool `json:"adminOnly,omitempty"`
|
||||
Default any `json:"default"`
|
||||
// Uppercase folds a text value to capitals. It belongs to the definition rather than
|
||||
// to the kind: initials are read as capitals, and a person's name is not — folding
|
||||
// every text setting would have the launcher greeting somebody as MATT.
|
||||
Uppercase bool `json:"uppercase,omitempty"`
|
||||
// Placeholder is what the console shows in an empty field, which for these settings is
|
||||
// what happens when nobody fills it in. Blank is a legal value for both of them, so the
|
||||
// field has to say what blank means or it reads as a setting that was never finished.
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
AdminOnly bool `json:"adminOnly,omitempty"`
|
||||
Default any `json:"default"`
|
||||
}
|
||||
|
||||
func option(value, label string) preferenceOption {
|
||||
@@ -74,6 +82,17 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
Key: "profileInitials", Name: "Profile initials", Area: "Profile",
|
||||
Description: "Up to two characters shown in this person's user-switcher avatar. Leave blank to generate them from their name.",
|
||||
Kind: preferenceText, Default: "", MaxLength: 2, AdminOnly: true,
|
||||
Uppercase: true, Placeholder: "Generated from their name",
|
||||
},
|
||||
{
|
||||
// The friendly name Memby addresses somebody by, and nothing more: it is not a
|
||||
// second username and nothing is keyed on it. Blank is the ordinary state — the
|
||||
// television falls back to the account name — so this is only worth setting where
|
||||
// the account name is not what anybody would call the person.
|
||||
Key: "shortName", Name: "Short name", Area: "Profile",
|
||||
Description: "The friendly name Memby greets this person by. Leave blank to use their account name.",
|
||||
Kind: preferenceText, Default: "", MaxLength: shortNameMaxLength, AdminOnly: true,
|
||||
Placeholder: "Their account name",
|
||||
},
|
||||
{
|
||||
Key: "homeSections", Name: "Home rows", Area: "Home",
|
||||
@@ -237,6 +256,10 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
},
|
||||
}
|
||||
|
||||
// shortNameMaxLength bounds the friendly name. It is a first name on a launcher, not a
|
||||
// field to write a sentence in, and the greeting it lands in shares its line with a clock.
|
||||
const shortNameMaxLength = 24
|
||||
|
||||
// maxListEntries bounds the free-form id lists. They come from a television, and a row
|
||||
// list long enough to matter is already a bug on that end.
|
||||
const maxListEntries = 200
|
||||
@@ -334,7 +357,10 @@ func normalizePreference(definition preferenceDefinition, value any) any {
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if !strings.ContainsAny(trimmed, "\n\r") &&
|
||||
(definition.MaxLength <= 0 || len([]rune(trimmed)) <= definition.MaxLength) {
|
||||
return strings.ToUpper(trimmed)
|
||||
if definition.Uppercase {
|
||||
return strings.ToUpper(trimmed)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,37 @@ func TestNormalizePreferencesBoundsAndNormalisesProfileInitials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A short name is a person's name, so unlike the initials beside it in the catalogue it
|
||||
// keeps the case it was typed in. Folding it would have the launcher greeting somebody as
|
||||
// MATT, which is the whole reason Uppercase is per-definition rather than per-kind.
|
||||
func TestNormalizePreferencesKeepsShortNameCaseAndBoundsIt(t *testing.T) {
|
||||
if got := normalizePreferences(map[string]any{"shortName": " Matt "})["shortName"]; got != "Matt" {
|
||||
t.Errorf("shortName = %v, want Matt", got)
|
||||
}
|
||||
long := strings.Repeat("a", shortNameMaxLength+1)
|
||||
for _, value := range []any{long, "Ma\ntt", 12} {
|
||||
if got := normalizePreferences(map[string]any{"shortName": value})["shortName"]; got != "" {
|
||||
t.Errorf("shortName for %v = %v, want the account-name fallback", value, got)
|
||||
}
|
||||
}
|
||||
if got := normalizePreferences(nil)["shortName"]; got != "" {
|
||||
t.Errorf("default shortName = %v, want blank", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The short name is admin-owned like the initials, so a television saving an unrelated
|
||||
// setting must not be what quietly clears it.
|
||||
func TestDevicePreferenceWritePreservesAdminShortName(t *testing.T) {
|
||||
stored, err := json.Marshal(normalizePreferences(map[string]any{"shortName": "Matt"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged := preserveAdminPreferences(map[string]any{"showTitleLogo": false}, stored)
|
||||
if normalizePreferences(merged)["shortName"] != "Matt" {
|
||||
t.Errorf("shortName = %v, want preserved Matt", normalizePreferences(merged)["shortName"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicePreferenceWritePreservesAdminInitials(t *testing.T) {
|
||||
stored, err := json.Marshal(normalizePreferences(map[string]any{"profileInitials": "MC"}))
|
||||
if err != nil {
|
||||
|
||||
@@ -56,6 +56,13 @@ type radarrScheduleItem struct {
|
||||
MembyLifecycle string `json:"MembyLifecycle,omitempty"`
|
||||
MembyLifecycleText string `json:"MembyLifecycleText,omitempty"`
|
||||
MembyPlayable bool `json:"MembyPlayable"`
|
||||
// The Emby film this card stands for, when the library already holds it — the
|
||||
// MembySeriesItemId arrangement, and for the same reason: it is what decides whether
|
||||
// pressing the card opens the ordinary Memby page or the Radarr-only one. A film the
|
||||
// household has not downloaded carries none. The detail route resolves it again from
|
||||
// live data, because this row is cached for the day and a film imported at lunchtime
|
||||
// must not be stuck behind a cache until midnight.
|
||||
MembyMovieItemID string `json:"MembyMovieItemId,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) {
|
||||
@@ -90,7 +97,7 @@ func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := buildRadarrRow(movies, now, location)
|
||||
row, err := buildRadarrRow(movies, now, location, s.embyMovieIndex(ctx, movies))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,7 +121,37 @@ func (s *Server) cachedRadarrRow(ctx context.Context, key string) *recommend.Row
|
||||
return &row
|
||||
}
|
||||
|
||||
func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Location) (*recommend.Row, error) {
|
||||
// embyMovieIndex answers which of these films Emby already holds, keyed by TMDb id.
|
||||
//
|
||||
// Films are matched on the id both systems record rather than on their titles, which is
|
||||
// what the Sonarr schedule row has to fall back on: Radarr writes a TMDb id and the
|
||||
// library import asks Emby for ProviderIds, so there is nothing here to guess at. A
|
||||
// failure is not fatal — the row is about what is coming, and losing the link only costs a
|
||||
// downloaded card its ordinary detail page.
|
||||
func (s *Server) embyMovieIndex(ctx context.Context, movies []radarr.Movie) map[int]string {
|
||||
if s.store == nil || len(movies) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int, 0, len(movies))
|
||||
for _, movie := range movies {
|
||||
if movie.TMDBID > 0 {
|
||||
ids = append(ids, movie.TMDBID)
|
||||
}
|
||||
}
|
||||
found, err := s.store.LibraryProviderItemIDs(ctx, "Tmdb", ids)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("emby movie index unavailable for schedule row", "error", err)
|
||||
return nil
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func buildRadarrRow(
|
||||
movies []radarr.Movie,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
embyItems map[int]string,
|
||||
) (*recommend.Row, error) {
|
||||
sort.SliceStable(movies, func(i, j int) bool {
|
||||
left, leftOK := effectiveRadarrRelease(movies[i])
|
||||
right, rightOK := effectiveRadarrRelease(movies[j])
|
||||
@@ -139,7 +176,9 @@ func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Locatio
|
||||
if localRelease.Before(dayStart) || !localRelease.Before(windowEnd) {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(toRadarrScheduleItem(movie, release, now, location))
|
||||
item := toRadarrScheduleItem(movie, release, now, location)
|
||||
item.MembyMovieItemID = embyItems[movie.TMDBID]
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// radarrItemPrefix is what a schedule card's id looks like: "radarr:412". The row has used
|
||||
// it since the movie schedule shipped, and it is also what the trailer routes recognise —
|
||||
// see radarrTrailerManifest — so a film with no Emby record can still be asked about
|
||||
// through the ordinary /v1/items/{id}/trailers path.
|
||||
const radarrItemPrefix = "radarr:"
|
||||
|
||||
// radarrMovieDetail is everything the Radarr-only detail page draws.
|
||||
//
|
||||
// It is deliberately not a BaseItem. A film Radarr is tracking but Emby has never imported
|
||||
// has no Emby record, no user data and nothing to play, and dressing it as one would put a
|
||||
// Play button, a watched tick and a progress bar on a page where all three are lies. The
|
||||
// television has a state of its own for this, and the moment Emby does hold the film
|
||||
// [EmbyItemID] is what sends the viewer to the ordinary page instead.
|
||||
//
|
||||
// Every piece of wording here is the gateway's, the arrangement the schedule cards, the
|
||||
// hero captions and the lifecycle tags already take: a phrasing invented next month reads
|
||||
// correctly on a television that predates it.
|
||||
type radarrMovieDetail struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"originalTitle,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
RuntimeMinutes int `json:"runtimeMinutes,omitempty"`
|
||||
Genres []string `json:"genres"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Certificate string `json:"certificate,omitempty"`
|
||||
Monitored bool `json:"monitored"`
|
||||
// Radarr's own lifecycle word, as the schedule card wears it: ANNOUNCED, IN CINEMAS,
|
||||
// RELEASED. Distinct from [StateLabel], which is about the household's copy.
|
||||
Lifecycle string `json:"lifecycle,omitempty"`
|
||||
LifecycleText string `json:"lifecycleText,omitempty"`
|
||||
// The subtle status treatment at the top of the page: "Coming Soon", "Awaiting
|
||||
// Release", "Not Yet Available", with one line under it saying what that means here.
|
||||
StateLabel string `json:"stateLabel"`
|
||||
StateDetail string `json:"stateDetail,omitempty"`
|
||||
// The one prominent date. "Expected 14 November 2026" when something has published the
|
||||
// day, "Expected November 2026" when the day is inferred rather than published, and
|
||||
// "Release date not yet announced" when nothing is known — never a precise-looking
|
||||
// date standing in for a guess.
|
||||
ExpectedLabel string `json:"expectedLabel"`
|
||||
// Cinema, digital and physical dates as Radarr holds them, for the viewer who wants to
|
||||
// know which of the three the headline came from. Any of them may be absent.
|
||||
ReleaseDates []radarrReleaseDate `json:"releaseDates"`
|
||||
// The sentence saying, in as many words, that this cannot be watched here yet.
|
||||
AvailabilityNotice string `json:"availabilityNotice"`
|
||||
// Whether the Trailer action should be offered at all. Deciding it here rather than on
|
||||
// the television is what keeps the button from being one that fails after selection.
|
||||
TrailerAvailable bool `json:"trailerAvailable"`
|
||||
// Scores from the same store every other page reads, when this title has been looked
|
||||
// up before. Empty is the honest answer and the strip simply does not appear.
|
||||
Ratings []movieRating `json:"ratings"`
|
||||
// Set once Emby holds the film. The television reopens on the ordinary detail page
|
||||
// when it sees this, which is how a title stops being a Radarr card without anything
|
||||
// having to be invalidated.
|
||||
EmbyItemID string `json:"embyItemId,omitempty"`
|
||||
}
|
||||
|
||||
type radarrReleaseDate struct {
|
||||
// cinema | digital | physical — a lookup key, not prose.
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRadarrMovie(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
_ = sess
|
||||
movieID, ok := radarrMovieID(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "a radarr movie id is required")
|
||||
return
|
||||
}
|
||||
if !s.radarrEnabled(r.Context()) {
|
||||
writeError(w, http.StatusNotFound, "radarr is not available")
|
||||
return
|
||||
}
|
||||
movie, err := s.radarrMovie(r.Context(), movieID)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(r.Context(), w, err, "could not read that movie")
|
||||
return
|
||||
}
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
detail := buildRadarrMovieDetail(movie, time.Now().In(location), location)
|
||||
detail.EmbyItemID = s.embyMovieItemID(r.Context(), movie.TMDBID)
|
||||
detail.Ratings = s.radarrMovieRatings(r.Context(), movie)
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// radarrMovieID reads the movie out of either form of id: the card's own "radarr:412", and
|
||||
// the bare number, because a caller holding the number should not have to know the prefix.
|
||||
func radarrMovieID(raw string) (int, bool) {
|
||||
trimmed := strings.TrimPrefix(strings.TrimSpace(raw), radarrItemPrefix)
|
||||
id, err := strconv.Atoi(trimmed)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// radarrMovie reads one film, preferring the household's cached catalogue.
|
||||
//
|
||||
// That catalogue is one request answering for every title, already shared across the house
|
||||
// and already refreshed on its own schedule, so a detail page opening normally costs Radarr
|
||||
// nothing at all. Asking directly is the fallback for a title added since it was read.
|
||||
func (s *Server) radarrMovie(ctx context.Context, movieID int) (radarr.Movie, error) {
|
||||
if movies, err := s.radarrMovieCatalogue(ctx); err == nil {
|
||||
for _, movie := range movies {
|
||||
if movie.ID == movieID {
|
||||
return movie, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.radarr.Movie(ctx, movieID)
|
||||
}
|
||||
|
||||
// embyMovieItemID is embyMovieIndex for one title. A failure costs the redirect and never
|
||||
// the page: the worst case is a Radarr page for a film Emby has quietly imported, which the
|
||||
// next home refresh corrects.
|
||||
func (s *Server) embyMovieItemID(ctx context.Context, tmdbID int) string {
|
||||
if s.store == nil || tmdbID <= 0 {
|
||||
return ""
|
||||
}
|
||||
found, err := s.store.LibraryProviderItemIDs(ctx, "Tmdb", []int{tmdbID})
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("emby movie lookup failed for radarr detail", "error", err)
|
||||
return ""
|
||||
}
|
||||
return found[tmdbID]
|
||||
}
|
||||
|
||||
// radarrMovieRatings reuses the household's ratings store rather than Radarr's own scores.
|
||||
// Radarr carries a ratings block, but a page showing TMDb's number from Radarr here and
|
||||
// MDBList's everywhere else would print two different scores for one film under one name.
|
||||
func (s *Server) radarrMovieRatings(ctx context.Context, movie radarr.Movie) []movieRating {
|
||||
settings, enabled := s.mdblistSettings(ctx)
|
||||
if !enabled || s.mdblist == nil || s.store == nil {
|
||||
return []movieRating{}
|
||||
}
|
||||
key := store.RatingKey{MediaType: "movie"}
|
||||
switch {
|
||||
case movie.TMDBID > 0:
|
||||
key.Provider, key.ProviderID = "tmdb", strconv.Itoa(movie.TMDBID)
|
||||
case strings.TrimSpace(movie.IMDBID) != "":
|
||||
key.Provider, key.ProviderID = "imdb", strings.TrimSpace(movie.IMDBID)
|
||||
default:
|
||||
return []movieRating{}
|
||||
}
|
||||
ratings, err := s.loadMDBListRatings(ctx, settings.APIKey, key)
|
||||
if err != nil {
|
||||
s.logMDBListFailure(ctx, "ratings unavailable", radarrItemPrefix+strconv.Itoa(movie.ID), err)
|
||||
return []movieRating{}
|
||||
}
|
||||
return selectedMovieRatings(settings.Sources, ratings)
|
||||
}
|
||||
|
||||
// buildRadarrMovieDetail is the whole of the page's wording, and it is pure so that every
|
||||
// case a household can actually produce — a film with three dates, one with only a cinema
|
||||
// date, one Radarr has never been given a date for at all — is answerable without a Radarr.
|
||||
func buildRadarrMovieDetail(movie radarr.Movie, now time.Time, location *time.Location) radarrMovieDetail {
|
||||
release, hasRelease := effectiveRadarrRelease(movie)
|
||||
lifecycle := movieLifecycleTag(movie.Status)
|
||||
detail := radarrMovieDetail{
|
||||
ID: radarrItemPrefix + strconv.Itoa(movie.ID),
|
||||
Title: strings.TrimSpace(movie.Title),
|
||||
Overview: strings.TrimSpace(movie.Overview),
|
||||
Year: movie.Year,
|
||||
RuntimeMinutes: movie.Runtime,
|
||||
Genres: nonNilStrings(movie.Genres),
|
||||
Studio: strings.TrimSpace(movie.Studio),
|
||||
Certificate: strings.TrimSpace(movie.Certification),
|
||||
Monitored: movie.Monitored,
|
||||
Lifecycle: lifecycle.Status,
|
||||
LifecycleText: lifecycle.Label,
|
||||
ExpectedLabel: radarrExpectedLabel(release, hasRelease, now, location),
|
||||
ReleaseDates: radarrReleaseDates(movie, location),
|
||||
AvailabilityNotice: "Not available to watch in Memby yet",
|
||||
TrailerAvailable: strings.TrimSpace(movie.YouTubeTrailerID) != "",
|
||||
Ratings: []movieRating{},
|
||||
}
|
||||
// Only when it says something the heading does not, the rule the ordinary Details pane
|
||||
// already applies: a film whose original title is its title is the common case, and
|
||||
// printing it is a row that reads as a mistake.
|
||||
if original := strings.TrimSpace(movie.OriginalTitle); !strings.EqualFold(original, detail.Title) {
|
||||
detail.OriginalTitle = original
|
||||
}
|
||||
detail.StateLabel, detail.StateDetail = radarrMovieState(movie, release, hasRelease, now)
|
||||
return detail
|
||||
}
|
||||
|
||||
// radarrMovieState is the status treatment at the top of the page: two or three words for
|
||||
// what this film is doing, and a line saying what that means to somebody who wanted to
|
||||
// watch it tonight.
|
||||
func radarrMovieState(
|
||||
movie radarr.Movie, release radarrRelease, hasRelease bool, now time.Time,
|
||||
) (string, string) {
|
||||
switch {
|
||||
case movie.HasFile:
|
||||
// Downloaded, and yet this page is what opened — so Emby has not scanned it in
|
||||
// yet. A matter of minutes rather than of months, and worth saying so.
|
||||
return "Almost Ready", "Downloaded — waiting for Memby's library to pick it up"
|
||||
case !movie.Monitored:
|
||||
return "Not Tracked", "This film is not being monitored, so no copy is being sought"
|
||||
case !hasRelease:
|
||||
// Deliberately not "Release date not yet announced" — that is what the page has
|
||||
// just printed as its headline, and the line under a state exists to add to it.
|
||||
return "Awaiting Release", "Nothing to download until a date is announced"
|
||||
case release.at.After(now):
|
||||
return "Coming Soon", "Not released yet"
|
||||
default:
|
||||
return "Not Yet Available", "Released — waiting for a copy to arrive"
|
||||
}
|
||||
}
|
||||
|
||||
// radarrExpectedLabel is the one date the page leads with, and most of its job is refusing
|
||||
// to be precise about a date nothing has published.
|
||||
//
|
||||
// Radarr's digital date is a published fact and is printed to the day. The cinema-plus-a-
|
||||
// month estimate the schedule row falls back on is not, so it is printed to the month:
|
||||
// "Expected November 2026" is true where "Expected 14 November 2026" is a number somebody
|
||||
// would plan an evening around. Nothing known at all is said plainly rather than guessed.
|
||||
func radarrExpectedLabel(
|
||||
release radarrRelease, hasRelease bool, now time.Time, location *time.Location,
|
||||
) string {
|
||||
if !hasRelease {
|
||||
return "Release date not yet announced"
|
||||
}
|
||||
local := release.at.In(location)
|
||||
verb := "Expected "
|
||||
if !local.After(now.In(location)) {
|
||||
verb = "Released "
|
||||
}
|
||||
if release.estimated {
|
||||
return verb + local.Format("January 2006")
|
||||
}
|
||||
return verb + local.Format("2 January 2006")
|
||||
}
|
||||
|
||||
// radarrReleaseDates lists what Radarr actually holds, so a viewer can see which of the
|
||||
// three the headline came from. Only dates that exist appear; an absent one is absent
|
||||
// rather than dashed.
|
||||
func radarrReleaseDates(movie radarr.Movie, location *time.Location) []radarrReleaseDate {
|
||||
dates := []radarrReleaseDate{}
|
||||
add := func(kind, label string, value *time.Time) {
|
||||
if value == nil || value.IsZero() {
|
||||
return
|
||||
}
|
||||
dates = append(dates, radarrReleaseDate{
|
||||
Kind: kind,
|
||||
Label: label,
|
||||
Value: value.In(location).Format("2 January 2006"),
|
||||
})
|
||||
}
|
||||
add("cinema", "In cinemas", movie.InCinemas)
|
||||
add("digital", "Digital release", movie.DigitalRelease)
|
||||
add("physical", "Physical release", movie.PhysicalRelease)
|
||||
return dates
|
||||
}
|
||||
|
||||
// radarrTrailerManifest is the trailer chain for a film with no Emby record.
|
||||
//
|
||||
// It is the same manifest shape the ordinary path builds, so the television's existing
|
||||
// trailer machinery — the availability check, the resolve call, the report, the player's
|
||||
// candidate exclusion and its retry onto the next provider — works on a Radarr card with no
|
||||
// second implementation anywhere. The one candidate is Radarr's own YouTube trailer id,
|
||||
// which comes from TMDb's official trailer field and is ranked as an official source
|
||||
// rather than as a spare.
|
||||
func (s *Server) radarrTrailerManifest(ctx context.Context, itemID string, movieID int) (trailerManifest, error) {
|
||||
manifest := trailerManifest{SubjectID: itemID, Candidates: []trailerCandidate{}}
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return manifest, nil
|
||||
}
|
||||
movie, err := s.radarrMovie(ctx, movieID)
|
||||
if err != nil {
|
||||
return trailerManifest{}, err
|
||||
}
|
||||
manifest.Title = strings.TrimSpace(movie.Title)
|
||||
trailerID := strings.TrimSpace(movie.YouTubeTrailerID)
|
||||
if trailerID == "" {
|
||||
return manifest, nil
|
||||
}
|
||||
source := "https://www.youtube.com/watch?v=" + trailerID
|
||||
manifest.Candidates = append(manifest.Candidates, trailerCandidate{
|
||||
ID: trailerCandidateID("youtube", source),
|
||||
Provider: "youtube",
|
||||
Name: "Official Trailer",
|
||||
SourceURL: source,
|
||||
Priority: remoteTrailerPriority("youtube", "Official Trailer"),
|
||||
})
|
||||
return manifest, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
)
|
||||
|
||||
func radarrDetailDay(year int, month time.Month, day int) time.Time {
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func TestRadarrMovieID(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
raw string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{raw: "radarr:412", want: 412, ok: true},
|
||||
{raw: " radarr:412 ", want: 412, ok: true},
|
||||
{raw: "412", want: 412, ok: true},
|
||||
{raw: "", ok: false},
|
||||
{raw: "radarr:", ok: false},
|
||||
{raw: "radarr:0", ok: false},
|
||||
{raw: "radarr:-3", ok: false},
|
||||
{raw: "abc123", ok: false},
|
||||
} {
|
||||
id, ok := radarrMovieID(testCase.raw)
|
||||
if ok != testCase.ok || id != testCase.want {
|
||||
t.Fatalf("radarrMovieID(%q) = %d, %v; want %d, %v",
|
||||
testCase.raw, id, ok, testCase.want, testCase.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A published digital date is named to the day; the schedule row's cinema-plus-a-month
|
||||
// estimate is named only to the month, because it is a guess and a guess printed as
|
||||
// "14 November" is one somebody plans an evening around.
|
||||
func TestRadarrExpectedLabelPrecision(t *testing.T) {
|
||||
now := radarrDetailDay(2026, time.August, 19)
|
||||
published := radarrRelease{at: radarrDetailDay(2026, time.November, 14)}
|
||||
estimated := radarrRelease{at: radarrDetailDay(2026, time.November, 14), estimated: true}
|
||||
past := radarrRelease{at: radarrDetailDay(2026, time.March, 3)}
|
||||
|
||||
if got := radarrExpectedLabel(published, true, now, time.UTC); got != "Expected 14 November 2026" {
|
||||
t.Fatalf("published: %q", got)
|
||||
}
|
||||
if got := radarrExpectedLabel(estimated, true, now, time.UTC); got != "Expected November 2026" {
|
||||
t.Fatalf("estimated: %q", got)
|
||||
}
|
||||
if got := radarrExpectedLabel(past, true, now, time.UTC); got != "Released 3 March 2026" {
|
||||
t.Fatalf("past: %q", got)
|
||||
}
|
||||
if got := radarrExpectedLabel(radarrRelease{}, false, now, time.UTC); got != "Release date not yet announced" {
|
||||
t.Fatalf("unknown: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadarrMovieStateReadsTheHouseholdsCopy(t *testing.T) {
|
||||
now := radarrDetailDay(2026, time.August, 19)
|
||||
future := radarrRelease{at: radarrDetailDay(2026, time.November, 14)}
|
||||
past := radarrRelease{at: radarrDetailDay(2026, time.March, 3)}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
movie radarr.Movie
|
||||
release radarrRelease
|
||||
hasRelease bool
|
||||
want string
|
||||
}{
|
||||
{name: "coming soon", movie: radarr.Movie{Monitored: true}, release: future, hasRelease: true, want: "Coming Soon"},
|
||||
{name: "out but not here", movie: radarr.Movie{Monitored: true}, release: past, hasRelease: true, want: "Not Yet Available"},
|
||||
{name: "no date", movie: radarr.Movie{Monitored: true}, want: "Awaiting Release"},
|
||||
{name: "unmonitored", movie: radarr.Movie{}, release: future, hasRelease: true, want: "Not Tracked"},
|
||||
{
|
||||
name: "downloaded but unscanned",
|
||||
movie: radarr.Movie{Monitored: true, HasFile: true},
|
||||
release: past,
|
||||
hasRelease: true,
|
||||
want: "Almost Ready",
|
||||
},
|
||||
} {
|
||||
label, detail := radarrMovieState(testCase.movie, testCase.release, testCase.hasRelease, now)
|
||||
if label != testCase.want {
|
||||
t.Fatalf("%s: state = %q, want %q", testCase.name, label, testCase.want)
|
||||
}
|
||||
if detail == "" {
|
||||
t.Fatalf("%s: a state with no explanation under it", testCase.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRadarrMovieDetail(t *testing.T) {
|
||||
cinema := radarrDetailDay(2026, time.October, 2)
|
||||
digital := radarrDetailDay(2026, time.November, 14)
|
||||
movie := radarr.Movie{
|
||||
ID: 412,
|
||||
TMDBID: 9001,
|
||||
Title: "The Quiet Coast",
|
||||
OriginalTitle: "The Quiet Coast",
|
||||
Overview: " A harbour town in winter. ",
|
||||
Year: 2026,
|
||||
Runtime: 118,
|
||||
Genres: []string{"Drama", "Mystery"},
|
||||
Studio: "Kōwhai Pictures",
|
||||
Certification: "M",
|
||||
Status: "announced",
|
||||
Monitored: true,
|
||||
YouTubeTrailerID: "abc123",
|
||||
InCinemas: &cinema,
|
||||
DigitalRelease: &digital,
|
||||
Images: []radarr.Image{{CoverType: "poster"}, {CoverType: "fanart"}},
|
||||
}
|
||||
|
||||
detail := buildRadarrMovieDetail(movie, radarrDetailDay(2026, time.August, 19), time.UTC)
|
||||
|
||||
if detail.ID != "radarr:412" {
|
||||
t.Fatalf("id: %q", detail.ID)
|
||||
}
|
||||
if detail.Overview != "A harbour town in winter." {
|
||||
t.Fatalf("overview: %q", detail.Overview)
|
||||
}
|
||||
// The same title twice is the common case and reads as a mistake when printed.
|
||||
if detail.OriginalTitle != "" {
|
||||
t.Fatalf("original title repeated: %q", detail.OriginalTitle)
|
||||
}
|
||||
if detail.ExpectedLabel != "Expected 14 November 2026" {
|
||||
t.Fatalf("expected label: %q", detail.ExpectedLabel)
|
||||
}
|
||||
if detail.StateLabel != "Coming Soon" {
|
||||
t.Fatalf("state: %q", detail.StateLabel)
|
||||
}
|
||||
if detail.LifecycleText != "ANNOUNCED" || detail.Lifecycle != "announced" {
|
||||
t.Fatalf("lifecycle: %q/%q", detail.Lifecycle, detail.LifecycleText)
|
||||
}
|
||||
if !detail.TrailerAvailable {
|
||||
t.Fatalf("a film with a trailer id must offer the action: %+v", detail)
|
||||
}
|
||||
if detail.AvailabilityNotice == "" {
|
||||
t.Fatal("the page must say it cannot be watched here")
|
||||
}
|
||||
if len(detail.ReleaseDates) != 2 ||
|
||||
detail.ReleaseDates[0].Kind != "cinema" || detail.ReleaseDates[0].Value != "2 October 2026" ||
|
||||
detail.ReleaseDates[1].Kind != "digital" || detail.ReleaseDates[1].Value != "14 November 2026" {
|
||||
t.Fatalf("release dates: %+v", detail.ReleaseDates)
|
||||
}
|
||||
// Never null on the wire: the television decodes these as lists.
|
||||
if detail.Genres == nil || detail.Ratings == nil {
|
||||
t.Fatalf("nil collections: %+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// A film with nothing but a cinema date is the case the precision rule exists for: the
|
||||
// schedule row places it a month later, and the page must not present that as a fact.
|
||||
func TestBuildRadarrMovieDetailCinemaOnly(t *testing.T) {
|
||||
cinema := radarrDetailDay(2026, time.October, 2)
|
||||
detail := buildRadarrMovieDetail(
|
||||
radarr.Movie{ID: 7, Title: "Harbour Lights", InCinemas: &cinema, Monitored: true, Status: "inCinemas"},
|
||||
radarrDetailDay(2026, time.August, 19),
|
||||
time.UTC,
|
||||
)
|
||||
if detail.ExpectedLabel != "Expected November 2026" {
|
||||
t.Fatalf("expected label: %q", detail.ExpectedLabel)
|
||||
}
|
||||
if len(detail.ReleaseDates) != 1 || detail.ReleaseDates[0].Kind != "cinema" {
|
||||
t.Fatalf("release dates: %+v", detail.ReleaseDates)
|
||||
}
|
||||
if detail.TrailerAvailable {
|
||||
t.Fatal("a film with no trailer id must not offer the action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRadarrMovieDetailUnannounced(t *testing.T) {
|
||||
detail := buildRadarrMovieDetail(
|
||||
radarr.Movie{ID: 9, Title: "Untitled", OriginalTitle: "Sans Titre", Monitored: true, Status: "tba"},
|
||||
radarrDetailDay(2026, time.August, 19),
|
||||
time.UTC,
|
||||
)
|
||||
if detail.ExpectedLabel != "Release date not yet announced" {
|
||||
t.Fatalf("expected label: %q", detail.ExpectedLabel)
|
||||
}
|
||||
if detail.StateLabel != "Awaiting Release" {
|
||||
t.Fatalf("state: %q", detail.StateLabel)
|
||||
}
|
||||
if len(detail.ReleaseDates) != 0 {
|
||||
t.Fatalf("release dates: %+v", detail.ReleaseDates)
|
||||
}
|
||||
if detail.OriginalTitle != "Sans Titre" {
|
||||
t.Fatalf("a differing original title is worth printing: %q", detail.OriginalTitle)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInMonthWindo
|
||||
{ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true},
|
||||
{ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true},
|
||||
{ID: 6, Title: "Beyond Window", DigitalRelease: &beyondWindow, Monitored: true},
|
||||
}, now, location)
|
||||
}, now, location, map[int]string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ const (
|
||||
// Nothing here waits for the deployment to finish. The gateway that publishes this is the
|
||||
// one being retired; the one that comes back has no memory of having said it.
|
||||
func (s *Server) AnnounceDeployment(ctx context.Context) {
|
||||
s.publishAlert(ctx, deploymentAlert(time.Now().UTC()), deploymentAlertWindow)
|
||||
s.broadcast(ctx, notifySourceDeployment, deploymentAlert(time.Now().UTC()), deploymentAlertWindow)
|
||||
}
|
||||
|
||||
func deploymentAlert(now time.Time) clientAlert {
|
||||
@@ -84,7 +84,7 @@ func (s *Server) AnnounceLibrarySync(ctx context.Context, result library.Result)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s.publishAlert(ctx, clientAlert{
|
||||
s.broadcast(ctx, notifySourceLibrarySync, clientAlert{
|
||||
// Keyed on the minute the sync finished: two runs are two pieces of news, but a
|
||||
// retried publish of the same run is not.
|
||||
ID: fmt.Sprintf("library:%d", now.Truncate(time.Minute).Unix()),
|
||||
@@ -153,13 +153,13 @@ func (s *Server) WatchEmbyReachability(ctx context.Context) {
|
||||
reachable = false
|
||||
s.log.Warn("emby unreachable, announcing",
|
||||
"component", "emby-health", "failures", failures, "error", err)
|
||||
s.publishAlert(ctx, s.reachabilityAlert(false), reachabilityAlertWindow)
|
||||
s.broadcast(ctx, notifySourceEmbyHealth, s.reachabilityAlert(false), reachabilityAlertWindow)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !reachable {
|
||||
s.log.Info("emby reachable again, announcing", "component", "emby-health")
|
||||
s.publishAlert(ctx, s.reachabilityAlert(true), reachabilityAlertWindow)
|
||||
s.broadcast(ctx, notifySourceEmbyHealth, s.reachabilityAlert(true), reachabilityAlertWindow)
|
||||
}
|
||||
reachable = true
|
||||
failures = 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -95,21 +96,26 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
||||
message := change.Current.Title + " was added to Sonarr."
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, user.ID, sourceKey, "show-added", "",
|
||||
"Show added", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("Sonarr addition notification failed",
|
||||
"user", user.ID, "show", change.Current.Title, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: "show-added",
|
||||
Source: notifySourceSonarrLifecycle,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Title: "Show added",
|
||||
Body: change.Current.Title + " was added to Sonarr.",
|
||||
SourceKey: sourceKey,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
s.declineUser(ctx, notification, sonarrDeclineReason(prefs, preferenceErrors[user.ID]))
|
||||
continue
|
||||
}
|
||||
notifications++
|
||||
if s.notifyUser(ctx, notification) {
|
||||
notifications++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, change := range cancellations {
|
||||
@@ -125,21 +131,26 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
||||
message := change.Current.Title + " is now listed as cancelled in Sonarr."
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, user.ID, sourceKey, "show-cancelled", "",
|
||||
"Show cancelled", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("Sonarr cancellation notification failed",
|
||||
"user", user.ID, "show", change.Current.Title, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: "show-cancelled",
|
||||
Source: notifySourceSonarrLifecycle,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Title: "Show cancelled",
|
||||
Body: change.Current.Title + " is now listed as cancelled in Sonarr.",
|
||||
SourceKey: sourceKey,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
s.declineUser(ctx, notification, sonarrDeclineReason(prefs, preferenceErrors[user.ID]))
|
||||
continue
|
||||
}
|
||||
notifications++
|
||||
if s.notifyUser(ctx, notification) {
|
||||
notifications++
|
||||
}
|
||||
}
|
||||
}
|
||||
s.log.Info("Sonarr lifecycle scan complete",
|
||||
@@ -176,3 +187,21 @@ func sonarrBecameCancelled(previous, current string) bool {
|
||||
current == "cancelled" || current == "canceled"
|
||||
return active && cancelled
|
||||
}
|
||||
|
||||
// sonarrDeclineReason is the sentence the console prints beside a skipped row.
|
||||
//
|
||||
// The three refusals are genuinely different answers to "why was I not told", and a page
|
||||
// that collapsed them into "skipped" would send an operator to change a setting that was
|
||||
// never the problem. A preference that would not load is its own case: it is read as "not
|
||||
// now" rather than as consent, and that is a fact about the gateway rather than about the
|
||||
// viewer.
|
||||
func sonarrDeclineReason(prefs store.NotificationPreferences, unreadable bool) string {
|
||||
switch {
|
||||
case unreadable:
|
||||
return "this viewer's notification preferences could not be read"
|
||||
case !prefs.Enabled:
|
||||
return "this viewer has notifications switched off"
|
||||
default:
|
||||
return "this viewer has Sonarr alerts switched off"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,13 @@ func (s *Server) resolveLocalTrailer(
|
||||
}
|
||||
|
||||
func (s *Server) trailerManifest(ctx context.Context, sess store.Session, itemID string) (trailerManifest, error) {
|
||||
// A film Radarr is tracking has no Emby record to ask about local or remote trailers,
|
||||
// so its chain is built from what Radarr knows. It joins here rather than beside the
|
||||
// detail route because everything downstream — availability, resolve, report, the
|
||||
// player's walk through the candidates — is then unchanged for both kinds of subject.
|
||||
if movieID, ok := radarrMovieID(itemID); ok && strings.HasPrefix(itemID, radarrItemPrefix) {
|
||||
return s.radarrTrailerManifest(ctx, itemID, movieID)
|
||||
}
|
||||
key := cache.UserKey(sess.EmbyUserID, "trailers:v2:"+itemID)
|
||||
if s.cache != nil {
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -138,19 +139,31 @@ func (s *Server) sendWeeklyWatchTime(
|
||||
if total < watchTimeDigestFloor {
|
||||
continue
|
||||
}
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
continue
|
||||
}
|
||||
monthWatched := lookupWatchTimeRange(monthByID, monthByName, identity, account.Username)
|
||||
message := weeklyDigestMessage(
|
||||
total, time.Duration(monthWatched.Ms)*time.Millisecond, watched.TopTitle)
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, account.ID, key, watchTimeWeeklyKind, "", "Your week in Memby", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("weekly watch-time summary failed", "user", account.ID, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: watchTimeWeeklyKind,
|
||||
Source: notifySourceWatchTime,
|
||||
UserID: account.ID,
|
||||
Username: account.Username,
|
||||
Title: "Your week in Memby",
|
||||
Body: message,
|
||||
SourceKey: key,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle},
|
||||
}
|
||||
// The floor above is a judgement about the news; this is a judgement about the
|
||||
// person, and only the second one is worth recording. "You have summaries switched
|
||||
// off" is the answer to somebody reporting that they never get one, and it is not
|
||||
// findable anywhere else.
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off")
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
if s.notifyUser(ctx, notification) {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
if sent > 0 {
|
||||
s.log.Info("weekly watch-time summaries sent", "viewers", sent, "week", weekKey(now, location))
|
||||
@@ -183,18 +196,25 @@ func (s *Server) sendMonthlyWatchTime(
|
||||
if total < watchTimeDigestFloor {
|
||||
continue
|
||||
}
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
continue
|
||||
}
|
||||
message := monthlyDigestMessage(total, monthName, watched.TopTitle)
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, account.ID, key, watchTimeMonthlyKind,
|
||||
"", monthName+" in Memby", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("monthly watch-time summary failed", "user", account.ID, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: watchTimeMonthlyKind,
|
||||
Source: notifySourceWatchTime,
|
||||
UserID: account.ID,
|
||||
Username: account.Username,
|
||||
Title: monthName + " in Memby",
|
||||
Body: message,
|
||||
SourceKey: key,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle, "month": monthID},
|
||||
}
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off")
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
if s.notifyUser(ctx, notification) {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
if sent > 0 {
|
||||
s.log.Info("monthly watch-time summaries sent", "viewers", sent, "month", monthID)
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.55
|
||||
0.1.57
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -62,6 +63,11 @@ type Dispatcher struct {
|
||||
log *slog.Logger
|
||||
client *http.Client
|
||||
events *adminevents.Bus
|
||||
// notify is the audit trail every outbound notification lands in. This package is the
|
||||
// one producer that reports to it rather than being driven by it: the dispatcher has
|
||||
// its own queue, pacing and transport registry, and routing deliveries through
|
||||
// notify.Send would make the audit trail the thing deciding what Discord receives.
|
||||
notify *notify.Service
|
||||
|
||||
transports map[string]Transport
|
||||
queue chan job
|
||||
@@ -80,9 +86,12 @@ type Dispatcher struct {
|
||||
// SetPaused installs the server-wide quiet-time gate before Start is called.
|
||||
func (d *Dispatcher) SetPaused(paused func() bool) { d.paused = paused }
|
||||
|
||||
func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Dispatcher {
|
||||
func New(
|
||||
st *store.Store, log *slog.Logger, events *adminevents.Bus, notifier *notify.Service,
|
||||
) *Dispatcher {
|
||||
dispatcher := &Dispatcher{
|
||||
store: st, log: log.With("component", "integrations"), events: events,
|
||||
notify: notifier,
|
||||
client: &http.Client{Timeout: requestTimeout},
|
||||
transports: map[string]Transport{},
|
||||
queue: make(chan job, queueDepth),
|
||||
@@ -211,11 +220,12 @@ func (d *Dispatcher) post(ctx context.Context, integration store.Integration, ev
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
took := time.Since(started)
|
||||
if d.store != nil {
|
||||
record := store.IntegrationDelivery{
|
||||
IntegrationID: integration.ID, EventType: event.Type,
|
||||
Success: err == nil, StatusCode: status,
|
||||
DurationMS: time.Since(started).Milliseconds(), Error: message,
|
||||
DurationMS: took.Milliseconds(), Error: message,
|
||||
}
|
||||
if writeErr := d.store.RecordIntegrationDelivery(
|
||||
context.WithoutCancel(ctx), record,
|
||||
@@ -223,9 +233,53 @@ func (d *Dispatcher) post(ctx context.Context, integration store.Integration, ev
|
||||
d.log.Warn("delivery not recorded", "integration", integration.ID, "error", writeErr)
|
||||
}
|
||||
}
|
||||
// The per-integration delivery history above answers "is this destination healthy",
|
||||
// which is what the integrations page asks. This is the other question — "did Memby
|
||||
// tell anybody about that event" — and it is answered in one place for every channel,
|
||||
// which is the whole reason the notification log exists.
|
||||
d.notify.Log(ctx, notify.Notification{
|
||||
Channel: notify.ChannelWebhook,
|
||||
Kind: event.Type,
|
||||
Source: "integrations",
|
||||
Title: event.Title,
|
||||
Body: event.Summary,
|
||||
// The destination's NAME, never its address: a Discord webhook URL is the
|
||||
// credential, and this row is rendered in the console.
|
||||
Target: integration.Name,
|
||||
SourceKey: integration.ID,
|
||||
Metadata: map[string]any{
|
||||
"integrationId": integration.ID,
|
||||
"kind": integration.Kind,
|
||||
"statusCode": status,
|
||||
},
|
||||
}, deliveryOutcome(status, err), took)
|
||||
return err
|
||||
}
|
||||
|
||||
// deliveryOutcome turns a transport's answer into an audit status.
|
||||
//
|
||||
// A webhook is the one channel that gets Delivered rather than Sent: somebody else's
|
||||
// service actually acknowledged this, where writing a row into a viewer's list is
|
||||
// finished the moment it returns with nobody to confirm it. The status code is kept in
|
||||
// the detail because "failed" on its own sends an operator to the wrong place — a 404 is
|
||||
// a webhook that has been deleted, a 429 is one that is merely busy.
|
||||
func deliveryOutcome(status int, err error) notify.Outcome {
|
||||
if err != nil {
|
||||
if status > 0 {
|
||||
return notify.Outcome{
|
||||
Status: notify.StatusFailed,
|
||||
Detail: fmt.Sprintf("HTTP %d: %s", status, err.Error()),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return notify.Failed(err)
|
||||
}
|
||||
if status > 0 {
|
||||
return notify.Delivered(fmt.Sprintf("HTTP %d", status))
|
||||
}
|
||||
return notify.Delivered("")
|
||||
}
|
||||
|
||||
// announceFailure puts a failed delivery back into the feed the operator is reading.
|
||||
//
|
||||
// It publishes a *different* type from the event that failed, and integration.failed is
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// Package notify is the one door every outbound notification leaves Memby through.
|
||||
//
|
||||
// Before it, each feature both decided to notify somebody and performed the delivery
|
||||
// itself: the Sonarr lifecycle scanner wrote a row into user_notifications, the library
|
||||
// ingester pushed a banner into Redis, the integrations dispatcher posted to Discord. Each
|
||||
// knew how to deliver and none knew that the others existed, so the only way to answer
|
||||
// "what did Memby send, to whom, and did it work" was to read three subsystems' log lines
|
||||
// and hope every one of them had logged.
|
||||
//
|
||||
// The flow is now
|
||||
//
|
||||
// feature/event → notify.Service → Deliverer → notification log
|
||||
//
|
||||
// and the audit trail is a property of the door rather than something each feature
|
||||
// remembers to do. A feature says *what* it wants said and to whom; which provider carries
|
||||
// it, and the record of what happened, belong here.
|
||||
//
|
||||
// Two rules hold the package up:
|
||||
//
|
||||
// - **Logging never blocks delivery.** The record is written after the provider has
|
||||
// already answered, on a context detached from the caller's, and a write that fails is
|
||||
// logged and swallowed. A notification history that could suppress a notification would
|
||||
// be worse than no history.
|
||||
// - **Nothing secret is ever recorded.** A webhook's address is its credential, and an
|
||||
// Emby token is a live upstream session; neither has any business in a table the
|
||||
// console renders. Notification carries a Target — a destination's *name* — never its
|
||||
// address, and Redact is the belt-and-braces pass before anything is stored.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Channel is how a notification reaches somebody. It is stored, so it is part of the
|
||||
// console's vocabulary: adding one means adding a Deliverer and nothing else.
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
// ChannelInApp is a stored notification in one viewer's own list — My Alerts on the
|
||||
// television. It follows the person to whichever set they sign into.
|
||||
ChannelInApp Channel = "in-app"
|
||||
// ChannelBroadcast is a service alert: the bar every signed-in television draws off
|
||||
// the /v1/status poll. It has no recipient, because the recipient is the household.
|
||||
ChannelBroadcast Channel = "broadcast"
|
||||
// ChannelWebhook is an outbound HTTP delivery to somebody else's service — Discord
|
||||
// today, and whatever the integrations package learns to speak next.
|
||||
ChannelWebhook Channel = "webhook"
|
||||
)
|
||||
|
||||
// Status is what became of one notification.
|
||||
//
|
||||
// Sent and Delivered are deliberately different answers. Most of Memby's channels are
|
||||
// stores rather than transports — writing a row into somebody's list is done the moment it
|
||||
// returns, and there is nobody to acknowledge it — so those report Sent. Delivered is
|
||||
// reserved for a provider that actually confirmed receipt, which today means a webhook
|
||||
// that answered 2xx. Collapsing the two would make the console claim an acknowledgement
|
||||
// that nothing ever gave.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusSent Status = "sent"
|
||||
StatusDelivered Status = "delivered"
|
||||
StatusFailed Status = "failed"
|
||||
StatusPending Status = "pending"
|
||||
// StatusSkipped is a notification that was deliberately not delivered, and it is the
|
||||
// most useful row on the page: a viewer's preferences declined it, a duplicate was
|
||||
// suppressed by its source key, or an operator has the window switched off. Without
|
||||
// it, "Memby never told me" and "Memby decided not to tell you" are the same silence.
|
||||
StatusSkipped Status = "skipped"
|
||||
)
|
||||
|
||||
// Notification is what a feature asks for. It describes the news, never the transport.
|
||||
type Notification struct {
|
||||
// Channel selects the provider.
|
||||
Channel Channel
|
||||
// Kind is the notification type: "show-return", "watch-time-week", "sonarr-import".
|
||||
// It is the client's vocabulary too, so it is passed through rather than translated.
|
||||
Kind string
|
||||
// Source names the service that decided to send this — "sonarr-lifecycle",
|
||||
// "watch-time-digest", "library-ingest". It answers "why did this arrive", which the
|
||||
// kind alone often cannot: two features can legitimately produce the same kind.
|
||||
Source string
|
||||
// UserID and Username identify the recipient. Both empty is a household broadcast,
|
||||
// which is a real answer rather than a missing one.
|
||||
UserID string
|
||||
Username string
|
||||
Title string
|
||||
Body string
|
||||
// ItemID links the notification to a title, where there is one.
|
||||
ItemID string
|
||||
// Target names a destination that is not a person — an integration's name, for a
|
||||
// webhook. Never its address: see the package comment.
|
||||
Target string
|
||||
// SourceKey is the caller's idempotency key where it has one. It is what lets the
|
||||
// console explain a skipped row as "already sent" rather than as an unexplained gap.
|
||||
SourceKey string
|
||||
// EventAt is when the news happened, where that differs from when it was sent — an
|
||||
// episode's broadcast time, a digest's period end.
|
||||
EventAt *time.Time
|
||||
// Metadata is free-form context for the detail view. Keep it small and keep it
|
||||
// non-secret; Redact drops anything whose key looks like a credential.
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// Outcome is what the provider reported.
|
||||
type Outcome struct {
|
||||
Status Status
|
||||
// Detail is the failure or the reason, and it is the whole value of the detail view:
|
||||
// "429 Too Many Requests", "the viewer has summaries switched off", "already sent".
|
||||
Detail string
|
||||
// Err is the delivery error where there was one, returned to the caller so a feature
|
||||
// that wants to react to a failure still can. It is never itself the audit trail.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Sent is the ordinary success for a store-shaped channel.
|
||||
func Sent() Outcome { return Outcome{Status: StatusSent} }
|
||||
|
||||
// Delivered is for a provider that confirmed receipt.
|
||||
func Delivered(detail string) Outcome { return Outcome{Status: StatusDelivered, Detail: detail} }
|
||||
|
||||
// Failed records a delivery that was attempted and did not work.
|
||||
func Failed(err error) Outcome {
|
||||
if err == nil {
|
||||
return Outcome{Status: StatusFailed, Detail: "delivery failed"}
|
||||
}
|
||||
return Outcome{Status: StatusFailed, Detail: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
// Skipped records a notification deliberately not delivered, with the reason.
|
||||
func Skipped(reason string) Outcome { return Outcome{Status: StatusSkipped, Detail: reason} }
|
||||
|
||||
// Deliverer is one channel's provider. A new channel is a type implementing this and a
|
||||
// Register call; nothing else in the package has a case per channel.
|
||||
type Deliverer interface {
|
||||
Channel() Channel
|
||||
Deliver(ctx context.Context, n Notification) Outcome
|
||||
}
|
||||
|
||||
// DelivererFunc adapts a plain function, which is what every provider in the gateway is:
|
||||
// a small closure over an existing subsystem.
|
||||
type DelivererFunc struct {
|
||||
Name Channel
|
||||
Fn func(ctx context.Context, n Notification) Outcome
|
||||
}
|
||||
|
||||
func (d DelivererFunc) Channel() Channel { return d.Name }
|
||||
|
||||
func (d DelivererFunc) Deliver(ctx context.Context, n Notification) Outcome {
|
||||
return d.Fn(ctx, n)
|
||||
}
|
||||
|
||||
// Record is one row of the audit trail — the notification as it was asked for, plus what
|
||||
// happened to it.
|
||||
type Record struct {
|
||||
OccurredAt time.Time
|
||||
Channel Channel
|
||||
Kind string
|
||||
Source string
|
||||
UserID string
|
||||
Username string
|
||||
Title string
|
||||
Body string
|
||||
ItemID string
|
||||
Target string
|
||||
SourceKey string
|
||||
Status Status
|
||||
Detail string
|
||||
DurationMS int64
|
||||
EventAt *time.Time
|
||||
Metadata json.RawMessage
|
||||
}
|
||||
|
||||
// Recorder is the audit trail's storage. An interface rather than *store.Store so the
|
||||
// package can be tested without a database, and so a Service built with no recorder — every
|
||||
// unit test of a feature that notifies — still delivers.
|
||||
type Recorder interface {
|
||||
RecordNotification(ctx context.Context, record Record) error
|
||||
}
|
||||
|
||||
// recordTimeout bounds the audit write. It is short on purpose: the notification has
|
||||
// already been delivered by the time this runs, so a slow database must cost the history
|
||||
// rather than hold up the feature that produced the news.
|
||||
const recordTimeout = 5 * time.Second
|
||||
|
||||
// Service is the door. One instance, created at start-up.
|
||||
type Service struct {
|
||||
recorder Recorder
|
||||
log *slog.Logger
|
||||
deliverers map[Channel]Deliverer
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(recorder Recorder, log *slog.Logger) *Service {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Service{
|
||||
recorder: recorder,
|
||||
log: log.With("component", "notify"),
|
||||
deliverers: map[Channel]Deliverer{},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Register installs a provider. Called at start-up only; the map is not guarded because
|
||||
// nothing registers after the first request is served.
|
||||
func (s *Service) Register(deliverers ...Deliverer) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, deliverer := range deliverers {
|
||||
if deliverer != nil {
|
||||
s.deliverers[deliverer.Channel()] = deliverer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send delivers a notification and records what happened.
|
||||
//
|
||||
// The order is the design: deliver, then record. A history written first would be a claim
|
||||
// rather than a record, and one written inside the delivery path would be able to fail the
|
||||
// delivery. A Service that is nil, or has no provider for the channel, still answers — a
|
||||
// feature must never have to nil-check the notification layer.
|
||||
func (s *Service) Send(ctx context.Context, n Notification) Outcome {
|
||||
if s == nil {
|
||||
return Skipped("notifications are not configured")
|
||||
}
|
||||
n = Redact(n)
|
||||
started := s.now()
|
||||
deliverer, ok := s.deliverers[n.Channel]
|
||||
var outcome Outcome
|
||||
if !ok {
|
||||
// A missing provider is a configuration fault, not a delivery failure, and it is
|
||||
// worth a row: a console showing every "show-return" as failed on a gateway with
|
||||
// no in-app provider is what would send somebody looking in the right place.
|
||||
outcome = Failed(errNoDeliverer{channel: n.Channel})
|
||||
} else {
|
||||
outcome = deliverer.Deliver(ctx, n)
|
||||
}
|
||||
s.record(ctx, n, outcome, s.now().Sub(started))
|
||||
return outcome
|
||||
}
|
||||
|
||||
// Log records a notification that some other code path delivered.
|
||||
//
|
||||
// It exists for the one producer that cannot reasonably be inverted: the integrations
|
||||
// dispatcher is a subscriber on the admin event bus with its own queue, pacing and
|
||||
// transport registry, and routing its deliveries back out through Send would make the
|
||||
// audit trail the thing that decides what Discord receives. It posts, then says what
|
||||
// happened. Prefer Send everywhere a feature is the one deciding to notify.
|
||||
func (s *Service) Log(ctx context.Context, n Notification, outcome Outcome, took time.Duration) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.record(ctx, Redact(n), outcome, took)
|
||||
}
|
||||
|
||||
func (s *Service) record(ctx context.Context, n Notification, outcome Outcome, took time.Duration) {
|
||||
if s.recorder == nil {
|
||||
return
|
||||
}
|
||||
record := Record{
|
||||
OccurredAt: s.now().UTC(),
|
||||
Channel: n.Channel,
|
||||
Kind: n.Kind,
|
||||
Source: n.Source,
|
||||
UserID: n.UserID,
|
||||
Username: n.Username,
|
||||
Title: n.Title,
|
||||
Body: n.Body,
|
||||
ItemID: n.ItemID,
|
||||
Target: n.Target,
|
||||
SourceKey: n.SourceKey,
|
||||
Status: outcome.Status,
|
||||
Detail: outcome.Detail,
|
||||
DurationMS: took.Milliseconds(),
|
||||
EventAt: n.EventAt,
|
||||
}
|
||||
if len(n.Metadata) > 0 {
|
||||
if raw, err := json.Marshal(n.Metadata); err == nil {
|
||||
record.Metadata = raw
|
||||
}
|
||||
}
|
||||
// Detached from the caller's context, for the reason the search recorder is: a
|
||||
// television that navigated away, or a request that timed out, still sent this.
|
||||
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordTimeout)
|
||||
defer cancel()
|
||||
if err := s.recorder.RecordNotification(writeCtx, record); err != nil {
|
||||
s.log.Warn("notification not recorded",
|
||||
"channel", n.Channel, "kind", n.Kind, "status", outcome.Status, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errNoDeliverer struct{ channel Channel }
|
||||
|
||||
func (e errNoDeliverer) Error() string {
|
||||
return "no delivery provider for the " + string(e.channel) + " channel"
|
||||
}
|
||||
|
||||
// secretish matches a metadata key whose value must never be stored.
|
||||
//
|
||||
// The rule is a substring match on the key rather than an inspection of the value,
|
||||
// deliberately: a token is not recognisable by looking at it, and the one thing a caller
|
||||
// reliably gets right is what they called the field.
|
||||
var secretish = []string{
|
||||
"token", "secret", "password", "apikey", "api_key", "credential",
|
||||
"webhook", "url", "authorization",
|
||||
}
|
||||
|
||||
// Redact is the last thing between a notification and the audit trail.
|
||||
//
|
||||
// Callers are already expected not to put a credential in a Notification — Target is a
|
||||
// destination's name and never its address — and this is what makes that a property of the
|
||||
// package rather than of every caller's diligence.
|
||||
func Redact(n Notification) Notification {
|
||||
if len(n.Metadata) == 0 {
|
||||
return n
|
||||
}
|
||||
cleaned := make(map[string]any, len(n.Metadata))
|
||||
for key, value := range n.Metadata {
|
||||
if isSecretKey(key) {
|
||||
continue
|
||||
}
|
||||
cleaned[key] = value
|
||||
}
|
||||
n.Metadata = cleaned
|
||||
return n
|
||||
}
|
||||
|
||||
func isSecretKey(key string) bool {
|
||||
lowered := strings.ToLower(key)
|
||||
for _, needle := range secretish {
|
||||
if strings.Contains(lowered, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func quiet() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
type capture struct {
|
||||
records []Record
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *capture) RecordNotification(_ context.Context, record Record) error {
|
||||
c.records = append(c.records, record)
|
||||
return c.err
|
||||
}
|
||||
|
||||
func service(t *testing.T, recorder Recorder, fn func(context.Context, Notification) Outcome) *Service {
|
||||
t.Helper()
|
||||
s := New(recorder, quiet())
|
||||
if fn != nil {
|
||||
s.Register(DelivererFunc{Name: ChannelInApp, Fn: fn})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSendDeliversThenRecords(t *testing.T) {
|
||||
recorder := &capture{}
|
||||
delivered := false
|
||||
s := service(t, recorder, func(context.Context, Notification) Outcome {
|
||||
// The record must not exist yet: the whole ordering claim is that a notification is
|
||||
// delivered first and described afterwards.
|
||||
if len(recorder.records) != 0 {
|
||||
t.Fatal("the audit trail was written before the notification was delivered")
|
||||
}
|
||||
delivered = true
|
||||
return Sent()
|
||||
})
|
||||
|
||||
outcome := s.Send(context.Background(), Notification{
|
||||
Channel: ChannelInApp, Kind: "show-return", Source: "test",
|
||||
UserID: "u1", Username: "Ada", Title: "New episode coming",
|
||||
})
|
||||
|
||||
if !delivered {
|
||||
t.Fatal("the notification was never delivered")
|
||||
}
|
||||
if outcome.Status != StatusSent {
|
||||
t.Fatalf("status = %q, want sent", outcome.Status)
|
||||
}
|
||||
if len(recorder.records) != 1 {
|
||||
t.Fatalf("recorded %d rows, want 1", len(recorder.records))
|
||||
}
|
||||
record := recorder.records[0]
|
||||
if record.UserID != "u1" || record.Kind != "show-return" || record.Status != StatusSent {
|
||||
t.Fatalf("record = %+v", record)
|
||||
}
|
||||
if record.OccurredAt.IsZero() {
|
||||
t.Fatal("the record carries no timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
// A history that could suppress a notification would be worse than no history, so a
|
||||
// recorder that will not write must not change what the caller is told.
|
||||
func TestRecorderFailureDoesNotAffectDelivery(t *testing.T) {
|
||||
recorder := &capture{err: errors.New("postgres is down")}
|
||||
s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() })
|
||||
|
||||
outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp, UserID: "u1"})
|
||||
|
||||
if outcome.Status != StatusSent {
|
||||
t.Fatalf("status = %q, want sent despite the failed write", outcome.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// The producers here are deliberately re-run — the digest job fires hourly and re-sends the
|
||||
// same weekly key all evening — so a cancelled caller must not be able to lose the record
|
||||
// of the one pass that actually delivered.
|
||||
func TestRecordSurvivesACancelledCaller(t *testing.T) {
|
||||
recorder := &capture{}
|
||||
s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s.Send(ctx, Notification{Channel: ChannelInApp, UserID: "u1"})
|
||||
|
||||
if len(recorder.records) != 1 {
|
||||
t.Fatalf("recorded %d rows, want 1 from a cancelled caller", len(recorder.records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkippedIsRecordedWithItsReason(t *testing.T) {
|
||||
recorder := &capture{}
|
||||
s := service(t, recorder, func(context.Context, Notification) Outcome {
|
||||
return Skipped("this viewer has summaries switched off")
|
||||
})
|
||||
|
||||
outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp, UserID: "u1"})
|
||||
|
||||
if outcome.Status != StatusSkipped {
|
||||
t.Fatalf("status = %q, want skipped", outcome.Status)
|
||||
}
|
||||
if got := recorder.records[0].Detail; got != "this viewer has summaries switched off" {
|
||||
t.Fatalf("detail = %q; a skip with no reason is the row this page exists to avoid", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A channel with no provider is a configuration fault, and it is worth a row: silence would
|
||||
// look exactly like a household in which nothing happened.
|
||||
func TestMissingProviderIsRecordedAsAFailure(t *testing.T) {
|
||||
recorder := &capture{}
|
||||
s := service(t, recorder, nil)
|
||||
|
||||
outcome := s.Send(context.Background(), Notification{Channel: ChannelBroadcast, Kind: "x"})
|
||||
|
||||
if outcome.Status != StatusFailed {
|
||||
t.Fatalf("status = %q, want failed", outcome.Status)
|
||||
}
|
||||
if len(recorder.records) != 1 || recorder.records[0].Detail == "" {
|
||||
t.Fatalf("records = %+v, want one row naming the missing provider", recorder.records)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil service is what every unit test of a producing feature holds. It must answer rather
|
||||
// than panic, or every call site grows a nil check — which is how a notification comes to be
|
||||
// silently dropped.
|
||||
func TestNilServiceStillAnswers(t *testing.T) {
|
||||
var s *Service
|
||||
if outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp}); outcome.Status != StatusSkipped {
|
||||
t.Fatalf("status = %q, want skipped from a nil service", outcome.Status)
|
||||
}
|
||||
s.Log(context.Background(), Notification{}, Sent(), 0)
|
||||
s.Register(DelivererFunc{Name: ChannelInApp})
|
||||
}
|
||||
|
||||
// A service with no recorder still delivers. This is the shape a gateway built without a
|
||||
// database has, and the shape most feature tests want.
|
||||
func TestNoRecorderStillDelivers(t *testing.T) {
|
||||
delivered := false
|
||||
s := New(nil, quiet())
|
||||
s.Register(DelivererFunc{Name: ChannelInApp, Fn: func(context.Context, Notification) Outcome {
|
||||
delivered = true
|
||||
return Sent()
|
||||
}})
|
||||
|
||||
if s.Send(context.Background(), Notification{Channel: ChannelInApp}).Status != StatusSent {
|
||||
t.Fatal("delivery reported something other than sent")
|
||||
}
|
||||
if !delivered {
|
||||
t.Fatal("the notification was not delivered without a recorder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogRecordsWithoutDelivering(t *testing.T) {
|
||||
recorder := &capture{}
|
||||
called := false
|
||||
s := service(t, recorder, func(context.Context, Notification) Outcome {
|
||||
called = true
|
||||
return Sent()
|
||||
})
|
||||
|
||||
s.Log(context.Background(), Notification{
|
||||
Channel: ChannelWebhook, Kind: "login.failed", Target: "Family Discord",
|
||||
}, Delivered("HTTP 204"), 120*time.Millisecond)
|
||||
|
||||
if called {
|
||||
t.Fatal("Log delivered the notification; it must only record one somebody else sent")
|
||||
}
|
||||
record := recorder.records[0]
|
||||
if record.Status != StatusDelivered || record.Detail != "HTTP 204" {
|
||||
t.Fatalf("record = %+v", record)
|
||||
}
|
||||
if record.DurationMS != 120 {
|
||||
t.Fatalf("durationMs = %d, want 120", record.DurationMS)
|
||||
}
|
||||
}
|
||||
|
||||
// The audit trail is rendered in the console, so anything that looks like a credential must
|
||||
// never reach it — regardless of how careful the caller was.
|
||||
func TestRedactDropsCredentialShapedMetadata(t *testing.T) {
|
||||
cleaned := Redact(Notification{Metadata: map[string]any{
|
||||
"integrationId": "disc-1",
|
||||
"webhookUrl": "https://discord.com/api/webhooks/123/s3cr3t",
|
||||
"apiKey": "abcd",
|
||||
"embyToken": "live-session",
|
||||
"Authorization": "Bearer x",
|
||||
"statusCode": 204,
|
||||
}})
|
||||
|
||||
for _, banned := range []string{"webhookUrl", "apiKey", "embyToken", "Authorization"} {
|
||||
if _, present := cleaned.Metadata[banned]; present {
|
||||
t.Errorf("%q survived redaction", banned)
|
||||
}
|
||||
}
|
||||
if cleaned.Metadata["integrationId"] != "disc-1" || cleaned.Metadata["statusCode"] != 204 {
|
||||
t.Fatalf("redaction dropped ordinary context: %+v", cleaned.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRedactsBeforeRecording(t *testing.T) {
|
||||
recorder := &capture{}
|
||||
s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() })
|
||||
|
||||
s.Send(context.Background(), Notification{
|
||||
Channel: ChannelInApp,
|
||||
UserID: "u1",
|
||||
Metadata: map[string]any{"series": "The Bear", "webhookUrl": "https://example.test/hook"},
|
||||
})
|
||||
|
||||
var stored map[string]any
|
||||
if err := json.Unmarshal(recorder.records[0].Metadata, &stored); err != nil {
|
||||
t.Fatalf("metadata did not round-trip: %v", err)
|
||||
}
|
||||
if _, present := stored["webhookUrl"]; present {
|
||||
t.Fatal("a credential-shaped key reached the audit trail through Send")
|
||||
}
|
||||
if stored["series"] != "The Bear" {
|
||||
t.Fatalf("stored metadata = %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// The provider still receives the notification it was handed; redaction is about what is
|
||||
// stored, not about what is delivered.
|
||||
func TestRedactionDoesNotChangeWhatIsDelivered(t *testing.T) {
|
||||
var seen Notification
|
||||
s := service(t, &capture{}, func(_ context.Context, n Notification) Outcome {
|
||||
seen = n
|
||||
return Sent()
|
||||
})
|
||||
|
||||
s.Send(context.Background(), Notification{
|
||||
Channel: ChannelInApp, UserID: "u1", Title: "Your week in Memby",
|
||||
Body: "You watched 4 hours.",
|
||||
})
|
||||
|
||||
if seen.Title != "Your week in Memby" || seen.Body != "You watched 4 hours." {
|
||||
t.Fatalf("the provider received %+v", seen)
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,15 @@ type Movie struct {
|
||||
InCinemas *time.Time `json:"inCinemas"`
|
||||
// Radarr's own lifecycle word for the title: tba, announced, inCinemas, released,
|
||||
// deleted. It is what the schedule card's lifecycle tag says.
|
||||
Status string `json:"status"`
|
||||
Status string `json:"status"`
|
||||
// Metadata Radarr carries for a film the household does not hold yet, and which
|
||||
// therefore has no Emby record to read it from. It is the whole substance of the
|
||||
// Radarr-only detail page; the schedule card itself uses none of it.
|
||||
OriginalTitle string `json:"originalTitle,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Certification string `json:"certification,omitempty"`
|
||||
YouTubeTrailerID string `json:"youTubeTrailerId,omitempty"`
|
||||
IMDBID string `json:"imdbId,omitempty"`
|
||||
HasFile bool `json:"hasFile"`
|
||||
Monitored bool `json:"monitored"`
|
||||
MovieFile *MovieFile `json:"movieFile"`
|
||||
@@ -139,6 +147,21 @@ func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Movie, e
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
// Movie is one tracked film, for the case the cached catalogue cannot answer: a title
|
||||
// added to Radarr since the catalogue was last read. The catalogue is still tried first —
|
||||
// this is the fallback, not the ordinary path, because a detail page opening must not cost
|
||||
// a round trip Radarr has already answered once for the whole household.
|
||||
func (c *Client) Movie(ctx context.Context, movieID int) (Movie, error) {
|
||||
if movieID <= 0 {
|
||||
return Movie{}, fmt.Errorf("radarr: invalid movie id")
|
||||
}
|
||||
var movie Movie
|
||||
if err := c.get(ctx, "/api/v3/movie/"+strconv.Itoa(movieID), &movie); err != nil {
|
||||
return Movie{}, err
|
||||
}
|
||||
return movie, nil
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
|
||||
req, err := c.request(ctx, "/api/v3/movie/lookup", url.Values{"term": {term}})
|
||||
if err != nil {
|
||||
|
||||
@@ -281,16 +281,26 @@ func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]Noti
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertNotification writes one notification into a viewer's own list.
|
||||
//
|
||||
// It reports whether a row was actually inserted, which is what separates the two answers
|
||||
// the source key produces: a genuine delivery, and a repeat of one already sitting in
|
||||
// somebody's list. Both are ordinary — the digest job runs hourly and re-sends the same
|
||||
// weekly key all evening on purpose — but the notification log has to be able to tell them
|
||||
// apart, or every catch-up run would read as a second summary nobody received.
|
||||
func (s *Store) UpsertNotification(
|
||||
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
) (bool, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_notifications
|
||||
(emby_user_id, source_key, kind, item_id, title, message, event_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (emby_user_id, source_key) DO NOTHING`,
|
||||
userID, sourceKey, kind, itemID, title, message, eventAt)
|
||||
return err
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) {
|
||||
@@ -325,6 +335,19 @@ func (s *Store) MarkNotificationRead(ctx context.Context, userID string, id int6
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkNotificationUnread puts a notification back to new.
|
||||
//
|
||||
// The counterpart to MarkNotificationRead, and deliberately a plain assignment rather than
|
||||
// that one's COALESCE: read is sticky because it is set by merely looking at a row, so a
|
||||
// second glance must not move the timestamp, while unread is only ever the viewer saying so
|
||||
// and means exactly one thing.
|
||||
func (s *Store) MarkNotificationUnread(ctx context.Context, userID string, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE user_notifications SET read_at = NULL
|
||||
WHERE id = $1 AND emby_user_id = $2`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DismissNotification(ctx context.Context, userID string, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE user_notifications SET dismissed_at = now()
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
)
|
||||
|
||||
// The notification log: what Memby sent, to whom, over which channel, and what became of
|
||||
// it. Written by internal/notify — nothing else writes this table, which is the whole
|
||||
// point of it — and read only by the console.
|
||||
//
|
||||
// It is deliberately a separate table from user_notifications rather than a set of extra
|
||||
// columns on it. That table is *state*: one viewer's undismissed list, which they empty.
|
||||
// This is *history*: it keeps a row for a notification that was dismissed, for one that
|
||||
// was never delivered, and for a broadcast that belongs to no viewer at all — none of
|
||||
// which the other table can represent.
|
||||
|
||||
// NotificationRetention is how far back the log goes. Ninety days is long enough that a
|
||||
// question about "the summary I never got last month" is still answerable, and short
|
||||
// enough that the table cannot outgrow the database on a household gateway. The
|
||||
// housekeeping task prunes to it; the console derives its widest window from it, so the
|
||||
// page can never offer a range the data does not cover.
|
||||
const NotificationRetention = 90 * 24 * time.Hour
|
||||
|
||||
// notificationTextLimit bounds a stored string. A notification body is a sentence or two
|
||||
// by construction, and this is only here so a bug in a producer cannot write a megabyte
|
||||
// per row into the audit trail.
|
||||
const notificationTextLimit = 2000
|
||||
|
||||
// NotificationLogEntry is one delivered — or refused — notification as the console reads
|
||||
// it.
|
||||
type NotificationLogEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
Channel string `json:"channel"`
|
||||
Kind string `json:"kind"`
|
||||
Source string `json:"source"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
ItemID string `json:"itemId,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
SourceKey string `json:"sourceKey,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
EventAt *time.Time `json:"eventAt,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationLogFilter is the console's question. Every field is optional and they
|
||||
// combine with AND, which is what makes the filter bar above the table read the way it
|
||||
// behaves.
|
||||
type NotificationLogFilter struct {
|
||||
UserID string
|
||||
Kinds []string
|
||||
Channels []string
|
||||
Statuses []string
|
||||
Sources []string
|
||||
// Query searches the title, the body, the failure detail and the recipient's name. One
|
||||
// box rather than four, because an operator arriving here is looking for a *thing* they
|
||||
// half remember and does not yet know which column it is in.
|
||||
Query string
|
||||
From time.Time
|
||||
To time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// NotificationLogPage is a window on the log plus the counts the page heads itself with.
|
||||
type NotificationLogPage struct {
|
||||
Entries []NotificationLogEntry `json:"entries"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// NotificationLogTotals summarises the filtered window. Counted by its own query rather
|
||||
// than tallied from the page, for the reason SearchTotals is: the page is capped, so
|
||||
// adding it up would report the first hundred rows' totals as the window's.
|
||||
type NotificationLogTotals struct {
|
||||
Total int `json:"total"`
|
||||
Sent int `json:"sent"`
|
||||
Delivered int `json:"delivered"`
|
||||
Failed int `json:"failed"`
|
||||
Pending int `json:"pending"`
|
||||
Skipped int `json:"skipped"`
|
||||
Users int `json:"users"`
|
||||
}
|
||||
|
||||
// NotificationFacet is one value of a filterable column and how many rows carry it. The
|
||||
// console builds its dropdowns from these rather than from a list of constants, so the
|
||||
// filter can neither offer a type that matches nothing nor miss one a feature added after
|
||||
// the page was written — the stance the activity feed's type filter takes.
|
||||
type NotificationFacet struct {
|
||||
Value string `json:"value"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// NotificationFacets is every dropdown on the page.
|
||||
type NotificationFacets struct {
|
||||
Kinds []NotificationFacet `json:"kinds"`
|
||||
Channels []NotificationFacet `json:"channels"`
|
||||
Statuses []NotificationFacet `json:"statuses"`
|
||||
Sources []NotificationFacet `json:"sources"`
|
||||
}
|
||||
|
||||
// RecordNotification writes one row of the audit trail.
|
||||
//
|
||||
// It implements notify.Recorder, which is the only thing that calls it. Text is clamped
|
||||
// here rather than at the caller so one careless producer cannot be the reason the console
|
||||
// takes a second to draw.
|
||||
func (s *Store) RecordNotification(ctx context.Context, record notify.Record) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil
|
||||
}
|
||||
occurred := record.OccurredAt
|
||||
if occurred.IsZero() {
|
||||
occurred = time.Now().UTC()
|
||||
}
|
||||
var metadata any
|
||||
if len(record.Metadata) > 0 {
|
||||
metadata = []byte(record.Metadata)
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO notification_log
|
||||
(occurred_at, channel, kind, source, emby_user_id, username, title, body,
|
||||
item_id, target, source_key, status, detail, duration_ms, event_at, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,
|
||||
occurred, string(record.Channel), record.Kind, record.Source,
|
||||
record.UserID, record.Username,
|
||||
clampText(record.Title), clampText(record.Body),
|
||||
record.ItemID, record.Target, record.SourceKey,
|
||||
string(record.Status), clampText(record.Detail),
|
||||
record.DurationMS, record.EventAt, metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: record notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampText(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= notificationTextLimit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:notificationTextLimit]) + "…"
|
||||
}
|
||||
|
||||
// notificationWhere builds the shared predicate. The log, the totals and the facets all
|
||||
// answer for the *same* filtered window, so they must be filtered identically — writing
|
||||
// the clause three times is how a page comes to show a total that disagrees with its own
|
||||
// table.
|
||||
func notificationWhere(filter NotificationLogFilter) (string, []any) {
|
||||
clauses := []string{"TRUE"}
|
||||
args := []any{}
|
||||
add := func(clause string, value any) {
|
||||
args = append(args, value)
|
||||
clauses = append(clauses, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if filter.UserID != "" {
|
||||
add("emby_user_id = $%d", filter.UserID)
|
||||
}
|
||||
if len(filter.Kinds) > 0 {
|
||||
add("kind = ANY($%d)", filter.Kinds)
|
||||
}
|
||||
if len(filter.Channels) > 0 {
|
||||
add("channel = ANY($%d)", filter.Channels)
|
||||
}
|
||||
if len(filter.Statuses) > 0 {
|
||||
add("status = ANY($%d)", filter.Statuses)
|
||||
}
|
||||
if len(filter.Sources) > 0 {
|
||||
add("source = ANY($%d)", filter.Sources)
|
||||
}
|
||||
if !filter.From.IsZero() {
|
||||
add("occurred_at >= $%d", filter.From)
|
||||
}
|
||||
if !filter.To.IsZero() {
|
||||
add("occurred_at < $%d", filter.To)
|
||||
}
|
||||
if query := strings.TrimSpace(filter.Query); query != "" {
|
||||
// ILIKE over four columns rather than a tsvector: this table is a few tens of
|
||||
// thousands of rows on a household gateway, always read with a date bound, and the
|
||||
// operator is looking for a substring of a title or an error message — which is
|
||||
// exactly what full-text search is worst at.
|
||||
add("(title ILIKE $%[1]d OR body ILIKE $%[1]d OR detail ILIKE $%[1]d OR username ILIKE $%[1]d)",
|
||||
"%"+query+"%")
|
||||
}
|
||||
return strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
// NotificationLog reads the filtered window, newest first.
|
||||
func (s *Store) NotificationLog(
|
||||
ctx context.Context, filter NotificationLogFilter,
|
||||
) (NotificationLogPage, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
where, args := notificationWhere(filter)
|
||||
|
||||
page := NotificationLogPage{Entries: []NotificationLogEntry{}, Limit: limit, Offset: offset}
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM notification_log WHERE `+where, args...,
|
||||
).Scan(&page.Total); err != nil {
|
||||
return page, fmt.Errorf("store: count notification log: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, occurred_at, channel, kind, source, emby_user_id, username, title, body,
|
||||
item_id, target, source_key, status, detail, duration_ms, event_at, metadata
|
||||
FROM notification_log
|
||||
WHERE `+where+`
|
||||
ORDER BY occurred_at DESC, id DESC
|
||||
LIMIT $`+fmt.Sprint(len(args)+1)+` OFFSET $`+fmt.Sprint(len(args)+2),
|
||||
append(args, limit, offset)...)
|
||||
if err != nil {
|
||||
return page, fmt.Errorf("store: read notification log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var entry NotificationLogEntry
|
||||
var metadata []byte
|
||||
if err := rows.Scan(
|
||||
&entry.ID, &entry.OccurredAt, &entry.Channel, &entry.Kind, &entry.Source,
|
||||
&entry.UserID, &entry.Username, &entry.Title, &entry.Body,
|
||||
&entry.ItemID, &entry.Target, &entry.SourceKey, &entry.Status, &entry.Detail,
|
||||
&entry.DurationMS, &entry.EventAt, &metadata,
|
||||
); err != nil {
|
||||
return page, fmt.Errorf("store: scan notification log: %w", err)
|
||||
}
|
||||
if len(metadata) > 0 {
|
||||
entry.Metadata = json.RawMessage(metadata)
|
||||
}
|
||||
page.Entries = append(page.Entries, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return page, fmt.Errorf("store: read notification log: %w", err)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// NotificationLogTotals counts the same window the log is read with.
|
||||
func (s *Store) NotificationLogTotals(
|
||||
ctx context.Context, filter NotificationLogFilter,
|
||||
) (NotificationLogTotals, error) {
|
||||
where, args := notificationWhere(filter)
|
||||
var totals NotificationLogTotals
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE status = 'sent'),
|
||||
count(*) FILTER (WHERE status = 'delivered'),
|
||||
count(*) FILTER (WHERE status = 'failed'),
|
||||
count(*) FILTER (WHERE status = 'pending'),
|
||||
count(*) FILTER (WHERE status = 'skipped'),
|
||||
count(DISTINCT emby_user_id) FILTER (WHERE emby_user_id <> '')
|
||||
FROM notification_log
|
||||
WHERE `+where, args...).Scan(
|
||||
&totals.Total, &totals.Sent, &totals.Delivered, &totals.Failed,
|
||||
&totals.Pending, &totals.Skipped, &totals.Users)
|
||||
if err != nil {
|
||||
return totals, fmt.Errorf("store: notification totals: %w", err)
|
||||
}
|
||||
return totals, nil
|
||||
}
|
||||
|
||||
// NotificationLogFacets lists what the filters may offer.
|
||||
//
|
||||
// Deliberately computed over the retention window rather than over the operator's current
|
||||
// filter: a dropdown whose options disappear as you narrow the table is one you cannot use
|
||||
// to widen the question again.
|
||||
func (s *Store) NotificationLogFacets(
|
||||
ctx context.Context, since time.Time,
|
||||
) (NotificationFacets, error) {
|
||||
facets := NotificationFacets{
|
||||
Kinds: []NotificationFacet{}, Channels: []NotificationFacet{},
|
||||
Statuses: []NotificationFacet{}, Sources: []NotificationFacet{},
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT 'kind', kind, count(*) FROM notification_log
|
||||
WHERE occurred_at >= $1 AND kind <> '' GROUP BY kind
|
||||
UNION ALL
|
||||
SELECT 'channel', channel, count(*) FROM notification_log
|
||||
WHERE occurred_at >= $1 AND channel <> '' GROUP BY channel
|
||||
UNION ALL
|
||||
SELECT 'status', status, count(*) FROM notification_log
|
||||
WHERE occurred_at >= $1 AND status <> '' GROUP BY status
|
||||
UNION ALL
|
||||
SELECT 'source', source, count(*) FROM notification_log
|
||||
WHERE occurred_at >= $1 AND source <> '' GROUP BY source
|
||||
ORDER BY 3 DESC, 2`, since)
|
||||
if err != nil {
|
||||
return facets, fmt.Errorf("store: notification facets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var group string
|
||||
var facet NotificationFacet
|
||||
if err := rows.Scan(&group, &facet.Value, &facet.Count); err != nil {
|
||||
return facets, fmt.Errorf("store: scan notification facet: %w", err)
|
||||
}
|
||||
switch group {
|
||||
case "kind":
|
||||
facets.Kinds = append(facets.Kinds, facet)
|
||||
case "channel":
|
||||
facets.Channels = append(facets.Channels, facet)
|
||||
case "status":
|
||||
facets.Statuses = append(facets.Statuses, facet)
|
||||
case "source":
|
||||
facets.Sources = append(facets.Sources, facet)
|
||||
}
|
||||
}
|
||||
return facets, rows.Err()
|
||||
}
|
||||
|
||||
// NotificationLogDays is the daily shape of the filtered window, for the chart above the
|
||||
// table. Grouped in the database's own timezone, the stance the sign-in history takes, so
|
||||
// an evening notification stays on the day it happened.
|
||||
type NotificationLogDay struct {
|
||||
Day string `json:"day"`
|
||||
Sent int `json:"sent"`
|
||||
Failed int `json:"failed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Delivered int `json:"delivered"`
|
||||
}
|
||||
|
||||
func (s *Store) NotificationLogDays(
|
||||
ctx context.Context, filter NotificationLogFilter,
|
||||
) ([]NotificationLogDay, error) {
|
||||
where, args := notificationWhere(filter)
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT to_char(date_trunc('day', occurred_at), 'YYYY-MM-DD'),
|
||||
count(*) FILTER (WHERE status = 'sent'),
|
||||
count(*) FILTER (WHERE status = 'failed'),
|
||||
count(*) FILTER (WHERE status = 'skipped'),
|
||||
count(*) FILTER (WHERE status = 'delivered')
|
||||
FROM notification_log
|
||||
WHERE `+where+`
|
||||
GROUP BY 1 ORDER BY 1`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: notification days: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
days := []NotificationLogDay{}
|
||||
for rows.Next() {
|
||||
var day NotificationLogDay
|
||||
if err := rows.Scan(&day.Day, &day.Sent, &day.Failed, &day.Skipped, &day.Delivered); err != nil {
|
||||
return nil, fmt.Errorf("store: scan notification day: %w", err)
|
||||
}
|
||||
days = append(days, day)
|
||||
}
|
||||
return days, rows.Err()
|
||||
}
|
||||
|
||||
// PruneNotificationLog is the retention policy, run by the housekeeping scheduler.
|
||||
func (s *Store) PruneNotificationLog(ctx context.Context, older time.Duration) (int64, error) {
|
||||
if older <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM notification_log WHERE occurred_at < now() - $1::interval`,
|
||||
older.String())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: prune notification log: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// notificationWhere is the one predicate the log, its totals, its daily chart and the page
|
||||
// count all read with. It is worth pinning hard for two reasons: a placeholder numbered
|
||||
// wrong is a query that either fails or, worse, filters on the wrong argument, and a clause
|
||||
// that drifts between the four readers is a page whose total disagrees with its own table.
|
||||
|
||||
func TestNotificationWhereIsEmptyByDefault(t *testing.T) {
|
||||
where, args := notificationWhere(NotificationLogFilter{})
|
||||
if where != "TRUE" {
|
||||
t.Fatalf("where = %q, want an unfiltered predicate", where)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("args = %v, want none", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationWhereNumbersPlaceholdersInOrder(t *testing.T) {
|
||||
from := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)
|
||||
where, args := notificationWhere(NotificationLogFilter{
|
||||
UserID: "u1",
|
||||
Kinds: []string{"show-return", "watch-time-week"},
|
||||
Channels: []string{"in-app"},
|
||||
Statuses: []string{"failed", "skipped"},
|
||||
Sources: []string{"watch-time-digest"},
|
||||
Query: "bear",
|
||||
From: from,
|
||||
To: to,
|
||||
})
|
||||
|
||||
// One placeholder per argument, in the order the arguments are appended. The search
|
||||
// clause reuses its placeholder across four columns, which is why the count of distinct
|
||||
// placeholders is what matters rather than the count of "$".
|
||||
for i := range args {
|
||||
marker := "$" + itoa(i+1)
|
||||
if !strings.Contains(where, marker) {
|
||||
t.Fatalf("clause %q never uses %s; the arguments and the placeholders disagree", where, marker)
|
||||
}
|
||||
}
|
||||
if len(args) != 8 {
|
||||
t.Fatalf("args = %d, want 8", len(args))
|
||||
}
|
||||
if args[0] != "u1" {
|
||||
t.Fatalf("args[0] = %v, want the user id first", args[0])
|
||||
}
|
||||
if args[len(args)-1] != "%bear%" {
|
||||
t.Fatalf("args[last] = %v, want the wrapped search term", args[len(args)-1])
|
||||
}
|
||||
if args[6] != to {
|
||||
t.Fatalf("args[6] = %v, want the upper bound", args[6])
|
||||
}
|
||||
}
|
||||
|
||||
// Every filter combines with AND. A page whose controls quietly ORed together would answer
|
||||
// a different question from the one the filter bar describes.
|
||||
func TestNotificationWhereCombinesWithAnd(t *testing.T) {
|
||||
where, _ := notificationWhere(NotificationLogFilter{
|
||||
UserID: "u1", Statuses: []string{"failed"},
|
||||
})
|
||||
if strings.Contains(where, " OR emby_user_id") {
|
||||
t.Fatalf("clause %q ORs its filters together", where)
|
||||
}
|
||||
if strings.Count(where, " AND ") != 2 {
|
||||
t.Fatalf("clause %q does not AND both filters", where)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty list is not a filter. Sending `ANY('{}')` would match nothing, so a page whose
|
||||
// dropdown is on "any" would show an empty table.
|
||||
func TestNotificationWhereIgnoresEmptyLists(t *testing.T) {
|
||||
where, args := notificationWhere(NotificationLogFilter{
|
||||
Kinds: []string{}, Channels: nil, Statuses: []string{}, Query: " ",
|
||||
})
|
||||
if where != "TRUE" || len(args) != 0 {
|
||||
t.Fatalf("where = %q args = %v; an unset filter must not narrow anything", where, args)
|
||||
}
|
||||
}
|
||||
|
||||
// The search box covers the four columns an operator half-remembers something from, and it
|
||||
// must use one placeholder for all of them — repeating the argument four times would put
|
||||
// the later filters' placeholders out of step.
|
||||
func TestNotificationWhereSearchesFourColumnsWithOneArgument(t *testing.T) {
|
||||
where, args := notificationWhere(NotificationLogFilter{Query: "timeout"})
|
||||
for _, column := range []string{"title ILIKE", "body ILIKE", "detail ILIKE", "username ILIKE"} {
|
||||
if !strings.Contains(where, column) {
|
||||
t.Errorf("search does not cover %s", column)
|
||||
}
|
||||
}
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("args = %d, want one shared search argument", len(args))
|
||||
}
|
||||
if strings.Count(where, "$1") != 4 {
|
||||
t.Fatalf("clause %q does not reuse $1 across all four columns", where)
|
||||
}
|
||||
}
|
||||
|
||||
// The retention constant is what the console derives its widest window from, so a change to
|
||||
// one that is not a change to the other would offer a range the prune has already emptied.
|
||||
func TestNotificationRetentionIsWholeDays(t *testing.T) {
|
||||
if NotificationRetention%(24*time.Hour) != 0 {
|
||||
t.Fatalf("retention %v is not a whole number of days", NotificationRetention)
|
||||
}
|
||||
if days := int(NotificationRetention / (24 * time.Hour)); days != 90 {
|
||||
t.Fatalf("retention = %d days, want 90", days)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampTextBoundsAStoredString(t *testing.T) {
|
||||
if got := clampText("short"); got != "short" {
|
||||
t.Fatalf("clampText shortened an ordinary string to %q", got)
|
||||
}
|
||||
long := strings.Repeat("é", notificationTextLimit+50)
|
||||
got := clampText(long)
|
||||
// Counted in runes, not bytes: a body in Japanese must not be cut at a third of an
|
||||
// English one's length, and never mid-character.
|
||||
if runes := []rune(got); len(runes) != notificationTextLimit+1 {
|
||||
t.Fatalf("clamped to %d runes, want %d plus the ellipsis", len(runes), notificationTextLimit)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Fatal("a clamped string does not say that it was clamped")
|
||||
}
|
||||
}
|
||||
@@ -831,3 +831,42 @@ CREATE INDEX IF NOT EXISTS library_ingest_pending_idx
|
||||
WHERE state = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS library_ingest_recent_idx
|
||||
ON library_ingest_queue (updated_at DESC);
|
||||
|
||||
-- The outbound notification log: what Memby sent, to whom, over which channel, and what
|
||||
-- became of it. Written only by internal/notify, which every producer now goes through,
|
||||
-- so this is one audit trail rather than a per-feature guess.
|
||||
--
|
||||
-- Deliberately separate from user_notifications. That table is one viewer's undismissed
|
||||
-- list — state they empty — where this is history: it keeps the row for a notification
|
||||
-- that was dismissed, for one that was deliberately skipped, and for a broadcast that
|
||||
-- belongs to no viewer at all, none of which the other table can represent.
|
||||
--
|
||||
-- emby_user_id is '' rather than NULL for a household broadcast, so every filter is an
|
||||
-- equality test and no query needs a NULL case.
|
||||
CREATE TABLE IF NOT EXISTS notification_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
channel TEXT NOT NULL, -- in-app | broadcast | webhook
|
||||
kind TEXT NOT NULL DEFAULT '', -- show-return, watch-time-week, …
|
||||
source TEXT NOT NULL DEFAULT '', -- the service that decided to send
|
||||
emby_user_id TEXT NOT NULL DEFAULT '', -- '' is the whole household
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
item_id TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT '', -- a destination's NAME, never its address
|
||||
source_key TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL, -- sent | delivered | failed | pending | skipped
|
||||
detail TEXT NOT NULL DEFAULT '', -- the failure, or why it was skipped
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
event_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
-- The page's default read is the whole log newest-first, and every filtered read still
|
||||
-- bounds on the date; the remaining three cover the columns the filter bar offers.
|
||||
CREATE INDEX IF NOT EXISTS notification_log_time_idx ON notification_log (occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS notification_log_user_idx
|
||||
ON notification_log (emby_user_id, occurred_at DESC) WHERE emby_user_id <> '';
|
||||
CREATE INDEX IF NOT EXISTS notification_log_status_idx ON notification_log (status, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS notification_log_kind_idx ON notification_log (kind, occurred_at DESC);
|
||||
|
||||
Reference in New Issue
Block a user