2026-08-07 10:44:17 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-08-10 08:54:23 +12:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
2026-08-07 10:44:17 +12:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/radarr"
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var heroNow = time.Date(2026, 8, 7, 20, 0, 0, 0, time.UTC)
|
|
|
|
|
|
|
|
|
|
func heroDaysAgo(days int) time.Time { return heroNow.AddDate(0, 0, -days) }
|
|
|
|
|
|
|
|
|
|
func heroItem(id, name, itemType string) json.RawMessage {
|
|
|
|
|
raw, err := json.Marshal(map[string]any{"Id": id, "Name": name, "Type": itemType})
|
|
|
|
|
if err != nil {
|
|
|
|
|
panic(err)
|
|
|
|
|
}
|
|
|
|
|
return raw
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func heroMovieCandidate(id string, released time.Time, rating float64, rated bool) heroCandidate {
|
|
|
|
|
return heroCandidate{
|
|
|
|
|
ID: id,
|
|
|
|
|
Name: id,
|
|
|
|
|
Kind: heroMovie,
|
|
|
|
|
Item: heroItem(id, id, "Movie"),
|
|
|
|
|
ReleasedAt: released,
|
|
|
|
|
Rating: rating,
|
|
|
|
|
Rated: rated,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The request this whole feature answers: a well-received release should be able to lead
|
|
|
|
|
// over a fresher one nobody liked, rather than the launcher being a list sorted only by
|
|
|
|
|
// whichever date happened to be attached.
|
|
|
|
|
func TestHeroRankingPrefersAWellRatedReleaseOverAFresherPoorOne(t *testing.T) {
|
|
|
|
|
candidates := []heroCandidate{
|
|
|
|
|
heroMovieCandidate("fresh-and-bad", heroDaysAgo(1), 0.30, true),
|
|
|
|
|
heroMovieCandidate("recent-and-good", heroDaysAgo(6), 0.92, true),
|
|
|
|
|
}
|
|
|
|
|
ranked := rankHeroCandidates(candidates, heroNow, 4)
|
|
|
|
|
if len(ranked) != 2 || ranked[0].ID != "recent-and-good" {
|
|
|
|
|
t.Fatalf("expected the well-reviewed release to lead, got %+v", heroIDs(ranked))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The other half of the same rule: quality must not be able to overturn recency
|
|
|
|
|
// altogether, or the hero becomes a list of the library's best films and stops being
|
|
|
|
|
// about what is new at all.
|
|
|
|
|
func TestHeroRankingKeepsAFreshReleaseAheadOfAnOldAcclaimedOne(t *testing.T) {
|
|
|
|
|
candidates := []heroCandidate{
|
|
|
|
|
heroMovieCandidate("old-masterpiece", heroDaysAgo(400), 1.0, true),
|
|
|
|
|
heroMovieCandidate("new-and-decent", heroDaysAgo(2), 0.70, true),
|
|
|
|
|
}
|
|
|
|
|
ranked := rankHeroCandidates(candidates, heroNow, 4)
|
|
|
|
|
if ranked[0].ID != "new-and-decent" {
|
|
|
|
|
t.Fatalf("expected the new release to lead, got %+v", heroIDs(ranked))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An unrated title is not a bad title. On a household that has never configured MDBList
|
|
|
|
|
// this is every title, and burying them would leave the hero empty.
|
|
|
|
|
func TestHeroRankingDoesNotBuryUnratedTitles(t *testing.T) {
|
|
|
|
|
candidates := []heroCandidate{
|
|
|
|
|
heroMovieCandidate("rated-poorly", heroDaysAgo(3), 0.20, true),
|
|
|
|
|
heroMovieCandidate("unrated", heroDaysAgo(3), 0, false),
|
|
|
|
|
}
|
|
|
|
|
ranked := rankHeroCandidates(candidates, heroNow, 4)
|
|
|
|
|
if ranked[0].ID != "unrated" {
|
|
|
|
|
t.Fatalf("expected the unrated title to outrank the poorly rated one, got %+v", heroIDs(ranked))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Radarr's cinema + 30 days is a guess, and a guess must not outrank a published date.
|
|
|
|
|
func TestHeroRankingPenalisesAnEstimatedRelease(t *testing.T) {
|
|
|
|
|
known := heroMovieCandidate("known", heroDaysAgo(4), 0.60, true)
|
|
|
|
|
estimated := heroMovieCandidate("estimated", heroDaysAgo(4), 0.60, true)
|
|
|
|
|
estimated.Estimated = true
|
|
|
|
|
ranked := rankHeroCandidates([]heroCandidate{estimated, known}, heroNow, 4)
|
|
|
|
|
if ranked[0].ID != "known" {
|
|
|
|
|
t.Fatalf("expected the published date to lead, got %+v", heroIDs(ranked))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A release Radarr has dated but which has not happened yet belongs to the schedule row.
|
|
|
|
|
// The hero exists to be pressed.
|
|
|
|
|
func TestHeroRecencyIgnoresFutureReleases(t *testing.T) {
|
|
|
|
|
if score := heroRecency(heroNow.AddDate(0, 0, 3), heroNow); score != 0 {
|
|
|
|
|
t.Fatalf("expected a future release to score 0, got %v", score)
|
|
|
|
|
}
|
|
|
|
|
if score := heroRecency(time.Time{}, heroNow); score != 0 {
|
|
|
|
|
t.Fatalf("expected an unknown release to score 0, got %v", score)
|
|
|
|
|
}
|
|
|
|
|
if score := heroRecency(heroDaysAgo(heroWindowDays), heroNow); score != 0 {
|
|
|
|
|
t.Fatalf("expected the window edge to score 0, got %v", score)
|
|
|
|
|
}
|
|
|
|
|
if score := heroRecency(heroNow, heroNow); score != 1 {
|
|
|
|
|
t.Fatalf("expected a release today to score 1, got %v", score)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHeroRankingDeduplicatesAndCaps(t *testing.T) {
|
|
|
|
|
candidates := []heroCandidate{
|
|
|
|
|
heroMovieCandidate("a", heroDaysAgo(1), 0.9, true),
|
|
|
|
|
heroMovieCandidate("a", heroDaysAgo(1), 0.9, true),
|
|
|
|
|
heroMovieCandidate("b", heroDaysAgo(2), 0.8, true),
|
|
|
|
|
heroMovieCandidate("c", heroDaysAgo(3), 0.7, true),
|
|
|
|
|
}
|
|
|
|
|
ranked := rankHeroCandidates(candidates, heroNow, 2)
|
|
|
|
|
if len(ranked) != 2 || ranked[0].ID != "a" || ranked[1].ID != "b" {
|
|
|
|
|
t.Fatalf("expected [a b], got %+v", heroIDs(ranked))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A candidate with no payload cannot be drawn, so it must never take one of four slots.
|
|
|
|
|
func TestHeroRankingSkipsCandidatesWithNothingToDraw(t *testing.T) {
|
|
|
|
|
empty := heroMovieCandidate("empty", heroDaysAgo(1), 1.0, true)
|
|
|
|
|
empty.Item = nil
|
|
|
|
|
ranked := rankHeroCandidates(
|
|
|
|
|
[]heroCandidate{empty, heroMovieCandidate("real", heroDaysAgo(9), 0.4, true)},
|
|
|
|
|
heroNow, 4,
|
|
|
|
|
)
|
|
|
|
|
if len(ranked) != 1 || ranked[0].ID != "real" {
|
|
|
|
|
t.Fatalf("expected only the drawable candidate, got %+v", heroIDs(ranked))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Labels are claims. Each one has to have been earned, which is exactly what the
|
|
|
|
|
// positional captions this replaced could not promise.
|
|
|
|
|
func TestHeroLabels(t *testing.T) {
|
|
|
|
|
series := heroMovieCandidate("s", heroDaysAgo(2), 0.5, true)
|
|
|
|
|
series.Kind = heroSeriesPremiere
|
|
|
|
|
season := series
|
|
|
|
|
season.Kind = heroSeasonPremiere
|
|
|
|
|
|
|
|
|
|
acclaimedOld := heroMovieCandidate("old", heroDaysAgo(300), 0.88, true)
|
|
|
|
|
quietOld := heroMovieCandidate("quiet", heroDaysAgo(300), 0.40, true)
|
|
|
|
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
candidate heroCandidate
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{series, heroLabelSeriesPremiere},
|
|
|
|
|
{season, heroLabelSeasonPremiere},
|
|
|
|
|
{heroMovieCandidate("new", heroDaysAgo(2), 0.5, true), heroLabelNewRelease},
|
|
|
|
|
{acclaimedOld, heroLabelAcclaimed},
|
|
|
|
|
{quietOld, heroLabelLibrary},
|
|
|
|
|
}
|
|
|
|
|
for _, testCase := range cases {
|
|
|
|
|
if got := heroLabel(testCase.candidate, heroNow); got != testCase.want {
|
|
|
|
|
t.Fatalf("label for %q: want %q, got %q", testCase.candidate.ID, testCase.want, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A card with nothing true to say says nothing, rather than inventing a reason.
|
|
|
|
|
func TestHeroReasonIsEmptyWithoutEvidence(t *testing.T) {
|
|
|
|
|
quiet := heroMovieCandidate("quiet", heroDaysAgo(300), 0.40, true)
|
|
|
|
|
if reason := heroReason(quiet, heroNow, time.UTC); reason != "" {
|
|
|
|
|
t.Fatalf("expected no reason, got %q", reason)
|
|
|
|
|
}
|
|
|
|
|
unknown := heroMovieCandidate("unknown", time.Time{}, 0, false)
|
|
|
|
|
if reason := heroReason(unknown, heroNow, time.UTC); reason != "" {
|
|
|
|
|
t.Fatalf("expected no reason, got %q", reason)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHeroReasonWording(t *testing.T) {
|
|
|
|
|
premiere := heroMovieCandidate("p", heroDaysAgo(1), 0.5, true)
|
|
|
|
|
premiere.Kind = heroSeriesPremiere
|
|
|
|
|
if got, want := heroReason(premiere, heroNow, time.UTC), "A new series premiered yesterday"; got != want {
|
|
|
|
|
t.Fatalf("want %q, got %q", want, got)
|
|
|
|
|
}
|
|
|
|
|
estimated := heroMovieCandidate("e", heroDaysAgo(0), 0.5, true)
|
|
|
|
|
estimated.Estimated = true
|
|
|
|
|
if got, want := heroReason(estimated, heroNow, time.UTC), "Expected to have landed today"; got != want {
|
|
|
|
|
t.Fatalf("want %q, got %q", want, got)
|
|
|
|
|
}
|
|
|
|
|
acclaimed := heroMovieCandidate("a", heroDaysAgo(1), 0.9, true)
|
|
|
|
|
if got, want := heroReason(acclaimed, heroNow, time.UTC), "Well reviewed, released yesterday"; got != want {
|
|
|
|
|
t.Fatalf("want %q, got %q", want, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ratings are read off the card the launcher is about to be sent, and averaged across
|
|
|
|
|
// providers measured on different scales.
|
|
|
|
|
func TestHeroRatingOfAveragesAcrossScales(t *testing.T) {
|
|
|
|
|
raw, err := json.Marshal(map[string]any{
|
|
|
|
|
"Id": "x",
|
|
|
|
|
"MembyRatings": []map[string]string{
|
|
|
|
|
{"source": "imdb", "score": "8.0"},
|
|
|
|
|
{"source": "tomatoes", "score": "90"},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
rating, rated := heroRatingOf(raw)
|
|
|
|
|
if !rated {
|
|
|
|
|
t.Fatal("expected the card to be rated")
|
|
|
|
|
}
|
|
|
|
|
if want := (0.8 + 0.9) / 2; rating < want-0.0001 || rating > want+0.0001 {
|
|
|
|
|
t.Fatalf("want %v, got %v", want, rating)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Emby's own score orders the hero when MDBList has never been asked. It is never drawn
|
|
|
|
|
// (that would name a provider nobody consulted) but ordering four cards by it claims
|
|
|
|
|
// nothing to anyone, and it is what makes this work before ratings are configured.
|
|
|
|
|
func TestHeroRatingFallsBackToCommunityRating(t *testing.T) {
|
|
|
|
|
raw, err := json.Marshal(map[string]any{"Id": "x", "CommunityRating": 7.5})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
rating, rated := heroRatingOf(raw)
|
|
|
|
|
if !rated || rating < 0.74 || rating > 0.76 {
|
|
|
|
|
t.Fatalf("want 0.75, got %v (rated=%v)", rating, rated)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHeroRatingUnratedIsNotZero(t *testing.T) {
|
|
|
|
|
if _, rated := heroRatingOf(heroItem("x", "X", "Movie")); rated {
|
|
|
|
|
t.Fatal("expected an item with no scores to be unrated")
|
|
|
|
|
}
|
|
|
|
|
// An out-of-range or unparsable score is the same as no score, not a score of nil.
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
"Id": "x",
|
|
|
|
|
"MembyRatings": []map[string]string{{"source": "imdb", "score": "n/a"}},
|
|
|
|
|
})
|
|
|
|
|
if _, rated := heroRatingOf(raw); rated {
|
|
|
|
|
t.Fatal("expected an unparsable score to leave the card unrated")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Radarr's digital date is the answer Emby's PremiereDate is asked for and gets wrong.
|
|
|
|
|
func TestHeroReleaseIndexPrefersTMDBThenTitleAndYear(t *testing.T) {
|
|
|
|
|
digital := heroDaysAgo(3)
|
|
|
|
|
cinema := heroDaysAgo(40)
|
|
|
|
|
index := newHeroReleaseIndex([]radarr.Movie{
|
|
|
|
|
{ID: 1, TMDBID: 550, Title: "Fight Club", Year: 1999, DigitalRelease: &digital},
|
|
|
|
|
{ID: 2, Title: "The Thing", Year: 1982, InCinemas: &cinema},
|
|
|
|
|
{ID: 3, Title: "The Thing", Year: 2011, InCinemas: &cinema},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
release, ok := index.lookup("550", "", 0)
|
|
|
|
|
if !ok || !release.at.Equal(digital) || release.estimated {
|
|
|
|
|
t.Fatalf("expected the exact tmdb match, got %+v (ok=%v)", release, ok)
|
|
|
|
|
}
|
|
|
|
|
// A title with no tmdb id still resolves, and the year keeps the remake apart from
|
|
|
|
|
// the original.
|
|
|
|
|
if _, ok := index.lookup("", "The Thing", 1982); !ok {
|
|
|
|
|
t.Fatal("expected the title/year fallback to resolve")
|
|
|
|
|
}
|
|
|
|
|
estimated, ok := index.lookup("", "The Thing", 2011)
|
|
|
|
|
if !ok || !estimated.estimated {
|
|
|
|
|
t.Fatalf("expected a cinema date to be reported as estimated, got %+v", estimated)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := index.lookup("", "Nothing Like It", 2020); ok {
|
|
|
|
|
t.Fatal("expected an unknown title to resolve to nothing")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sonarrPremiereEpisode(
|
|
|
|
|
series string, seasonNumber, episodeNumber int, aired time.Time, hasFile bool,
|
|
|
|
|
) sonarr.Episode {
|
|
|
|
|
return sonarr.Episode{
|
|
|
|
|
SeasonNumber: seasonNumber,
|
|
|
|
|
EpisodeNumber: episodeNumber,
|
|
|
|
|
AirDateUTC: &aired,
|
|
|
|
|
HasFile: hasFile,
|
|
|
|
|
Series: sonarr.Series{Title: series},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "Series premieres only, not random TV series" is this function, and most of it is
|
|
|
|
|
// about refusing.
|
|
|
|
|
func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) {
|
|
|
|
|
index := seriesIndex{
|
2026-08-11 12:08:51 +12:00
|
|
|
"newshow": {ID: "emby-new"},
|
|
|
|
|
"returning": {ID: "emby-returning"},
|
|
|
|
|
"notinlibrary": {},
|
|
|
|
|
"specials": {ID: "emby-specials"},
|
|
|
|
|
"undownloaded": {ID: "emby-undownloaded"},
|
2026-08-07 10:44:17 +12:00
|
|
|
}
|
|
|
|
|
delete(index, "notinlibrary")
|
|
|
|
|
|
|
|
|
|
episodes := []sonarr.Episode{
|
|
|
|
|
sonarrPremiereEpisode("New Show", 1, 1, heroDaysAgo(2), true),
|
|
|
|
|
sonarrPremiereEpisode("Returning", 3, 1, heroDaysAgo(5), true),
|
|
|
|
|
// Not a premiere: an ordinary episode of something already under way.
|
|
|
|
|
sonarrPremiereEpisode("Returning", 3, 4, heroDaysAgo(1), true),
|
|
|
|
|
// Season 0 is specials, not a premiere.
|
|
|
|
|
sonarrPremiereEpisode("Specials", 0, 1, heroDaysAgo(1), true),
|
|
|
|
|
// Not downloaded — news for the schedule row, not a card to press.
|
|
|
|
|
sonarrPremiereEpisode("Undownloaded", 1, 1, heroDaysAgo(1), false),
|
|
|
|
|
// Emby has never imported this show, so the card would have no page.
|
|
|
|
|
sonarrPremiereEpisode("Not In Library", 1, 1, heroDaysAgo(1), true),
|
|
|
|
|
// Outside the window.
|
|
|
|
|
sonarrPremiereEpisode("New Show", 1, 1, heroDaysAgo(90), true),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
premieres := sonarrPremieres(episodes, index, heroDaysAgo(heroWindowDays), heroNow)
|
|
|
|
|
if len(premieres) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 premieres, got %d: %+v", len(premieres), premieres)
|
|
|
|
|
}
|
|
|
|
|
if premieres[0].EmbySeriesID != "emby-new" || premieres[0].SeasonNumber != 1 {
|
|
|
|
|
t.Fatalf("expected the newest premiere first, got %+v", premieres[0])
|
|
|
|
|
}
|
|
|
|
|
if premieres[1].EmbySeriesID != "emby-returning" || premieres[1].SeasonNumber != 3 {
|
|
|
|
|
t.Fatalf("expected the returning show second, got %+v", premieres[1])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-08-11 12:08:51 +12:00
|
|
|
index := seriesIndex{"show": {ID: "emby-show"}}
|
2026-08-07 10:44:17 +12:00
|
|
|
premieres := sonarrPremieres([]sonarr.Episode{
|
|
|
|
|
sonarrPremiereEpisode("Show", 1, 1, heroDaysAgo(15), true),
|
|
|
|
|
sonarrPremiereEpisode("Show", 2, 1, heroDaysAgo(2), true),
|
|
|
|
|
}, index, heroDaysAgo(heroWindowDays), heroNow)
|
|
|
|
|
if len(premieres) != 1 {
|
|
|
|
|
t.Fatalf("expected one card, got %d", len(premieres))
|
|
|
|
|
}
|
|
|
|
|
if premieres[0].SeasonNumber != 2 {
|
|
|
|
|
t.Fatalf("expected the newer season, got season %d", premieres[0].SeasonNumber)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The hero draws from the rows the launcher is already being sent, and the rows it must
|
|
|
|
|
// not draw from are the ones whose cards cannot be pressed or are already in progress.
|
|
|
|
|
func TestHeroMovieCandidatesSkipUnpressableRows(t *testing.T) {
|
|
|
|
|
scheduleCard, _ := json.Marshal(map[string]any{
|
|
|
|
|
"Id": "radarr:1", "Name": "Coming Soon", "Type": "Movie",
|
|
|
|
|
"MembySource": "radarr", "MembyPlayable": false,
|
|
|
|
|
})
|
|
|
|
|
rows := []recommend.Row{
|
|
|
|
|
{ID: "continue", Kind: "continue", Items: []json.RawMessage{heroItem("resume", "Resume", "Movie")}},
|
|
|
|
|
{ID: "movie-schedule", Kind: "movie-schedule", Items: []json.RawMessage{scheduleCard}},
|
|
|
|
|
{ID: "latest", Kind: "latest", Items: []json.RawMessage{
|
|
|
|
|
heroItem("film", "Film", "Movie"),
|
|
|
|
|
heroItem("show", "Show", "Series"),
|
|
|
|
|
}},
|
|
|
|
|
}
|
|
|
|
|
candidates, facts := heroMovieCandidates(rows)
|
|
|
|
|
if len(candidates) != 1 || candidates[0].ID != "film" {
|
|
|
|
|
t.Fatalf("expected only the playable film, got %+v", heroIDs(candidates))
|
|
|
|
|
}
|
|
|
|
|
if _, ok := facts["film"]; !ok {
|
|
|
|
|
t.Fatal("expected the film's facts to be recorded for the Radarr lookup")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Emby writes dates in more than one shape, and one it will not parse is unknown rather
|
|
|
|
|
// than fatal.
|
|
|
|
|
func TestParseEmbyDate(t *testing.T) {
|
|
|
|
|
for _, value := range []string{
|
|
|
|
|
"2026-08-01T00:00:00.0000000Z",
|
|
|
|
|
"2026-08-01T00:00:00Z",
|
|
|
|
|
"2026-08-01",
|
|
|
|
|
} {
|
|
|
|
|
parsed, err := parseEmbyDate(value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("%q: %v", value, err)
|
|
|
|
|
}
|
|
|
|
|
if parsed.Year() != 2026 || parsed.Month() != time.August || parsed.Day() != 1 {
|
|
|
|
|
t.Fatalf("%q parsed to %v", value, parsed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, err := parseEmbyDate("not a date"); err == nil {
|
|
|
|
|
t.Fatal("expected an error")
|
|
|
|
|
}
|
|
|
|
|
if _, err := parseEmbyDate(""); err == nil {
|
|
|
|
|
t.Fatal("expected an error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The caption and the reason ride on the item, and an unknown field must survive being
|
|
|
|
|
// decorated — the payload is Emby's, forwarded verbatim.
|
|
|
|
|
func TestInjectHeroFieldsPreservesUnknownFields(t *testing.T) {
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{"Id": "x", "SomethingNew": "keep me"})
|
|
|
|
|
out := injectHeroFields(raw, heroLabelNewRelease, "Released yesterday")
|
|
|
|
|
var members map[string]any
|
|
|
|
|
if err := json.Unmarshal(out, &members); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if members["SomethingNew"] != "keep me" {
|
|
|
|
|
t.Fatalf("unknown field was dropped: %v", members)
|
|
|
|
|
}
|
|
|
|
|
if members[heroLabelField] != heroLabelNewRelease {
|
|
|
|
|
t.Fatalf("label missing: %v", members)
|
|
|
|
|
}
|
|
|
|
|
if members[heroReasonField] != "Released yesterday" {
|
|
|
|
|
t.Fatalf("reason missing: %v", members)
|
|
|
|
|
}
|
|
|
|
|
// Nothing to say leaves the payload exactly as it was.
|
|
|
|
|
if got := injectHeroFields(raw, "", ""); string(got) != string(raw) {
|
|
|
|
|
t.Fatalf("expected the payload untouched, got %s", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 08:54:23 +12:00
|
|
|
// heroPool is a merit-ordered pool, the shape rotateHeroCandidates is handed: 0 is the
|
|
|
|
|
// best-scoring candidate and the numbers descend from there.
|
|
|
|
|
func heroPool(size int) []heroCandidate {
|
|
|
|
|
pool := make([]heroCandidate, 0, size)
|
|
|
|
|
for index := 0; index < size; index++ {
|
|
|
|
|
id := "title-" + strconv.Itoa(index)
|
|
|
|
|
pool = append(pool, heroMovieCandidate(id, heroDaysAgo(index), 0.9, true))
|
|
|
|
|
}
|
|
|
|
|
return pool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The property that makes this a rotation rather than a shuffle: whatever slot is drawn,
|
|
|
|
|
// the first card came from the best band and the second from the one below it. A
|
|
|
|
|
// well-reviewed new release can never be pushed down the row by variation.
|
|
|
|
|
func TestHeroRotationDrawsOneFromEachMeritBand(t *testing.T) {
|
|
|
|
|
pool := heroPool(heroPoolLimit)
|
|
|
|
|
band := heroPoolLimit / heroRowLimit
|
|
|
|
|
for hour := 0; hour < 24; hour++ {
|
|
|
|
|
now := time.Date(2026, 8, 7, hour, 0, 0, 0, time.UTC)
|
|
|
|
|
seed := heroVariationSeed("viewer", heroRotationSlot(now, time.UTC))
|
|
|
|
|
drawn := rotateHeroCandidates(pool, seed, heroRowLimit)
|
|
|
|
|
if len(drawn) != heroRowLimit {
|
|
|
|
|
t.Fatalf("hour %d: expected %d cards, got %v", hour, heroRowLimit, heroIDs(drawn))
|
|
|
|
|
}
|
|
|
|
|
for slot, candidate := range drawn {
|
|
|
|
|
index, err := strconv.Atoi(strings.TrimPrefix(candidate.ID, "title-"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if index/band != slot {
|
|
|
|
|
t.Fatalf("hour %d: slot %d drew %s, out of band %d",
|
|
|
|
|
hour, slot, candidate.ID, index/band)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The complaint this feature answers: the same two titles led the launcher for five days.
|
|
|
|
|
// The lead card has to actually move, both across a day and across days.
|
|
|
|
|
func TestHeroRotationChangesThroughTheDayAndAcrossDays(t *testing.T) {
|
|
|
|
|
pool := heroPool(heroPoolLimit)
|
|
|
|
|
leads := map[string]bool{}
|
|
|
|
|
for day := 7; day < 12; day++ {
|
|
|
|
|
for hour := 0; hour < 24; hour += 24 / heroRotationsPerDay {
|
|
|
|
|
now := time.Date(2026, 8, day, hour, 0, 0, 0, time.UTC)
|
|
|
|
|
seed := heroVariationSeed("viewer", heroRotationSlot(now, time.UTC))
|
|
|
|
|
leads[rotateHeroCandidates(pool, seed, heroRowLimit)[0].ID] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// The top band holds three titles; over five days of four slots each, a draw that
|
|
|
|
|
// never moved off one of them would be a rotation in name only.
|
|
|
|
|
if len(leads) < 2 {
|
|
|
|
|
t.Fatalf("expected the lead card to move, saw only %v", leads)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The home response is cached for a minute and rebuilt constantly behind it. A hero that
|
|
|
|
|
// re-drew per request would change under somebody walking along the row.
|
|
|
|
|
func TestHeroRotationIsStableWithinASlot(t *testing.T) {
|
|
|
|
|
pool := heroPool(heroPoolLimit)
|
|
|
|
|
location := time.UTC
|
|
|
|
|
first := heroRotationSlot(time.Date(2026, 8, 7, 19, 0, 0, 0, location), location)
|
|
|
|
|
second := heroRotationSlot(time.Date(2026, 8, 7, 20, 30, 0, 0, location), location)
|
|
|
|
|
if first != second {
|
|
|
|
|
t.Fatalf("expected one evening slot, got %q and %q", first, second)
|
|
|
|
|
}
|
|
|
|
|
seed := heroVariationSeed("viewer", first)
|
|
|
|
|
want := heroIDs(rotateHeroCandidates(pool, seed, heroRowLimit))
|
|
|
|
|
for attempt := 0; attempt < 5; attempt++ {
|
|
|
|
|
got := heroIDs(rotateHeroCandidates(pool, seed, heroRowLimit))
|
|
|
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
|
|
|
t.Fatalf("draw %d differed: %v vs %v", attempt, got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Two people signed into the same house get their own draw — there is no reason for the
|
|
|
|
|
// same card to lead both televisions at the same moment.
|
|
|
|
|
func TestHeroRotationVariesByViewer(t *testing.T) {
|
|
|
|
|
pool := heroPool(heroPoolLimit)
|
|
|
|
|
slot := heroRotationSlot(heroNow, time.UTC)
|
|
|
|
|
same := 0
|
|
|
|
|
for _, viewer := range []string{"a", "b", "c", "d"} {
|
|
|
|
|
if rotateHeroCandidates(pool, heroVariationSeed(viewer, slot), heroRowLimit)[0].ID ==
|
|
|
|
|
rotateHeroCandidates(pool, heroVariationSeed("a", slot), heroRowLimit)[0].ID {
|
|
|
|
|
same++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if same == 4 {
|
|
|
|
|
t.Fatal("every viewer drew the same lead card")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A pool with nothing spare to rotate between must go out in merit order, not be reordered
|
|
|
|
|
// for the sake of it — this is the household whose library has barely eight hero-worthy
|
|
|
|
|
// titles in it.
|
|
|
|
|
func TestHeroRotationKeepsMeritOrderWithNothingToRotate(t *testing.T) {
|
|
|
|
|
pool := heroPool(heroRowLimit)
|
|
|
|
|
drawn := rotateHeroCandidates(pool, "seed", heroRowLimit)
|
|
|
|
|
for index, candidate := range drawn {
|
|
|
|
|
if candidate.ID != pool[index].ID {
|
|
|
|
|
t.Fatalf("expected merit order, got %v", heroIDs(drawn))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if rotateHeroCandidates(nil, "seed", heroRowLimit) != nil {
|
|
|
|
|
t.Fatal("expected no cards from no candidates")
|
|
|
|
|
}
|
|
|
|
|
if rotateHeroCandidates(pool, "seed", 0) != nil {
|
|
|
|
|
t.Fatal("expected no cards for no slots")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The slot is the household's local part of the day, not UTC's — "the evening" has to mean
|
|
|
|
|
// the viewer's evening, for the reason the direct path's daily rotation is local too.
|
|
|
|
|
func TestHeroRotationSlotIsLocal(t *testing.T) {
|
|
|
|
|
auckland := time.FixedZone("NZST", 12*60*60)
|
|
|
|
|
// Midday in Auckland is the previous day in UTC, and must not be read as the small
|
|
|
|
|
// hours of it.
|
|
|
|
|
now := time.Date(2026, 8, 7, 12, 0, 0, 0, auckland)
|
|
|
|
|
if got, want := heroRotationSlot(now, auckland), "2026-08-07#2"; got != want {
|
|
|
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
|
|
|
}
|
|
|
|
|
if got, want := heroRotationSlot(now, time.UTC), "2026-08-07#0"; got != want {
|
|
|
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
|
|
|
}
|
|
|
|
|
// The date is in the slot, or the four slots repeat themselves every day.
|
|
|
|
|
tomorrow := heroRotationSlot(now.AddDate(0, 0, 1), auckland)
|
|
|
|
|
if tomorrow == heroRotationSlot(now, auckland) {
|
|
|
|
|
t.Fatalf("expected the day to be part of the slot, got %q", tomorrow)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 23:41:10 +12:00
|
|
|
func TestPinnedHeroMoviesLeadAndOrganicSelectionFillsTheGrid(t *testing.T) {
|
|
|
|
|
pinned := []heroCandidate{
|
|
|
|
|
{ID: "custom-2", Item: json.RawMessage(`{"Id":"custom-2"}`)},
|
|
|
|
|
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`)},
|
|
|
|
|
}
|
|
|
|
|
organic := []heroCandidate{
|
|
|
|
|
// A pinned title can also qualify organically; it must still appear only once.
|
|
|
|
|
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`), Kind: heroSeasonPremiere},
|
|
|
|
|
{ID: "release-1", Item: json.RawMessage(`{"Id":"release-1"}`)},
|
|
|
|
|
{ID: "release-2", Item: json.RawMessage(`{"Id":"release-2"}`)},
|
|
|
|
|
{ID: "library-1", Item: json.RawMessage(`{"Id":"library-1"}`)},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := mergePinnedHeroCandidates(pinned, organic, organic[1:], 4)
|
|
|
|
|
want := []string{"custom-2", "custom-1", "release-1", "release-2"}
|
|
|
|
|
if strings.Join(heroIDs(got), ",") != strings.Join(want, ",") {
|
|
|
|
|
t.Fatalf("pinned hero order = %v, want %v", heroIDs(got), want)
|
|
|
|
|
}
|
|
|
|
|
if got[1].Kind != heroSeasonPremiere {
|
|
|
|
|
t.Fatalf("pin lost its natural premiere evidence: %+v", got[1])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPinnedHeroMergeHonoursAnEmptyOrShortGrid(t *testing.T) {
|
|
|
|
|
candidate := heroCandidate{ID: "custom", Item: json.RawMessage(`{"Id":"custom"}`)}
|
|
|
|
|
if got := mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 0); got != nil {
|
|
|
|
|
t.Fatalf("zero-sized grid = %v, want nil", heroIDs(got))
|
|
|
|
|
}
|
|
|
|
|
if got := heroIDs(mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 4)); strings.Join(got, ",") != "custom" {
|
|
|
|
|
t.Fatalf("short grid = %v, want custom", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPrimeSubtitleChangesOnlyTheLargeFirstCard(t *testing.T) {
|
|
|
|
|
candidate := heroCandidate{ReleasedAt: heroDaysAgo(2), Kind: heroMovie}
|
|
|
|
|
if got := heroReasonForPosition(candidate, 0, " Family pick tonight ", heroNow, time.UTC); got != "Family pick tonight" {
|
|
|
|
|
t.Fatalf("prime subtitle = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := heroReasonForPosition(candidate, 1, "Family pick tonight", heroNow, time.UTC); got == "Family pick tonight" || !strings.Contains(got, "Released") {
|
|
|
|
|
t.Fatalf("secondary card lost its natural reason: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := heroReasonForPosition(candidate, 0, "", heroNow, time.UTC); !strings.Contains(got, "Released") {
|
|
|
|
|
t.Fatalf("blank override did not fall back to the natural reason: %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPinnedSeriesUsesTheSameNaturalPresentationAsOrganicTitles(t *testing.T) {
|
|
|
|
|
candidate := heroCandidate{Kind: heroSeries, ReleasedAt: heroDaysAgo(3)}
|
|
|
|
|
if got := heroLabel(candidate, heroNow); got != heroLabelNewRelease {
|
|
|
|
|
t.Fatalf("recent series label = %q, want %q", got, heroLabelNewRelease)
|
|
|
|
|
}
|
|
|
|
|
if got := heroReason(candidate, heroNow, time.UTC); !strings.Contains(got, "new series premiered") {
|
|
|
|
|
t.Fatalf("recent series reason = %q", got)
|
|
|
|
|
}
|
|
|
|
|
candidate.ReleasedAt = heroDaysAgo(100)
|
|
|
|
|
candidate.Rated, candidate.Rating = true, 0.9
|
|
|
|
|
if got := heroLabel(candidate, heroNow); got != heroLabelAcclaimed {
|
|
|
|
|
t.Fatalf("older acclaimed series label = %q, want %q", got, heroLabelAcclaimed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAdminHeroSearchAcceptsFilmsAndSeriesOnly(t *testing.T) {
|
|
|
|
|
for _, itemType := range []string{"Movie", "Series"} {
|
|
|
|
|
if item, ok := adminHeroItem(heroItem("id", "Title", itemType)); !ok || item.Type != itemType {
|
|
|
|
|
t.Fatalf("%s was not accepted: %+v, %t", itemType, item, ok)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, ok := adminHeroItem(heroItem("episode", "Episode", "Episode")); ok {
|
|
|
|
|
t.Fatal("episode was accepted as a pinnable hero title")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 10:44:17 +12:00
|
|
|
func heroIDs(candidates []heroCandidate) []string {
|
|
|
|
|
ids := make([]string, 0, len(candidates))
|
|
|
|
|
for _, candidate := range candidates {
|
|
|
|
|
ids = append(ids, candidate.ID)
|
|
|
|
|
}
|
|
|
|
|
return ids
|
|
|
|
|
}
|