156 lines
6.4 KiB
Go
156 lines
6.4 KiB
Go
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
|
||
|
|
}
|