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.sonarrEnabled(ctx) || !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{}} }