package api import ( "context" "encoding/json" "fmt" "sort" "strings" "time" "github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/recommend" ) const radarrCalendarCachePrefix = "radarr:calendar:v2:" const radarrScheduleDays = 5 const radarrTheatricalDelayDays = 30 type radarrRelease struct { at time.Time estimated bool } type radarrScheduleItem struct { ID string `json:"Id"` Name string `json:"Name"` Type string `json:"Type"` Overview string `json:"Overview,omitempty"` ProductionYear int `json:"ProductionYear,omitempty"` RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` Genres []string `json:"Genres"` ImageTags map[string]string `json:"ImageTags"` BackdropImageTags []string `json:"BackdropImageTags"` MembySource string `json:"MembySource"` MembyAirsAt string `json:"MembyAirsAt"` MembyAirDayLabel string `json:"MembyAirDayLabel"` MembyAirLabel string `json:"MembyAirLabel"` MembyAvailability string `json:"MembyAvailability"` MembyAvailabilityText string `json:"MembyAvailabilityText"` MembyPlayable bool `json:"MembyPlayable"` } func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) { if s.radarr == nil { return nil, nil } location := s.cfg.RadarrLocation if location == nil { location = time.Local } now := time.Now().In(location) dayStart := localDayStart(now, location) cacheKey := radarrCalendarCachePrefix + dayStart.Format("2006-01-02") if row := s.cachedRadarrRow(ctx, cacheKey); row != nil { return row, nil } s.radarrMu.Lock() defer s.radarrMu.Unlock() if row := s.cachedRadarrRow(ctx, cacheKey); row != nil { return row, nil } // A cinema release can stand in for an unknown digital date at cinema + 30 days, // so include the preceding 30 days in the Radarr query. buildRadarrRow applies the // actual five-day effective-release window after the response arrives. movies, err := s.radarr.Calendar( ctx, dayStart.AddDate(0, 0, -radarrTheatricalDelayDays), dayStart.AddDate(0, 0, radarrScheduleDays), ) if err != nil { return nil, err } row, err := buildRadarrRow(movies, now, location) if err != nil { return nil, err } if body, marshalErr := json.Marshal(row); marshalErr == nil { if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.RadarrTTL); cacheErr != nil { s.log.Warn("radarr calendar cache write failed", "error", cacheErr) } } return row, nil } func (s *Server) cachedRadarrRow(ctx context.Context, key string) *recommend.Row { raw, err := s.cache.Get(ctx, key) if err != nil { return nil } var row recommend.Row if json.Unmarshal(raw, &row) != nil { return nil } return &row } func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Location) (*recommend.Row, error) { sort.SliceStable(movies, func(i, j int) bool { left, leftOK := effectiveRadarrRelease(movies[i]) right, rightOK := effectiveRadarrRelease(movies[j]) if !leftOK { return false } if !rightOK { return true } return left.at.Before(right.at) }) items := make([]json.RawMessage, 0, len(movies)) dayStart := localDayStart(now, location) windowEnd := dayStart.AddDate(0, 0, radarrScheduleDays) for _, movie := range movies { release, ok := effectiveRadarrRelease(movie) if !ok { continue } localRelease := release.at.In(location) if localRelease.Before(dayStart) || !localRelease.Before(windowEnd) { continue } raw, err := json.Marshal(toRadarrScheduleItem(movie, release, now, location)) if err != nil { return nil, err } items = append(items, raw) } return &recommend.Row{ ID: "radarr-upcoming-movies", Title: "Upcoming Movie releases", Kind: "movie-schedule", Items: items, }, nil } // effectiveRadarrRelease prefers Radarr's actual digital date. Cinema + 30 days is // only a fallback when Radarr has no digital date at all. In particular, an old movie // with an old digital date cannot appear because of a newer cinema/re-release date. func effectiveRadarrRelease(movie radarr.Movie) (radarrRelease, bool) { if movie.DigitalRelease != nil { return radarrRelease{at: *movie.DigitalRelease}, true } if movie.InCinemas != nil { return radarrRelease{ at: movie.InCinemas.AddDate(0, 0, radarrTheatricalDelayDays), estimated: true, }, true } return radarrRelease{}, false } func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Time, location *time.Location) radarrScheduleItem { availabilityText := "Upcoming digital release" if release.estimated { availabilityText = "Estimated digital release" } item := radarrScheduleItem{ ID: fmt.Sprintf("radarr:%d", movie.ID), Name: movie.Title, Type: "MembyRadarrMovie", Overview: movie.Overview, ProductionYear: movie.Year, RunTimeTicks: int64(movie.Runtime) * 600_000_000, Genres: nonNilStrings(movie.Genres), ImageTags: map[string]string{}, BackdropImageTags: []string{}, MembySource: "radarr", MembyAvailability: "upcoming", MembyAvailabilityText: availabilityText, MembyPlayable: false, } if hasRadarrCover(movie.Images, "poster") { item.ImageTags["Primary"] = "radarr" } if hasRadarrCover(movie.Images, "fanart") { item.BackdropImageTags = []string{"radarr"} } localRelease := release.at.In(location) item.MembyAirsAt = localRelease.Format(time.RFC3339) item.MembyAirDayLabel = scheduleAirDayLabel(localRelease, now, location) item.MembyAirLabel = digitalReleaseLabel(localRelease, now, location, release.estimated) switch { case movie.HasFile: item.MembyAvailability = "available" item.MembyAvailabilityText = "Downloaded" if movie.MovieFile != nil && movie.MovieFile.DateAdded != nil { item.MembyAvailabilityText = "Added at " + movie.MovieFile.DateAdded.In(location).Format("3:04 PM") } case !movie.Monitored: item.MembyAvailability = "unmonitored" item.MembyAvailabilityText = "Not monitored" case release.at.Before(now): item.MembyAvailability = "awaiting" item.MembyAvailabilityText = "Awaiting download" } return item } func digitalReleaseLabel(release, now time.Time, location *time.Location, estimated bool) string { release = release.In(location) now = now.In(location) today := localDayStart(now, location) releaseDay := localDayStart(release, location) prefix := "Digital release " if estimated { prefix = "Estimated digital release " } switch { case releaseDay.Equal(today): return prefix + "today" case releaseDay.Equal(today.AddDate(0, 0, 1)): return prefix + "tomorrow" default: return prefix + release.Format("Monday") } } func hasRadarrCover(images []radarr.Image, coverType string) bool { for _, image := range images { if strings.EqualFold(image.CoverType, coverType) { return true } } return false }