App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -2,9 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
@@ -33,42 +36,49 @@ func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request, sess stor
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
key := cache.UserKey(sess.EmbyUserID, "related:v1:"+itemID)
|
||||
key := cache.UserKey(sess.EmbyUserID, "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
|
||||
}
|
||||
|
||||
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")
|
||||
item, ok := s.relatedSubject(w, r, sess, itemID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
reasons, related, err := s.recommender.RelatedTo(
|
||||
ctx, credentials(sess), decoded[0], relatedRowSize,
|
||||
)
|
||||
reasons, related, err := s.recommender.RelatedTo(ctx, credentials(sess), item, relatedRowSize)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(w, err, "could not load related titles")
|
||||
// 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
|
||||
}
|
||||
|
||||
items := nonNilRaws(recommend.Raws(related))
|
||||
s.decorateItemRatings(ctx, items)
|
||||
body, err := json.Marshal(relatedResponse{
|
||||
Reasons: nonNilStrings(reasons),
|
||||
Items: nonNilRaws(recommend.Raws(related)),
|
||||
Items: items,
|
||||
})
|
||||
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)
|
||||
// An empty carousel is cached 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.
|
||||
ttl := s.cfg.ItemTTL
|
||||
if len(items) == 0 {
|
||||
ttl = relatedEmptyTTL
|
||||
}
|
||||
if err := s.cache.Set(ctx, key, body, ttl); err != nil {
|
||||
s.loggerFor(ctx).Warn("related cache write failed", "error", err)
|
||||
}
|
||||
w.Header().Set("X-Memby-Cache", "miss")
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
@@ -78,6 +88,60 @@ func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request, sess stor
|
||||
// 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{}
|
||||
|
||||
Reference in New Issue
Block a user