0.2.81
This commit is contained in:
@@ -362,6 +362,16 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
|
||||
if _, ok := body["preferencesRevision"]; !ok {
|
||||
t.Fatalf("status response carried no preferences revision: %v", body)
|
||||
}
|
||||
// Likewise present with no store behind it. This is the whole delivery channel for an
|
||||
// operator's hero change: a television comparing against a missing field would go on
|
||||
// drawing yesterday's hero until it was next restarted.
|
||||
hero, ok := body["hero"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("status response carried no hero revision: %v", body)
|
||||
}
|
||||
if revision, _ := hero["revision"].(string); revision == "" {
|
||||
t.Fatalf("hero revision was empty; nothing would ever be refetched: %v", hero)
|
||||
}
|
||||
}
|
||||
|
||||
// With the probe switched off there is nothing to say, and a client must not be handed a
|
||||
|
||||
@@ -111,6 +111,9 @@ type Server struct {
|
||||
// featurePolicy keeps the operator's feature switches out of the request path. See
|
||||
// features_cache.go.
|
||||
featurePolicy featurePolicyCache
|
||||
// heroPolicy keeps the operator's hero choices out of the request path, which now
|
||||
// includes the status poll every open television makes. See hero_revision.go.
|
||||
heroPolicy heroPolicyCache
|
||||
// upstream deduplicates concurrent cache misses for the same key, so two televisions
|
||||
// asking for the same expensive answer at the same moment cost one upstream call
|
||||
// rather than two. See coalesce.go.
|
||||
@@ -272,6 +275,7 @@ func (s *Server) Routes() http.Handler {
|
||||
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
|
||||
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
|
||||
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
|
||||
v1.Handle("GET /v1/genres/affinity", s.authed(s.handleGenreAffinity))
|
||||
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
||||
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
||||
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The Genres browser's rail is a fixed catalogue in product order on the television, and
|
||||
// that order is deliberately not server data — it must not jump around while home rows are
|
||||
// arriving. What *is* server data is the evidence: Tracearr already knows who watched what,
|
||||
// and the imported catalogue already knows each title's genres, so the gateway can say
|
||||
// which labels a viewer actually watches and let the set decide what to do about it.
|
||||
//
|
||||
// The split is the point. The gateway sends Emby's own genre spellings with a weight each;
|
||||
// the television folds them into its sixteen categories through the alias table it already
|
||||
// owns and re-orders its own rail. A gateway that sorted the categories itself would need a
|
||||
// second copy of that catalogue, and the two would disagree the first time a category
|
||||
// gained an alias — which is the failure the aliases exist to fix in the first place.
|
||||
|
||||
const (
|
||||
// genreAffinityTTL is how long one viewer's reading is kept. Long, because this is
|
||||
// exactly the sort of answer that must never be on the path of opening a page and
|
||||
// because taste does not move in an evening — but not indefinite, so a household whose
|
||||
// viewing changes sees the rail follow it within a day.
|
||||
genreAffinityTTL = 6 * time.Hour
|
||||
|
||||
// genreAffinityEmptyTTL remembers "nothing to say about this viewer" for a shorter
|
||||
// span. A new account genuinely acquires a history, and a six-hour no would leave their
|
||||
// first evening of watching invisible until the following morning.
|
||||
genreAffinityEmptyTTL = time.Hour
|
||||
)
|
||||
|
||||
// genreAffinityEntry is one label on the wire.
|
||||
type genreAffinityEntry struct {
|
||||
Genre string `json:"genre"`
|
||||
Weight float64 `json:"weight"`
|
||||
}
|
||||
|
||||
// genreAffinityResponse is what a television is told.
|
||||
//
|
||||
// Sessions rides along because the ordering rule on the set refuses to personalise below a
|
||||
// floor, and a share of nothing is not evidence: a viewer three sessions old would
|
||||
// otherwise have one of those three deciding what leads their rail. It is the count of
|
||||
// qualifying sessions the weights were built from, not the household's total.
|
||||
type genreAffinityResponse struct {
|
||||
Sessions int `json:"sessions"`
|
||||
Genres []genreAffinityEntry `json:"genres"`
|
||||
}
|
||||
|
||||
// normaliseGenreAffinity scales the weights so the most-watched genre is 1.
|
||||
//
|
||||
// Pure, and the reason it exists is that the television's rule is written in shares rather
|
||||
// than in counts: a household that watches every night and one that watches on Sundays must
|
||||
// personalise the same way, and a floor expressed in raw sessions would mean something
|
||||
// different for each of them. Anything not positive is dropped rather than sent as a zero —
|
||||
// a genre with no weight is one the ordering has nothing to say about, and saying so with a
|
||||
// row invites the set to treat it as a considered nil.
|
||||
func normaliseGenreAffinity(affinity store.GenreAffinity) genreAffinityResponse {
|
||||
out := genreAffinityResponse{Sessions: affinity.Sessions, Genres: []genreAffinityEntry{}}
|
||||
top := 0.0
|
||||
for _, entry := range affinity.Genres {
|
||||
if entry.Score > top {
|
||||
top = entry.Score
|
||||
}
|
||||
}
|
||||
if top <= 0 {
|
||||
return out
|
||||
}
|
||||
for _, entry := range affinity.Genres {
|
||||
if entry.Genre == "" || entry.Score <= 0 {
|
||||
continue
|
||||
}
|
||||
out.Genres = append(out.Genres, genreAffinityEntry{
|
||||
Genre: entry.Genre,
|
||||
Weight: entry.Score / top,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleGenreAffinity answers which genres this viewer watches.
|
||||
//
|
||||
// It is its own route rather than a field on /v1/home for the reason the update verdict is:
|
||||
// home is cached per user and is the response every television in the house is waiting on,
|
||||
// while this is asked for at most once per session by the one set whose viewer has opened
|
||||
// the Genres browser. Nothing on the launcher wants it.
|
||||
//
|
||||
// Every way this can fail is the same answer — an empty reading, which the television reads
|
||||
// as "use the default order". A rail that refused to draw because Tracearr was unreachable
|
||||
// would be a personalisation feature costing somebody their genre list.
|
||||
func (s *Server) handleGenreAffinity(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
key := cache.UserKey(sess.EmbyUserID, "genre-affinity:v1")
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
return
|
||||
}
|
||||
|
||||
response := s.genreAffinityFor(ctx, sess)
|
||||
body, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, genreAffinityResponse{Genres: []genreAffinityEntry{}})
|
||||
return
|
||||
}
|
||||
ttl := genreAffinityTTL
|
||||
if len(response.Genres) == 0 {
|
||||
ttl = genreAffinityEmptyTTL
|
||||
}
|
||||
if err := s.cache.Set(ctx, key, body, ttl); err != nil {
|
||||
s.loggerFor(ctx).Warn("genre affinity cache write failed", "error", err)
|
||||
}
|
||||
w.Header().Set("X-Memby-Cache", "miss")
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// genreAffinityFor is the reading itself, separated from the caching so the failure stance
|
||||
// is stated once: nothing below returns an error, because there is no trouble here a viewer
|
||||
// could act on and the rail has a perfectly good answer without it.
|
||||
func (s *Server) genreAffinityFor(ctx context.Context, sess store.Session) genreAffinityResponse {
|
||||
empty := genreAffinityResponse{Genres: []genreAffinityEntry{}}
|
||||
if s.store == nil {
|
||||
return empty
|
||||
}
|
||||
// The operator's switch is honoured even though the rows are already in Postgres.
|
||||
// Tracearr switched off means the household has said Memby may not read their viewing,
|
||||
// and old rows are still their viewing.
|
||||
if !s.integrationEnabled(ctx, integrationTracearr) {
|
||||
return empty
|
||||
}
|
||||
identity, err := s.store.TracearrIdentity(ctx, sess.EmbyUserID)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Debug("genre affinity identity failed", "error", err)
|
||||
}
|
||||
// The session's own username is the fallback join, and it is the only one a viewer the
|
||||
// recommendation builder has never profiled has.
|
||||
username := identity.Username
|
||||
if username == "" {
|
||||
username = sess.Username
|
||||
}
|
||||
affinity, err := s.store.TracearrGenreAffinity(ctx, identity.TracearrUserID, username, time.Now())
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("genre affinity failed", "error", err)
|
||||
return empty
|
||||
}
|
||||
response := normaliseGenreAffinity(affinity)
|
||||
s.loggerFor(ctx).Debug("genre affinity read",
|
||||
"sessions", response.Sessions, "genres", len(response.Genres))
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestNormaliseGenreAffinityScalesToTheTopGenre(t *testing.T) {
|
||||
response := normaliseGenreAffinity(store.GenreAffinity{
|
||||
Sessions: 40,
|
||||
Genres: []store.GenreWeight{
|
||||
{Genre: "Crime", Score: 20},
|
||||
{Genre: "Drama", Score: 10},
|
||||
{Genre: "Western", Score: 1},
|
||||
},
|
||||
})
|
||||
if response.Sessions != 40 {
|
||||
t.Fatalf("sessions = %d, want 40", response.Sessions)
|
||||
}
|
||||
if len(response.Genres) != 3 {
|
||||
t.Fatalf("genres = %d, want 3", len(response.Genres))
|
||||
}
|
||||
if response.Genres[0].Genre != "Crime" || response.Genres[0].Weight != 1 {
|
||||
t.Fatalf("top = %+v, want Crime at 1", response.Genres[0])
|
||||
}
|
||||
if response.Genres[1].Weight != 0.5 {
|
||||
t.Fatalf("Drama = %v, want 0.5", response.Genres[1].Weight)
|
||||
}
|
||||
}
|
||||
|
||||
// A share is the whole point: two households with wildly different amounts of viewing must
|
||||
// produce the same ordering from the same balance of it.
|
||||
func TestNormaliseGenreAffinityIsScaleFree(t *testing.T) {
|
||||
light := normaliseGenreAffinity(store.GenreAffinity{Sessions: 12, Genres: []store.GenreWeight{
|
||||
{Genre: "Comedy", Score: 3}, {Genre: "Horror", Score: 1},
|
||||
}})
|
||||
heavy := normaliseGenreAffinity(store.GenreAffinity{Sessions: 900, Genres: []store.GenreWeight{
|
||||
{Genre: "Comedy", Score: 300}, {Genre: "Horror", Score: 100},
|
||||
}})
|
||||
for i := range light.Genres {
|
||||
if light.Genres[i] != heavy.Genres[i] {
|
||||
t.Fatalf("entry %d: %+v vs %+v", i, light.Genres[i], heavy.Genres[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty or nonsensical reading has to come back as an empty *list* rather than as null,
|
||||
// because the television reads "no genres" as "use the default order" and a null would be
|
||||
// one more shape for it to have an opinion about.
|
||||
func TestNormaliseGenreAffinityRefusesToInventWeights(t *testing.T) {
|
||||
for name, affinity := range map[string]store.GenreAffinity{
|
||||
"nothing watched": {},
|
||||
"all zero": {Sessions: 5, Genres: []store.GenreWeight{{Genre: "Drama"}}},
|
||||
"negative": {Sessions: 5, Genres: []store.GenreWeight{{Genre: "Drama", Score: -2}}},
|
||||
} {
|
||||
response := normaliseGenreAffinity(affinity)
|
||||
if response.Genres == nil {
|
||||
t.Fatalf("%s: genres is nil, want an empty list", name)
|
||||
}
|
||||
if len(response.Genres) != 0 {
|
||||
t.Fatalf("%s: genres = %+v, want none", name, response.Genres)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A blank label cannot be matched against anything on the television, so it is dropped
|
||||
// rather than sent as a row with a weight the set would have to know to ignore.
|
||||
func TestNormaliseGenreAffinityDropsBlankLabels(t *testing.T) {
|
||||
response := normaliseGenreAffinity(store.GenreAffinity{Sessions: 20, Genres: []store.GenreWeight{
|
||||
{Genre: "Drama", Score: 4}, {Genre: "", Score: 2},
|
||||
}})
|
||||
if len(response.Genres) != 1 || response.Genres[0].Genre != "Drama" {
|
||||
t.Fatalf("genres = %+v, want Drama alone", response.Genres)
|
||||
}
|
||||
}
|
||||
@@ -673,11 +673,10 @@ func (s *Server) heroRow(
|
||||
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{}
|
||||
}
|
||||
// The cached read, not a query: this document is now also read by the status poll, and
|
||||
// it must be the same copy the cache key was derived from a moment ago or the answer
|
||||
// filed under a revision would not be the answer that revision names.
|
||||
policy := s.currentHeroPolicy(ctx)
|
||||
placementPolicy := policy.Placement(store.HeroPlacementHome)
|
||||
pinned := s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs)
|
||||
// Manual pins always lead. Schedules resolve on the gateway (never on a television),
|
||||
|
||||
@@ -29,11 +29,13 @@ func (s *Server) handleActiveHero(w http.ResponseWriter, r *http.Request, sess s
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
key := cache.UserKey(sess.EmbyUserID, "hero:active:v1:"+placement+":"+heroRotationSlot(now, location))
|
||||
location := s.heroLocation()
|
||||
// Keyed by the revision the status poll publishes, which already carries the rotation
|
||||
// slot. That is what makes an operator's change reachable immediately without dropping
|
||||
// anything else the household has cached: the old entry is not invalidated, it is
|
||||
// simply no longer named. See heroRevision.
|
||||
key := cache.UserKey(sess.EmbyUserID, "hero:active:v2:"+placement+":"+
|
||||
heroRevision(s.currentHeroPolicy(r.Context()), sess.EmbyUserID, now, location))
|
||||
if raw, err := s.cache.Get(r.Context(), key); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
@@ -75,17 +77,10 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
|
||||
s.decorateItemRatings(ctx, result.Items)
|
||||
rows := []recommend.Row{{ID: "hero-candidates-" + placement, Kind: "catalogue", Items: result.Items}}
|
||||
|
||||
policy, err := s.store.HeroPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("section hero policy unavailable", "placement", placement, "error", err)
|
||||
policy = store.HeroPolicy{}
|
||||
}
|
||||
policy := s.currentHeroPolicy(ctx)
|
||||
placementPolicy := policy.Placement(placement)
|
||||
pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement)
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
location := s.heroLocation()
|
||||
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
|
||||
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
|
||||
|
||||
|
||||
@@ -260,7 +260,16 @@ func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "could not save hero policy")
|
||||
return
|
||||
}
|
||||
s.invalidateAllHomeCaches(r.Context())
|
||||
// No cache sweep. The hero revision is part of the home and section-hero cache keys,
|
||||
// so the entries built under the policy just replaced are already unreachable and age
|
||||
// out on their own TTL; this only drops *this* instance's copy of the document, which
|
||||
// is what makes the operator's own next read the answer they just saved rather than
|
||||
// the one they replaced.
|
||||
//
|
||||
// It used to call InvalidateUser for every account in the house, which threw away
|
||||
// every cached item lookup, image and row the household had in order to change four
|
||||
// cards — so a hero edit made the next launcher on every set rebuild from Emby.
|
||||
s.heroPolicy.invalidate()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"saved": true})
|
||||
}
|
||||
|
||||
@@ -277,16 +286,3 @@ func uniqueHeroIDs(ids []string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) invalidateAllHomeCaches(ctx context.Context) {
|
||||
users, err := s.store.KnownUsers(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("hero cache invalidation could not list users", "error", err)
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
if err := s.cache.InvalidateUser(ctx, user.ID); err != nil {
|
||||
s.loggerFor(ctx).Warn("hero cache invalidation failed", "user", user.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// heroRevisionSchema is bumped when the *shape* of what a revision covers changes, so a
|
||||
// gateway deployed mid-evening cannot hand a television a revision it has already acted
|
||||
// on for a hero that would now resolve differently.
|
||||
const heroRevisionSchema = 1
|
||||
|
||||
// heroPolicyTTL is how stale the cached hero policy may be, and it is the featurePolicyTTL
|
||||
// figure for the featurePolicyTTL reason: the operator is the only writer, their own write
|
||||
// clears this instance's copy outright, so the window is "how long until an instance that
|
||||
// did not make the change notices" rather than "how long until my change takes effect".
|
||||
//
|
||||
// It matters more here than it did there. /v1/status carries the hero revision now, which
|
||||
// means every open television would otherwise read this document from Postgres every ten
|
||||
// seconds to answer a question whose answer changes when somebody presses Save.
|
||||
const heroPolicyTTL = 5 * time.Second
|
||||
|
||||
// heroPolicyCache is the featurePolicyCache arrangement over the hero document: a stale
|
||||
// read takes the lock and refreshes, and every other caller waits for that one refresh
|
||||
// rather than starting its own.
|
||||
type heroPolicyCache struct {
|
||||
mu sync.Mutex
|
||||
value store.HeroPolicy
|
||||
valid bool
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
func (c *heroPolicyCache) read(
|
||||
ctx context.Context, load func(context.Context) store.HeroPolicy,
|
||||
) store.HeroPolicy {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.valid && time.Since(c.fetched) < heroPolicyTTL {
|
||||
return c.value
|
||||
}
|
||||
c.value = load(ctx)
|
||||
c.valid = true
|
||||
c.fetched = time.Now()
|
||||
return c.value
|
||||
}
|
||||
|
||||
func (c *heroPolicyCache) invalidate() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.valid = false
|
||||
}
|
||||
|
||||
// currentHeroPolicy is read by the status poll, by /v1/home and by /v1/heroes/active, so
|
||||
// it is cached rather than queried — see heroPolicyCache.
|
||||
//
|
||||
// Every failure degrades to an empty policy, which is the automatic hero: a launcher that
|
||||
// would not compose because a pin could not be looked up is a far worse trade than one
|
||||
// evening's operator choices being missed.
|
||||
func (s *Server) currentHeroPolicy(ctx context.Context) store.HeroPolicy {
|
||||
if s.store == nil {
|
||||
return store.HeroPolicy{}
|
||||
}
|
||||
return s.heroPolicy.read(ctx, func(ctx context.Context) store.HeroPolicy {
|
||||
policy, err := s.store.HeroPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
|
||||
return store.HeroPolicy{}
|
||||
}
|
||||
return policy
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) heroLocation() *time.Location {
|
||||
if location := s.cfg.RadarrLocation; location != nil {
|
||||
return location
|
||||
}
|
||||
return time.Local
|
||||
}
|
||||
|
||||
// heroRevision is what makes an operator's hero change arrive on the poll the television
|
||||
// is already making, and it is deliberately the themeRevision shape rather than a counter
|
||||
// in a table: what a hero resolves to changes when nobody has written anything — a
|
||||
// schedule window opens, the rotation slot turns over — and neither of those is a row
|
||||
// anybody updates.
|
||||
//
|
||||
// It is a hash of *what would be resolved* rather than of the stored document, which is
|
||||
// what keeps a save that changed nothing from repainting every launcher in the house. It
|
||||
// is per viewer because schedules can be, and because the rotation seed already is.
|
||||
//
|
||||
// The same value keys the cached answers (see handleHome and handleActiveHero), so the
|
||||
// revision a television holds names exactly the entry it would be served: there is no
|
||||
// state in which the poll says something moved and the fetch that follows returns what
|
||||
// the set already had.
|
||||
func heroRevision(policy store.HeroPolicy, userID string, now time.Time, location *time.Location) string {
|
||||
digest := fnv.New64a()
|
||||
write := func(part string) {
|
||||
_, _ = digest.Write([]byte(part))
|
||||
_, _ = digest.Write([]byte{0})
|
||||
}
|
||||
write(strconv.Itoa(heroRevisionSchema))
|
||||
// The slot rather than the clock: the draw is stable within it, so hashing the time
|
||||
// itself would move the revision on every poll and refetch four heroes a minute.
|
||||
write(heroRotationSlot(now, location))
|
||||
// A fixed order, never a map range: two instances disagreeing about a revision is
|
||||
// indistinguishable from a change, and would refetch on alternate polls.
|
||||
for _, name := range []string{store.HeroPlacementHome, store.HeroPlacementMovies, store.HeroPlacementTVShows} {
|
||||
placement := policy.Placement(name)
|
||||
write(name)
|
||||
for _, id := range placement.PinnedItemIDs {
|
||||
write(id)
|
||||
}
|
||||
write(placement.PrimeSubtitle)
|
||||
// Only the schedules that are *in force* for this viewer right now. A schedule
|
||||
// added for tomorrow evening changes nothing on screen tonight, and moving the
|
||||
// revision for it would be a repaint with nothing behind it.
|
||||
for _, id := range activeHeroScheduleIDs(policy.Schedules, name, userID, now, location) {
|
||||
write(id)
|
||||
}
|
||||
}
|
||||
return strconv.FormatUint(digest.Sum64(), 10)
|
||||
}
|
||||
|
||||
// heroStatus is the summary /v1/status carries: one opaque string, compared only for
|
||||
// equality, which is all a television needs to know whether the hero it is drawing is
|
||||
// still the one the gateway would compose.
|
||||
func heroStatus(revision string) map[string]any {
|
||||
return map[string]any{"revision": revision}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
var heroRevisionZone = time.FixedZone("NZST", 12*60*60)
|
||||
|
||||
func heroRevisionAt(policy store.HeroPolicy, user string, at time.Time) string {
|
||||
return heroRevision(policy, user, at, heroRevisionZone)
|
||||
}
|
||||
|
||||
func pinnedPolicy(placement string, ids ...string) store.HeroPolicy {
|
||||
return store.HeroPolicy{Placements: map[string]store.HeroPlacementPolicy{
|
||||
placement: {PinnedItemIDs: ids},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestHeroRevisionIsStableWithinASlot(t *testing.T) {
|
||||
policy := pinnedPolicy(store.HeroPlacementHome, "film-1")
|
||||
morning := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||
if heroRevisionAt(policy, "user-1", morning) != heroRevisionAt(policy, "user-1", morning.Add(90*time.Minute)) {
|
||||
t.Fatal("hero revision moved without anything changing; every poll would refetch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroRevisionMovesWhenAnOperatorEditsPins(t *testing.T) {
|
||||
now := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||
before := heroRevisionAt(pinnedPolicy(store.HeroPlacementHome, "film-1"), "user-1", now)
|
||||
for name, policy := range map[string]store.HeroPolicy{
|
||||
"added": pinnedPolicy(store.HeroPlacementHome, "film-1", "film-2"),
|
||||
"removed": pinnedPolicy(store.HeroPlacementHome),
|
||||
"replaced": pinnedPolicy(store.HeroPlacementHome, "film-2"),
|
||||
} {
|
||||
if heroRevisionAt(policy, "user-1", now) == before {
|
||||
t.Fatalf("hero revision did not move when a pin was %s", name)
|
||||
}
|
||||
}
|
||||
// Order is the operator's decision about which card leads, so reordering is a change
|
||||
// even though the same titles are pinned.
|
||||
reordered := pinnedPolicy(store.HeroPlacementHome, "film-2", "film-1")
|
||||
if heroRevisionAt(reordered, "user-1", now) ==
|
||||
heroRevisionAt(pinnedPolicy(store.HeroPlacementHome, "film-1", "film-2"), "user-1", now) {
|
||||
t.Fatal("hero revision did not move when pins were reordered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroRevisionSeparatesPlacements(t *testing.T) {
|
||||
now := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||
home := heroRevisionAt(pinnedPolicy(store.HeroPlacementHome, "film-1"), "user-1", now)
|
||||
movies := heroRevisionAt(pinnedPolicy(store.HeroPlacementMovies, "film-1"), "user-1", now)
|
||||
if home == movies {
|
||||
t.Fatal("pinning a title to Movies read as the same policy as pinning it to Home")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroRevisionIgnoresACosmeticSave(t *testing.T) {
|
||||
now := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||
saved := pinnedPolicy(store.HeroPlacementHome, "film-1")
|
||||
// UpdatedAt moves on every write. Hashing the document rather than what it resolves to
|
||||
// would repaint every launcher in the house for a save that changed nothing.
|
||||
resaved := pinnedPolicy(store.HeroPlacementHome, "film-1")
|
||||
resaved.UpdatedAt = now
|
||||
if heroRevisionAt(saved, "user-1", now) != heroRevisionAt(resaved, "user-1", now) {
|
||||
t.Fatal("hero revision moved for a save that changed nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroRevisionMovesWhenAScheduleOpensAndCloses(t *testing.T) {
|
||||
policy := store.HeroPolicy{Schedules: []store.HeroSchedule{{
|
||||
ID: "s1", ItemID: "film-9", Enabled: true,
|
||||
Frequency: "daily", StartTime: "20:00", EndTime: "22:00",
|
||||
Placements: []string{store.HeroPlacementHome},
|
||||
}}}
|
||||
before := time.Date(2026, 8, 20, 19, 0, 0, 0, heroRevisionZone)
|
||||
during := time.Date(2026, 8, 20, 21, 0, 0, 0, heroRevisionZone)
|
||||
// Two readings within one rotation slot, so only the schedule can be the difference.
|
||||
if heroRotationSlot(before, heroRevisionZone) != heroRotationSlot(during, heroRevisionZone) {
|
||||
t.Fatal("test times straddle a rotation slot; the schedule is no longer the only variable")
|
||||
}
|
||||
if heroRevisionAt(policy, "user-1", before) == heroRevisionAt(policy, "user-1", during) {
|
||||
t.Fatal("hero revision did not move when a scheduled hero came into force")
|
||||
}
|
||||
empty := store.HeroPolicy{}
|
||||
if heroRevisionAt(policy, "user-1", before) != heroRevisionAt(empty, "user-1", before) {
|
||||
t.Fatal("a schedule that is not yet in force changed the revision; the launcher would repaint for nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroRevisionIsPerViewerForAPersonalSchedule(t *testing.T) {
|
||||
now := time.Date(2026, 8, 20, 21, 0, 0, 0, heroRevisionZone)
|
||||
policy := store.HeroPolicy{Schedules: []store.HeroSchedule{{
|
||||
ID: "s1", ItemID: "film-9", Enabled: true, UserID: "user-1",
|
||||
Frequency: "daily", StartTime: "20:00", EndTime: "22:00",
|
||||
Placements: []string{store.HeroPlacementHome},
|
||||
}}}
|
||||
if heroRevisionAt(policy, "user-1", now) == heroRevisionAt(policy, "user-2", now) {
|
||||
t.Fatal("one viewer's scheduled hero moved another viewer's revision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroRevisionMovesWithTheRotationSlot(t *testing.T) {
|
||||
policy := pinnedPolicy(store.HeroPlacementHome, "film-1")
|
||||
morning := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
|
||||
evening := time.Date(2026, 8, 20, 20, 0, 0, 0, heroRevisionZone)
|
||||
if heroRevisionAt(policy, "user-1", morning) == heroRevisionAt(policy, "user-1", evening) {
|
||||
t.Fatal("hero revision did not move across rotation slots; the day's draw would never be refetched")
|
||||
}
|
||||
}
|
||||
@@ -75,11 +75,18 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
|
||||
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
||||
hero := supportsHomeHero(r)
|
||||
now := time.Now()
|
||||
// The hero revision is part of the key rather than something to invalidate. An
|
||||
// operator's change therefore makes the entries built under the old policy simply
|
||||
// unreachable, and they age out on their own TTL — where the sweep it replaced dropped
|
||||
// every cached answer this household had, artwork and item lookups included, to change
|
||||
// four cards. See heroRevision.
|
||||
heroRev := heroRevision(s.currentHeroPolicy(ctx), sess.EmbyUserID, now, s.heroLocation())
|
||||
key := cache.UserKey(
|
||||
sess.EmbyUserID,
|
||||
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
||||
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
|
||||
":d"+sess.DeviceID,
|
||||
":hr"+heroRev+":d"+sess.DeviceID,
|
||||
)
|
||||
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
@@ -331,7 +338,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
// does draw it put it somewhere sensible.
|
||||
if hero {
|
||||
compose := timing.Start(ctx, timing.StageHero)
|
||||
row := s.heroRow(ctx, out.Rows, sess.EmbyUserID, time.Now())
|
||||
row := s.heroRow(ctx, out.Rows, sess.EmbyUserID, now)
|
||||
compose()
|
||||
if row != nil {
|
||||
out.Rows = append([]recommend.Row{*row}, out.Rows...)
|
||||
|
||||
@@ -161,6 +161,14 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
// season has to reach a set that is already switched on, without anybody doing
|
||||
// anything.
|
||||
"theme": themeStatus(s.themeFor(r.Context(), sess)),
|
||||
// The hero, as one opaque revision rather than the cards themselves — the theme
|
||||
// precedent, for the same reason. A television refetches its heroes only when this
|
||||
// moves, which is what turns an operator pinning a title into a change that arrives
|
||||
// on the poll the set is already making rather than at the next cold start. It also
|
||||
// keys the cached answers, so a revision that has moved always names a fresh build.
|
||||
"hero": heroStatus(heroRevision(
|
||||
s.currentHeroPolicy(r.Context()), sess.EmbyUserID, time.Now(), s.heroLocation(),
|
||||
)),
|
||||
// Whether this viewer may ask the household for titles. Per person rather than per
|
||||
// household, so it cannot ride the feature map beside it: the allowlist is the
|
||||
// operator's decision about one account, and every television is polling this
|
||||
|
||||
Reference in New Issue
Block a user