248 lines
9.0 KiB
Go
248 lines
9.0 KiB
Go
package api
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// The TV calendar is the schedule row's other shape: the same Sonarr episodes, laid out a
|
||
|
|
// month at a time rather than as the next five days. It shares the row's item builder on
|
||
|
|
// purpose — a card is a card, and the availability badges, lifecycle tags and Emby series
|
||
|
|
// link have to mean the same thing on both screens or the calendar becomes a second,
|
||
|
|
// slightly different account of the same week.
|
||
|
|
//
|
||
|
|
// Bump the prefix when the item contract changes, so an older layout cannot survive a
|
||
|
|
// deployment until the cached months expire.
|
||
|
|
const calendarCachePrefix = "sonarr:calendar:month:v2:"
|
||
|
|
|
||
|
|
// How far either side of the present a viewer may travel. Sonarr answers for any date it
|
||
|
|
// has been asked about, and a held D-pad on the month header would otherwise walk it into
|
||
|
|
// the 2050s one request at a time. A year each way covers "when does the new season start"
|
||
|
|
// and "what did we miss in March" and stops there.
|
||
|
|
const calendarMonthRange = 12
|
||
|
|
|
||
|
|
type calendarResponse struct {
|
||
|
|
// Available is false when the household runs no Sonarr. The page says so rather than
|
||
|
|
// drawing an empty grid that looks like a month in which nothing airs.
|
||
|
|
Available bool `json:"available"`
|
||
|
|
// Month is the machine key ("2026-08"); Label is what the header prints.
|
||
|
|
Month string `json:"month"`
|
||
|
|
Label string `json:"label"`
|
||
|
|
// Empty when the month is at the end of the travelable range, which is what makes the
|
||
|
|
// header's arrows disappear rather than fail.
|
||
|
|
Previous string `json:"previous,omitempty"`
|
||
|
|
Next string `json:"next,omitempty"`
|
||
|
|
// The household's today, sent only when it falls inside this month — the client has no
|
||
|
|
// business deciding which cell to ring from a television's own clock when the gateway
|
||
|
|
// already knows the household's zone.
|
||
|
|
Today string `json:"today,omitempty"`
|
||
|
|
// The grid's shape. Sending it rather than a date per cell keeps civil-calendar
|
||
|
|
// arithmetic in one place: the television draws DayCount cells after FirstWeekday
|
||
|
|
// blanks and never has to know which years are leap years.
|
||
|
|
FirstWeekday int `json:"firstWeekday"`
|
||
|
|
DayCount int `json:"dayCount"`
|
||
|
|
Days []calendarDay `json:"days"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// calendarDay carries only the days that have something on them. A month of empty cells is
|
||
|
|
// the client's to draw, and sending thirty-one mostly-empty objects would triple the
|
||
|
|
// response for nothing.
|
||
|
|
type calendarDay struct {
|
||
|
|
Date string `json:"date"`
|
||
|
|
Day int `json:"day"`
|
||
|
|
Items []json.RawMessage `json:"items"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||
|
|
ctx := r.Context()
|
||
|
|
if s.sonarr == nil || !s.featureEnabled(ctx, featureTVCalendar) {
|
||
|
|
writeJSON(w, http.StatusOK, emptyCalendar())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
location := s.sonarrLocation()
|
||
|
|
now := time.Now().In(location)
|
||
|
|
month, ok := parseCalendarMonth(r.URL.Query().Get("month"), now, location)
|
||
|
|
if !ok {
|
||
|
|
writeError(w, http.StatusBadRequest, "invalid month")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
calendar, err := s.sonarrCalendarMonth(ctx, month, now, location)
|
||
|
|
if err != nil {
|
||
|
|
// A month nobody can fetch is reported as an empty month rather than as a failure:
|
||
|
|
// the page is informational, and a viewer pressing Right past a Sonarr hiccup
|
||
|
|
// should land on a quiet month and be able to press Left back out of it.
|
||
|
|
s.loggerFor(ctx).Warn("calendar month unavailable",
|
||
|
|
"month", month.Format("2006-01"), "error", err)
|
||
|
|
calendar = buildCalendarMonth(nil, month, now, location, nil)
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, calendar)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) sonarrLocation() *time.Location {
|
||
|
|
if s.cfg.SonarrLocation != nil {
|
||
|
|
return s.cfg.SonarrLocation
|
||
|
|
}
|
||
|
|
return time.Local
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) sonarrCalendarMonth(
|
||
|
|
ctx context.Context,
|
||
|
|
month, now time.Time,
|
||
|
|
location *time.Location,
|
||
|
|
) (calendarResponse, error) {
|
||
|
|
key := calendarCachePrefix + month.Format("2006-01")
|
||
|
|
if cached := s.cachedCalendar(ctx, key); cached != nil {
|
||
|
|
return *cached, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// The same shared lock the schedule row takes, for the same reason: several televisions
|
||
|
|
// opening the calendar together must not each stampede Sonarr on the one miss.
|
||
|
|
s.sonarrMu.Lock()
|
||
|
|
defer s.sonarrMu.Unlock()
|
||
|
|
if cached := s.cachedCalendar(ctx, key); cached != nil {
|
||
|
|
return *cached, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
episodes, err := s.sonarr.Calendar(ctx, month, month.AddDate(0, 1, 0))
|
||
|
|
if err != nil {
|
||
|
|
return calendarResponse{}, err
|
||
|
|
}
|
||
|
|
calendar := buildCalendarMonth(episodes, month, now, location, s.embySeriesIndex(ctx))
|
||
|
|
if body, marshalErr := json.Marshal(calendar); marshalErr == nil {
|
||
|
|
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||
|
|
s.loggerFor(ctx).Warn("calendar cache write failed", "error", cacheErr)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return calendar, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// cachedCalendar refuses an entry whose Today no longer agrees with the household's, so a
|
||
|
|
// month cached before midnight cannot leave the ring on yesterday. Everything else in the
|
||
|
|
// response is fixed for the month and safely reusable.
|
||
|
|
func (s *Server) cachedCalendar(ctx context.Context, key string) *calendarResponse {
|
||
|
|
raw, err := s.cache.Get(ctx, key)
|
||
|
|
if err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
var cached calendarResponse
|
||
|
|
if json.Unmarshal(raw, &cached) != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
now := time.Now().In(s.sonarrLocation())
|
||
|
|
if cached.Today != "" && cached.Today != now.Format("2006-01-02") {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return &cached
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseCalendarMonth turns the client's `month` parameter into the first instant of that
|
||
|
|
// month in the household's zone. An empty parameter is the present month, which is what a
|
||
|
|
// television asks for when it opens the page and the only request an older client makes.
|
||
|
|
//
|
||
|
|
// Out-of-range months are refused rather than clamped: a clamp would answer a request for
|
||
|
|
// 2031 with this year's August and the header would then disagree with the grid.
|
||
|
|
func parseCalendarMonth(value string, now time.Time, location *time.Location) (time.Time, bool) {
|
||
|
|
now = now.In(location)
|
||
|
|
current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location)
|
||
|
|
trimmed := strings.TrimSpace(value)
|
||
|
|
if trimmed == "" {
|
||
|
|
return current, true
|
||
|
|
}
|
||
|
|
parsed, err := time.ParseInLocation("2006-01", trimmed, location)
|
||
|
|
if err != nil {
|
||
|
|
return time.Time{}, false
|
||
|
|
}
|
||
|
|
month := time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location)
|
||
|
|
if month.Before(current.AddDate(0, -calendarMonthRange, 0)) ||
|
||
|
|
month.After(current.AddDate(0, calendarMonthRange, 0)) {
|
||
|
|
return time.Time{}, false
|
||
|
|
}
|
||
|
|
return month, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// buildCalendarMonth is the whole layout rule, pure so the edges — a month starting on a
|
||
|
|
// Sunday, a February, the day either side of the travel limit — are testable without a
|
||
|
|
// Sonarr to hand.
|
||
|
|
func buildCalendarMonth(
|
||
|
|
episodes []sonarr.Episode,
|
||
|
|
month, now time.Time,
|
||
|
|
location *time.Location,
|
||
|
|
series seriesIndex,
|
||
|
|
) calendarResponse {
|
||
|
|
month = month.In(location)
|
||
|
|
monthEnd := month.AddDate(0, 1, 0)
|
||
|
|
current := time.Date(now.In(location).Year(), now.In(location).Month(), 1, 0, 0, 0, 0, location)
|
||
|
|
|
||
|
|
calendar := calendarResponse{
|
||
|
|
Available: true,
|
||
|
|
Month: month.Format("2006-01"),
|
||
|
|
Label: month.Format("January 2006"),
|
||
|
|
// Sunday is 0, matching the weekday header the grid draws.
|
||
|
|
FirstWeekday: int(month.Weekday()),
|
||
|
|
DayCount: int(monthEnd.Sub(month).Hours() / 24),
|
||
|
|
Days: []calendarDay{},
|
||
|
|
}
|
||
|
|
if previous := month.AddDate(0, -1, 0); !previous.Before(current.AddDate(0, -calendarMonthRange, 0)) {
|
||
|
|
calendar.Previous = previous.Format("2006-01")
|
||
|
|
}
|
||
|
|
if next := month.AddDate(0, 1, 0); !next.After(current.AddDate(0, calendarMonthRange, 0)) {
|
||
|
|
calendar.Next = next.Format("2006-01")
|
||
|
|
}
|
||
|
|
if today := now.In(location); !today.Before(month) && today.Before(monthEnd) {
|
||
|
|
calendar.Today = today.Format("2006-01-02")
|
||
|
|
}
|
||
|
|
|
||
|
|
sort.SliceStable(episodes, func(i, j int) bool {
|
||
|
|
if episodes[i].AirDateUTC == nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if episodes[j].AirDateUTC == nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC)
|
||
|
|
})
|
||
|
|
|
||
|
|
// A map plus one ordered slice, rather than a slice searched per episode: a busy month
|
||
|
|
// is a few hundred episodes and the days they land on are already in order.
|
||
|
|
byDate := make(map[string]int, 31)
|
||
|
|
for _, episode := range episodes {
|
||
|
|
if episode.AirDateUTC == nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
// Sonarr is asked in the household's zone but answers in UTC, so an episode airing
|
||
|
|
// late on the 31st elsewhere can fall outside this month once converted back.
|
||
|
|
airTime := episode.AirDateUTC.In(location)
|
||
|
|
if airTime.Before(month) || !airTime.Before(monthEnd) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
raw, err := json.Marshal(toSonarrScheduleItem(episode, now, location, series))
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
date := airTime.Format("2006-01-02")
|
||
|
|
index, seen := byDate[date]
|
||
|
|
if !seen {
|
||
|
|
calendar.Days = append(calendar.Days, calendarDay{
|
||
|
|
Date: date,
|
||
|
|
Day: airTime.Day(),
|
||
|
|
Items: []json.RawMessage{},
|
||
|
|
})
|
||
|
|
index = len(calendar.Days) - 1
|
||
|
|
byDate[date] = index
|
||
|
|
}
|
||
|
|
calendar.Days[index].Items = append(calendar.Days[index].Items, raw)
|
||
|
|
}
|
||
|
|
return calendar
|
||
|
|
}
|
||
|
|
|
||
|
|
func emptyCalendar() calendarResponse {
|
||
|
|
return calendarResponse{Days: []calendarDay{}}
|
||
|
|
}
|