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

156 lines
6.4 KiB
Go
Raw Normal View History

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