This commit is contained in:
ponzischeme89
2026-08-19 06:57:59 +12:00
parent 8c847c59b8
commit 2b43b9ef12
94 changed files with 6359 additions and 778 deletions
+5
View File
@@ -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))
+10 -5
View File
@@ -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)
}
+20 -4
View File
@@ -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
+12 -12
View File
@@ -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.
+19 -2
View File
@@ -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))
+1 -1
View File
@@ -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.",
},
+16
View File
@@ -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",
+8 -4
View File
@@ -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
+1 -1
View File
@@ -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)
}
+19 -4
View File
@@ -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:
+163
View File
@@ -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)
}
+24 -8
View File
@@ -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 {
+29 -3
View File
@@ -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
}
}
}
+32
View File
@@ -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 {
+42 -3
View File
@@ -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
}
+306
View File
@@ -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
}
+192
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
+4 -4
View File
@@ -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
+51 -22
View File
@@ -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"
}
}
+7
View File
@@ -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 {
+37 -17
View File
@@ -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
View File
@@ -1 +1 @@
0.1.55
0.1.57
+56 -2
View File
@@ -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
+343
View File
@@ -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
}
+248
View File
@@ -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)
}
}
+24 -1
View File
@@ -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 {
+26 -3
View File
@@ -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()
+378
View File
@@ -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
}
+129
View File
@@ -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")
}
}
+39
View File
@@ -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);