136 lines
5.5 KiB
Go
136 lines
5.5 KiB
Go
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
|
|
}
|