Files
memby/server/internal/api/hero_revision.go
T

134 lines
5.1 KiB
Go
Raw Normal View History

2026-08-20 07:54:03 +12:00
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}
}