package api import ( "context" "encoding/json" "fmt" "sort" "time" ) // Kinds are part of the wire contract: an unknown kind is rendered with the generic // banner rather than dropped, so adding one never needs an app release. const ( alertKindSonarrAired = "sonarr-aired" alertKindRadarrImport = "radarr-import" alertKindLibrarySync = "library-updated" alertKindServerDown = "server-unreachable" alertKindServerUp = "server-restored" alertKindDeploying = "server-deploying" // A TV shows one banner at a time; more than a few queued up is noise, not news. maxAlerts = 3 ) // clientAlert is a short-lived, informational nudge delivered on the /v1/status poll — // the only channel the app already listens to while it is open. It carries no action: // the client slides it in, shows it for a few seconds and forgets it. type clientAlert struct { ID string `json:"id"` Kind string `json:"kind"` // Label is the banner's eyebrow ("JUST AIRED", "NEW MOVIE ADDED"). It travels with // the alert so a new kind of news reads correctly on an app that predates it; a // client seeing no label falls back to its own wording. Label string `json:"label,omitempty"` Title string `json:"title"` Message string `json:"message"` ItemID string `json:"itemId,omitempty"` ImageTag string `json:"imageTag,omitempty"` // AiredAt is when the news happened — broadcast time for an episode, import time // for a movie. It orders the merged list; the client does not read it. AiredAt string `json:"airedAt,omitempty"` } // mergeAlerts interleaves the alert sources newest-first and trims to what a TV can // usefully show. Each source is already sorted, but a movie imported ten minutes ago // should still come before an episode that aired two hours back. func mergeAlerts(groups ...[]clientAlert) []clientAlert { merged := make([]clientAlert, 0, maxAlerts) for _, group := range groups { merged = append(merged, group...) } sort.SliceStable(merged, func(i, j int) bool { return alertTime(merged[i]).After(alertTime(merged[j])) }) if len(merged) > maxAlerts { merged = merged[:maxAlerts] } return merged } // alertTime is zero for an alert with no timestamp, which sorts it last rather than // dropping it — an unorderable alert is still news. func alertTime(alert clientAlert) time.Time { when, err := time.Parse(time.RFC3339, alert.AiredAt) if err != nil { return time.Time{} } return when } // Alerts that are *events* — a film imported, the library refreshed, Emby stopping and // starting to answer — are published into one shared Redis list and served from it, // rather than derived on each poll the way the Sonarr calendar is. // // A list with expiring entries rather than a push: the gateway holds no connection to a // television, so there is nothing to push to. A window is what lets a set that was off // or in the screensaver when the event happened still hear about it, and each TV dedupes // by alert id so nobody is told twice. const publishedAlertsCacheKey = "alerts:events:v1" // More than this stored at once means something is generating events in bulk, and a // viewer does not want twenty banners about it — the newest few are the news. const maxStoredAlerts = 8 // storedAlert is a clientAlert with the moment it stops being news. Expiry is held with // the record rather than as a Redis TTL because they share one key: the list outlives // any single entry in it. type storedAlert struct { Alert clientAlert `json:"alert"` ExpiresAt time.Time `json:"expiresAt"` } // publishAlert makes one event visible to every signed-in TV for window. // // 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. func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window time.Duration) { if window <= 0 || alert.ID == "" || s.cache == nil { return } // 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 // answer; this instance is the only writer. s.alertMu.Lock() defer s.alertMu.Unlock() now := time.Now().UTC() 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 } // 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) } } // appendAlert is the pure half of publishing: prune what has expired, replace any earlier // copy of the same alert rather than stacking duplicates, keep the newest few. func appendAlert( existing []storedAlert, alert clientAlert, expiresAt, now time.Time, ) []storedAlert { kept := make([]storedAlert, 0, len(existing)+1) for _, entry := range existing { if entry.Alert.ID == alert.ID || !entry.ExpiresAt.After(now) { continue } kept = append(kept, entry) } kept = append(kept, storedAlert{Alert: alert, ExpiresAt: expiresAt}) sort.SliceStable(kept, func(i, j int) bool { return alertTime(kept[i].Alert).After(alertTime(kept[j].Alert)) }) if len(kept) > maxStoredAlerts { kept = kept[:maxStoredAlerts] } return kept } // publishedAlerts is the read side, called on every /v1/status poll. func (s *Server) publishedAlerts(ctx context.Context) []clientAlert { return liveAlerts(s.storedAlerts(ctx), time.Now().UTC()) } func (s *Server) storedAlerts(ctx context.Context) []storedAlert { // Redis is where these live, so no Redis means no news — never a failed status poll. // The status endpoint is what a client falls back on when everything else is broken; // it must not acquire a hard dependency for the sake of a banner. if s.cache == nil { return nil } raw, err := s.cache.Get(ctx, publishedAlertsCacheKey) if err != nil { return nil } var stored []storedAlert if json.Unmarshal(raw, &stored) != nil { return nil } return stored } // liveAlerts drops entries whose window has closed. Expiry is applied on read as well as // on write, so an alert stops being offered on time even if no event follows it. func liveAlerts(stored []storedAlert, now time.Time) []clientAlert { alerts := make([]clientAlert, 0, len(stored)) for _, entry := range stored { if !entry.ExpiresAt.After(now) { continue } alerts = append(alerts, entry.Alert) } return alerts } // sonarrAiredAlerts reads the calendar through the same cache the five-day schedule row // uses, so polling clients never cost a Sonarr request of their own. func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert { if s.sonarr == nil || s.cfg.SonarrAlertWindow <= 0 { return nil } row, err := s.sonarrAiringTodayRow(ctx) if err != nil { s.loggerFor(ctx).Warn("sonarr alerts unavailable", "error", err) return nil } if row == nil { return nil } items := make([]sonarrScheduleItem, 0, len(row.Items)) for _, raw := range row.Items { var item sonarrScheduleItem if json.Unmarshal(raw, &item) != nil { continue } items = append(items, item) } location := s.cfg.SonarrLocation if location == nil { location = time.Local } return buildSonarrAlerts(items, time.Now().In(location), s.cfg.SonarrAlertWindow) } // buildSonarrAlerts announces episodes that have aired but are not in Emby yet — the // gap the viewer would otherwise experience as "it's Tuesday, so why isn't it there". // Anything already downloaded is deliberately silent: it is on the home screen, which // says it better than a banner would. func buildSonarrAlerts(items []sonarrScheduleItem, now time.Time, window time.Duration) []clientAlert { if window <= 0 { return nil } type dated struct { alert clientAlert airs time.Time } found := make([]dated, 0, len(items)) for _, item := range items { if item.MembyAirsAt == "" { continue } airsAt, err := time.Parse(time.RFC3339, item.MembyAirsAt) if err != nil { continue } // Not yet aired, or aired so long ago that saying so is no longer news. if airsAt.After(now) || now.Sub(airsAt) > window { continue } message, ok := airedMessage(item, airsAt) if !ok { continue } found = append(found, dated{ alert: clientAlert{ ID: item.ID + ":aired", Kind: alertKindSonarrAired, Label: "JUST AIRED", Title: item.Name, Message: message, ItemID: item.ID, ImageTag: item.ImageTags["Primary"], AiredAt: item.MembyAirsAt, }, airs: airsAt, }) } // Newest first: if several aired inside the window, the most recent is the one the // viewer is most likely to be waiting on. sort.SliceStable(found, func(i, j int) bool { return found[i].airs.After(found[j].airs) }) if len(found) > maxAlerts { found = found[:maxAlerts] } alerts := make([]clientAlert, 0, len(found)) for _, entry := range found { alerts = append(alerts, entry.alert) } return alerts } func airedMessage(item sonarrScheduleItem, airsAt time.Time) (string, bool) { episode := item.MembyEpisodeCode if item.MembyEpisodeTitle != "" { episode = fmt.Sprintf("%s — %s", episode, item.MembyEpisodeTitle) } switch item.MembyAvailability { case "downloading": return fmt.Sprintf("%s aired at %s and is downloading now.", episode, airsAt.Format("3:04 PM")), true case "awaiting": return fmt.Sprintf("%s aired at %s and will be in Emby soon.", episode, airsAt.Format("3:04 PM")), true default: // available (already watchable) and unmonitored (never coming) both have // nothing useful to announce. return "", false } }