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 }