2026-08-11 23:41:10 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// WatchSonarrLifecycle seeds the durable status history on startup, then refreshes it
|
|
|
|
|
// daily. 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) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) {
|
2026-08-14 11:47:32 +12:00
|
|
|
if !s.sonarrEnabled(ctx) || interval <= 0 {
|
2026-08-11 23:41:10 +12:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
scan := func() {
|
|
|
|
|
if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil {
|
|
|
|
|
s.log.Warn("Sonarr lifecycle scan failed", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
scan()
|
|
|
|
|
ticker := time.NewTicker(interval)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
scan()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
|
|
|
|
series, err := s.sonarr.Series(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
changes, err := s.store.RecordSonarrSeriesStatuses(ctx, observations)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
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-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))
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
users, err := s.store.KnownUsers(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
if preferenceErrors[user.ID] || !prefs.Enabled {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
eventAt := change.Current.ObservedAt
|
|
|
|
|
sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
|
|
|
|
message := change.Current.Title + " was added to Sonarr."
|
|
|
|
|
if err := s.store.UpsertNotification(
|
|
|
|
|
ctx, user.ID, sourceKey, "show-added", "",
|
|
|
|
|
"Show added", message, &eventAt,
|
|
|
|
|
); err != nil {
|
|
|
|
|
s.log.Warn("Sonarr addition notification failed",
|
|
|
|
|
"user", user.ID, "show", change.Current.Title, "error", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
notifications++
|
|
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
|
|
}
|
|
|
|
|
if preferenceErrors[user.ID] || !prefs.Enabled {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
eventAt := change.Current.ObservedAt
|
|
|
|
|
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
|
|
|
|
message := change.Current.Title + " is now listed as cancelled in Sonarr."
|
|
|
|
|
if err := s.store.UpsertNotification(
|
|
|
|
|
ctx, user.ID, sourceKey, "show-cancelled", "",
|
|
|
|
|
"Show cancelled", message, &eventAt,
|
|
|
|
|
); err != nil {
|
|
|
|
|
s.log.Warn("Sonarr cancellation notification failed",
|
|
|
|
|
"user", user.ID, "show", change.Current.Title, "error", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
notifications++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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-11 23:41:10 +12:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
}
|