package api import ( "encoding/json" "net/http" "net/url" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/store" ) type seriesEpisodesResponse struct { Items []json.RawMessage `json:"items"` } type flagRequest struct { Value bool `json:"value"` } // handleItem serves full metadata for one item. The TV asks for this only after D-pad // focus settles, so it is worth caching for longer than a home row. func (s *Server) handleItem(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 } // Version the entry when the detail contract grows so older cached payloads cannot // hide newly requested fields such as People. key := cache.UserKey(sess.EmbyUserID, "item: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, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail) if err != nil { s.writeUpstreamError(w, err, "could not load the item") return } if err := s.cache.Set(ctx, key, item, s.cfg.ItemTTL); err != nil { s.log.Warn("item cache write failed", "error", err) } w.Header().Set("X-Memby-Cache", "miss") writeRaw(w, http.StatusOK, item) } // handleSeriesEpisodes supplies the complete episode browser in one cached response. // The TV groups by ParentIndexNumber locally, so changing seasons never reaches Emby. func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() seriesID := r.PathValue("id") if seriesID == "" { writeError(w, http.StatusBadRequest, "series id is required") return } key := cache.UserKey(sess.EmbyUserID, "series-episodes:"+seriesID) if raw, err := s.cache.Get(ctx, key); err == nil { w.Header().Set("X-Memby-Cache", "hit") writeRaw(w, http.StatusOK, raw) return } result, err := s.emby.Episodes(ctx, credentials(sess), seriesID, url.Values{ "UserId": {sess.EmbyUserID}, "Fields": {"Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio"}, "EnableUserData": {"true"}, "EnableImages": {"true"}, "EnableImageTypes": {"Primary,Thumb,Backdrop"}, "ImageTypeLimit": {"1"}, "Limit": {"1000"}, }) if err != nil { s.writeUpstreamError(w, err, "could not load series episodes") return } items := result.Items if items == nil { items = []json.RawMessage{} } body, err := json.Marshal(seriesEpisodesResponse{Items: items}) if err != nil { writeError(w, http.StatusInternalServerError, "could not encode series episodes") return } if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil { s.log.Warn("series episodes cache write failed", "error", err) } w.Header().Set("X-Memby-Cache", "miss") writeRaw(w, http.StatusOK, body) } // handleTrailer answers with the item's first local trailer, or 404 when it has none. // The screensaver's Play action uses this before asking for a playback URL. func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess store.Session) { itemID := r.PathValue("id") if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } result, err := s.emby.LocalTrailers(r.Context(), credentials(sess), itemID) if err != nil { s.writeUpstreamError(w, err, "could not load trailers") return } if len(result.Items) == 0 { writeError(w, http.StatusNotFound, "no trailer available") return } writeRaw(w, http.StatusOK, result.Items[0]) } func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request, sess store.Session) { s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) { return s.emby.SetFavorite(r.Context(), credentials(sess), itemID, value) }) } func (s *Server) handlePlayed(w http.ResponseWriter, r *http.Request, sess store.Session) { s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) { return s.emby.SetPlayed(r.Context(), credentials(sess), itemID, value) }) } // setFlag applies a user-data mutation and drops this user's cached views, so the next // home request reflects it rather than serving the row it just contradicted. func (s *Server) setFlag( w http.ResponseWriter, r *http.Request, sess store.Session, apply func(itemID string, value bool) (json.RawMessage, error), ) { itemID := r.PathValue("id") if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } var req flagRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } userData, err := apply(itemID, req.Value) if err != nil { s.writeUpstreamError(w, err, "could not update the item") return } if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { s.log.Warn("cache invalidation failed", "error", err) } if s.forYou != nil { s.forYou.MarkDirty(r.Context(), sess) s.forYou.RefreshAsync(sess, true) } writeRaw(w, http.StatusOK, userData) }