0.2.40 & server 0.1.30
This commit is contained in:
+108
-1
@@ -35,11 +35,19 @@ package api
|
||||
// - **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"
|
||||
@@ -68,6 +76,16 @@ const (
|
||||
// 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
|
||||
@@ -214,6 +232,87 @@ func rankHeroCandidates(candidates []heroCandidate, now time.Time, limit int) []
|
||||
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
|
||||
@@ -541,6 +640,7 @@ func sonarrPremieres(
|
||||
func (s *Server) heroRow(
|
||||
ctx context.Context,
|
||||
rows []recommend.Row,
|
||||
userID string,
|
||||
now time.Time,
|
||||
) *recommend.Row {
|
||||
location := s.cfg.RadarrLocation
|
||||
@@ -548,7 +648,14 @@ func (s *Server) heroRow(
|
||||
location = time.Local
|
||||
}
|
||||
candidates := s.heroCandidates(ctx, rows, now)
|
||||
ranked := rankHeroCandidates(candidates, now, heroRowLimit)
|
||||
// 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)
|
||||
ranked := rotateHeroCandidates(
|
||||
pool,
|
||||
heroVariationSeed(userID, heroRotationSlot(now, location)),
|
||||
heroRowLimit,
|
||||
)
|
||||
if len(ranked) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user