374 lines
13 KiB
Go
374 lines
13 KiB
Go
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(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) {
|
|
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.decorateItemRatings(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(sess.EmbyUserID, "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{}
|
|
}
|
|
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])
|
|
}
|
|
|
|
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)
|
|
}
|