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:v3:" 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"` // Radarr's lifecycle for the title — announced, in cinemas, released — which is a // different question from whether the household's copy has downloaded yet. MembyLifecycle string `json:"MembyLifecycle,omitempty"` MembyLifecycleText string `json:"MembyLifecycleText,omitempty"` 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.loggerFor(ctx).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, } lifecycle := movieLifecycleTag(movie.Status) item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label 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 } const radarrMovieCacheKey = "radarr:movies:v1" // radarrMovieCatalogue is Radarr's whole movie list, cached the way sonarrSeriesCatalogue is // and for the same reason: the request page needs the state of every title one viewer has // ever asked for, and per-title lookups would be a round trip per card on a page somebody is // waiting in front of. Shared across the household, because Radarr's catalogue is. // // Failure degrades to asking Radarr directly — a cache that is down costs latency, never the // answer. func (s *Server) radarrMovieCatalogue(ctx context.Context) ([]radarr.Movie, error) { if s.radarr == nil { return nil, fmt.Errorf("radarr: not configured") } if movies := s.cachedRadarrMovies(ctx); movies != nil { return movies, nil } s.radarrMu.Lock() defer s.radarrMu.Unlock() if movies := s.cachedRadarrMovies(ctx); movies != nil { return movies, nil } movies, err := s.radarr.Movies(ctx) if err != nil { return nil, err } if body, marshalErr := json.Marshal(movies); marshalErr == nil { if cacheErr := s.cache.Set(ctx, radarrMovieCacheKey, body, s.cfg.RadarrTTL); cacheErr != nil { s.loggerFor(ctx).Warn("radarr movie cache write failed", "error", cacheErr) } } return movies, nil } func (s *Server) cachedRadarrMovies(ctx context.Context) []radarr.Movie { raw, err := s.cache.Get(ctx, radarrMovieCacheKey) if err != nil { return nil } var movies []radarr.Movie if json.Unmarshal(raw, &movies) != nil { return nil } return movies }