87 lines
2.7 KiB
Go
87 lines
2.7 KiB
Go
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
|
|
}
|