Files
memby/server/internal/api/sonarr_test.go
2026-08-26 21:31:05 +12:00

307 lines
11 KiB
Go

package api
import (
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
air := time.Date(2026, 7, 27, 8, 0, 0, 0, time.UTC)
added := time.Date(2026, 7, 27, 8, 12, 0, 0, time.UTC)
row, err := buildSonarrRow([]sonarr.Episode{{
ID: 42,
SeriesID: 7,
SeasonNumber: 2,
EpisodeNumber: 4,
Title: "The Crossing",
AirDateUTC: &air,
HasFile: true,
Monitored: true,
EpisodeFile: &sonarr.EpisodeFile{DateAdded: &added},
Series: sonarr.Series{
ID: 7,
Title: "Northbound",
Monitored: true,
Images: []sonarr.Image{
{CoverType: "poster"},
{CoverType: "fanart"},
},
},
}}, time.Date(2026, 7, 27, 21, 0, 0, 0, location), location, nil)
if err != nil {
t.Fatal(err)
}
if row.ID != "sonarr-airing-today" || row.Kind != "schedule" || len(row.Items) != 1 {
t.Fatalf("unexpected row: %+v", row)
}
var item sonarrScheduleItem
if err := json.Unmarshal(row.Items[0], &item); err != nil {
t.Fatal(err)
}
if item.ID != "sonarr:7:42" || item.MembyEpisodeCode != "S02E04" {
t.Fatalf("unexpected identity: %+v", item)
}
if item.MembyAvailability != "available" || item.MembyAvailabilityText != "Added at 8:12 PM" {
t.Fatalf("unexpected availability: %+v", item)
}
if item.ImageTags["Primary"] == "" || len(item.BackdropImageTags) != 1 {
t.Fatalf("artwork was not exposed: %+v", item)
}
}
// Sonarr's calendar is asked with unmonitored=true, because the calendar page and the
// aired banners want the whole picture. The launcher row does not: a show nobody follows
// is never coming, so it has no business being announced as airing.
func TestBuildSonarrRowDropsUnmonitoredShows(t *testing.T) {
location := time.UTC
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
air := time.Date(2026, 7, 27, 20, 0, 0, 0, location)
episode := func(id int, series sonarr.Series) sonarr.Episode {
utc := air
return sonarr.Episode{
ID: id, SeriesID: series.ID, SeasonNumber: 1, EpisodeNumber: id,
Title: "Episode", AirDateUTC: &utc, Monitored: true, Series: series,
}
}
row, err := buildSonarrRow([]sonarr.Episode{
episode(1, sonarr.Series{ID: 1, Title: "Followed", Monitored: true}),
episode(2, sonarr.Series{ID: 2, Title: "Dropped"}),
// No expanded series record is no evidence either way, so the card stays.
episode(3, sonarr.Series{}),
}, now, location, nil)
if err != nil {
t.Fatal(err)
}
ids := make([]string, 0, len(row.Items))
for _, raw := range row.Items {
var item sonarrScheduleItem
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatal(err)
}
ids = append(ids, item.ID)
}
if len(ids) != 2 || ids[0] != "sonarr:1:1" || ids[1] != "sonarr:0:3" {
t.Fatalf("unmonitored shows were not dropped: %v", ids)
}
}
func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
location := time.UTC
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": {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
return sonarr.Episode{
ID: 1, SeriesID: 1, SeasonNumber: 1, EpisodeNumber: 1, AirDateUTC: &utc,
Series: sonarr.Series{ID: 1, Title: title, Year: year, Monitored: true},
}
}
cases := map[string]struct {
episode sonarr.Episode
want string
}{
// The year decides between a remake and its original.
"year qualified": {episode("Northbound", 2024), "emby-2024"},
// Sonarr and Emby disagree about a year more often than about a name.
"year mismatch falls back to the title": {episode("Northbound", 1999), "emby-old"},
"punctuation and case are ignored": {episode("Harbour!", 0), "emby-harbour"},
"not in the library": {episode("Unimported", 2026), ""},
}
for name, testCase := range cases {
t.Run(name, func(t *testing.T) {
row, err := buildSonarrRow([]sonarr.Episode{testCase.episode}, now, location, index)
if err != nil {
t.Fatal(err)
}
var item sonarrScheduleItem
if err := json.Unmarshal(row.Items[0], &item); err != nil {
t.Fatal(err)
}
if item.MembySeriesItemID != testCase.want {
t.Fatalf("series id = %q, want %q", item.MembySeriesItemID, testCase.want)
}
})
}
}
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)
today := time.Date(2026, 7, 28, 20, 30, 0, 0, location).UTC()
thursday := time.Date(2026, 7, 30, 19, 0, 0, 0, location).UTC()
schedule := buildPrerollSchedule([]sonarr.Episode{
{
ID: 14, SeriesID: 7, SeasonNumber: 1, EpisodeNumber: 4, Title: "Tonight",
AirDateUTC: &today, Series: sonarr.Series{
ID: 7, Title: "Northbound", Images: []sonarr.Image{{CoverType: "fanart"}},
},
},
{
SeasonNumber: 2, EpisodeNumber: 1, Title: "Later",
AirDateUTC: &thursday, HasFile: true, Series: sonarr.Series{Title: "Harbour"},
},
}, now, location)
if len(schedule.Today) != 1 || schedule.Today[0].Series != "Northbound" ||
schedule.Today[0].Schedule != "8:30 PM" || schedule.Today[0].ItemID != "sonarr:7:14" ||
schedule.Today[0].ImageType != "backdrop" {
t.Fatalf("unexpected today schedule: %+v", schedule.Today)
}
if len(schedule.ThisWeek) != 1 || schedule.ThisWeek[0].EpisodeCode != "S02E01" ||
schedule.ThisWeek[0].Availability != "Downloaded" {
t.Fatalf("unexpected week schedule: %+v", schedule.ThisWeek)
}
}
func TestBuildSonarrRowCoversFiveDaysAndUsesRelativeAirLabels(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 29, 8, 0, 0, 0, location)
episode := func(id int, air time.Time) sonarr.Episode {
utc := air.UTC()
return sonarr.Episode{
ID: id, SeriesID: id, SeasonNumber: 1, EpisodeNumber: id,
Title: "Episode", AirDateUTC: &utc,
Series: sonarr.Series{ID: id, Title: "Show", Monitored: true},
}
}
row, err := buildSonarrRow([]sonarr.Episode{
// Sonarr's response order is not part of its contract. Deliberately put the
// later episode first to ensure the row is authored chronologically.
episode(3, time.Date(2026, 8, 2, 16, 0, 0, 0, location)),
episode(2, time.Date(2026, 7, 30, 16, 0, 0, 0, location)),
episode(1, time.Date(2026, 7, 29, 16, 0, 0, 0, location)),
episode(4, time.Date(2026, 8, 3, 16, 0, 0, 0, location)),
}, now, location, nil)
if err != nil {
t.Fatal(err)
}
if row.Title != "Shows airing in the next 5 days" || len(row.Items) != 3 {
t.Fatalf("five-day row = %+v", row)
}
labels := make([]string, 0, len(row.Items))
dayLabels := make([]string, 0, len(row.Items))
for _, raw := range row.Items {
var item sonarrScheduleItem
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatal(err)
}
labels = append(labels, item.MembyAirLabel)
dayLabels = append(dayLabels, item.MembyAirDayLabel)
}
want := []string{
"In 8 hours (4:00 PM)",
"Tomorrow: 4:00 PM",
"Sunday: 4:00 PM",
}
for i := range want {
if labels[i] != want[i] {
t.Fatalf("labels = %#v, want %#v", labels, want)
}
}
wantDays := []string{"Today", "Tomorrow", "Sunday"}
for i := range wantDays {
if dayLabels[i] != wantDays[i] {
t.Fatalf("day labels = %#v, want %#v", dayLabels, wantDays)
}
}
}
func TestResolveNextAiringOnlyAnswersForContinuingShowsWithAFutureAiring(t *testing.T) {
location := time.UTC
now := time.Date(2026, 8, 26, 12, 0, 0, 0, location)
tomorrow := now.AddDate(0, 0, 1)
yesterday := now.AddDate(0, 0, -1)
catalogue := []sonarr.Series{
{Title: "Continuing Show", Status: "continuing", NextAiring: &tomorrow},
{Title: "Ended Show", Status: "ended", NextAiring: &tomorrow},
{Title: "Cancelled Show", Status: "cancelled", NextAiring: &tomorrow},
{Title: "No Schedule Show", Status: "continuing", NextAiring: nil},
{Title: "Already Aired Show", Status: "continuing", NextAiring: &yesterday},
}
if _, info, ok := resolveNextAiring(catalogue, "Continuing Show", now, location); !ok {
t.Fatal("expected a continuing show with a future airing to resolve")
} else if info.DayLabel != "Tomorrow" {
t.Fatalf("day label = %q, want Tomorrow", info.DayLabel)
}
cases := []string{
"Ended Show", "Cancelled Show", "No Schedule Show", "Already Aired Show", "Unknown Show",
}
for _, name := range cases {
if _, _, ok := resolveNextAiring(catalogue, name, now, location); ok {
t.Fatalf("%q should not have produced a next-airing answer", name)
}
}
if _, _, ok := resolveNextAiring(catalogue, " ", now, location); ok {
t.Fatal("a blank series name should never resolve")
}
}
func TestResolveNextAiringWordingMatchesTheScheduleRow(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
air := now.Add(8 * time.Hour)
catalogue := []sonarr.Series{
{Title: "Northbound", Status: "continuing", NextAiring: &air},
}
_, info, ok := resolveNextAiring(catalogue, "Northbound", now, location)
if !ok {
t.Fatal("expected a resolved answer")
}
wantLabel := scheduleAirLabel(air, now, location)
wantDayLabel := scheduleAirDayLabel(air, now, location)
if info.Label != wantLabel || info.DayLabel != wantDayLabel {
t.Fatalf("wording drifted from the schedule row: got %+v, want label=%q day=%q",
info, wantLabel, wantDayLabel)
}
}