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. // // The ranking alone is not the row, though, because the evidence behind it is stable: a // release date does not move and a score settles within a week, so the top of the merit // order stayed the same four cards for days at a time on a library nothing new had arrived // in. `rotateHeroCandidates` is the answer — a draw from merit bands, re-made a few times a // day — and the reason it is a rotation within bands rather than a shuffle is written up // there. import ( "context" "encoding/json" "errors" "hash/fnv" "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" "github.com/ponzischeme89/memby/server/internal/store" ) 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 row is drawn from a pool of the best candidates rather than being the top of // the ranking outright — three times what is sent, so each band holds three titles // the scorer could barely separate and there is something to rotate between. heroPoolLimit = heroRowLimit * 3 // How often the draw is re-made. Four is the shape of an evening rather than an // arbitrary number: morning, afternoon, evening and late, so a set switched on after // dinner does not lead with the card it led with at breakfast. heroRotationsPerDay = 4 ) // 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 heroSeries 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 Source string } // 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 } func heroSource(candidate heroCandidate) string { if candidate.Source != "" { return candidate.Source } if candidate.Kind == heroSeriesPremiere || candidate.Kind == heroSeasonPremiere { return "continue_world" } return "recommended_for_user" } // 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 } // rotateHeroCandidates decides which of several near-equal titles leads, and changes its // mind through the day. // // The ranking underneath it is stable by design, and so are the facts behind it: a digital // release date does not move, a premiere aired when it aired, and a score settles within a // week. So the top of the merit order is the same card for as long as nothing new arrives — // in practice five days at a stretch — and the launcher reads as a screen nobody maintains. // // The fix is the `selectSeeds` shape rather than a shuffle, because a shuffle is exactly // how the best-reviewed release of the week ends up in the fourth slot. The pool is cut // into as many equal bands as there are cards to send and one title is drawn from each, so // merit still decides *which band* a title is in — the first card always comes from the // best three, the second from the next three — and variation only picks between titles the // scorer could not meaningfully separate. Three properties are the point and are tested: // // - Bands are in merit order, so nothing well-reviewed and new can fall past the slot its // score earned. It is a rotation within bands, never a reordering of the ranking. // - One slot always yields the same cards. 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. // - The last band takes the remainder, so a pool that does not divide evenly is still // drawn from in full. func rotateHeroCandidates(ranked []heroCandidate, variation string, limit int) []heroCandidate { if limit <= 0 || len(ranked) == 0 { return nil } // Nothing to rotate between: every candidate is going out anyway, and in merit order // is the best order to send them in. if len(ranked) <= limit { return append([]heroCandidate(nil), ranked...) } band := len(ranked) / limit out := make([]heroCandidate, 0, limit) for index := 0; index < limit; index++ { start := index * band end := start + band if index == limit-1 { end = len(ranked) } best := start for candidate := start + 1; candidate < end; candidate++ { if heroVariation(variation, ranked[candidate].ID) < heroVariation(variation, ranked[best].ID) { best = candidate } } out = append(out, ranked[best]) } return out } // heroRotationSlot names the part of the day the draw belongs to. // // It is the household's local day, not UTC, for the same reason the daily hero rotation on // the direct path is local: "changes during the evening" has to mean the viewer's evening. // The date is in the string as well as the slot, or the four slots would repeat themselves // every day and a card dropped at breakfast would be back tomorrow morning. func heroRotationSlot(now time.Time, location *time.Location) string { if location == nil { location = time.UTC } local := now.In(location) slot := local.Hour() * heroRotationsPerDay / 24 return local.Format("2006-01-02") + "#" + strconv.Itoa(slot) } // heroVariationSeed keys the draw to one viewer and one slot. Per user, because two people // signed into the same house have different rows behind the hero and there is no reason // for them to be shown the same lead card at the same moment. func heroVariationSeed(userID string, slot string) string { return userID + "@" + slot } func heroVariation(seed, value string) uint64 { hash := fnv.New64a() _, _ = hash.Write([]byte(seed)) _, _ = hash.Write([]byte{0}) _, _ = hash.Write([]byte(value)) return hash.Sum64() } // 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 { if location == nil { location = time.UTC } local := now.In(location) if candidate.Source == "time_sensitive" { return "Tonight's pick" } if candidate.Source == "continue_world" && local.Hour() >= 20 { return "You normally watch an episode around now" } if candidate.Source == "favourite_genre" && local.Weekday() == time.Sunday && local.Hour() < 18 { return "Something easy for Sunday" } if candidate.Source == "trending" { return "Trending amongst Memby viewers" } 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 == heroSeries && fresh: 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 "" } } func heroReasonForPosition( candidate heroCandidate, position int, primeSubtitle string, now time.Time, location *time.Location, ) string { if position == 0 && strings.TrimSpace(primeSubtitle) != "" { return strings.TrimSpace(primeSubtitle) } return heroReason(candidate, now, location) } // 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 // Watched is Emby's own answer for this viewer. The rows are fetched per person and // carry their user data, so it costs nothing to read. Watched 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"` UserData struct { Played bool `json:"Played"` } `json:"UserData"` } 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), Watched: payload.UserData.Played, } 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, userID string, now time.Time, ) *recommend.Row { location := s.cfg.RadarrLocation if location == nil { location = time.Local } candidates := s.heroCandidates(ctx, rows, now) // The cached read, not a query: this document is now also read by the status poll, and // it must be the same copy the cache key was derived from a moment ago or the answer // filed under a revision would not be the answer that revision names. policy := s.currentHeroPolicy(ctx) placementPolicy := policy.Placement(store.HeroPlacementHome) pinned := s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs) // Manual pins always lead. Schedules resolve on the gateway (never on a television), // then the existing automatic/release-aware selection fills any remaining places. scheduledIDs := activeHeroScheduleIDs(policy.Schedules, store.HeroPlacementHome, userID, now, location) scheduled := s.pinnedHeroCandidates(ctx, scheduledIDs) // Rank a pool, then draw the row out of it. Ranking straight to the row's length is // what made the hero the same four cards for a week — see rotateHeroCandidates. pool := rankHeroCandidates(candidates, now, heroPoolLimit) organic := rotateHeroCandidates( pool, heroVariationSeed(userID, heroRotationSlot(now, location)), heroRowLimit, ) ranked := mergePinnedHeroCandidates(append(pinned, scheduled...), candidates, organic, heroRowLimit) if len(ranked) == 0 { return nil } items := make([]json.RawMessage, 0, len(ranked)) for index, candidate := range ranked { items = append(items, injectHeroFields( candidate.Item, heroLabel(candidate, now), heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location), )) } return &recommend.Row{ ID: heroRowID, Title: "Featured", Kind: heroRowKind, Items: items, } } // mergePinnedHeroCandidates places explicit operator choices in the visible grid first, // then fills it with the normal release-aware rotation. When a pin also qualified // organically, its release or premiere evidence is retained so pinning changes placement, // never presentation. func mergePinnedHeroCandidates( pinned, evidence, organic []heroCandidate, limit int, ) []heroCandidate { if limit <= 0 { return nil } out := make([]heroCandidate, 0, limit) seen := map[string]bool{} evidenceByID := make(map[string]heroCandidate, len(evidence)) for _, candidate := range evidence { evidenceByID[candidate.ID] = candidate } for index, candidate := range pinned { if natural, ok := evidenceByID[candidate.ID]; ok { pinned[index] = natural } } appendUnique := func(candidates []heroCandidate) { for _, candidate := range candidates { if len(out) == limit { return } if candidate.ID == "" || len(candidate.Item) == 0 || seen[candidate.ID] { continue } seen[candidate.ID] = true out = append(out, candidate) } } appendUnique(pinned) appendUnique(organic) return out } func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID string, now time.Time, location *time.Location) []string { type active struct { id string priority int start time.Time } matched := []active{} for _, schedule := range schedules { if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) || !heroScheduleActiveAt(schedule, now, location) { continue } matchesPlacement := false if len(schedule.Placements) == 0 && placement == store.HeroPlacementHome { matchesPlacement = true } for _, candidate := range schedule.Placements { if candidate == placement { matchesPlacement = true break } } if !matchesPlacement { continue } matched = append(matched, active{schedule.ItemID, schedule.Priority, schedule.StartAt}) } sort.SliceStable(matched, func(i, j int) bool { if matched[i].priority != matched[j].priority { return matched[i].priority > matched[j].priority } return matched[i].start.After(matched[j].start) }) ids := make([]string, 0, len(matched)) for _, item := range matched { ids = append(ids, item.id) } return ids } func heroScheduleActiveAt(schedule store.HeroSchedule, now time.Time, location *time.Location) bool { if location == nil { location = time.Local } if schedule.Frequency != "daily" && schedule.Frequency != "weekly" { if now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) { return false } return len(schedule.Weekdays) == 0 || heroScheduleHasWeekday(schedule.Weekdays, int(now.In(location).Weekday())) } start, startErr := time.Parse("15:04", schedule.StartTime) end, endErr := time.Parse("15:04", schedule.EndTime) if startErr != nil || endErr != nil { return false } local := now.In(location) minute := local.Hour()*60 + local.Minute() startMinute := start.Hour()*60 + start.Minute() endMinute := end.Hour()*60 + end.Minute() if startMinute == endMinute { return false } effectiveDay := int(local.Weekday()) active := minute >= startMinute && minute < endMinute if startMinute > endMinute { active = minute >= startMinute || minute < endMinute if minute < endMinute { effectiveDay = (effectiveDay + 6) % 7 } } if !active || schedule.Frequency == "daily" || len(schedule.Weekdays) == 0 { return active } return heroScheduleHasWeekday(schedule.Weekdays, effectiveDay) } func heroScheduleHasWeekday(days []int, weekday int) bool { for _, day := range days { if day == weekday { return true } } return false } // pinnedHeroCandidates resolves policy against the imported catalogue. A deleted or // unsupported id quietly drops out, so an old admin choice can never make Home fail. func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroCandidate { items, err := s.store.LibraryItemsByID(ctx, ids) if err != nil { s.loggerFor(ctx).Warn("pinned hero titles unavailable", "error", err) return nil } byID := make(map[string]heroCandidate, len(items)) for _, raw := range items { fact, ok := heroFactsOf(raw) if !ok || !fact.Playable || (!strings.EqualFold(fact.Type, "Movie") && !strings.EqualFold(fact.Type, "Series")) { continue } rating, rated := heroRatingOf(raw) kind := heroMovie if strings.EqualFold(fact.Type, "Series") { kind = heroSeries } byID[fact.ID] = heroCandidate{ ID: fact.ID, Name: fact.Name, Kind: kind, Item: raw, ReleasedAt: fact.Premiere, Rating: rating, Rated: rated, Source: "admin_pinned", } } out := make([]heroCandidate, 0, len(ids)) for _, id := range ids { if candidate, ok := byID[id]; ok { out = append(out, candidate) } } return out } // 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 } // A film somebody has already seen is not something to lead the launcher // with: the hero exists to be pressed, and the answer to "watch this // tonight" cannot be a film that finished last week. It is only ever the // whole title here — a film halfway through belongs to Continue Watching, // which is not a candidate row at all. // // Series are deliberately not filtered this way. A show marked watched is // one somebody is up to date with, which is exactly who a season premiere // is news for, and premieres come from Sonarr rather than from here. if fact.Watched { continue } seen[fact.ID] = true facts[fact.ID] = fact rating, rated := heroRatingOf(raw) source := "recommended_for_user" rowText := strings.ToLower(row.Kind + " " + row.ID) if strings.Contains(rowText, "favorite") { source = "favourite_genre" } if strings.Contains(rowText, "trending") { source = "trending" } if strings.Contains(rowText, "season") { source = "seasonal" } if strings.Contains(rowText, "latest") { source = "new_release" } candidates = append(candidates, heroCandidate{ ID: fact.ID, Name: fact.Name, Kind: heroMovie, Item: raw, ReleasedAt: fact.Premiere, Rating: rating, Rated: rated, Source: source, }) } } 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.householdLocation() 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.decorateItems(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, Source: "continue_world", }) } return candidates }