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)