364 lines
13 KiB
Go
364 lines
13 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:"
|
|
|
|
// 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
|
|
}
|