package api import ( "context" "encoding/json" "math/rand" "net/http" "strings" "time" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) // magicResponse is one drawn title and the wording that goes with it. // // The item rides as Emby's own JSON, the convention every other row and detail response // takes, so the television parses it with the single BaseItem model it already has and a // field added to the catalogue tomorrow needs nothing here. [recommend.MagicSelection] // carries a parsed item rather than raw bytes, so the handler re-reads it — one lookup on a // button press, against a pick the viewer is about to watch for two hours. type magicResponse struct { Item json.RawMessage `json:"item"` // Reasons is the same explanation layer a detail page uses. The television prints the // title; these are what let it say *why* without the server having to word a sentence // the client's copy conventions would then have to match. Reasons []string `json:"reasons,omitempty"` } // magicRequest is what the player knows and the server does not: the film playing right now // and the last few this button already offered. Repetition protection lives with the caller // because it is the caller that knows what it has already put in front of somebody. type magicRequest struct { ExcludeIDs []string `json:"excludeIds"` // AvailableMinutes is zero for no limit. Nothing sends it yet; it is on the wire because // "I have an hour" is the obvious next thing to ask and the picker already takes it. AvailableMinutes int `json:"availableMinutes"` } // handleMagic answers "put something good on". // // Deliberately a POST with a body rather than a GET with a query string: the exclusion list // grows with every press, and a URL that lengthens each time is one a proxy or an access log // eventually truncates — which would silently start repeating films. // // It is never cached. The whole point of the button is that pressing it twice gives two // answers, and a cached one would give the same film until the entry expired. func (s *Server) handleMagic(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() var req magicRequest if r.Body != nil { // A body that will not parse is an empty one: the exclusions are an optimisation, // and refusing the press over them would trade a slightly worse pick for no pick. _ = json.NewDecoder(r.Body).Decode(&req) } pool, cached := s.magicPool(ctx, sess) selection, ok := recommend.ChooseMagic(pool, recommend.MagicOptions{ ExcludeIDs: req.ExcludeIDs, AvailableMinutes: req.AvailableMinutes, // The one non-deterministic thing about the feature, named in one place. Roll: rand.Float64(), }) if !ok && cached { // Everything the kept pool held has already been offered. That is a pool that has // run its course rather than a household with nothing left, so it is rebuilt once // before the button is allowed to say no. pool = s.rebuildMagicPool(ctx, sess) selection, ok = recommend.ChooseMagic(pool, recommend.MagicOptions{ ExcludeIDs: req.ExcludeIDs, AvailableMinutes: req.AvailableMinutes, Roll: rand.Float64(), }) } if !ok { // A household that has run out of unseen library is not an error, and the television // says so quietly rather than showing a failure over somebody's film. s.loggerFor(ctx).Debug("magic found nothing", "excluded", len(req.ExcludeIDs)) writeError(w, http.StatusNotFound, "nothing to suggest") return } item, err := s.detailItem(ctx, sess, selection.ItemID) if err != nil { s.writeUpstreamError(ctx, w, err, "could not load the suggestion") return } s.loggerFor(ctx).Info("magic picked", "item", selection.ItemID, "title", selection.Title, "score", selection.Score, "pool", selection.PoolSize, "signals", strings.Join(selection.Signals, ","), ) writeJSON(w, http.StatusOK, magicResponse{ Item: item, Reasons: selection.Reasons, }) } // magicPool returns the kept pool, and whether it came from the cache. // // The press is made with the film paused behind a loading panel, so what happens on it // matters: building a pool is two complete reads of this viewer's Emby history plus a // catalogue query, and none of that answer changes between one press and the next. Keeping // it turns every press after the first into arithmetic over a few dozen numbers. func (s *Server) magicPool(ctx context.Context, sess store.Session) ([]recommend.MagicCandidate, bool) { if raw, err := s.cache.Get(ctx, cache.MagicPoolKey(sess.EmbyUserID)); err == nil { var pool []recommend.MagicCandidate if err := json.Unmarshal(raw, &pool); err == nil && len(pool) > 0 { return pool, true } } return s.rebuildMagicPool(ctx, sess), false } func (s *Server) rebuildMagicPool(ctx context.Context, sess store.Session) []recommend.MagicCandidate { pool := s.recommender.MagicPool(ctx, credentials(sess), time.Time{}) if len(pool) == 0 { // Deliberately not cached: an empty pool is a household whose library or Emby was // unavailable far more often than it is one with no films, and keeping that answer // would withdraw the button for hours over a moment's trouble. return nil } if raw, err := json.Marshal(pool); err == nil { if err := s.cache.Set(ctx, cache.MagicPoolKey(sess.EmbyUserID), raw, s.cfg.MagicPoolTTL); err != nil { s.loggerFor(ctx).Warn("magic pool cache write failed", "error", err) } } return pool }