0.2.40 & server 0.1.30

This commit is contained in:
ponzischeme89
2026-08-10 08:54:23 +12:00
parent 78d26effbf
commit e1bb687df4
5 changed files with 258 additions and 6 deletions
+14 -3
View File
@@ -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
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.
- **No daily rotation on the server's hero.** The facts behind it already change daily, and
rotating a merit ranking is exactly how the best-reviewed release of the week lands in the
fourth slot. The rotation below belongs to the direct path, which has no merit to rank by.
- **The row is a draw from merit bands, re-made four times a day.** Ranking straight to the
row's length was the original design, on the reasoning that the facts behind it change
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
card's text column is what gives way when a title wraps onto two lines, so whatever is last
+108 -1
View File
@@ -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
}
+134
View File
@@ -2,6 +2,8 @@ package api
import (
"encoding/json"
"strconv"
"strings"
"testing"
"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 {
ids := make([]string, 0, len(candidates))
for _, candidate := range candidates {
+1 -1
View File
@@ -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
// does draw it put it somewhere sensible.
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...)
}
}
+1 -1
View File
@@ -1 +1 @@
0.1.29
0.1.30