package api import ( "context" "encoding/json" "fmt" "net/http" "sort" "strings" "time" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" ) // Bump when the authored row contract changes so an older ordering cannot survive a // deployment until the previous daily cache expires. const sonarrCalendarCachePrefix = "sonarr:calendar:v4:" const sonarrPrerollCachePrefix = "sonarr:preroll:v2:" const sonarrSeriesCacheKey = "sonarr:series:v1" const sonarrScheduleDays = 5 // sonarrSeriesCatalogue is Sonarr's whole series list, cached the way the calendar is. // // Every caller here wants the same thing — the lifecycle, monitored flag and next airing // for one or two shows — and each was paying `/api/v3/series` in full to get it. On a // household with a few hundred followed shows that is a large response Sonarr assembles // from its own database, and it sat in front of things a viewer is waiting on: adding a // show to My Shows, which fetches it *after* the write, and the season-finale lookup on a // detail page. This is where the second or two came from. // // It is shared rather than per user — Sonarr's catalogue belongs to the household, not to // whoever asked — and it takes MEMBY_SONARR_TTL, the same five minutes the calendar rows // take. Short enough that following a show in Sonarr shows up on the next visit, long // enough that a viewer working through My Shows pays for it once. // // Every failure degrades to asking Sonarr directly: a cache that is down must cost latency, // never the answer. func (s *Server) sonarrSeriesCatalogue(ctx context.Context) ([]sonarr.Series, error) { if !s.sonarrEnabled(ctx) { return nil, fmt.Errorf("sonarr: not configured") } if series := s.cachedSonarrSeries(ctx); series != nil { return series, nil } // The same kind of shared lock the calendar takes, for the same reason: several // televisions opening together must not each stampede Sonarr on the one miss. Its own // mutex rather than sonarrMu, so an add to My Shows never waits behind a launcher // rebuilding the schedule row. s.sonarrSeriesMu.Lock() defer s.sonarrSeriesMu.Unlock() if series := s.cachedSonarrSeries(ctx); series != nil { return series, nil } series, err := s.sonarr.Series(ctx) if err != nil { return nil, err } if body, marshalErr := json.Marshal(series); marshalErr == nil { if cacheErr := s.cache.Set(ctx, sonarrSeriesCacheKey, body, s.cfg.SonarrTTL); cacheErr != nil { s.loggerFor(ctx).Warn("sonarr series cache write failed", "error", cacheErr) } } return series, nil } func (s *Server) cachedSonarrSeries(ctx context.Context) []sonarr.Series { raw, err := s.cache.Get(ctx, sonarrSeriesCacheKey) if err != nil { return nil } var series []sonarr.Series if json.Unmarshal(raw, &series) != nil { return nil } return series } type prerollScheduleResponse struct { Today []prerollScheduleEntry `json:"today"` ThisWeek []prerollScheduleEntry `json:"thisWeek"` } type prerollScheduleEntry struct { ItemID string `json:"itemId"` ImageType string `json:"imageType,omitempty"` Series string `json:"series"` Episode string `json:"episode"` EpisodeCode string `json:"episodeCode"` Schedule string `json:"schedule"` Availability string `json:"availability,omitempty"` } func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.Session) { if !s.sonarrEnabled(r.Context()) { writeJSON(w, http.StatusOK, emptyPrerollSchedule()) return } schedule, err := s.sonarrPrerollSchedule(r.Context()) if err != nil { // Pre-roll is decorative and must never become a playback dependency. s.loggerFor(r.Context()).Warn("preroll schedule unavailable", "error", err) writeJSON(w, http.StatusOK, emptyPrerollSchedule()) return } writeJSON(w, http.StatusOK, schedule) } func (s *Server) sonarrPrerollSchedule(ctx context.Context) (prerollScheduleResponse, error) { location := s.cfg.SonarrLocation if location == nil { location = time.Local } now := time.Now().In(location) dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) key := sonarrPrerollCachePrefix + dayStart.Format("2006-01-02") if raw, err := s.cache.Get(ctx, key); err == nil { var cached prerollScheduleResponse if json.Unmarshal(raw, &cached) == nil { return normalizePrerollSchedule(cached), nil } } s.sonarrMu.Lock() defer s.sonarrMu.Unlock() if raw, err := s.cache.Get(ctx, key); err == nil { var cached prerollScheduleResponse if json.Unmarshal(raw, &cached) == nil { return normalizePrerollSchedule(cached), nil } } episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, 7)) if err != nil { return emptyPrerollSchedule(), err } schedule := buildPrerollSchedule(episodes, now, location) if raw, err := json.Marshal(schedule); err == nil { if cacheErr := s.cache.Set(ctx, key, raw, s.cfg.SonarrTTL); cacheErr != nil { s.loggerFor(ctx).Warn("preroll schedule cache write failed", "error", cacheErr) } } return schedule, nil } func buildPrerollSchedule( episodes []sonarr.Episode, now time.Time, location *time.Location, ) prerollScheduleResponse { 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) }) todayEnd := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1) result := emptyPrerollSchedule() for _, episode := range episodes { if episode.AirDateUTC == nil { continue } airTime := episode.AirDateUTC.In(location) seriesID := episode.Series.ID if seriesID == 0 { seriesID = episode.SeriesID } entry := prerollScheduleEntry{ ItemID: fmt.Sprintf("sonarr:%d:%d", seriesID, episode.ID), Series: episode.Series.Title, Episode: episode.Title, EpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber), Availability: prerollAvailability(episode), } if hasCover(episode.Series.Images, "fanart") { entry.ImageType = "backdrop" } else if hasCover(episode.Series.Images, "poster") { entry.ImageType = "primary" } if airTime.Before(todayEnd) { entry.Schedule = airTime.Format("3:04 PM") if len(result.Today) < 4 { result.Today = append(result.Today, entry) } } else { entry.Schedule = airTime.Format("Monday · 3:04 PM") if len(result.ThisWeek) < 6 { result.ThisWeek = append(result.ThisWeek, entry) } } } return result } func prerollAvailability(episode sonarr.Episode) string { switch { case episode.HasFile: return "Downloaded" case episode.Grabbed: return "Downloading" default: return "" } } func emptyPrerollSchedule() prerollScheduleResponse { return prerollScheduleResponse{ Today: []prerollScheduleEntry{}, ThisWeek: []prerollScheduleEntry{}, } } func normalizePrerollSchedule(value prerollScheduleResponse) prerollScheduleResponse { if value.Today == nil { value.Today = []prerollScheduleEntry{} } if value.ThisWeek == nil { value.ThisWeek = []prerollScheduleEntry{} } return value } // sonarrScheduleItem deliberately looks enough like an Emby item to reuse the fast home // card renderer, while its Memby fields mark it as informational and non-playable. type sonarrScheduleItem 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"` MembyEpisodeTitle string `json:"MembyEpisodeTitle"` MembyEpisodeCode string `json:"MembyEpisodeCode"` MembyAirsAt string `json:"MembyAirsAt,omitempty"` MembyAddedAt string `json:"MembyAddedAt,omitempty"` MembyAirDayLabel string `json:"MembyAirDayLabel"` MembyAirLabel string `json:"MembyAirLabel"` MembyAvailability string `json:"MembyAvailability"` MembyAvailabilityText string `json:"MembyAvailabilityText"` MembyEpisodeEvent string `json:"MembyEpisodeEvent,omitempty"` // Sonarr's lifecycle for the *show*, distinct from this episode's availability: one // says whether more episodes are coming at all, the other whether this one is here yet. MembyLifecycle string `json:"MembyLifecycle,omitempty"` MembyLifecycleText string `json:"MembyLifecycleText,omitempty"` MembyPlayable bool `json:"MembyPlayable"` // The Emby series this episode belongs to, when the library holds it. It is what lets // the card open the show's own page; a show Sonarr follows but Emby has never imported // simply carries none, and the card stays informational as it always was. MembySeriesItemID string `json:"MembySeriesItemId,omitempty"` ParentLogoItemID string `json:"ParentLogoItemId,omitempty"` ParentLogoImageTag string `json:"ParentLogoImageTag,omitempty"` } // seriesIndex resolves a Sonarr series title and year onto an Emby item id. It is a map // rather than a store call per episode: one row can carry a dozen episodes of the same // show, and the answer is the same for all of them. type seriesReference struct { ID string LogoTag string } type seriesIndex map[string]seriesReference // embySeriesIndex builds the lookup for one row build. A failure is not fatal — the row // is about what is *about* to air, and losing the link only costs the card its page. func (s *Server) embySeriesIndex(ctx context.Context) seriesIndex { if s.store == nil { return nil } refs, err := s.store.SeriesRefs(ctx) if err != nil { s.loggerFor(ctx).Warn("emby series index unavailable for schedule row", "error", err) return nil } index := make(seriesIndex, len(refs)*2) for _, ref := range refs { key := normalizedShowTitle(ref.Name) if key == "" { continue } // The year-qualified key is written first and never overwritten, so a remake // cannot claim the original's page when both are in the library. value := seriesReference{ID: ref.ID, LogoTag: ref.LogoTag} if ref.Year > 0 { if _, seen := index[seriesIndexKey(key, ref.Year)]; !seen { index[seriesIndexKey(key, ref.Year)] = value } } if _, seen := index[key]; !seen { index[key] = value } } return index } func seriesIndexKey(normalizedTitle string, year int) string { return fmt.Sprintf("%s|%d", normalizedTitle, year) } // lookup prefers the title/year pair and falls back to the title alone, because Sonarr // and Emby disagree about a show's year more often than they disagree about its name. func (index seriesIndex) lookup(title string, year int) string { key := normalizedShowTitle(title) if key == "" || len(index) == 0 { return "" } if year > 0 { if ref, ok := index[seriesIndexKey(key, year)]; ok { return ref.ID } } return index[key].ID } func (index seriesIndex) logo(title string, year int) (string, string) { key := normalizedShowTitle(title) if key == "" || len(index) == 0 { return "", "" } ref := index[key] if year > 0 { if qualified, ok := index[seriesIndexKey(key, year)]; ok { ref = qualified } } if ref.ID == "" || ref.LogoTag == "" { return "", "" } return ref.ID, ref.LogoTag } func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) { if !s.sonarrEnabled(ctx) { return nil, nil } location := s.cfg.SonarrLocation if location == nil { location = time.Local } now := time.Now().In(location) dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) cacheKey := sonarrCalendarCachePrefix + dayStart.Format("2006-01-02") if row := s.cachedSonarrRow(ctx, cacheKey); row != nil { return row, nil } // A shared lock prevents several users opening the app together from stampeding // Sonarr on the one cache miss each day. s.sonarrMu.Lock() defer s.sonarrMu.Unlock() if row := s.cachedSonarrRow(ctx, cacheKey); row != nil { return row, nil } episodes, err := s.sonarr.Calendar( ctx, dayStart, dayStart.AddDate(0, 0, sonarrScheduleDays), ) if err != nil { return nil, err } row, err := buildSonarrRow(episodes, now, location, s.embySeriesIndex(ctx)) if err != nil { return nil, err } body, err := json.Marshal(row) if err == nil { if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.SonarrTTL); cacheErr != nil { s.loggerFor(ctx).Warn("sonarr calendar cache write failed", "error", cacheErr) } } return row, nil } func (s *Server) cachedSonarrRow(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 buildSonarrRow( episodes []sonarr.Episode, now time.Time, location *time.Location, series seriesIndex, ) (*recommend.Row, error) { 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) }) items := make([]json.RawMessage, 0, len(episodes)) dayStart := localDayStart(now, location) windowEnd := dayStart.AddDate(0, 0, sonarrScheduleDays) for _, episode := range episodes { if episode.AirDateUTC == nil || !scheduleShowFollowed(episode) { continue } localAirTime := episode.AirDateUTC.In(location) if localAirTime.Before(dayStart) || !localAirTime.Before(windowEnd) { continue } item := toSonarrScheduleItem(episode, now, location, series) raw, err := json.Marshal(item) if err != nil { return nil, err } items = append(items, raw) } return &recommend.Row{ ID: "sonarr-airing-today", Title: "Shows airing in the next 5 days", Kind: "schedule", Items: items, }, nil } // scheduleShowFollowed answers whether a show is one the household is actually waiting // for. Sonarr's calendar is asked with unmonitored=true — the calendar page and the aired // banners both want the whole picture — but the launcher row answers "what is coming", // and an unmonitored show is never coming. Absence of a series record is not evidence of // anything, so an episode carrying no expanded series is kept rather than dropped. func scheduleShowFollowed(episode sonarr.Episode) bool { if episode.Series.ID <= 0 { return true } return episode.Series.Monitored } func toSonarrScheduleItem( episode sonarr.Episode, now time.Time, location *time.Location, series seriesIndex, ) sonarrScheduleItem { seriesID := episode.SeriesID if episode.Series.ID > 0 { seriesID = episode.Series.ID } item := sonarrScheduleItem{ ID: fmt.Sprintf("sonarr:%d:%d", seriesID, episode.ID), Name: episode.Series.Title, Type: "MembySonarrEpisode", Overview: episode.Overview, ProductionYear: episode.Series.Year, RunTimeTicks: int64(episode.Runtime) * 600_000_000, Genres: nonNilStrings(episode.Series.Genres), ImageTags: map[string]string{}, BackdropImageTags: []string{}, MembySource: "sonarr", MembyEpisodeTitle: episode.Title, MembyEpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber), MembyPlayable: false, } item.MembySeriesItemID = series.lookup(episode.Series.Title, episode.Series.Year) item.ParentLogoItemID, item.ParentLogoImageTag = series.logo( episode.Series.Title, episode.Series.Year, ) item.MembyEpisodeEvent = scheduleEpisodeEvent(episode) lifecycle := seriesLifecycleTag(episode.Series.Status) item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label if hasCover(episode.Series.Images, "poster") { item.ImageTags["Primary"] = "sonarr" } if hasCover(episode.Series.Images, "fanart") { item.BackdropImageTags = []string{"sonarr"} } if episode.AirDateUTC != nil { airTime := episode.AirDateUTC.In(location) item.MembyAirsAt = airTime.Format(time.RFC3339) item.MembyAirDayLabel = scheduleAirDayLabel(airTime, now, location) item.MembyAirLabel = scheduleAirLabel(airTime, now, location) } else { item.MembyAirDayLabel = "Upcoming" item.MembyAirLabel = "Coming up" } addedAt := episodeFileAddedAt(episode) switch { case episode.HasFile: item.MembyAvailability = "available" item.MembyAvailabilityText = "Downloaded" if addedAt != nil { localAdded := addedAt.In(location) item.MembyAddedAt = localAdded.Format(time.RFC3339) item.MembyAvailabilityText = "Added at " + localAdded.Format("3:04 PM") } case episode.Grabbed: item.MembyAvailability = "downloading" item.MembyAvailabilityText = "Downloading" case !episode.Monitored: item.MembyAvailability = "unmonitored" item.MembyAvailabilityText = "Not monitored" case episode.AirDateUTC != nil && episode.AirDateUTC.Before(now): item.MembyAvailability = "awaiting" item.MembyAvailabilityText = "Awaiting download" default: item.MembyAvailability = "upcoming" item.MembyAvailabilityText = "Upcoming" } return item } func scheduleEpisodeEvent(episode sonarr.Episode) string { if episode.SeasonNumber <= 0 { return "" } switch strings.ToLower(strings.TrimSpace(episode.FinaleType)) { case "series", "seriesfinale": return "Series finale" case "season", "seasonfinale": return "Season finale" case "midseason", "midseasonfinale": return "Mid-season finale" } if episode.EpisodeNumber == 1 { return "Season premiere" } return "" } func scheduleAirDayLabel(airTime, now time.Time, location *time.Location) string { airTime = airTime.In(location) now = now.In(location) today := localDayStart(now, location) airDay := localDayStart(airTime, location) switch { case airDay.Equal(today): return "Today" case airDay.Equal(today.AddDate(0, 0, 1)): return "Tomorrow" default: return airTime.Format("Monday") } } func scheduleAirLabel(airTime, now time.Time, location *time.Location) string { airTime = airTime.In(location) now = now.In(location) clock := airTime.Format("3:04 PM") today := localDayStart(now, location) airDay := localDayStart(airTime, location) switch { case airDay.Equal(today) && !airTime.After(now): return "Aired today at " + clock case airDay.Equal(today): remaining := airTime.Sub(now) if remaining < time.Hour { minutes := int((remaining + time.Minute - 1) / time.Minute) return fmt.Sprintf("In %d minutes (%s)", minutes, clock) } hours := int((remaining + time.Hour - 1) / time.Hour) return fmt.Sprintf("In %d hours (%s)", hours, clock) case airDay.Equal(today.AddDate(0, 0, 1)): return "Tomorrow: " + clock default: return airTime.Format("Monday") + ": " + clock } } // 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) } func episodeFileAddedAt(episode sonarr.Episode) *time.Time { if episode.EpisodeFile == nil { return nil } return episode.EpisodeFile.DateAdded } func hasCover(images []sonarr.Image, coverType string) bool { for _, image := range images { if strings.EqualFold(image.CoverType, coverType) { return true } } return false } func nonNilStrings(values []string) []string { if values == nil { return []string{} } return values }