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" // 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"` Title string `json:"title"` Message string `json:"message"` ItemID string `json:"itemId,omitempty"` ImageTag string `json:"imageTag,omitempty"` AiredAt string `json:"airedAt,omitempty"` } // sonarrAiredAlerts reads the day's calendar through the same cache the airing-today 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.log.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, 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 } }