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 }