299 lines
11 KiB
Go
299 lines
11 KiB
Go
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"
|
|
// A new episode that has finished scanning in. Distinct from sonarr-aired, which is
|
|
// about an episode that has been broadcast and is *not* here yet — the two are opposite
|
|
// halves of the same wait and a viewer reads them differently.
|
|
alertKindSonarrImport = "sonarr-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.
|
|
//
|
|
// 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 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
|
|
// 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 {
|
|
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 {
|
|
return fmt.Errorf("alert store failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
window := s.sonarrAlertWindow()
|
|
if !s.sonarrEnabled(ctx) || window <= 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)
|
|
}
|
|
return buildSonarrAlerts(items, time.Now().In(s.householdLocation()), window)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|