0.2.40 & server 0.1.30
This commit is contained in:
@@ -1781,9 +1781,20 @@ returns nothing rather than throwing. Things to preserve:
|
|||||||
reason — an older television has no idea the kind is special. That floor is the version
|
reason — an older television has no idea the kind is special. That floor is the version
|
||||||
the feature shipped *in* rather than one after it, so a 0.2.27 build predating it would
|
the feature shipped *in* rather than one after it, so a 0.2.27 build predating it would
|
||||||
draw the duplicate row; moving the floor up is the fix if that ever bites.
|
draw the duplicate row; moving the floor up is the fix if that ever bites.
|
||||||
- **No daily rotation on the server's hero.** The facts behind it already change daily, and
|
- **The row is a draw from merit bands, re-made four times a day.** Ranking straight to the
|
||||||
rotating a merit ranking is exactly how the best-reviewed release of the week lands in the
|
row's length was the original design, on the reasoning that the facts behind it change
|
||||||
fourth slot. The rotation below belongs to the direct path, which has no merit to rank by.
|
daily — and they do not: a digital release date does not move, a premiere aired when it
|
||||||
|
aired, a score settles within a week, so the same two cards led the launcher for five days
|
||||||
|
at a stretch. `rotateHeroCandidates` ranks a pool of `heroPoolLimit` instead, cuts it into
|
||||||
|
as many bands as there are cards to send, and draws one from each by
|
||||||
|
`heroVariationSeed(userID, heroRotationSlot(now, location))` — the `selectSeeds` shape, and
|
||||||
|
for the same reason it is not a shuffle: merit still decides which *band* a title is in, so
|
||||||
|
the best-reviewed release of the week can never land in the fourth slot, and variation only
|
||||||
|
picks between titles the scorer could not separate. The slot is the household's local part
|
||||||
|
of the day and carries the date, the seed is per viewer, and a pool with no spare
|
||||||
|
candidates goes out in merit order untouched. Home is cached for a minute and rebuilt
|
||||||
|
constantly behind it, which is why one slot must always yield the same draw.
|
||||||
|
The rotation further down belongs to the direct path, which has no merit to rank by.
|
||||||
|
|
||||||
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
|
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
|
||||||
card's text column is what gives way when a title wraps onto two lines, so whatever is last
|
card's text column is what gives way when a title wraps onto two lines, so whatever is last
|
||||||
|
|||||||
+108
-1
@@ -35,11 +35,19 @@ package api
|
|||||||
// - **Every card it produces is playable.** A premiere the household has not downloaded
|
// - **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
|
// 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.
|
// 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"hash/fnv"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -68,6 +76,16 @@ const (
|
|||||||
// Candidates are capped before scoring. A launcher is a few hundred cards; this is a
|
// 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.
|
// guard against a future row type offering a thousand, not a limit anything reaches.
|
||||||
heroCandidateLimit = 240
|
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
|
// 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
|
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.
|
// 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
|
// 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(
|
func (s *Server) heroRow(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
rows []recommend.Row,
|
rows []recommend.Row,
|
||||||
|
userID string,
|
||||||
now time.Time,
|
now time.Time,
|
||||||
) *recommend.Row {
|
) *recommend.Row {
|
||||||
location := s.cfg.RadarrLocation
|
location := s.cfg.RadarrLocation
|
||||||
@@ -548,7 +648,14 @@ func (s *Server) heroRow(
|
|||||||
location = time.Local
|
location = time.Local
|
||||||
}
|
}
|
||||||
candidates := s.heroCandidates(ctx, rows, now)
|
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 {
|
if len(ranked) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -403,6 +405,138 @@ func TestInjectHeroFieldsPreservesUnknownFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// heroPool is a merit-ordered pool, the shape rotateHeroCandidates is handed: 0 is the
|
||||||
|
// best-scoring candidate and the numbers descend from there.
|
||||||
|
func heroPool(size int) []heroCandidate {
|
||||||
|
pool := make([]heroCandidate, 0, size)
|
||||||
|
for index := 0; index < size; index++ {
|
||||||
|
id := "title-" + strconv.Itoa(index)
|
||||||
|
pool = append(pool, heroMovieCandidate(id, heroDaysAgo(index), 0.9, true))
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// The property that makes this a rotation rather than a shuffle: whatever slot is drawn,
|
||||||
|
// the first card came from the best band and the second from the one below it. A
|
||||||
|
// well-reviewed new release can never be pushed down the row by variation.
|
||||||
|
func TestHeroRotationDrawsOneFromEachMeritBand(t *testing.T) {
|
||||||
|
pool := heroPool(heroPoolLimit)
|
||||||
|
band := heroPoolLimit / heroRowLimit
|
||||||
|
for hour := 0; hour < 24; hour++ {
|
||||||
|
now := time.Date(2026, 8, 7, hour, 0, 0, 0, time.UTC)
|
||||||
|
seed := heroVariationSeed("viewer", heroRotationSlot(now, time.UTC))
|
||||||
|
drawn := rotateHeroCandidates(pool, seed, heroRowLimit)
|
||||||
|
if len(drawn) != heroRowLimit {
|
||||||
|
t.Fatalf("hour %d: expected %d cards, got %v", hour, heroRowLimit, heroIDs(drawn))
|
||||||
|
}
|
||||||
|
for slot, candidate := range drawn {
|
||||||
|
index, err := strconv.Atoi(strings.TrimPrefix(candidate.ID, "title-"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if index/band != slot {
|
||||||
|
t.Fatalf("hour %d: slot %d drew %s, out of band %d",
|
||||||
|
hour, slot, candidate.ID, index/band)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The complaint this feature answers: the same two titles led the launcher for five days.
|
||||||
|
// The lead card has to actually move, both across a day and across days.
|
||||||
|
func TestHeroRotationChangesThroughTheDayAndAcrossDays(t *testing.T) {
|
||||||
|
pool := heroPool(heroPoolLimit)
|
||||||
|
leads := map[string]bool{}
|
||||||
|
for day := 7; day < 12; day++ {
|
||||||
|
for hour := 0; hour < 24; hour += 24 / heroRotationsPerDay {
|
||||||
|
now := time.Date(2026, 8, day, hour, 0, 0, 0, time.UTC)
|
||||||
|
seed := heroVariationSeed("viewer", heroRotationSlot(now, time.UTC))
|
||||||
|
leads[rotateHeroCandidates(pool, seed, heroRowLimit)[0].ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The top band holds three titles; over five days of four slots each, a draw that
|
||||||
|
// never moved off one of them would be a rotation in name only.
|
||||||
|
if len(leads) < 2 {
|
||||||
|
t.Fatalf("expected the lead card to move, saw only %v", leads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func TestHeroRotationIsStableWithinASlot(t *testing.T) {
|
||||||
|
pool := heroPool(heroPoolLimit)
|
||||||
|
location := time.UTC
|
||||||
|
first := heroRotationSlot(time.Date(2026, 8, 7, 19, 0, 0, 0, location), location)
|
||||||
|
second := heroRotationSlot(time.Date(2026, 8, 7, 20, 30, 0, 0, location), location)
|
||||||
|
if first != second {
|
||||||
|
t.Fatalf("expected one evening slot, got %q and %q", first, second)
|
||||||
|
}
|
||||||
|
seed := heroVariationSeed("viewer", first)
|
||||||
|
want := heroIDs(rotateHeroCandidates(pool, seed, heroRowLimit))
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
got := heroIDs(rotateHeroCandidates(pool, seed, heroRowLimit))
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("draw %d differed: %v vs %v", attempt, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two people signed into the same house get their own draw — there is no reason for the
|
||||||
|
// same card to lead both televisions at the same moment.
|
||||||
|
func TestHeroRotationVariesByViewer(t *testing.T) {
|
||||||
|
pool := heroPool(heroPoolLimit)
|
||||||
|
slot := heroRotationSlot(heroNow, time.UTC)
|
||||||
|
same := 0
|
||||||
|
for _, viewer := range []string{"a", "b", "c", "d"} {
|
||||||
|
if rotateHeroCandidates(pool, heroVariationSeed(viewer, slot), heroRowLimit)[0].ID ==
|
||||||
|
rotateHeroCandidates(pool, heroVariationSeed("a", slot), heroRowLimit)[0].ID {
|
||||||
|
same++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if same == 4 {
|
||||||
|
t.Fatal("every viewer drew the same lead card")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pool with nothing spare to rotate between must go out in merit order, not be reordered
|
||||||
|
// for the sake of it — this is the household whose library has barely eight hero-worthy
|
||||||
|
// titles in it.
|
||||||
|
func TestHeroRotationKeepsMeritOrderWithNothingToRotate(t *testing.T) {
|
||||||
|
pool := heroPool(heroRowLimit)
|
||||||
|
drawn := rotateHeroCandidates(pool, "seed", heroRowLimit)
|
||||||
|
for index, candidate := range drawn {
|
||||||
|
if candidate.ID != pool[index].ID {
|
||||||
|
t.Fatalf("expected merit order, got %v", heroIDs(drawn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rotateHeroCandidates(nil, "seed", heroRowLimit) != nil {
|
||||||
|
t.Fatal("expected no cards from no candidates")
|
||||||
|
}
|
||||||
|
if rotateHeroCandidates(pool, "seed", 0) != nil {
|
||||||
|
t.Fatal("expected no cards for no slots")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slot is the household's local part of the day, not UTC's — "the evening" has to mean
|
||||||
|
// the viewer's evening, for the reason the direct path's daily rotation is local too.
|
||||||
|
func TestHeroRotationSlotIsLocal(t *testing.T) {
|
||||||
|
auckland := time.FixedZone("NZST", 12*60*60)
|
||||||
|
// Midday in Auckland is the previous day in UTC, and must not be read as the small
|
||||||
|
// hours of it.
|
||||||
|
now := time.Date(2026, 8, 7, 12, 0, 0, 0, auckland)
|
||||||
|
if got, want := heroRotationSlot(now, auckland), "2026-08-07#2"; got != want {
|
||||||
|
t.Fatalf("expected %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
if got, want := heroRotationSlot(now, time.UTC), "2026-08-07#0"; got != want {
|
||||||
|
t.Fatalf("expected %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
// The date is in the slot, or the four slots repeat themselves every day.
|
||||||
|
tomorrow := heroRotationSlot(now.AddDate(0, 0, 1), auckland)
|
||||||
|
if tomorrow == heroRotationSlot(now, auckland) {
|
||||||
|
t.Fatalf("expected the day to be part of the slot, got %q", tomorrow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func heroIDs(candidates []heroCandidate) []string {
|
func heroIDs(candidates []heroCandidate) []string {
|
||||||
ids := make([]string, 0, len(candidates))
|
ids := make([]string, 0, len(candidates))
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
// among the shelves means nothing, and being first is what lets an older reader that
|
// among the shelves means nothing, and being first is what lets an older reader that
|
||||||
// does draw it put it somewhere sensible.
|
// does draw it put it somewhere sensible.
|
||||||
if hero {
|
if hero {
|
||||||
if row := s.heroRow(ctx, out.Rows, time.Now()); row != nil {
|
if row := s.heroRow(ctx, out.Rows, sess.EmbyUserID, time.Now()); row != nil {
|
||||||
out.Rows = append([]recommend.Row{*row}, out.Rows...)
|
out.Rows = append([]recommend.Row{*row}, out.Rows...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.29
|
0.1.30
|
||||||
|
|||||||
Reference in New Issue
Block a user