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} }