0.2.72 - Magic button, Next up fixes

This commit is contained in:
ponzischeme89
2026-08-17 11:41:36 +12:00
parent 36cb1324fd
commit 12b27c77c3
2655 changed files with 5435 additions and 254 deletions
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"math/rand"
"net/http"
"strings"
"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)
}
selection, ok := s.recommender.MagicPick(ctx, credentials(sess), 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 {
// 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.emby.Item(ctx, credentials(sess), selection.Item.ID, fieldsDetail)
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not load the suggestion")
return
}
decorated := []json.RawMessage{item}
s.decorateItemRatings(ctx, decorated)
s.loggerFor(ctx).Info("magic picked",
"item", selection.Item.ID,
"title", selection.Item.Name,
"score", selection.Score,
"pool", selection.PoolSize,
"signals", strings.Join(selection.Signals, ","),
)
writeJSON(w, http.StatusOK, magicResponse{
Item: decorated[0],
Reasons: selection.Reasons,
})
}