Files
memby/server/internal/api/sonarr.go
T

612 lines
20 KiB
Go

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
}
}
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
}