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:" // radarrScheduleDays is a month where the Sonarr schedule row's is a week, because films // and episodes arrive at quite different rates. A household's Sonarr calendar fills a week // several times over; its Radarr calendar, measured against the real catalogue, yields // about one title a fortnight — at five days the row was empty or held a single card most // of the time, which reads as a broken shelf rather than a quiet month. const radarrScheduleDays = 30 // radarrTheatricalDelayDays is the median cinema-to-digital gap for recent releases, used // only to estimate a digital date Radarr has not published. It shares a figure with // radarrScheduleDays by coincidence, not by meaning — they are free to move apart. const radarrTheatricalDelayDays = 30 // radarrWeekdayLabelDays is how far out a bare weekday still names an unambiguous day. // Beyond it the label carries a date, or a release five weeks away reads as this Friday. const radarrWeekdayLabelDays = 6 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"` // The Emby film this card stands for, when the library already holds it — the // MembySeriesItemId arrangement, and for the same reason: it is what decides whether // pressing the card opens the ordinary Memby page or the Radarr-only one. A film the // household has not downloaded carries none. The detail route resolves it again from // live data, because this row is cached for the day and a film imported at lunchtime // must not be stuck behind a cache until midnight. MembyMovieItemID string `json:"MembyMovieItemId,omitempty"` } func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) { if !s.radarrEnabled(ctx) { 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, s.embyMovieIndex(ctx, movies)) 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 } // embyMovieIndex answers which of these films Emby already holds, keyed by TMDb id. // // Films are matched on the id both systems record rather than on their titles, which is // what the Sonarr schedule row has to fall back on: Radarr writes a TMDb id and the // library import asks Emby for ProviderIds, so there is nothing here to guess at. A // failure is not fatal — the row is about what is coming, and losing the link only costs a // downloaded card its ordinary detail page. func (s *Server) embyMovieIndex(ctx context.Context, movies []radarr.Movie) map[int]string { if s.store == nil || len(movies) == 0 { return nil } ids := make([]int, 0, len(movies)) for _, movie := range movies { if movie.TMDBID > 0 { ids = append(ids, movie.TMDBID) } } found, err := s.store.LibraryProviderItemIDs(ctx, "Tmdb", ids) if err != nil { s.loggerFor(ctx).Warn("emby movie index unavailable for schedule row", "error", err) return nil } return found } func buildRadarrRow( movies []radarr.Movie, now time.Time, location *time.Location, embyItems map[int]string, ) (*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 } item := toRadarrScheduleItem(movie, release, now, location) item.MembyMovieItemID = embyItems[movie.TMDBID] raw, err := json.Marshal(item) 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 = radarrReleaseDayLabel(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 } // radarrReleaseDayLabel is the card's own day chip. It is Radarr's rather than // scheduleAirDayLabel because that one answers for a seven-day Sonarr window, where every // date it can be handed is inside the coming week and a weekday is never ambiguous. func radarrReleaseDayLabel(release, now time.Time, location *time.Location) string { today := localDayStart(now.In(location), location) releaseDay := localDayStart(release.In(location), location) switch { case releaseDay.Equal(today): return "Today" case releaseDay.Equal(today.AddDate(0, 0, 1)): return "Tomorrow" case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)): return releaseDay.Format("Monday") default: return releaseDay.Format("2 Jan") } } func digitalReleaseLabel(release, now time.Time, location *time.Location, estimated bool) string { today := localDayStart(now.In(location), location) releaseDay := localDayStart(release.In(location), 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" case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)): return prefix + releaseDay.Format("Monday") default: return prefix + releaseDay.Format("2 January") } } 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.radarrEnabled(ctx) { 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 } s.cacheRadarrMovies(ctx, movies) return movies, nil } // cacheRadarrMovies stores the household's shared copy of Radarr's catalogue. // // Split out because the scheduled refresh writes it too: that task reads Radarr directly // — a refresh satisfied by the cache it exists to replace would do nothing — and would // otherwise need its own copy of the key, the TTL and the failure handling. func (s *Server) cacheRadarrMovies(ctx context.Context, movies []radarr.Movie) { body, err := json.Marshal(movies) if err != nil { return } if cacheErr := s.cache.Set(ctx, radarrMovieCacheKey, body, s.cfg.RadarrTTL); cacheErr != nil { s.loggerFor(ctx).Warn("radarr movie cache write failed", "error", cacheErr) } } 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 }