package api import ( "context" "fmt" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/notify" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" ) // 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 } // 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{} series, err := s.sonarr.Series(ctx) if err != nil { return result, fmt.Errorf("read Sonarr series: %w", err) } 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, }) } result.Series = len(observations) changes, err := s.store.RecordSonarrSeriesStatuses(ctx, observations) if err != nil { return result, err } result.Changes = len(changes) cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes)) additions := make([]store.SonarrSeriesStatusChange, 0, len(changes)) for _, change := range changes { switch sonarrLifecycleNotificationKind(change.PreviousStatus, change.Current.Status) { case "show-added": additions = append(additions, change) case "show-cancelled": cancellations = append(cancellations, change) } } result.Added, result.Cancelled = len(additions), len(cancellations) if len(cancellations) == 0 && len(additions) == 0 { s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes)) return result, nil } users, err := s.store.KnownUsers(ctx) if err != nil { return result, err } preferences := map[string]store.NotificationPreferences{} preferenceErrors := map[string]bool{} notifications := 0 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) 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 } if s.notifyUser(ctx, notification) { notifications++ } } } 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) notification := notify.Notification{ Kind: "show-cancelled", Source: notifySourceSonarrLifecycle, UserID: user.ID, Username: user.Username, Title: "Show cancelled", Body: change.Current.Title + " has been cancelled.", 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 } if s.notifyUser(ctx, notification) { notifications++ } } } result.Notifications = notifications s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes), "added", len(additions), "cancelled", len(cancellations), "notifications", notifications) return result, nil } func sonarrLifecycleNotificationKind(previous, current string) string { if strings.TrimSpace(previous) == "" { return "show-added" } if sonarrBecameCancelled(previous, current) { return "show-cancelled" } return "" } 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 } // 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" } }