Files
memby/server/internal/api/hero.go
T
2026-08-14 09:40:03 +12:00

1104 lines
38 KiB
Go

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
}
// 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
}
// 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 {
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)
policy, err := s.store.HeroPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
policy = store.HeroPolicy{}
}
pinned := s.pinnedHeroCandidates(ctx, policy.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, 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, policy.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, 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) || now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
continue
}
if len(schedule.Weekdays) > 0 {
weekday := int(now.In(location).Weekday())
found := false
for _, day := range schedule.Weekdays {
if day == weekday {
found = true
break
}
}
if !found {
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
}
// 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,
}
}
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)
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
}