package api import ( "context" "encoding/json" "net/http" "net/url" "strconv" "strings" "sync" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/timing" ) 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(viewerKeyOf(ctx, sess), 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) { item, _, err := s.cachedRead(ctx, itemDetailKey(sess.EmbyUserID, itemID), s.cfg.ItemTTL, func(ctx context.Context) (json.RawMessage, error) { raw, err := s.emby.Item( timing.WithLabel(ctx, "emby.item"), 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{raw} s.decorateItems(ctx, decorated) return decorated[0], nil }) return item, err } // 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 } // Cached for the household rather than per viewer, and outside the namespace a // playback stop invalidates. Whether an episode closes its season is a fact about // the season; it carries no user data, and it was previously being thrown away by // the very event that most often precedes somebody asking for it — the stop report // at the end of the episode before. key := cache.MetadataKey("season-finale:v2:" + itemID) body, hit, err := s.cachedRead(ctx, key, s.cfg.ItemTTL, func(ctx context.Context) (json.RawMessage, error) { return json.Marshal(s.seasonFinale(ctx, sess, itemID)) }) if err != nil { s.writeUpstreamError(ctx, w, err, "could not inspect the playing episode") return } writeCached(w, hit, body) } // seasonFinale is four upstream reads and only three of them are a chain. // // The episode has to be read before its series can be, and the series before Sonarr can // be searched for it — but Sonarr's series catalogue depends on none of that, and used // to be fetched fourth, in sequence, after both Emby lookups had returned. It is now // fetched beside them, which takes a leg off the wait for the ordinary case and all of // it for a household whose catalogue is still warm. // // Every failure below answers "not a finale" rather than an error. This decorates the // end of an episode; being unable to say is the same outcome as saying no, and a viewer // must never be shown a failure for it. func (s *Server) seasonFinale( ctx context.Context, sess store.Session, itemID string, ) seasonFinaleResponse { empty := seasonFinaleResponse{} var ( catalogue []sonarr.Series catalogueErr error wg sync.WaitGroup ) wg.Add(1) go func() { defer wg.Done() catalogue, catalogueErr = s.sonarrSeriesCatalogue(timing.WithLabel(ctx, "sonarr.series")) }() // Whatever happens below, the catalogue read has to be waited for: it is running on // this request's context and returning without it would leave a goroutine writing to // variables the caller has moved on from. defer wg.Wait() currentRaw, err := s.emby.Item( timing.WithLabel(ctx, "emby.episode"), credentials(sess), itemID, "ProviderIds,SeriesId,SeriesName,ParentIndexNumber,IndexNumber", ) if err != nil { s.loggerFor(ctx).Warn("season finale episode unavailable", "item", itemID, "error", err) return empty } var current finaleEmbyItem if json.Unmarshal(currentRaw, ¤t) != nil || !strings.EqualFold(current.Type, "Episode") || current.SeriesID == "" || current.ParentIndexNumber <= 0 || current.IndexNumber <= 0 { return empty } seriesRaw, err := s.emby.Item( timing.WithLabel(ctx, "emby.series"), credentials(sess), current.SeriesID, "ProviderIds") if err != nil { s.loggerFor(ctx).Warn("season finale series metadata unavailable", "item", itemID, "error", err) return empty } var seriesItem finaleEmbyItem if json.Unmarshal(seriesRaw, &seriesItem) != nil { return empty } tvdbID, err := strconv.Atoi(providerID(seriesItem.ProviderIDs, "tvdb")) if err != nil || tvdbID <= 0 { return empty } wg.Wait() if catalogueErr != nil { s.loggerFor(ctx).Warn("season finale Sonarr series unavailable", "item", itemID, "error", catalogueErr) return empty } sonarrSeriesID := 0 for _, candidate := range catalogue { if candidate.TVDBID == tvdbID { sonarrSeriesID = candidate.ID break } } if sonarrSeriesID == 0 { return empty } episodes, err := s.sonarr.Episodes(timing.WithLabel(ctx, "sonarr.episodes"), sonarrSeriesID) if err != nil { s.loggerFor(ctx).Warn("season finale Sonarr episodes unavailable", "item", itemID, "error", err) return empty } if !isSeasonFinale(current.ParentIndexNumber, current.IndexNumber, episodes) { return empty } return seasonFinaleResponse{ SeasonFinale: true, SeriesName: current.SeriesName, SeasonNumber: current.ParentIndexNumber, EpisodeNumber: current.IndexNumber, } } 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 } // Per viewer, because the browser marks what has been watched — and coalesced, // because this is the largest request the television makes and it is made twice for // the same show as a matter of course: the launcher warms it while a Continue // Watching card is focused, and the page asks again when somebody presses. A // long-running show is a thousand records, so two of them is a real cost on the one // press that must feel free. key := cache.UserKey(viewerKeyOf(ctx, sess), "series-episodes:"+seriesID) body, hit, err := s.cachedRead(ctx, key, s.cfg.ItemTTL, func(ctx context.Context) (json.RawMessage, error) { result, err := s.emby.Episodes( timing.WithLabel(ctx, "emby.episodes"), 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 { return nil, err } items := result.Items if items == nil { items = []json.RawMessage{} } // The ticks down the episode list are the clearest statement this app makes // about what somebody has seen, so they are the last place the account's // answer may be left standing. The cache key is the viewer's, so this is // stored per person rather than decorated on the way out. s.decorateViewerState(ctx, items) return json.Marshal(seriesEpisodesResponse{Items: items}) }) if err != nil { s.writeUpstreamError(ctx, w, err, "could not load series episodes") return } writeCached(w, hit, 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]) } // The four routes below are the whole of what Memby writes back to Emby about a person: // a favourite, a watched flag, a hidden resume item and a playback report. Each one now // asks who is watching first, because that is the entire promise of a shadow viewer — the // Emby account lends them the library and never learns what they did with it. func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request, sess store.Session) { s.setFlag(w, r, sess, func(viewer store.Viewer, itemID string, value bool) (json.RawMessage, error) { if !viewer.IsMain() { return s.setShadowFlag(r.Context(), viewer, itemID, func() error { return s.store.SetViewerFavourite(r.Context(), viewer.ID, itemID, value) }) } 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(viewer store.Viewer, itemID string, value bool) (json.RawMessage, error) { if !viewer.IsMain() { return s.setShadowFlag(r.Context(), viewer, itemID, func() error { return s.store.SetViewerPlayed(r.Context(), viewer.ID, itemID, value) }) } return s.emby.SetPlayed(r.Context(), credentials(sess), itemID, value) }) } // setShadowFlag applies a Memby-side mutation and answers in the shape Emby would have. // // Re-reading the row rather than describing the write is deliberate: the response is what // the television draws the card from, and a favourite pressed on a title that is also part // way through has to come back carrying the position as well as the heart. func (s *Server) setShadowFlag( ctx context.Context, viewer store.Viewer, itemID string, apply func() error, ) (json.RawMessage, error) { if err := apply(); err != nil { return nil, err } state, err := s.store.ViewerStateFor(ctx, viewer.ID, itemID) if err != nil { return nil, err } return viewerUserData(state), nil } 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 } viewer := s.activeViewer(r.Context(), sess, r) var userData json.RawMessage var err error if viewer.IsMain() { userData, err = s.emby.HideFromResume(r.Context(), credentials(sess), itemID) } else { userData, err = s.setShadowFlag(r.Context(), viewer, itemID, func() error { return s.store.HideViewerFromResume(r.Context(), viewer.ID, 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(), viewer.ID); err != nil { s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err) } // For You is built from the Emby account's own history, so a shadow viewer's press // says nothing about it. Marking it dirty would rebuild the main viewer's row out of // somebody else's choice. if s.forYou != nil && viewer.IsMain() { 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(viewer store.Viewer, itemID string, value bool) (json.RawMessage, error), ) { viewer := s.activeViewer(r.Context(), sess, r) 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(viewer, 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(), viewer.ID); err != nil { s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err) } // For You is built from the Emby account's own history, so a shadow viewer's press // says nothing about it. Marking it dirty would rebuild the main viewer's row out of // somebody else's choice. if s.forYou != nil && viewer.IsMain() { s.forYou.MarkDirty(r.Context(), sess) } writeRaw(w, http.StatusOK, userData) }