Files
memby/server/internal/api/related.go
2026-08-20 15:06:00 +12:00

163 lines
5.9 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/timing"
)
// fieldsRelated is the detail set. It once added Studios on its own account — the
// explanation layer names the studio a viewer keeps returning to — and the Details tab has
// since put Studios in [fieldsDetail] for everybody. Kept as its own name so the
// explanation layer's requirement stays stated rather than depending on another feature.
const fieldsRelated = fieldsDetail
// 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(viewerKeyOf(ctx, sess), "related:v2:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
item, ok := s.relatedSubject(w, r, sess, itemID)
if !ok {
return
}
// Coalesced, because this route is asked for twice for the same title as a matter of
// course: the launcher warms it while a card is focused and the detail page asks for
// it again the moment somebody presses. Those two requests used to build the answer
// twice, and building it is the same Emby fan-out the home rows pay for.
body, err := s.buildCached(ctx, key, relatedTTL(s.cfg.ItemTTL),
func(ctx context.Context) (json.RawMessage, error) {
reasons, related, err := s.recommender.RelatedTo(
timing.WithLabel(ctx, "emby.related"), credentials(sess), item, relatedRowSize)
if err != nil {
return nil, err
}
items := nonNilRaws(recommend.Raws(related))
s.decorateItems(ctx, items)
return json.Marshal(relatedResponse{
Reasons: nonNilStrings(reasons),
Items: items,
})
})
if err != nil {
// RelatedTo degrades rather than failing, so the only error it returns is the
// viewer having navigated on. That is the television saving work, not a fault.
if expectedClientDisconnect(r, err) {
s.loggerFor(ctx).Debug("related request abandoned", "item", itemID)
return
}
s.writeUpstreamError(ctx, w, err, "could not load related titles")
return
}
writeCached(w, false, body)
}
// relatedTTL keeps an empty carousel briefly rather than for the item lifetime: it
// usually means something upstream was unwell, and the ten-minute answer would otherwise
// outlive the minute of trouble that produced it.
func relatedTTL(itemTTL time.Duration) func(json.RawMessage) time.Duration {
return func(body json.RawMessage) time.Duration {
var decoded relatedResponse
if json.Unmarshal(body, &decoded) == nil && len(decoded.Items) == 0 {
return relatedEmptyTTL
}
return itemTTL
}
}
// 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
// relatedEmptyTTL keeps a carousel-less answer only long enough to stop a page that is
// being scrolled past from asking twice.
const relatedEmptyTTL = time.Minute
// relatedSubject resolves the title the page is about, and is the first place this
// endpoint refuses to fail: the imported catalogue holds the same payload Emby would
// have returned, so a detail page opened while Emby is unwell still gets its genres —
// which is all `genreNeighbours` needs to fill the strip from Postgres alone.
//
// It writes the response itself when there is nothing to answer with, and reports
// whether the caller should carry on.
func (s *Server) relatedSubject(
w http.ResponseWriter, r *http.Request, sess store.Session, itemID string,
) (recommend.Item, bool) {
ctx := r.Context()
raw, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsRelated)
if err == nil {
if decoded := recommend.Decode([]json.RawMessage{raw}); len(decoded) > 0 {
return decoded[0], true
}
}
// A rejected session is the viewer's problem to act on and must reach the TV as a
// 401; an abandoned request is nobody's. Neither is worth a catalogue read.
if err != nil {
if expectedClientDisconnect(r, err) {
s.loggerFor(ctx).Debug("related request abandoned", "item", itemID)
return recommend.Item{}, false
}
var apiErr *emby.APIError
if errors.As(err, &apiErr) &&
(apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
writeError(w, http.StatusUnauthorized, "emby rejected the session")
return recommend.Item{}, false
}
}
if raws, storeErr := s.store.LibraryItemsByID(ctx, []string{itemID}); storeErr == nil {
if decoded := recommend.Decode(raws); len(decoded) > 0 {
s.loggerFor(ctx).Warn(
"related item served from the imported catalogue", "item", itemID, "error", err,
)
return decoded[0], true
}
}
if err == nil {
writeError(w, http.StatusNotFound, "item not found")
return recommend.Item{}, false
}
s.writeUpstreamError(ctx, w, err, "could not load the item")
return recommend.Item{}, false
}
func nonNilRaws(values []json.RawMessage) []json.RawMessage {
if values == nil {
return []json.RawMessage{}
}
return values
}