This commit is contained in:
ponzischeme89
2026-08-20 07:54:03 +12:00
parent 769fe01c84
commit 434371e9cf
37 changed files with 1767 additions and 102 deletions
+10
View File
@@ -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
+4
View File
@@ -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))
+155
View File
@@ -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)
}
}
+4 -5
View File
@@ -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),
+9 -14
View File
@@ -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)
+10 -14
View File
@@ -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)
}
}
}
+133
View File
@@ -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}
}
+112
View File
@@ -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")
}
}
+9 -2
View File
@@ -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...)
+8
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.1.60
0.1.61
+139
View File
@@ -0,0 +1,139 @@
package store
import (
"context"
"fmt"
"strconv"
"strings"
"time"
)
// GenreWeight is one genre label and how much of this viewer's watching it accounts for.
//
// The label is Emby's own spelling, verbatim, because the television is what turns labels
// into the categories it draws — "Science Fiction", "Sci-Fi" and "Sci-Fi & Fantasy" are one
// shelf there and three different rows here. Folding them on this side would mean the
// gateway holding a second copy of a catalogue that is deliberately product design on the
// set, and the two would drift the first time a category gained an alias.
type GenreWeight struct {
Genre string
Score float64
}
// GenreAffinity is everything one reading of a viewer's history came to: the weighted
// genres and how many sessions were behind them.
//
// Sessions is carried because it is the only thing that separates "this household watches
// Westerns" from "somebody put a Western on once" — the television refuses to personalise
// below a floor, and a share of a tiny total is not evidence.
type GenreAffinity struct {
Genres []GenreWeight
Sessions int
}
const (
// genreAffinityWindow is how far back a session still counts for. Long enough that a
// household which watches a few evenings a week has something to say, short enough that
// the crime phase somebody went through two years ago is no longer shaping their rail.
genreAffinityWindow = 180 * 24 * time.Hour
// The recency bands. Recent viewing should count for more, and three flat bands are the
// whole of "lightly weight" — an exponential decay would need a half-life nobody could
// defend and would make the answer move between two readings taken the same evening.
genreAffinityRecentWindow = 30 * 24 * time.Hour
genreAffinityMidWindow = 90 * 24 * time.Hour
// genreAffinityLimit caps the labels returned. A real library has a few dozen distinct
// genre strings and the television folds them into sixteen categories; the tail past
// this cannot change an ordering.
genreAffinityLimit = 60
// genreAffinityEngagement is the least of a title somebody must have reached before it
// says anything about their taste. A session that stopped four minutes in is evidence
// they did *not* want it, and counting those is how a rail comes to lead with the genre
// somebody keeps abandoning.
genreAffinityEngagement = 0.5
)
// TracearrGenreAffinity weighs the genres one viewer actually watches.
//
// Nothing here is stored: Tracearr's sessions are already in Postgres and the imported
// catalogue already holds each title's genres as an indexed array, so this is a join over
// two tables the gateway keeps for other reasons. A per-user genre table would be a copy of
// both, wrong the moment either changed, and would need its own reconciliation to stay
// honest — the same trade watchedMsExpr makes for watch time.
//
// An episode is credited to its *series'* genres, which is what emby_series_id is for: an
// episode row in the catalogue inherits them anyway, and a household that watches one crime
// drama nightly should read as watching crime rather than as watching nothing identifiable.
//
// The identity is matched two ways for the reason attributeWatchTime does it — the username
// is what Tracearr and Emby genuinely share, and the recorded Tracearr id is what the
// recommendation builder actually matched, so a viewer renamed in one system keeps their
// history rather than silently reading as new.
func (s *Store) TracearrGenreAffinity(
ctx context.Context,
tracearrUserID, username string,
now time.Time,
) (GenreAffinity, error) {
id := strings.TrimSpace(tracearrUserID)
name := strings.ToLower(strings.TrimSpace(username))
if id == "" && name == "" {
return GenreAffinity{}, nil
}
rows, err := s.pool.Query(ctx, `
WITH viewed AS (
SELECT coalesce(nullif(emby_series_id, ''), emby_item_id) AS item_id,
CASE
WHEN started_at >= $5 THEN 1.0
WHEN started_at >= $4 THEN 0.6
ELSE 0.3
END AS weight
FROM tracearr_sessions
WHERE started_at >= $3
AND (($1::text <> '' AND tracearr_user_id = $1::text)
OR ($2::text <> '' AND lower(username) = $2::text))
AND (watched OR (
total_duration_ms > 0
AND progress_ms::float8 / total_duration_ms >= $6::float8
))
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
),
joined AS (
SELECT viewed.weight, library_items.genres
FROM viewed
JOIN library_items ON library_items.id = viewed.item_id
WHERE cardinality(library_items.genres) > 0
),
scored AS (
SELECT btrim(label) AS genre, sum(joined.weight)::float8 AS score
FROM joined CROSS JOIN LATERAL unnest(joined.genres) AS label
WHERE btrim(label) <> ''
GROUP BY btrim(label)
)
SELECT scored.genre, scored.score, (SELECT count(*) FROM joined)::int
FROM scored
ORDER BY scored.score DESC, scored.genre
LIMIT `+strconv.Itoa(genreAffinityLimit),
id, name,
now.Add(-genreAffinityWindow),
now.Add(-genreAffinityMidWindow),
now.Add(-genreAffinityRecentWindow),
genreAffinityEngagement,
)
if err != nil {
return GenreAffinity{}, fmt.Errorf("store: tracearr genre affinity: %w", err)
}
defer rows.Close()
out := GenreAffinity{Genres: []GenreWeight{}}
for rows.Next() {
var weight GenreWeight
var sessions int
if err := rows.Scan(&weight.Genre, &weight.Score, &sessions); err != nil {
return GenreAffinity{}, fmt.Errorf("store: scan tracearr genre affinity: %w", err)
}
out.Genres = append(out.Genres, weight)
out.Sessions = sessions
}
return out, rows.Err()
}
+24
View File
@@ -2,8 +2,11 @@ package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Watch time is read out of tracearr_sessions rather than stored again.
@@ -178,3 +181,24 @@ func (s *Store) TracearrIdentities(ctx context.Context) (map[string]Recommendati
}
return out, rows.Err()
}
// TracearrIdentity is one Emby account's recorded Tracearr identity, or the zero value for
// somebody the recommendation builder has never profiled.
//
// The map above is what the console needs — it draws the whole household in one pass — and
// this is what a single request needs. Absence is not an error: a viewer with no profile
// row is matched by username alone, which is the join the two systems genuinely share.
func (s *Store) TracearrIdentity(ctx context.Context, embyUserID string) (RecommendationIdentity, error) {
var identity RecommendationIdentity
err := s.pool.QueryRow(ctx, `
SELECT tracearr_user_id, tracearr_username
FROM recommendation_user_profiles
WHERE emby_user_id = $1`, embyUserID).Scan(&identity.TracearrUserID, &identity.Username)
if errors.Is(err, pgx.ErrNoRows) {
return RecommendationIdentity{}, nil
}
if err != nil {
return RecommendationIdentity{}, fmt.Errorf("store: tracearr identity: %w", err)
}
return identity, nil
}