0.3.32
This commit is contained in:
@@ -582,6 +582,153 @@ func scheduleAirLabel(airTime, now time.Time, location *time.Location) string {
|
||||
}
|
||||
}
|
||||
|
||||
// nextAiringInfo is what the in-player notice needs about a continuing show's next
|
||||
// episode: the household's own wording for when it airs, and the episode code when it can
|
||||
// be resolved cheaply.
|
||||
type nextAiringInfo struct {
|
||||
Label string
|
||||
DayLabel string
|
||||
EpisodeCode string
|
||||
}
|
||||
|
||||
// nextEpisodeAiringEnabled gates the whole feature: Sonarr must be configured and the
|
||||
// operator must not have switched it off.
|
||||
func (s *Server) nextEpisodeAiringEnabled(ctx context.Context) bool {
|
||||
return s.sonarrEnabled(ctx) && s.featureEnabled(ctx, featureNextEpisodeAiring)
|
||||
}
|
||||
|
||||
// nextAiringInfoFor resolves a currently-playing episode's series against Sonarr's own
|
||||
// catalogue (already cached by sonarrSeriesCatalogue) and answers only when the show is
|
||||
// still being made and Sonarr has a scheduled airing in the future. Matched by title alone
|
||||
// — a playing episode's summary carries no series year, and title collisions are rare
|
||||
// enough that My Shows accepts the same trade when a follow was saved with no year either.
|
||||
func (s *Server) nextAiringInfoFor(ctx context.Context, seriesName string) (nextAiringInfo, bool) {
|
||||
if !s.nextEpisodeAiringEnabled(ctx) {
|
||||
return nextAiringInfo{}, false
|
||||
}
|
||||
catalogue, err := s.sonarrSeriesCatalogue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Debug("next airing lookup unavailable", "error", err)
|
||||
return nextAiringInfo{}, false
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
matched, info, ok := resolveNextAiring(catalogue, seriesName, now, location)
|
||||
if !ok {
|
||||
return nextAiringInfo{}, false
|
||||
}
|
||||
info.EpisodeCode = s.nextAiringEpisodeCode(ctx, matched.ID, *matched.NextAiring)
|
||||
return info, true
|
||||
}
|
||||
|
||||
// resolveNextAiring is the pure half of nextAiringInfoFor: given Sonarr's catalogue and the
|
||||
// series being played, decide whether there is anything to say and, when there is, the
|
||||
// matched series so the caller can look up its episode code. Kept apart from the Server
|
||||
// method so the matching, lifecycle and future-airing rules can be pinned without a live
|
||||
// cache or Sonarr client.
|
||||
func resolveNextAiring(
|
||||
catalogue []sonarr.Series, seriesName string, now time.Time, location *time.Location,
|
||||
) (*sonarr.Series, nextAiringInfo, bool) {
|
||||
seriesName = strings.TrimSpace(seriesName)
|
||||
if seriesName == "" {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
key := normalizedShowTitle(seriesName)
|
||||
var matched *sonarr.Series
|
||||
for i := range catalogue {
|
||||
if normalizedShowTitle(catalogue[i].Title) != key {
|
||||
continue
|
||||
}
|
||||
matched = &catalogue[i]
|
||||
break
|
||||
}
|
||||
if matched == nil {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
if seriesLifecycleTag(matched.Status).Status != "continuing" {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
if matched.NextAiring == nil || !matched.NextAiring.After(now) {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
airTime := matched.NextAiring.In(location)
|
||||
return matched, nextAiringInfo{
|
||||
Label: scheduleAirLabel(airTime, now, location),
|
||||
DayLabel: scheduleAirDayLabel(airTime, now, location),
|
||||
}, true
|
||||
}
|
||||
|
||||
// nextAiringEpisodeCode is a best-effort lookup against the same 5-day calendar window the
|
||||
// schedule row already fetches and caches. A next airing beyond that window, or one Sonarr
|
||||
// has not yet attached to a specific episode, omits the code rather than guessing at it.
|
||||
func (s *Server) nextAiringEpisodeCode(ctx context.Context, seriesID int, nextAiring time.Time) string {
|
||||
episodes, err := s.sonarrUpcomingEpisodes(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, episode := range episodes {
|
||||
candidateSeriesID := episode.SeriesID
|
||||
if episode.Series.ID > 0 {
|
||||
candidateSeriesID = episode.Series.ID
|
||||
}
|
||||
if candidateSeriesID != seriesID || episode.AirDateUTC == nil {
|
||||
continue
|
||||
}
|
||||
if episode.AirDateUTC.Equal(nextAiring) {
|
||||
return fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sonarrUpcomingEpisodes shares the schedule row's own cached calendar window, so this
|
||||
// feature costs no extra request to Sonarr beyond what the launcher's schedule row already
|
||||
// pays for once a day.
|
||||
func (s *Server) sonarrUpcomingEpisodes(ctx context.Context) ([]sonarr.Episode, error) {
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
dayStart := localDayStart(now, location)
|
||||
key := sonarrCalendarCachePrefix + "episodes:" + dayStart.Format("2006-01-02")
|
||||
if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil {
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
s.sonarrMu.Lock()
|
||||
defer s.sonarrMu.Unlock()
|
||||
if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil {
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, sonarrScheduleDays))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body, marshalErr := json.Marshal(episodes); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("sonarr upcoming episodes cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
func (s *Server) cachedSonarrEpisodes(ctx context.Context, key string) []sonarr.Episode {
|
||||
raw, err := s.cache.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var episodes []sonarr.Episode
|
||||
if json.Unmarshal(raw, &episodes) != nil {
|
||||
return nil
|
||||
}
|
||||
return episodes
|
||||
}
|
||||
|
||||
func localDayStart(value time.Time, location *time.Location) time.Time {
|
||||
value = value.In(location)
|
||||
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location)
|
||||
|
||||
Reference in New Issue
Block a user