package api import ( "context" "encoding/json" "net/http" "sync" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/timing" ) // extrasResponse is the Extras tab: featurettes, deleted scenes, interviews and the local // trailer, as Emby's own item JSON so the television's single BaseItem model decodes it. type extrasResponse struct { Items []json.RawMessage `json:"items"` } // handleExtras joins the two lists Emby keeps an item's supplementary video in. // // It has to be a join, because Emby answers the question in two places and neither includes // the other: `SpecialFeatures` holds the featurettes and deleted scenes, `LocalTrailers` // holds the trailer, and a detail page asking only one of them shows an Extras tab missing // the thing most titles that have anything actually have. // // Done here rather than on the television for the ordinary reason: in gateway mode the set // holds no Emby credential. Doing it here also means one request instead of two on the path // that matters — the tab is probed on every detail page open. func (s *Server) handleExtras(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 } // Cached for the household rather than per viewer, and outside the namespace a // playback stop invalidates: what a film ships with is a fact about the film, and // nothing in this response carries user data. Empty answers included — most of a // library has no extras at all and every detail page asks, so the "no" is the entry // worth keeping. key := cache.MetadataKey("extras:v2:" + itemID) body, hit, err := s.cachedRead(ctx, key, s.cfg.ItemTTL, func(ctx context.Context) (json.RawMessage, error) { return s.extras(ctx, credentials(sess), itemID) }) if err != nil { s.writeUpstreamError(ctx, w, err, "could not load extras") return } writeCached(w, hit, body) } // extras joins the two places Emby keeps them. // // The two lookups are independent and are made together. They used to run in turn, which // on a detail page open — where this is one of five requests the television makes at once // — was one round trip of pure waiting for no reason at all. // // Neither list failing is fatal, and only both failing is a failure worth reporting: a // title can perfectly well have a trailer and no special features, or the reverse, and an // Extras tab withheld because one of two lookups was unwell is the worse outcome. func (s *Server) extras( ctx context.Context, cred emby.Credentials, itemID string, ) (json.RawMessage, error) { var ( trailers *emby.ItemsResult trailerErr error features *emby.ItemsResult featureErr error wg sync.WaitGroup ) wg.Add(2) go func() { defer wg.Done() trailers, trailerErr = s.emby.LocalTrailers(timing.WithLabel(ctx, "emby.trailers"), cred, itemID) }() go func() { defer wg.Done() features, featureErr = s.emby.SpecialFeatures(timing.WithLabel(ctx, "emby.features"), cred, itemID) }() wg.Wait() if trailerErr != nil && featureErr != nil { return nil, featureErr } var items []json.RawMessage if trailerErr == nil && trailers != nil { items = append(items, trailers.Items...) } if featureErr == nil && features != nil { items = append(items, features.Items...) } return json.Marshal(extrasResponse{Items: nonNilRaws(dedupeExtras(items))}) } // dedupeExtras drops the same file appearing in both lists, in first-seen order. // // A trailer filed as a special feature *and* as a local trailer is an ordinary way for a // library to be laid out, and the television renders these into a keyed grid — which throws // on a repeated key rather than merely looking wrong. Anything without an id is dropped for // the same reason. func dedupeExtras(items []json.RawMessage) []json.RawMessage { seen := make(map[string]struct{}, len(items)) out := make([]json.RawMessage, 0, len(items)) for _, raw := range items { var identified struct { ID string `json:"Id"` } if err := json.Unmarshal(raw, &identified); err != nil || identified.ID == "" { continue } if _, repeated := seen[identified.ID]; repeated { continue } seen[identified.ID] = struct{}{} out = append(out, raw) } return out }