Files
memby/server/internal/api/sonarr_status.go
T

202 lines
6.9 KiB
Go
Raw Normal View History

2026-08-11 23:41:10 +12:00
package api
import (
"context"
"fmt"
"strconv"
"strings"
"time"
2026-08-19 06:57:59 +12:00
"github.com/ponzischeme89/memby/server/internal/notify"
2026-08-11 23:41:10 +12:00
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
2026-08-19 14:25:44 +12:00
// sonarrLifecycleResult is what one scan looked at and what moved.
//
// Counted rather than only logged because these are the figures the integrations console
// prints beside the run: "412 series checked, 3 changed" is an answer, where "the scan
// finished" is a line in a log an operator has to go and find.
type sonarrLifecycleResult struct {
Series int
Changes int
Added int
Cancelled int
Notifications int
2026-08-11 23:41:10 +12:00
}
2026-08-19 14:25:44 +12:00
// scanSonarrLifecycle seeds the durable status history and records what changed since
// the last reading. History stores changes rather than identical daily snapshots: it still
// records the complete lifecycle while making an active-to-cancelled transition
// unambiguous.
func (s *Server) scanSonarrLifecycle(ctx context.Context) (sonarrLifecycleResult, error) {
result := sonarrLifecycleResult{}
2026-08-11 23:41:10 +12:00
series, err := s.sonarr.Series(ctx)
if err != nil {
2026-08-19 14:25:44 +12:00
return result, fmt.Errorf("read Sonarr series: %w", err)
2026-08-11 23:41:10 +12:00
}
now := time.Now().UTC()
observations := make([]store.SonarrSeriesStatus, 0, len(series))
for _, item := range series {
key := sonarrSeriesStatusKey(item)
if key == "" || strings.TrimSpace(item.Status) == "" {
continue
}
observations = append(observations, store.SonarrSeriesStatus{
SeriesKey: key, SonarrSeriesID: item.ID, TVDBID: item.TVDBID,
Title: item.Title, Year: item.Year, Status: item.Status, ObservedAt: now,
})
}
2026-08-19 14:25:44 +12:00
result.Series = len(observations)
2026-08-11 23:41:10 +12:00
changes, err := s.store.RecordSonarrSeriesStatuses(ctx, observations)
if err != nil {
2026-08-19 14:25:44 +12:00
return result, err
2026-08-11 23:41:10 +12:00
}
2026-08-19 14:25:44 +12:00
result.Changes = len(changes)
2026-08-11 23:41:10 +12:00
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
2026-08-12 08:25:15 +12:00
additions := make([]store.SonarrSeriesStatusChange, 0, len(changes))
2026-08-11 23:41:10 +12:00
for _, change := range changes {
2026-08-12 08:25:15 +12:00
switch sonarrLifecycleNotificationKind(change.PreviousStatus, change.Current.Status) {
case "show-added":
additions = append(additions, change)
case "show-cancelled":
2026-08-11 23:41:10 +12:00
cancellations = append(cancellations, change)
}
}
2026-08-19 14:25:44 +12:00
result.Added, result.Cancelled = len(additions), len(cancellations)
2026-08-12 08:25:15 +12:00
if len(cancellations) == 0 && len(additions) == 0 {
2026-08-11 23:41:10 +12:00
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
2026-08-19 14:25:44 +12:00
return result, nil
2026-08-11 23:41:10 +12:00
}
users, err := s.store.KnownUsers(ctx)
if err != nil {
2026-08-19 14:25:44 +12:00
return result, err
2026-08-11 23:41:10 +12:00
}
preferences := map[string]store.NotificationPreferences{}
preferenceErrors := map[string]bool{}
notifications := 0
2026-08-12 08:25:15 +12:00
for _, change := range additions {
for _, user := range users {
prefs, ok := preferences[user.ID]
if !ok && !preferenceErrors[user.ID] {
prefs, err = s.store.NotificationPreferences(ctx, user.ID)
if err != nil {
preferenceErrors[user.ID] = true
s.log.Warn("notification preferences unavailable during Sonarr lifecycle scan",
"user", user.ID, "error", err)
continue
}
preferences[user.ID] = prefs
}
eventAt := change.Current.ObservedAt
sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID)
2026-08-19 06:57:59 +12:00
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]))
2026-08-12 08:25:15 +12:00
continue
}
2026-08-19 06:57:59 +12:00
if s.notifyUser(ctx, notification) {
notifications++
}
2026-08-12 08:25:15 +12:00
}
}
2026-08-11 23:41:10 +12:00
for _, change := range cancellations {
for _, user := range users {
prefs, ok := preferences[user.ID]
if !ok && !preferenceErrors[user.ID] {
prefs, err = s.store.NotificationPreferences(ctx, user.ID)
if err != nil {
preferenceErrors[user.ID] = true
s.log.Warn("notification preferences unavailable during Sonarr lifecycle scan",
"user", user.ID, "error", err)
continue
}
preferences[user.ID] = prefs
}
eventAt := change.Current.ObservedAt
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
2026-08-19 06:57:59 +12:00
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]))
2026-08-11 23:41:10 +12:00
continue
}
2026-08-19 06:57:59 +12:00
if s.notifyUser(ctx, notification) {
notifications++
}
2026-08-11 23:41:10 +12:00
}
}
2026-08-19 14:25:44 +12:00
result.Notifications = notifications
2026-08-11 23:41:10 +12:00
s.log.Info("Sonarr lifecycle scan complete",
"series", len(observations), "changes", len(changes),
2026-08-12 08:25:15 +12:00
"added", len(additions), "cancelled", len(cancellations), "notifications", notifications)
2026-08-19 14:25:44 +12:00
return result, nil
2026-08-11 23:41:10 +12:00
}
2026-08-12 08:25:15 +12:00
func sonarrLifecycleNotificationKind(previous, current string) string {
if strings.TrimSpace(previous) == "" {
return "show-added"
}
if sonarrBecameCancelled(previous, current) {
return "show-cancelled"
}
return ""
}
2026-08-11 23:41:10 +12:00
func sonarrSeriesStatusKey(series sonarr.Series) string {
if series.TVDBID > 0 {
return "tvdb:" + strconv.Itoa(series.TVDBID)
}
if series.ID > 0 {
return "sonarr:" + strconv.Itoa(series.ID)
}
return ""
}
func sonarrBecameCancelled(previous, current string) bool {
previous = strings.ToLower(strings.TrimSpace(previous))
current = strings.ToLower(strings.TrimSpace(current))
active := previous == "continuing" || previous == "upcoming"
cancelled := current == "ended" || current == "deleted" ||
current == "cancelled" || current == "canceled"
return active && cancelled
}
2026-08-19 06:57:59 +12:00
// 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"
}
}