package api import ( "encoding/json" "net/http" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) // fieldsRelated adds Studios to the detail set: the explanation layer names the studio a // viewer keeps returning to, and Emby omits it unless asked. const fieldsRelated = fieldsDetail + ",Studios" // relatedResponse is what the detail page renders: a strip of short reasons under the // description, and the carousel beneath the page. type relatedResponse struct { Reasons []string `json:"reasons"` Items []json.RawMessage `json:"items"` } // handleRelated explains one title to one viewer and lists what resembles it. // // Building the taste profile costs the same Emby fan-out the home rows pay for, so the // whole answer is cached per user and item. It is deliberately *not* folded into // `/v1/items/{id}`: that response is shared with the screensaver and the player, and this // one is only ever needed once a detail page is open. func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() itemID := r.PathValue("id") if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } key := cache.UserKey(sess.EmbyUserID, "related:v1:"+itemID) if raw, err := s.cache.Get(ctx, key); err == nil { w.Header().Set("X-Memby-Cache", "hit") writeRaw(w, http.StatusOK, raw) return } raw, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsRelated) if err != nil { s.writeUpstreamError(w, err, "could not load the item") return } decoded := recommend.Decode([]json.RawMessage{raw}) if len(decoded) == 0 { writeError(w, http.StatusNotFound, "item not found") return } reasons, related, err := s.recommender.RelatedTo( ctx, credentials(sess), decoded[0], relatedRowSize, ) if err != nil { s.writeUpstreamError(w, err, "could not load related titles") return } body, err := json.Marshal(relatedResponse{ Reasons: nonNilStrings(reasons), Items: nonNilRaws(recommend.Raws(related)), }) if err != nil { writeError(w, http.StatusInternalServerError, "could not encode related titles") return } if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil { s.log.Warn("related cache write failed", "error", err) } w.Header().Set("X-Memby-Cache", "miss") writeRaw(w, http.StatusOK, body) } // relatedRowSize is a carousel's worth. The strip scrolls, but a viewer who reaches the // twelfth card has stopped looking for something like this one. const relatedRowSize = 12 func nonNilRaws(values []json.RawMessage) []json.RawMessage { if values == nil { return []json.RawMessage{} } return values }