Files
memby/server/internal/api/items.go
T
2026-07-29 15:26:27 +12:00

161 lines
5.1 KiB
Go

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)
}