2026-08-16 09:02:17 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-19 18:08:00 +12:00
|
|
|
"context"
|
2026-08-16 09:02:17 +12:00
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
2026-08-19 18:08:00 +12:00
|
|
|
"sync"
|
2026-08-16 09:02:17 +12:00
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/cache"
|
2026-08-19 18:08:00 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
2026-08-16 09:02:17 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
2026-08-19 18:08:00 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/timing"
|
2026-08-16 09:02:17 +12:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-19 18:08:00 +12:00
|
|
|
// 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)
|
|
|
|
|
})
|
2026-08-16 09:02:17 +12:00
|
|
|
if err != nil {
|
2026-08-19 18:08:00 +12:00
|
|
|
s.writeUpstreamError(ctx, w, err, "could not load extras")
|
2026-08-16 09:02:17 +12:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-19 18:08:00 +12:00
|
|
|
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
|
2026-08-16 09:02:17 +12:00
|
|
|
}
|
2026-08-19 18:08:00 +12:00
|
|
|
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))})
|
2026-08-16 09:02:17 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|