0.2.81
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user