2026-07-29 15:26:40 +12:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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:"
|
2026-08-02 22:10:19 +12:00
|
|
|
const sonarrPrerollCachePrefix = "sonarr:preroll:v2:"
|
|
|
|
|
const sonarrScheduleDays = 5
|
2026-07-29 15:26:40 +12:00
|
|
|
|
|
|
|
|
type prerollScheduleResponse struct {
|
|
|
|
|
Today []prerollScheduleEntry `json:"today"`
|
|
|
|
|
ThisWeek []prerollScheduleEntry `json:"thisWeek"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type prerollScheduleEntry struct {
|
2026-08-02 22:10:19 +12:00
|
|
|
ItemID string `json:"itemId"`
|
|
|
|
|
ImageType string `json:"imageType,omitempty"`
|
2026-07-29 15:26:40 +12:00
|
|
|
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.sonarr == nil {
|
|
|
|
|
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.
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Warn("preroll schedule unavailable", "error", err)
|
2026-07-29 15:26:40 +12:00
|
|
|
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 {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("preroll schedule cache write failed", "error", cacheErr)
|
2026-07-29 15:26:40 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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)
|
2026-08-02 22:10:19 +12:00
|
|
|
seriesID := episode.Series.ID
|
|
|
|
|
if seriesID == 0 {
|
|
|
|
|
seriesID = episode.SeriesID
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
entry := prerollScheduleEntry{
|
2026-08-02 22:10:19 +12:00
|
|
|
ItemID: fmt.Sprintf("sonarr:%d:%d", seriesID, episode.ID),
|
2026-07-29 15:26:40 +12:00
|
|
|
Series: episode.Series.Title,
|
|
|
|
|
Episode: episode.Title,
|
|
|
|
|
EpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
|
|
|
|
|
Availability: prerollAvailability(episode),
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
if hasCover(episode.Series.Images, "fanart") {
|
|
|
|
|
entry.ImageType = "backdrop"
|
|
|
|
|
} else if hasCover(episode.Series.Images, "poster") {
|
|
|
|
|
entry.ImageType = "primary"
|
|
|
|
|
}
|
2026-07-29 15:26:40 +12:00
|
|
|
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"`
|
2026-08-02 22:10:19 +12:00
|
|
|
MembyAirDayLabel string `json:"MembyAirDayLabel"`
|
2026-07-29 15:26:40 +12:00
|
|
|
MembyAirLabel string `json:"MembyAirLabel"`
|
|
|
|
|
MembyAvailability string `json:"MembyAvailability"`
|
|
|
|
|
MembyAvailabilityText string `json:"MembyAvailabilityText"`
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 seriesIndex map[string]string
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
if ref.Year > 0 {
|
|
|
|
|
if _, seen := index[seriesIndexKey(key, ref.Year)]; !seen {
|
|
|
|
|
index[seriesIndexKey(key, ref.Year)] = ref.ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, seen := index[key]; !seen {
|
|
|
|
|
index[key] = ref.ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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 id, ok := index[seriesIndexKey(key, year)]; ok {
|
|
|
|
|
return id
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return index[key]
|
2026-07-29 15:26:40 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
|
|
|
|
|
if s.sonarr == nil {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
episodes, err := s.sonarr.Calendar(
|
|
|
|
|
ctx,
|
|
|
|
|
dayStart,
|
|
|
|
|
dayStart.AddDate(0, 0, sonarrScheduleDays),
|
|
|
|
|
)
|
2026-07-29 15:26:40 +12:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
row, err := buildSonarrRow(episodes, now, location, s.embySeriesIndex(ctx))
|
2026-07-29 15:26:40 +12:00
|
|
|
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 {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("sonarr calendar cache write failed", "error", cacheErr)
|
2026-07-29 15:26:40 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
func buildSonarrRow(
|
|
|
|
|
episodes []sonarr.Episode,
|
|
|
|
|
now time.Time,
|
|
|
|
|
location *time.Location,
|
|
|
|
|
series seriesIndex,
|
|
|
|
|
) (*recommend.Row, error) {
|
2026-07-29 15:26:40 +12:00
|
|
|
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))
|
2026-08-02 22:10:19 +12:00
|
|
|
dayStart := localDayStart(now, location)
|
|
|
|
|
windowEnd := dayStart.AddDate(0, 0, sonarrScheduleDays)
|
2026-07-29 15:26:40 +12:00
|
|
|
for _, episode := range episodes {
|
2026-08-02 22:10:19 +12:00
|
|
|
if episode.AirDateUTC == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
localAirTime := episode.AirDateUTC.In(location)
|
|
|
|
|
if localAirTime.Before(dayStart) || !localAirTime.Before(windowEnd) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
item := toSonarrScheduleItem(episode, now, location, series)
|
2026-07-29 15:26:40 +12:00
|
|
|
raw, err := json.Marshal(item)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
items = append(items, raw)
|
|
|
|
|
}
|
|
|
|
|
return &recommend.Row{
|
|
|
|
|
ID: "sonarr-airing-today",
|
2026-08-02 22:10:19 +12:00
|
|
|
Title: "Shows airing in the next 5 days",
|
2026-07-29 15:26:40 +12:00
|
|
|
Kind: "schedule",
|
|
|
|
|
Items: items,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
func toSonarrScheduleItem(
|
|
|
|
|
episode sonarr.Episode,
|
|
|
|
|
now time.Time,
|
|
|
|
|
location *time.Location,
|
|
|
|
|
series seriesIndex,
|
|
|
|
|
) sonarrScheduleItem {
|
2026-07-29 15:26:40 +12:00
|
|
|
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,
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
item.MembySeriesItemID = series.lookup(episode.Series.Title, episode.Series.Year)
|
|
|
|
|
lifecycle := seriesLifecycleTag(episode.Series.Status)
|
|
|
|
|
item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label
|
2026-07-29 15:26:40 +12:00
|
|
|
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)
|
2026-08-02 22:10:19 +12:00
|
|
|
item.MembyAirDayLabel = scheduleAirDayLabel(airTime, now, location)
|
|
|
|
|
item.MembyAirLabel = scheduleAirLabel(airTime, now, location)
|
2026-07-29 15:26:40 +12:00
|
|
|
} else {
|
2026-08-02 22:10:19 +12:00
|
|
|
item.MembyAirDayLabel = "Upcoming"
|
|
|
|
|
item.MembyAirLabel = "Coming up"
|
2026-07-29 15:26:40 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
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
|
|
|
|
|
}
|