This commit is contained in:
ponzischeme89
2026-08-26 21:31:05 +12:00
parent a5f9a91832
commit 3d42c98947
33 changed files with 1172 additions and 69 deletions
+147
View File
@@ -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)