package api // The home hero — the four cards above the launcher's rows, and the one place the server // says "this, tonight" rather than "here is a shelf". // // It used to be chosen on the television: take the first movies off whichever rows looked // new or popular, rotate the starting point once a day. That was as good as the evidence // the client had, which is very little. Emby's PremiereDate is frequently whatever a // metadata agent guessed, nothing on the wire said whether a title was any good, and a // series could only ever reach the hero as a random show off a shelf. So a poorly // reviewed film imported last Tuesday led the launcher over the best-received release of // the month, and the return of a household's favourite show passed unremarked. // // The gateway has the three pieces of evidence the television does not: // // - **Radarr knows when a film actually came out.** `digitalRelease` is the date the // household could first have watched it, which is what a viewer means by "new". The // schedule row already prefers it over Emby's; the hero reads the same answer over a // backwards window instead of a forwards one. // - **Sonarr knows a premiere from an ordinary episode.** S01E01 is a new show, S02E01 // is a returning one, and both are news in a way that the fourth episode of a show // somebody is already halfway through is not. // - **MDBList knows whether it is worth the evening.** By the point this runs those // scores are already attached to the cards, so ranking by them costs nothing. // // Two properties are what stop this becoming a second recommendation engine, and both // are easy to give away: // // - **It asks Emby for nothing.** The movie candidates are the rows already assembled // and their ratings are already attached, so the expensive half of the launcher is // reused rather than repeated. What it does read is the two *arr calendars, and those // are cached for the day behind a shared lock like the schedule rows' — one household // pays one miss each per day, and the three reads run together rather than in turn // because this is the tail of a response every television is waiting on. // - **Every card it produces is playable.** A premiere the household has not downloaded // yet, or a film Radarr is still waiting on, is news for the schedule row — the hero // exists to be pressed, and a lead card that does nothing is worse than no lead card. import ( "context" "encoding/json" "errors" "sort" "strconv" "strings" "sync" "time" "github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/sonarr" ) const ( heroRowID = "hero" heroRowKind = "hero" // How far back a release can be and still be the reason a card leads. Digital // releases and premieres arrive in bursts, and a hero that empties out in a quiet // fortnight is a hero that falls back to the library — three weeks keeps it // populated without billing a six-week-old film as new. heroWindowDays = 21 // The television draws four cards. The row carries a few more so one it cannot draw // — no artwork, a type this build predates — costs a card rather than a gap. heroRowLimit = 8 // Candidates are capped before scoring. A launcher is a few hundred cards; this is a // guard against a future row type offering a thousand, not a limit anything reaches. heroCandidateLimit = 240 ) // The ranking. Recency and quality are deliberately close in weight: the request this // answers is that a well-received release should be able to beat a fresher one that // nobody liked, which needs quality to be worth roughly as much as a fortnight of age. const ( heroRecencyWeight = 0.55 heroRatingWeight = 0.45 // What a title nobody has rated is worth. Deliberately near the middle rather than // zero: a good score is meant to *lift* a title above the merely recent, not to bury // everything MDBList has never been asked about — which, on a household that has just // turned ratings on, is the entire library. heroUnratedScore = 0.55 // Radarr's cinema + 30 days is a guess (see effectiveRadarrRelease), and a guess // should not outrank a date somebody published. heroEstimatedPenalty = 0.12 // Where "well reviewed" starts, for the one label that claims it. heroAcclaimedRating = 0.75 ) // Caption wording is the gateway's, like MembyAirLabel and MembyLifecycleText. The // television renders the string it is handed, so a new kind of hero card reads correctly // on a build that predates it. const ( heroLabelSeriesPremiere = "SERIES PREMIERE" heroLabelSeasonPremiere = "NEW SEASON" heroLabelNewRelease = "NEW RELEASE" heroLabelAcclaimed = "HIGHLY RATED" heroLabelLibrary = "FROM YOUR LIBRARY" ) // The fields the hero adds to an item payload. Emby's JSON is otherwise forwarded // verbatim; these are injected the way MembyRatings is. const ( heroLabelField = "MembyHeroLabel" heroReasonField = "MembyHeroReason" ) const heroReleasedCachePrefix = "radarr:released:v1:" const heroPremiereCachePrefix = "sonarr:premieres:v1:" type heroKind int const ( heroMovie heroKind = iota heroSeriesPremiere heroSeasonPremiere ) // heroCandidate is one title the hero could lead with, and everything the ranking needs // to decide whether it should. type heroCandidate struct { ID string Name string Kind heroKind Item json.RawMessage // ReleasedAt is the *effective* release: Radarr's digital date for a film, the // premiere's air date for a show, and Emby's PremiereDate only when nothing better // is known. Zero means nothing is known at all, which is an answer — such a title // ranks on quality alone rather than being excluded. ReleasedAt time.Time Estimated bool // Rating is normalised onto 0..1 across whatever providers answered. Rated is false // when none did, which is a different thing from a score of zero. Rating float64 Rated bool } // heroRecency decays linearly across the window. // // A release in the future scores zero rather than more than one. The hero is a thing to // be pressed, and a title that has not come out yet belongs to the schedule row — this // only ever sees such a date because Radarr publishes a digital date before it arrives. func heroRecency(released, now time.Time) float64 { if released.IsZero() || released.After(now) { return 0 } age := now.Sub(released).Hours() / 24 if age >= heroWindowDays { return 0 } return 1 - age/heroWindowDays } func heroScore(candidate heroCandidate, now time.Time) float64 { rating := heroUnratedScore if candidate.Rated { rating = candidate.Rating } score := heroRecencyWeight*heroRecency(candidate.ReleasedAt, now) + heroRatingWeight*rating if candidate.Estimated { score -= heroEstimatedPenalty } return score } // rankHeroCandidates orders the hero and is the whole of the feature that can be reasoned // about without a network. // // The sort is stable and the tie-break is the order it was given, so the caller's own // preference survives two titles the scorer cannot separate. Deduplication keeps the // first appearance: a film that is both a Radarr release and a library card is the // release, which is the more specific thing to say about it. func rankHeroCandidates(candidates []heroCandidate, now time.Time, limit int) []heroCandidate { if limit <= 0 { return nil } type ranked struct { candidate heroCandidate position int score float64 } seen := make(map[string]bool, len(candidates)) scored := make([]ranked, 0, len(candidates)) for position, candidate := range candidates { if candidate.ID == "" || seen[candidate.ID] || len(candidate.Item) == 0 { continue } seen[candidate.ID] = true scored = append(scored, ranked{ candidate: candidate, position: position, score: heroScore(candidate, now), }) } sort.SliceStable(scored, func(i, j int) bool { if scored[i].score != scored[j].score { return scored[i].score > scored[j].score } return scored[i].position < scored[j].position }) if len(scored) > limit { scored = scored[:limit] } out := make([]heroCandidate, 0, len(scored)) for _, entry := range scored { out = append(out, entry.candidate) } return out } // heroLabel is the caption the card wears, and it may only say what is actually known. // // The labels it replaced were the card's *position* — the first slot was captioned NEW // RELEASE whatever was in it — which is how a 2019 film came to be announced as new. Each // of these is a claim the candidate has already satisfied. func heroLabel(candidate heroCandidate, now time.Time) string { switch candidate.Kind { case heroSeriesPremiere: return heroLabelSeriesPremiere case heroSeasonPremiere: return heroLabelSeasonPremiere } if heroRecency(candidate.ReleasedAt, now) > 0 { return heroLabelNewRelease } if candidate.Rated && candidate.Rating >= heroAcclaimedRating { return heroLabelAcclaimed } return heroLabelLibrary } // heroReason is the second line: why this, over the rest of the library. It is allowed to // be empty, and is empty precisely when there is nothing true to say — a card with no // evidence behind it says nothing rather than inventing a reason. func heroReason(candidate heroCandidate, now time.Time, location *time.Location) string { acclaimed := candidate.Rated && candidate.Rating >= heroAcclaimedRating fresh := heroRecency(candidate.ReleasedAt, now) > 0 switch { case candidate.Kind == heroSeriesPremiere && acclaimed: return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location) case candidate.Kind == heroSeriesPremiere: return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location) case candidate.Kind == heroSeasonPremiere: return "A new season started " + heroWhen(candidate.ReleasedAt, now, location) case fresh && acclaimed && candidate.Estimated: return "Well reviewed, and expected to have landed " + heroWhen(candidate.ReleasedAt, now, location) case fresh && acclaimed: return "Well reviewed, released " + heroWhen(candidate.ReleasedAt, now, location) case fresh && candidate.Estimated: return "Expected to have landed " + heroWhen(candidate.ReleasedAt, now, location) case fresh: return "Released " + heroWhen(candidate.ReleasedAt, now, location) case acclaimed: return "One of the best-reviewed titles in your library" default: return "" } } // heroWhen words a date the way somebody would say it out loud. Nothing here is more // precise than the evidence: a digital release date carries no time of day, so a card // never claims an hour. func heroWhen(released, now time.Time, location *time.Location) string { if location == nil { location = time.UTC } released = released.In(location) today := localDayStart(now, location) day := localDayStart(released, location) switch days := int(today.Sub(day).Hours() / 24); { case days <= 0: return "today" case days == 1: return "yesterday" case days < 7: return "on " + released.Format("Monday") case days < 14: return "last week" default: return "this month" } } // heroRatingOf reads the scores already attached to the card. // // It is the *mean* of what the household's chosen providers said, normalised onto 0..1. // The sources disagree about scale and about films — IMDb is generous, Rotten Tomatoes' // critics are not — and averaging them is a better answer than nominating a favourite and // letting one provider's blind spot decide what leads the launcher. func heroRatingOf(raw json.RawMessage) (float64, bool) { var payload struct { Ratings []movieRating `json:"MembyRatings"` CommunityRating *float64 `json:"CommunityRating"` } if json.Unmarshal(raw, &payload) != nil { return 0, false } var sum float64 var count int for _, rating := range payload.Ratings { source, known := movieRatingSources[strings.ToLower(strings.TrimSpace(rating.Source))] if !known || source.Maximum <= 0 { continue } value, err := strconv.ParseFloat(strings.TrimSpace(rating.Score), 64) if err != nil || value <= 0 || value > source.Maximum { continue } sum += value / source.Maximum count++ } if count > 0 { return sum / float64(count), true } // Emby's own CommunityRating is the fallback, and only here. The client is forbidden // from *drawing* it (a card naming a provider that was never asked is a lie), but // ordering four cards by it claims nothing to anybody — and it is what lets the hero // rank sensibly on a household that has not configured MDBList at all. if payload.CommunityRating != nil && *payload.CommunityRating > 0 && *payload.CommunityRating <= 10 { return *payload.CommunityRating / 10, true } return 0, false } // heroItemFacts pulls what the ranking needs out of an ordinary Emby item payload. type heroItemFacts struct { ID string Name string Type string Premiere time.Time Playable bool } func heroFactsOf(raw json.RawMessage) (heroItemFacts, bool) { var payload struct { ID string `json:"Id"` Name string `json:"Name"` Type string `json:"Type"` PremiereDate string `json:"PremiereDate"` Source string `json:"MembySource"` Playable *bool `json:"MembyPlayable"` } if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.ID) == "" { return heroItemFacts{}, false } facts := heroItemFacts{ ID: payload.ID, Name: strings.TrimSpace(payload.Name), Type: payload.Type, // A synthetic schedule card carries MembySource and is explicitly not playable. // Anything from Emby carries neither field, and is. Playable: strings.TrimSpace(payload.Source) == "" && (payload.Playable == nil || *payload.Playable), } if parsed, err := parseEmbyDate(payload.PremiereDate); err == nil { facts.Premiere = parsed } return facts, true } // parseEmbyDate accepts the shapes Emby writes a date in. A date it will not parse is // simply unknown, which the ranking already has a behaviour for. func parseEmbyDate(value string) (time.Time, error) { value = strings.TrimSpace(value) if value == "" { return time.Time{}, errNoDate } for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} { if parsed, err := time.Parse(layout, value); err == nil { return parsed, nil } } return time.Time{}, errNoDate } var errNoDate = errors.New("hero: unparsable date") // injectHeroFields writes the caption and the reason onto one card, the way // injectItemRatings writes the scores: through a map, so a field this build knows nothing // about survives the round trip. func injectHeroFields(raw json.RawMessage, label, reason string) json.RawMessage { if label == "" && reason == "" { return raw } var members map[string]json.RawMessage if json.Unmarshal(raw, &members) != nil || members == nil { return raw } if label != "" { if encoded, err := json.Marshal(label); err == nil { members[heroLabelField] = encoded } } if reason != "" { if encoded, err := json.Marshal(reason); err == nil { members[heroReasonField] = encoded } } out, err := json.Marshal(members) if err != nil { return raw } return out } // heroReleaseIndex answers "when did this film actually come out" for the candidates. // // It is keyed two ways because neither key is reliable on its own: a TMDB id is exact but // only exists for a library item the import has resolved, and a normalised title/year is // always available but can be wrong about a remake. The id is consulted first. type heroReleaseIndex struct { byTMDB map[string]radarrRelease byTitle map[string]radarrRelease } func newHeroReleaseIndex(movies []radarr.Movie) heroReleaseIndex { index := heroReleaseIndex{ byTMDB: make(map[string]radarrRelease, len(movies)), byTitle: make(map[string]radarrRelease, len(movies)*2), } for _, movie := range movies { release, ok := effectiveRadarrRelease(movie) if !ok { continue } if movie.TMDBID > 0 { index.byTMDB[strconv.Itoa(movie.TMDBID)] = release } title := normalizedShowTitle(movie.Title) if title == "" { continue } // Year-qualified first and never overwritten, so a remake cannot claim the // original's release date — the same rule seriesIndex applies. if movie.Year > 0 { if _, seen := index.byTitle[seriesIndexKey(title, movie.Year)]; !seen { index.byTitle[seriesIndexKey(title, movie.Year)] = release } } if _, seen := index.byTitle[title]; !seen { index.byTitle[title] = release } } return index } func (index heroReleaseIndex) lookup(tmdbID, title string, year int) (radarrRelease, bool) { if tmdbID != "" { if release, ok := index.byTMDB[tmdbID]; ok { return release, true } } key := normalizedShowTitle(title) if key == "" { return radarrRelease{}, false } if year > 0 { if release, ok := index.byTitle[seriesIndexKey(key, year)]; ok { return release, true } } release, ok := index.byTitle[key] return release, ok } // heroPremiere is one thing Sonarr calls a premiere, resolved onto the Emby series the // household can actually play. type heroPremiere struct { EmbySeriesID string SeasonNumber int AiredAt time.Time } // sonarrPremieres picks the premieres out of a calendar window. // // A premiere is the *first episode of a season* — S01E01 is a new show and S02E01 is a // returning one, and the answered design question is that both are news where the fourth // episode of something already in Continue Watching is not. Three filters do the work and // each removes a card that would misfire: // // - Season 0 is specials. A Christmas special is not a premiere. // - HasFile is required, because a hero card exists to be pressed. // - The show must be one Emby holds, or the card has no page and no artwork. // // The most recent premiere per series wins: a show that premiered and then returned // inside one window is one card about its newer season, not two. func sonarrPremieres( episodes []sonarr.Episode, series seriesIndex, from, until time.Time, ) []heroPremiere { best := map[string]heroPremiere{} order := make([]string, 0, len(episodes)) for _, episode := range episodes { if episode.EpisodeNumber != 1 || episode.SeasonNumber < 1 || !episode.HasFile { continue } if episode.AirDateUTC == nil { continue } aired := *episode.AirDateUTC if aired.Before(from) || aired.After(until) { continue } embyID := series.lookup(episode.Series.Title, episode.Series.Year) if embyID == "" { continue } existing, seen := best[embyID] if !seen { order = append(order, embyID) } if seen && !aired.After(existing.AiredAt) { continue } best[embyID] = heroPremiere{ EmbySeriesID: embyID, SeasonNumber: episode.SeasonNumber, AiredAt: aired, } } out := make([]heroPremiere, 0, len(order)) for _, embyID := range order { out = append(out, best[embyID]) } sort.SliceStable(out, func(i, j int) bool { return out[i].AiredAt.After(out[j].AiredAt) }) return out } // heroRow composes the row. It is the only impure part of the feature, and every failure // inside it costs a signal rather than the hero: a Radarr that will not answer means // films fall back to Emby's premiere dates, a Sonarr that will not answer means no // premieres, and neither means the launcher gets the ranking it had before. func (s *Server) heroRow( ctx context.Context, rows []recommend.Row, now time.Time, ) *recommend.Row { location := s.cfg.RadarrLocation if location == nil { location = time.Local } candidates := s.heroCandidates(ctx, rows, now) ranked := rankHeroCandidates(candidates, now, heroRowLimit) if len(ranked) == 0 { return nil } items := make([]json.RawMessage, 0, len(ranked)) for _, candidate := range ranked { items = append(items, injectHeroFields( candidate.Item, heroLabel(candidate, now), heroReason(candidate, now, location), )) } return &recommend.Row{ ID: heroRowID, Title: "Featured", Kind: heroRowKind, Items: items, } } // heroCandidates gathers everything eligible, premieres first. // // Premieres lead the input order so that they win a tie against a film of identical // score — a returning show is the more time-sensitive piece of news, and the scorer // cannot see that. func (s *Server) heroCandidates( ctx context.Context, rows []recommend.Row, now time.Time, ) []heroCandidate { movies, facts := heroMovieCandidates(rows) // Three independent reads, and on the one cache miss a day two of them are *arr round // trips. They run together rather than in turn because this is the tail of the home // response: every television in the house is waiting on it, and there is no reason for // Sonarr's answer to be behind Radarr's. var ( releases heroReleaseIndex premieres []heroCandidate providers map[string]string wg sync.WaitGroup ) wg.Add(3) go func() { defer wg.Done(); releases = s.heroReleaseIndex(ctx, now) }() go func() { defer wg.Done(); premieres = s.heroPremiereCandidates(ctx, now) }() go func() { defer wg.Done(); providers = s.heroProviderIDs(ctx, facts) }() wg.Wait() for index := range movies { fact := facts[movies[index].ID] // Radarr's digital date is preferred over Emby's PremiereDate wherever there is // one. That preference is the point of the feature: Emby's date is the // theatrical release where it is right at all, and is a metadata agent's guess // where it is not, so ranking "new releases" by it puts films in an order that // has nothing to do with when the household could first watch them. if release, ok := releases.lookup(providers[movies[index].ID], fact.Name, heroYearOf(fact)); ok { movies[index].ReleasedAt = release.at movies[index].Estimated = release.estimated } } return append(premieres, movies...) } // heroMovieCandidates reads the assembled rows. Nothing is fetched: these are the same // payloads the launcher is about to be sent, ratings already attached. func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]heroItemFacts) { candidates := make([]heroCandidate, 0, heroCandidateLimit) facts := make(map[string]heroItemFacts, heroCandidateLimit) seen := make(map[string]bool, heroCandidateLimit) for _, row := range rows { // Continue Watching is what somebody is already in the middle of, which is the // opposite of what a hero is for; the schedule rows are not playable at all. if row.Kind == "continue" || row.Kind == "schedule" || row.Kind == "movie-schedule" { continue } for _, raw := range row.Items { if len(candidates) >= heroCandidateLimit { return candidates, facts } fact, ok := heroFactsOf(raw) if !ok || seen[fact.ID] || !fact.Playable || !strings.EqualFold(fact.Type, "Movie") { continue } seen[fact.ID] = true facts[fact.ID] = fact rating, rated := heroRatingOf(raw) candidates = append(candidates, heroCandidate{ ID: fact.ID, Name: fact.Name, Kind: heroMovie, Item: raw, ReleasedAt: fact.Premiere, Rating: rating, Rated: rated, }) } } return candidates, facts } func heroYearOf(fact heroItemFacts) int { if fact.Premiere.IsZero() { return 0 } return fact.Premiere.Year() } // heroProviderIDs resolves the candidates onto TMDB ids so Radarr can be matched exactly. // A failure costs the exact match and leaves the title/year fallback. func (s *Server) heroProviderIDs( ctx context.Context, facts map[string]heroItemFacts, ) map[string]string { out := make(map[string]string, len(facts)) if s.store == nil || len(facts) == 0 { return out } ids := make([]string, 0, len(facts)) for id := range facts { ids = append(ids, id) } refs, err := s.store.LibraryProviderIDs(ctx, ids) if err != nil { s.loggerFor(ctx).Warn("hero provider ids unavailable", "error", err) return out } for id, ref := range refs { if tmdb := strings.TrimSpace(providerID(ref.ProviderIDs, "tmdb")); tmdb != "" { out[id] = tmdb } } return out } // heroReleaseIndex reads Radarr over the window that has already happened, cached for the // day beside the schedule row's forward-looking one. func (s *Server) heroReleaseIndex(ctx context.Context, now time.Time) heroReleaseIndex { empty := heroReleaseIndex{ byTMDB: map[string]radarrRelease{}, byTitle: map[string]radarrRelease{}, } if s.radarr == nil { return empty } location := s.cfg.RadarrLocation if location == nil { location = time.Local } dayStart := localDayStart(now.In(location), location) key := heroReleasedCachePrefix + dayStart.Format("2006-01-02") if raw, err := s.cache.Get(ctx, key); err == nil { var movies []radarr.Movie if json.Unmarshal(raw, &movies) == nil { return newHeroReleaseIndex(movies) } } s.radarrMu.Lock() defer s.radarrMu.Unlock() if raw, err := s.cache.Get(ctx, key); err == nil { var movies []radarr.Movie if json.Unmarshal(raw, &movies) == nil { return newHeroReleaseIndex(movies) } } // The cinema fallback is cinema + 30 days, so a film whose digital date is unknown // but which is inside the window had its cinema date up to 30 days before that. movies, err := s.radarr.Calendar( ctx, dayStart.AddDate(0, 0, -(heroWindowDays+radarrTheatricalDelayDays)), dayStart.AddDate(0, 0, 1), ) if err != nil { s.loggerFor(ctx).Warn("hero release calendar failed", "error", err) return empty } if body, marshalErr := json.Marshal(movies); marshalErr == nil { if cacheErr := s.cache.Set(ctx, key, body, s.cfg.RadarrTTL); cacheErr != nil { s.loggerFor(ctx).Warn("hero release cache write failed", "error", cacheErr) } } return newHeroReleaseIndex(movies) } // heroPremiereCandidates reads Sonarr's recent calendar and turns each premiere into the // Emby series card the household can play. func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []heroCandidate { if s.sonarr == nil || s.store == nil { return nil } location := s.cfg.SonarrLocation if location == nil { location = time.Local } dayStart := localDayStart(now.In(location), location) key := heroPremiereCachePrefix + dayStart.Format("2006-01-02") var episodes []sonarr.Episode if raw, err := s.cache.Get(ctx, key); err == nil { _ = json.Unmarshal(raw, &episodes) } if episodes == nil { s.sonarrMu.Lock() if raw, err := s.cache.Get(ctx, key); err == nil { _ = json.Unmarshal(raw, &episodes) } if episodes == nil { fetched, err := s.sonarr.Calendar( ctx, dayStart.AddDate(0, 0, -heroWindowDays), dayStart.AddDate(0, 0, 1), ) if err != nil { s.sonarrMu.Unlock() s.loggerFor(ctx).Warn("hero premiere calendar failed", "error", err) return nil } episodes = fetched if body, marshalErr := json.Marshal(episodes); marshalErr == nil { if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil { s.loggerFor(ctx).Warn("hero premiere cache write failed", "error", cacheErr) } } } s.sonarrMu.Unlock() } premieres := sonarrPremieres( episodes, s.embySeriesIndex(ctx), now.AddDate(0, 0, -heroWindowDays), now, ) if len(premieres) == 0 { return nil } ids := make([]string, 0, len(premieres)) for _, premiere := range premieres { ids = append(ids, premiere.EmbySeriesID) } payloads, err := s.store.LibraryItemsByID(ctx, ids) if err != nil { s.loggerFor(ctx).Warn("hero premiere series unavailable", "error", err) return nil } // The imported catalogue is shared by the household and deliberately carries no user // data, so these cards arrive without ratings. Decorating them is one indexed read // and is what lets a premiere be ranked on the same terms as a film. s.decorateItemRatings(ctx, payloads) byID := make(map[string]json.RawMessage, len(payloads)) for _, raw := range payloads { if id := itemIDOf(raw); id != "" { byID[id] = raw } } candidates := make([]heroCandidate, 0, len(premieres)) for _, premiere := range premieres { raw, ok := byID[premiere.EmbySeriesID] if !ok { continue } fact, ok := heroFactsOf(raw) if !ok { continue } kind := heroSeasonPremiere if premiere.SeasonNumber == 1 { kind = heroSeriesPremiere } rating, rated := heroRatingOf(raw) candidates = append(candidates, heroCandidate{ ID: fact.ID, Name: fact.Name, Kind: kind, Item: raw, ReleasedAt: premiere.AiredAt, Rating: rating, Rated: rated, }) } return candidates }