package api import ( "context" "encoding/json" "net/http" "net/url" "strconv" "strings" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" ) type seriesEpisodesResponse struct { Items []json.RawMessage `json:"items"` } type flagRequest struct { Value bool `json:"value"` } type seasonFinaleResponse struct { SeasonFinale bool `json:"seasonFinale"` SeriesName string `json:"seriesName,omitempty"` SeasonNumber int `json:"seasonNumber,omitempty"` EpisodeNumber int `json:"episodeNumber,omitempty"` } type finaleEmbyItem struct { Type string `json:"Type"` SeriesID string `json:"SeriesId"` SeriesName string `json:"SeriesName"` ParentIndexNumber int `json:"ParentIndexNumber"` IndexNumber int `json:"IndexNumber"` ProviderIDs map[string]string `json:"ProviderIds"` } // 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 } if raw, err := s.cache.Get(ctx, itemDetailKey(sess.EmbyUserID, itemID)); err == nil { w.Header().Set("X-Memby-Cache", "hit") writeRaw(w, http.StatusOK, raw) return } item, err := s.detailItem(ctx, sess, itemID) if err != nil { s.writeUpstreamError(ctx, w, err, "could not load the item") return } w.Header().Set("X-Memby-Cache", "miss") writeRaw(w, http.StatusOK, item) } // itemDetailKey is versioned so that when the detail contract grows, older cached payloads // cannot hide newly requested fields such as People or the stored ratings. func itemDetailKey(userID, itemID string) string { return cache.UserKey(userID, "item:v6:"+itemID) } // detailItem is the full record for one item, decorated and kept. // // It is shared rather than private to the item route because a Magic press hands back a // title the television is about to open a detail page for — and before this, that press // paid its own uncached Emby lookup and then the page paid a second one a moment later. func (s *Server) detailItem( ctx context.Context, sess store.Session, itemID string, ) (json.RawMessage, error) { key := itemDetailKey(sess.EmbyUserID, itemID) if raw, err := s.cache.Get(ctx, key); err == nil { return raw, nil } item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail) if err != nil { return nil, err } // A detail page can then draw its ratings with the rest of the hero rather than // after a second request. Anything not yet stored still arrives on /ratings. decorated := []json.RawMessage{item} s.decorateItemRatings(ctx, decorated) item = decorated[0] if err := s.cache.Set(ctx, key, item, s.cfg.ItemTTL); err != nil { s.loggerFor(ctx).Warn("item cache write failed", "error", err) } return item, nil } // handleSeasonFinale verifies an episode against Sonarr's complete season, including // future episodes. Looking only at Emby's downloaded files would call every current // weekly episode a finale until the following episode arrived. func (s *Server) handleSeasonFinale(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 } empty := seasonFinaleResponse{} if s.sonarr == nil { writeJSON(w, http.StatusOK, empty) return } key := cache.UserKey(sess.EmbyUserID, "season-finale:v1:"+itemID) if raw, err := s.cache.Get(ctx, key); err == nil { writeRaw(w, http.StatusOK, raw) return } currentRaw, err := s.emby.Item( ctx, credentials(sess), itemID, "ProviderIds,SeriesId,SeriesName,ParentIndexNumber,IndexNumber", ) if err != nil { s.writeUpstreamError(ctx, w, err, "could not inspect the playing episode") return } var current finaleEmbyItem if json.Unmarshal(currentRaw, ¤t) != nil || !strings.EqualFold(current.Type, "Episode") || current.SeriesID == "" || current.ParentIndexNumber <= 0 || current.IndexNumber <= 0 { s.writeSeasonFinaleResponse(ctx, key, empty, w) return } seriesRaw, err := s.emby.Item(ctx, credentials(sess), current.SeriesID, "ProviderIds") if err != nil { s.loggerFor(ctx).Warn("season finale series metadata unavailable", "item", itemID, "error", err) s.writeSeasonFinaleResponse(ctx, key, empty, w) return } var seriesItem finaleEmbyItem if json.Unmarshal(seriesRaw, &seriesItem) != nil { s.writeSeasonFinaleResponse(ctx, key, empty, w) return } tvdbID, err := strconv.Atoi(providerID(seriesItem.ProviderIDs, "tvdb")) if err != nil || tvdbID <= 0 { s.writeSeasonFinaleResponse(ctx, key, empty, w) return } series, err := s.sonarrSeriesCatalogue(ctx) if err != nil { s.loggerFor(ctx).Warn("season finale Sonarr series unavailable", "item", itemID, "error", err) s.writeSeasonFinaleResponse(ctx, key, empty, w) return } sonarrSeriesID := 0 for _, candidate := range series { if candidate.TVDBID == tvdbID { sonarrSeriesID = candidate.ID break } } if sonarrSeriesID == 0 { s.writeSeasonFinaleResponse(ctx, key, empty, w) return } episodes, err := s.sonarr.Episodes(ctx, sonarrSeriesID) if err != nil { s.loggerFor(ctx).Warn("season finale Sonarr episodes unavailable", "item", itemID, "error", err) s.writeSeasonFinaleResponse(ctx, key, empty, w) return } result := seasonFinaleResponse{ SeasonFinale: isSeasonFinale(current.ParentIndexNumber, current.IndexNumber, episodes), SeriesName: current.SeriesName, SeasonNumber: current.ParentIndexNumber, EpisodeNumber: current.IndexNumber, } if !result.SeasonFinale { result = empty } s.writeSeasonFinaleResponse(ctx, key, result, w) } func (s *Server) writeSeasonFinaleResponse( ctx context.Context, key string, result seasonFinaleResponse, w http.ResponseWriter, ) { body, err := json.Marshal(result) if err != nil { writeError(w, http.StatusInternalServerError, "could not encode finale status") return } if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil { s.loggerFor(ctx).Warn("season finale cache write failed", "error", err) } writeRaw(w, http.StatusOK, body) } func providerID(ids map[string]string, wanted string) string { for key, value := range ids { if strings.EqualFold(key, wanted) { return value } } return "" } func isSeasonFinale(seasonNumber, episodeNumber int, episodes []sonarr.Episode) bool { if seasonNumber <= 0 || episodeNumber <= 0 { return false } lastEpisode := 0 for _, episode := range episodes { if episode.SeasonNumber == seasonNumber && episode.EpisodeNumber > lastEpisode { lastEpisode = episode.EpisodeNumber } } return lastEpisode > 0 && episodeNumber == lastEpisode } // 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}, // PremiereDate is the one fact that tells two episodes of a list apart, so the // episode browser asks for it by name — Emby does not return it otherwise. "Fields": {"Overview,RunTimeTicks,SeriesName,PremiereDate,PrimaryImageAspectRatio"}, "EnableUserData": {"true"}, "EnableImages": {"true"}, "EnableImageTypes": {"Primary,Thumb,Backdrop"}, "ImageTypeLimit": {"1"}, "Limit": {"1000"}, }) if err != nil { s.writeUpstreamError(ctx, 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.loggerFor(ctx).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(r.Context(), 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) }) } func (s *Server) handleHideFromResume(w http.ResponseWriter, r *http.Request, sess store.Session) { itemID := r.PathValue("id") if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } userData, err := s.emby.HideFromResume(r.Context(), credentials(sess), itemID) if err != nil { s.writeUpstreamError(r.Context(), w, err, "could not remove the item from Continue Watching") return } if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err) } if s.forYou != nil { s.forYou.MarkDirty(r.Context(), sess) } writeRaw(w, http.StatusOK, userData) } // 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(r.Context(), w, err, "could not update the item") return } if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err) } if s.forYou != nil { s.forYou.MarkDirty(r.Context(), sess) } writeRaw(w, http.StatusOK, userData) }