Files
memby/server/internal/api/radarr.go
T
2026-08-14 11:47:32 +12:00

282 lines
9.0 KiB
Go

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.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)
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.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
}
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
}