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",
|
2026-08-21 23:15:42 +12:00
|
|
|
Body: change.Current.Title + " has been cancelled.",
|
2026-08-19 06:57:59 +12:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|