From e1bb687df4052a2b6867927731f3763b146a3d64 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 10 Aug 2026 08:54:23 +1200 Subject: [PATCH] 0.2.40 & server 0.1.30 --- CLAUDE.md | 17 +++- server/internal/api/hero.go | 109 +++++++++++++++++++++++- server/internal/api/hero_test.go | 134 ++++++++++++++++++++++++++++++ server/internal/api/home.go | 2 +- server/internal/buildinfo/VERSION | 2 +- 5 files changed, 258 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cafffeb..f6ec78f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/server/internal/api/hero.go b/server/internal/api/hero.go index c4397a4..21836e9 100644 --- a/server/internal/api/hero.go +++ b/server/internal/api/hero.go @@ -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 } diff --git a/server/internal/api/hero_test.go b/server/internal/api/hero_test.go index c9bb488..680a4b6 100644 --- a/server/internal/api/hero_test.go +++ b/server/internal/api/hero_test.go @@ -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 { diff --git a/server/internal/api/home.go b/server/internal/api/home.go index 8c3c694..350bb4e 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -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...) } } diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 5ef49d2..013adb7 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.29 +0.1.30