package api import ( "encoding/json" "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{ "newshow": "emby-new", "returning": "emby-returning", "notinlibrary": "", "specials": "emby-specials", "undownloaded": "emby-undownloaded", } 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) { index := seriesIndex{"show": "emby-show"} 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) } } func heroIDs(candidates []heroCandidate) []string { ids := make([]string, 0, len(candidates)) for _, candidate := range candidates { ids = append(ids, candidate.ID) } return ids }