229 lines
7.1 KiB
Go
229 lines
7.1 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: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
|
||
|
|
}
|