0.2.48 - Tv calender fixes
This commit is contained in:
@@ -152,6 +152,7 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/recommendations/preferences", s.authed(s.handleRecommendationPreferences))
|
||||
v1.Handle("GET /v1/for-you", s.authed(s.handleForYou))
|
||||
v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll))
|
||||
v1.Handle("GET /v1/calendar", s.authed(s.handleCalendar))
|
||||
v1.Handle("GET /v1/my-shows", s.authed(s.handleMyShows))
|
||||
v1.Handle("POST /v1/my-shows", s.authed(s.handleMyShows))
|
||||
v1.Handle("DELETE /v1/my-shows/{id}", s.authed(s.handleMyShow))
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The TV calendar is the schedule row's other shape: the same Sonarr episodes, laid out a
|
||||
// month at a time rather than as the next five days. It shares the row's item builder on
|
||||
// purpose — a card is a card, and the availability badges, lifecycle tags and Emby series
|
||||
// link have to mean the same thing on both screens or the calendar becomes a second,
|
||||
// slightly different account of the same week.
|
||||
//
|
||||
// Bump the prefix when the item contract changes, so an older layout cannot survive a
|
||||
// deployment until the cached months expire.
|
||||
const calendarCachePrefix = "sonarr:calendar:month:v2:"
|
||||
|
||||
// How far either side of the present a viewer may travel. Sonarr answers for any date it
|
||||
// has been asked about, and a held D-pad on the month header would otherwise walk it into
|
||||
// the 2050s one request at a time. A year each way covers "when does the new season start"
|
||||
// and "what did we miss in March" and stops there.
|
||||
const calendarMonthRange = 12
|
||||
|
||||
type calendarResponse struct {
|
||||
// Available is false when the household runs no Sonarr. The page says so rather than
|
||||
// drawing an empty grid that looks like a month in which nothing airs.
|
||||
Available bool `json:"available"`
|
||||
// Month is the machine key ("2026-08"); Label is what the header prints.
|
||||
Month string `json:"month"`
|
||||
Label string `json:"label"`
|
||||
// Empty when the month is at the end of the travelable range, which is what makes the
|
||||
// header's arrows disappear rather than fail.
|
||||
Previous string `json:"previous,omitempty"`
|
||||
Next string `json:"next,omitempty"`
|
||||
// The household's today, sent only when it falls inside this month — the client has no
|
||||
// business deciding which cell to ring from a television's own clock when the gateway
|
||||
// already knows the household's zone.
|
||||
Today string `json:"today,omitempty"`
|
||||
// The grid's shape. Sending it rather than a date per cell keeps civil-calendar
|
||||
// arithmetic in one place: the television draws DayCount cells after FirstWeekday
|
||||
// blanks and never has to know which years are leap years.
|
||||
FirstWeekday int `json:"firstWeekday"`
|
||||
DayCount int `json:"dayCount"`
|
||||
Days []calendarDay `json:"days"`
|
||||
}
|
||||
|
||||
// calendarDay carries only the days that have something on them. A month of empty cells is
|
||||
// the client's to draw, and sending thirty-one mostly-empty objects would triple the
|
||||
// response for nothing.
|
||||
type calendarDay struct {
|
||||
Date string `json:"date"`
|
||||
Day int `json:"day"`
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||||
ctx := r.Context()
|
||||
if s.sonarr == nil || !s.featureEnabled(ctx, featureTVCalendar) {
|
||||
writeJSON(w, http.StatusOK, emptyCalendar())
|
||||
return
|
||||
}
|
||||
location := s.sonarrLocation()
|
||||
now := time.Now().In(location)
|
||||
month, ok := parseCalendarMonth(r.URL.Query().Get("month"), now, location)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid month")
|
||||
return
|
||||
}
|
||||
calendar, err := s.sonarrCalendarMonth(ctx, month, now, location)
|
||||
if err != nil {
|
||||
// A month nobody can fetch is reported as an empty month rather than as a failure:
|
||||
// the page is informational, and a viewer pressing Right past a Sonarr hiccup
|
||||
// should land on a quiet month and be able to press Left back out of it.
|
||||
s.loggerFor(ctx).Warn("calendar month unavailable",
|
||||
"month", month.Format("2006-01"), "error", err)
|
||||
calendar = buildCalendarMonth(nil, month, now, location, nil)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, calendar)
|
||||
}
|
||||
|
||||
func (s *Server) sonarrLocation() *time.Location {
|
||||
if s.cfg.SonarrLocation != nil {
|
||||
return s.cfg.SonarrLocation
|
||||
}
|
||||
return time.Local
|
||||
}
|
||||
|
||||
func (s *Server) sonarrCalendarMonth(
|
||||
ctx context.Context,
|
||||
month, now time.Time,
|
||||
location *time.Location,
|
||||
) (calendarResponse, error) {
|
||||
key := calendarCachePrefix + month.Format("2006-01")
|
||||
if cached := s.cachedCalendar(ctx, key); cached != nil {
|
||||
return *cached, nil
|
||||
}
|
||||
|
||||
// The same shared lock the schedule row takes, for the same reason: several televisions
|
||||
// opening the calendar together must not each stampede Sonarr on the one miss.
|
||||
s.sonarrMu.Lock()
|
||||
defer s.sonarrMu.Unlock()
|
||||
if cached := s.cachedCalendar(ctx, key); cached != nil {
|
||||
return *cached, nil
|
||||
}
|
||||
|
||||
episodes, err := s.sonarr.Calendar(ctx, month, month.AddDate(0, 1, 0))
|
||||
if err != nil {
|
||||
return calendarResponse{}, err
|
||||
}
|
||||
calendar := buildCalendarMonth(episodes, month, now, location, s.embySeriesIndex(ctx))
|
||||
if body, marshalErr := json.Marshal(calendar); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("calendar cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return calendar, nil
|
||||
}
|
||||
|
||||
// cachedCalendar refuses an entry whose Today no longer agrees with the household's, so a
|
||||
// month cached before midnight cannot leave the ring on yesterday. Everything else in the
|
||||
// response is fixed for the month and safely reusable.
|
||||
func (s *Server) cachedCalendar(ctx context.Context, key string) *calendarResponse {
|
||||
raw, err := s.cache.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cached calendarResponse
|
||||
if json.Unmarshal(raw, &cached) != nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().In(s.sonarrLocation())
|
||||
if cached.Today != "" && cached.Today != now.Format("2006-01-02") {
|
||||
return nil
|
||||
}
|
||||
return &cached
|
||||
}
|
||||
|
||||
// parseCalendarMonth turns the client's `month` parameter into the first instant of that
|
||||
// month in the household's zone. An empty parameter is the present month, which is what a
|
||||
// television asks for when it opens the page and the only request an older client makes.
|
||||
//
|
||||
// Out-of-range months are refused rather than clamped: a clamp would answer a request for
|
||||
// 2031 with this year's August and the header would then disagree with the grid.
|
||||
func parseCalendarMonth(value string, now time.Time, location *time.Location) (time.Time, bool) {
|
||||
now = now.In(location)
|
||||
current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location)
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return current, true
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01", trimmed, location)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
month := time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location)
|
||||
if month.Before(current.AddDate(0, -calendarMonthRange, 0)) ||
|
||||
month.After(current.AddDate(0, calendarMonthRange, 0)) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return month, true
|
||||
}
|
||||
|
||||
// buildCalendarMonth is the whole layout rule, pure so the edges — a month starting on a
|
||||
// Sunday, a February, the day either side of the travel limit — are testable without a
|
||||
// Sonarr to hand.
|
||||
func buildCalendarMonth(
|
||||
episodes []sonarr.Episode,
|
||||
month, now time.Time,
|
||||
location *time.Location,
|
||||
series seriesIndex,
|
||||
) calendarResponse {
|
||||
month = month.In(location)
|
||||
monthEnd := month.AddDate(0, 1, 0)
|
||||
current := time.Date(now.In(location).Year(), now.In(location).Month(), 1, 0, 0, 0, 0, location)
|
||||
|
||||
calendar := calendarResponse{
|
||||
Available: true,
|
||||
Month: month.Format("2006-01"),
|
||||
Label: month.Format("January 2006"),
|
||||
// Sunday is 0, matching the weekday header the grid draws.
|
||||
FirstWeekday: int(month.Weekday()),
|
||||
DayCount: int(monthEnd.Sub(month).Hours() / 24),
|
||||
Days: []calendarDay{},
|
||||
}
|
||||
if previous := month.AddDate(0, -1, 0); !previous.Before(current.AddDate(0, -calendarMonthRange, 0)) {
|
||||
calendar.Previous = previous.Format("2006-01")
|
||||
}
|
||||
if next := month.AddDate(0, 1, 0); !next.After(current.AddDate(0, calendarMonthRange, 0)) {
|
||||
calendar.Next = next.Format("2006-01")
|
||||
}
|
||||
if today := now.In(location); !today.Before(month) && today.Before(monthEnd) {
|
||||
calendar.Today = today.Format("2006-01-02")
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// A map plus one ordered slice, rather than a slice searched per episode: a busy month
|
||||
// is a few hundred episodes and the days they land on are already in order.
|
||||
byDate := make(map[string]int, 31)
|
||||
for _, episode := range episodes {
|
||||
if episode.AirDateUTC == nil {
|
||||
continue
|
||||
}
|
||||
// Sonarr is asked in the household's zone but answers in UTC, so an episode airing
|
||||
// late on the 31st elsewhere can fall outside this month once converted back.
|
||||
airTime := episode.AirDateUTC.In(location)
|
||||
if airTime.Before(month) || !airTime.Before(monthEnd) {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(toSonarrScheduleItem(episode, now, location, series))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
date := airTime.Format("2006-01-02")
|
||||
index, seen := byDate[date]
|
||||
if !seen {
|
||||
calendar.Days = append(calendar.Days, calendarDay{
|
||||
Date: date,
|
||||
Day: airTime.Day(),
|
||||
Items: []json.RawMessage{},
|
||||
})
|
||||
index = len(calendar.Days) - 1
|
||||
byDate[date] = index
|
||||
}
|
||||
calendar.Days[index].Items = append(calendar.Days[index].Items, raw)
|
||||
}
|
||||
return calendar
|
||||
}
|
||||
|
||||
func emptyCalendar() calendarResponse {
|
||||
return calendarResponse{Days: []calendarDay{}}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
)
|
||||
|
||||
func calendarEpisode(id int, air time.Time, title string) sonarr.Episode {
|
||||
utc := air.UTC()
|
||||
return sonarr.Episode{
|
||||
ID: id,
|
||||
SeriesID: id,
|
||||
SeasonNumber: 1,
|
||||
EpisodeNumber: id,
|
||||
Title: title,
|
||||
AirDateUTC: &utc,
|
||||
Monitored: true,
|
||||
Series: sonarr.Series{ID: id, Title: title},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarMonthDescribesTheGrid(t *testing.T) {
|
||||
location := time.UTC
|
||||
// August 2026 begins on a Saturday and runs 31 days.
|
||||
month := time.Date(2026, 8, 1, 0, 0, 0, 0, location)
|
||||
now := time.Date(2026, 8, 11, 9, 0, 0, 0, location)
|
||||
|
||||
calendar := buildCalendarMonth(nil, month, now, location, nil)
|
||||
|
||||
if !calendar.Available {
|
||||
t.Fatal("a configured Sonarr must report the month as available")
|
||||
}
|
||||
if calendar.Month != "2026-08" || calendar.Label != "August 2026" {
|
||||
t.Fatalf("unexpected identity: %+v", calendar)
|
||||
}
|
||||
if calendar.FirstWeekday != int(time.Saturday) || calendar.DayCount != 31 {
|
||||
t.Fatalf("unexpected grid shape: %+v", calendar)
|
||||
}
|
||||
if calendar.Previous != "2026-07" || calendar.Next != "2026-09" {
|
||||
t.Fatalf("unexpected neighbours: %+v", calendar)
|
||||
}
|
||||
if calendar.Today != "2026-08-11" {
|
||||
t.Fatalf("today was not marked: %+v", calendar)
|
||||
}
|
||||
if calendar.Days == nil {
|
||||
t.Fatal("days must encode as an array rather than null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarMonthCountsFebruaryInALeapYear(t *testing.T) {
|
||||
location := time.UTC
|
||||
now := time.Date(2028, 2, 1, 0, 0, 0, 0, location)
|
||||
calendar := buildCalendarMonth(nil, now, now, location, nil)
|
||||
if calendar.DayCount != 29 {
|
||||
t.Fatalf("expected 29 days, got %d", calendar.DayCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarMonthOmitsTodayOutsideTheMonth(t *testing.T) {
|
||||
location := time.UTC
|
||||
month := time.Date(2026, 10, 1, 0, 0, 0, 0, location)
|
||||
now := time.Date(2026, 8, 11, 9, 0, 0, 0, location)
|
||||
if today := buildCalendarMonth(nil, month, now, location, nil).Today; today != "" {
|
||||
t.Fatalf("a month the household is not in must carry no today: %q", today)
|
||||
}
|
||||
}
|
||||
|
||||
// The arrows are what stop a held D-pad walking Sonarr into the next decade, so the edge of
|
||||
// the range must be reported as having no neighbour rather than as one more request.
|
||||
func TestBuildCalendarMonthStopsAtTheTravelLimit(t *testing.T) {
|
||||
location := time.UTC
|
||||
now := time.Date(2026, 8, 11, 9, 0, 0, 0, location)
|
||||
last := time.Date(2027, 8, 1, 0, 0, 0, 0, location)
|
||||
if next := buildCalendarMonth(nil, last, now, location, nil).Next; next != "" {
|
||||
t.Fatalf("expected no next month at the limit, got %q", next)
|
||||
}
|
||||
first := time.Date(2025, 8, 1, 0, 0, 0, 0, location)
|
||||
if previous := buildCalendarMonth(nil, first, now, location, nil).Previous; previous != "" {
|
||||
t.Fatalf("expected no previous month at the limit, got %q", previous)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarMonthGroupsEpisodesByLocalDay(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
month := time.Date(2026, 8, 1, 0, 0, 0, 0, location)
|
||||
now := time.Date(2026, 8, 11, 9, 0, 0, 0, location)
|
||||
|
||||
// Two on the 12th local, one on the 13th, and one that is the 31st in UTC but already
|
||||
// September where the household lives.
|
||||
episodes := []sonarr.Episode{
|
||||
calendarEpisode(2, time.Date(2026, 8, 12, 21, 0, 0, 0, location), "Harbour"),
|
||||
calendarEpisode(1, time.Date(2026, 8, 12, 20, 0, 0, 0, location), "Northbound"),
|
||||
calendarEpisode(3, time.Date(2026, 8, 13, 20, 0, 0, 0, location), "Deep Water"),
|
||||
calendarEpisode(4, time.Date(2026, 8, 31, 20, 0, 0, 0, time.UTC), "Rolled Over"),
|
||||
}
|
||||
|
||||
calendar := buildCalendarMonth(episodes, month, now, location, nil)
|
||||
|
||||
if len(calendar.Days) != 2 {
|
||||
t.Fatalf("expected two populated days, got %d: %+v", len(calendar.Days), calendar.Days)
|
||||
}
|
||||
if calendar.Days[0].Date != "2026-08-12" || calendar.Days[0].Day != 12 {
|
||||
t.Fatalf("unexpected first day: %+v", calendar.Days[0])
|
||||
}
|
||||
if len(calendar.Days[0].Items) != 2 {
|
||||
t.Fatalf("expected two episodes on the 12th: %+v", calendar.Days[0])
|
||||
}
|
||||
// Within a day the order is Sonarr's air time, which is the order they will be watched.
|
||||
var first sonarrScheduleItem
|
||||
if err := json.Unmarshal(calendar.Days[0].Items[0], &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Name != "Northbound" {
|
||||
t.Fatalf("episodes within a day must be ordered by air time: %+v", first)
|
||||
}
|
||||
if first.Type != "MembySonarrEpisode" || first.MembyPlayable {
|
||||
t.Fatalf("a calendar card must be the schedule row's informational item: %+v", first)
|
||||
}
|
||||
if calendar.Days[1].Date != "2026-08-13" {
|
||||
t.Fatalf("unexpected second day: %+v", calendar.Days[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCalendarMonthDefaultsToThePresent(t *testing.T) {
|
||||
location := time.UTC
|
||||
now := time.Date(2026, 8, 11, 9, 0, 0, 0, location)
|
||||
month, ok := parseCalendarMonth(" ", now, location)
|
||||
if !ok || month.Format("2006-01") != "2026-08" {
|
||||
t.Fatalf("unexpected default month: %v %v", month, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCalendarMonthRefusesNonsenseAndDistantMonths(t *testing.T) {
|
||||
location := time.UTC
|
||||
now := time.Date(2026, 8, 11, 9, 0, 0, 0, location)
|
||||
for _, value := range []string{"2026-13", "August", "2026-08-11", "2031-01", "2020-01"} {
|
||||
if _, ok := parseCalendarMonth(value, now, location); ok {
|
||||
t.Fatalf("expected %q to be refused", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"2026-09", "2027-08", "2025-08"} {
|
||||
if _, ok := parseCalendarMonth(value, now, location); !ok {
|
||||
t.Fatalf("expected %q to be accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
featureSeasonalThemes = "seasonal_themes"
|
||||
featureSeasonalDecorations = "seasonal_decorations"
|
||||
featureGenreBrowser = "genre_browser"
|
||||
featureTVCalendar = "tv_calendar"
|
||||
)
|
||||
|
||||
type featureDefinition struct {
|
||||
@@ -124,6 +125,13 @@ var featureCatalogue = []featureDefinition{
|
||||
DefaultEnabled: false, MinimumProtocol: 1, Capability: "genre_browser_v1",
|
||||
Recovery: "Takes effect on the next status poll; the browser is hidden when off.",
|
||||
},
|
||||
{
|
||||
Key: featureTVCalendar, Name: "TV calendar", Area: "Presentation",
|
||||
Description: "Show the month-by-month Sonarr calendar on the navigation rail. " +
|
||||
"Turning it off hides the destination and stops the gateway reading months.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
|
||||
Recovery: "Takes effect on the next status poll; the rail entry simply disappears.",
|
||||
},
|
||||
{
|
||||
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
|
||||
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
|
||||
|
||||
@@ -282,11 +282,11 @@ func sonarrPremiereEpisode(
|
||||
// about refusing.
|
||||
func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) {
|
||||
index := seriesIndex{
|
||||
"newshow": "emby-new",
|
||||
"returning": "emby-returning",
|
||||
"notinlibrary": "",
|
||||
"specials": "emby-specials",
|
||||
"undownloaded": "emby-undownloaded",
|
||||
"newshow": {ID: "emby-new"},
|
||||
"returning": {ID: "emby-returning"},
|
||||
"notinlibrary": {},
|
||||
"specials": {ID: "emby-specials"},
|
||||
"undownloaded": {ID: "emby-undownloaded"},
|
||||
}
|
||||
delete(index, "notinlibrary")
|
||||
|
||||
@@ -320,7 +320,7 @@ func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) {
|
||||
// A show that premiered and then returned inside one window is one card about its newer
|
||||
// season, not two cards about the same show.
|
||||
func TestSonarrPremieresKeepsOneCardPerSeries(t *testing.T) {
|
||||
index := seriesIndex{"show": "emby-show"}
|
||||
index := seriesIndex{"show": {ID: "emby-show"}}
|
||||
premieres := sonarrPremieres([]sonarr.Episode{
|
||||
sonarrPremiereEpisode("Show", 1, 1, heroDaysAgo(15), true),
|
||||
sonarrPremiereEpisode("Show", 2, 1, heroDaysAgo(2), true),
|
||||
|
||||
@@ -247,6 +247,7 @@ type sonarrScheduleItem struct {
|
||||
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"`
|
||||
@@ -255,13 +256,20 @@ type sonarrScheduleItem struct {
|
||||
// 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"`
|
||||
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 seriesIndex map[string]string
|
||||
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.
|
||||
@@ -282,13 +290,14 @@ func (s *Server) embySeriesIndex(ctx context.Context) seriesIndex {
|
||||
}
|
||||
// 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)] = ref.ID
|
||||
index[seriesIndexKey(key, ref.Year)] = value
|
||||
}
|
||||
}
|
||||
if _, seen := index[key]; !seen {
|
||||
index[key] = ref.ID
|
||||
index[key] = value
|
||||
}
|
||||
}
|
||||
return index
|
||||
@@ -306,11 +315,28 @@ func (index seriesIndex) lookup(title string, year int) string {
|
||||
return ""
|
||||
}
|
||||
if year > 0 {
|
||||
if id, ok := index[seriesIndexKey(key, year)]; ok {
|
||||
return id
|
||||
if ref, ok := index[seriesIndexKey(key, year)]; ok {
|
||||
return ref.ID
|
||||
}
|
||||
}
|
||||
return index[key]
|
||||
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) {
|
||||
@@ -438,6 +464,10 @@ func toSonarrScheduleItem(
|
||||
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") {
|
||||
@@ -483,6 +513,24 @@ func toSonarrScheduleItem(
|
||||
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)
|
||||
|
||||
@@ -57,9 +57,9 @@ func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
|
||||
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
|
||||
air := time.Date(2026, 7, 27, 20, 0, 0, 0, location)
|
||||
index := seriesIndex{
|
||||
"northbound": "emby-old",
|
||||
"northbound|2024": "emby-2024",
|
||||
"harbour": "emby-harbour",
|
||||
"northbound": {ID: "emby-old"},
|
||||
"northbound|2024": {ID: "emby-2024", LogoTag: "northbound-logo"},
|
||||
"harbour": {ID: "emby-harbour"},
|
||||
}
|
||||
episode := func(title string, year int) sonarr.Episode {
|
||||
utc := air
|
||||
@@ -96,6 +96,42 @@ func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleEpisodeEventNamesPremieresAndFinales(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
episode sonarr.Episode
|
||||
want string
|
||||
}{
|
||||
{"season premiere", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 1}, "Season premiere"},
|
||||
{"season finale", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 8, FinaleType: "seasonFinale"}, "Season finale"},
|
||||
{"series finale", sonarr.Episode{SeasonNumber: 5, EpisodeNumber: 10, FinaleType: "seriesFinale"}, "Series finale"},
|
||||
{"Sonarr season value", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 8, FinaleType: "season"}, "Season finale"},
|
||||
{"mid-season finale", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 5, FinaleType: "midseason"}, "Mid-season finale"},
|
||||
{"ordinary episode", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 4}, ""},
|
||||
{"special", sonarr.Episode{SeasonNumber: 0, EpisodeNumber: 1}, ""},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := scheduleEpisodeEvent(testCase.episode); got != testCase.want {
|
||||
t.Fatalf("event = %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleItemCarriesMatchedEmbyLogo(t *testing.T) {
|
||||
episode := sonarr.Episode{
|
||||
ID: 1, SeriesID: 7, SeasonNumber: 2, EpisodeNumber: 1,
|
||||
Series: sonarr.Series{ID: 7, Title: "Northbound", Year: 2024},
|
||||
}
|
||||
item := toSonarrScheduleItem(episode, time.Now(), time.UTC, seriesIndex{
|
||||
"northbound|2024": {ID: "emby-series", LogoTag: "logo-tag"},
|
||||
})
|
||||
if item.ParentLogoItemID != "emby-series" || item.ParentLogoImageTag != "logo-tag" {
|
||||
t.Fatalf("matched logo was not carried: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 28, 10, 0, 0, 0, location)
|
||||
|
||||
Reference in New Issue
Block a user