134 lines
5.1 KiB
Go
134 lines
5.1 KiB
Go
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}
|
||
|
|
}
|