package api import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "net/http" "sort" "strings" "time" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/store" ) type remoteTrailer struct { URL string `json:"Url"` Name string `json:"Name"` } type trailerSubject struct { Name string `json:"Name"` RemoteTrailers []remoteTrailer `json:"RemoteTrailers"` } type trailerCandidate struct { ID string `json:"id"` Provider string `json:"provider"` Name string `json:"name,omitempty"` SourceURL string `json:"sourceUrl,omitempty"` LocalItem json.RawMessage `json:"localItem,omitempty"` Priority int `json:"priority"` } type trailerManifest struct { SubjectID string `json:"subjectId"` Title string `json:"title"` Candidates []trailerCandidate `json:"candidates"` } type trailerAvailability struct { Available bool `json:"available"` Providers []string `json:"providers"` } type resolveTrailerRequest struct { ExcludedCandidateIDs []string `json:"excludedCandidateIds"` } type trailerPlaybackResponse struct { CandidateID string `json:"candidateId"` Provider string `json:"provider"` URL string `json:"url"` SourceURL string `json:"sourceUrl,omitempty"` Title string `json:"title"` ItemID string `json:"itemId,omitempty"` MediaSourceID string `json:"mediaSourceId,omitempty"` PlaySessionID string `json:"playSessionId,omitempty"` PlayMethod string `json:"playMethod,omitempty"` } type trailerReportRequest struct { CandidateID string `json:"candidateId"` Provider string `json:"provider"` Phase string `json:"phase"` Reason string `json:"reason,omitempty"` } func (s *Server) handleTrailers(w http.ResponseWriter, r *http.Request, sess store.Session) { itemID := strings.TrimSpace(r.PathValue("id")) if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } manifest, err := s.trailerManifest(r.Context(), sess, itemID) if err != nil { s.writeUpstreamError(r.Context(), w, err, "could not inspect trailers") return } providers := make([]string, 0, len(manifest.Candidates)) seen := map[string]bool{} for _, candidate := range manifest.Candidates { if !seen[candidate.Provider] { seen[candidate.Provider] = true providers = append(providers, candidate.Provider) } } writeJSON(w, http.StatusOK, trailerAvailability{ Available: len(manifest.Candidates) > 0, Providers: providers, }) } func (s *Server) handleResolveTrailer(w http.ResponseWriter, r *http.Request, sess store.Session) { itemID := strings.TrimSpace(r.PathValue("id")) if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } var request resolveTrailerRequest if r.Body != nil && json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&request) != nil { writeError(w, http.StatusBadRequest, "invalid trailer request") return } excluded := make(map[string]bool, len(request.ExcludedCandidateIDs)) for _, id := range request.ExcludedCandidateIDs { excluded[strings.TrimSpace(id)] = true } manifest, err := s.trailerManifest(r.Context(), sess, itemID) if err != nil { s.writeUpstreamError(r.Context(), w, err, "could not inspect trailers") return } for _, candidate := range s.preferredTrailerCandidates(r.Context(), sess, manifest) { if excluded[candidate.ID] { continue } if len(candidate.LocalItem) > 0 { if resolved, resolveErr := s.resolveLocalTrailer(r.Context(), sess, manifest, candidate); resolveErr == nil { writeJSON(w, http.StatusOK, resolved) return } continue } // Remote pages are resolved on the television. YouTube signs direct media URLs for // the resolving IP, so resolving here can make the URL unusable from the viewer's // network and makes provider traffic appear to come from the gateway. writeJSON(w, http.StatusOK, trailerPlaybackResponse{ CandidateID: candidate.ID, Provider: candidate.Provider, SourceURL: candidate.SourceURL, Title: trailerTitle(manifest.Title, candidate.Name), PlayMethod: "DirectPlay", }) return } writeError(w, http.StatusNotFound, "no playable trailer is available") } func (s *Server) handleTrailerReport(w http.ResponseWriter, r *http.Request, sess store.Session) { itemID := strings.TrimSpace(r.PathValue("id")) var report trailerReportRequest if itemID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&report) != nil { writeError(w, http.StatusBadRequest, "invalid trailer report") return } report.CandidateID = strings.TrimSpace(report.CandidateID) report.Provider = strings.ToLower(strings.TrimSpace(report.Provider)) report.Phase = strings.ToLower(strings.TrimSpace(report.Phase)) if report.CandidateID == "" || report.Provider == "" || (report.Phase != "started" && report.Phase != "failed" && report.Phase != "completed") { writeError(w, http.StatusBadRequest, "invalid trailer report") return } fields := []any{ "item_id", itemID, "provider", report.Provider, "candidate", report.CandidateID, "source_ip", requestClientIP(r), } if reason := strings.TrimSpace(report.Reason); reason != "" { fields = append(fields, "reason", reason) } s.loggerFor(r.Context()).Info("trailer playback "+report.Phase, fields...) if report.Phase == "started" { s.rememberTrailerCandidate(r.Context(), sess, itemID, report.CandidateID) } w.WriteHeader(http.StatusNoContent) } func (s *Server) preferredTrailerCandidates( ctx context.Context, sess store.Session, manifest trailerManifest, ) []trailerCandidate { out := append([]trailerCandidate(nil), manifest.Candidates...) if s.cache == nil { return out } preferred, err := s.cache.Get(ctx, cache.UserKey(sess.EmbyUserID, "trailer-success:v1:"+manifest.SubjectID)) if err != nil || len(preferred) == 0 { return out } id := string(preferred) sort.SliceStable(out, func(i, j int) bool { return out[i].ID == id && out[j].ID != id }) return out } func (s *Server) rememberTrailerCandidate( ctx context.Context, sess store.Session, itemID string, candidateID string, ) { if s.cache != nil && candidateID != "" { _ = s.cache.Set( ctx, cache.UserKey(sess.EmbyUserID, "trailer-success:v1:"+itemID), []byte(candidateID), 30*time.Minute, ) } } func (s *Server) resolveLocalTrailer( ctx context.Context, sess store.Session, manifest trailerManifest, candidate trailerCandidate, ) (trailerPlaybackResponse, error) { item, err := emby.Summarise(candidate.LocalItem) if err != nil || item.ID == "" { return trailerPlaybackResponse{}, errors.New("unreadable local trailer") } _, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles( ctx, credentials(sess), item.ID, 0, nil, "", false, s.effectivePlaybackCapabilities(ctx, sess), ) streamURL := s.emby.StreamURL(credentials(sess), item.ID) if negotiatedURL != "" { streamURL = negotiatedURL } if streamURL == "" { return trailerPlaybackResponse{}, errors.New("empty local trailer stream") } return trailerPlaybackResponse{ CandidateID: candidate.ID, Provider: candidate.Provider, URL: streamURL, Title: trailerTitle(manifest.Title, candidate.Name), ItemID: item.ID, MediaSourceID: mediaSourceID, PlaySessionID: playSessionID, PlayMethod: playMethod, }, nil } func (s *Server) trailerManifest(ctx context.Context, sess store.Session, itemID string) (trailerManifest, error) { // A film Radarr is tracking has no Emby record to ask about local or remote trailers, // so its chain is built from what Radarr knows. It joins here rather than beside the // detail route because everything downstream — availability, resolve, report, the // player's walk through the candidates — is then unchanged for both kinds of subject. if movieID, ok := radarrMovieID(itemID); ok && strings.HasPrefix(itemID, radarrItemPrefix) { return s.radarrTrailerManifest(ctx, itemID, movieID) } key := cache.UserKey(sess.EmbyUserID, "trailers:v2:"+itemID) if s.cache != nil { if raw, err := s.cache.Get(ctx, key); err == nil { var cached trailerManifest if json.Unmarshal(raw, &cached) == nil { return cached, nil } } } type localResult struct { items *emby.ItemsResult err error } type itemResult struct { raw json.RawMessage err error } localResultCh := make(chan localResult, 1) itemResultCh := make(chan itemResult, 1) go func() { items, err := s.emby.LocalTrailers(ctx, credentials(sess), itemID) localResultCh <- localResult{items: items, err: err} }() go func() { raw, err := s.emby.Item(ctx, credentials(sess), itemID, "RemoteTrailers") itemResultCh <- itemResult{raw: raw, err: err} }() localAnswer, itemAnswer := <-localResultCh, <-itemResultCh locals, localErr := localAnswer.items, localAnswer.err item, itemErr := itemAnswer.raw, itemAnswer.err if localErr != nil && itemErr != nil { return trailerManifest{}, errors.Join(localErr, itemErr) } partial := localErr != nil || itemErr != nil manifest := trailerManifest{SubjectID: itemID, Candidates: []trailerCandidate{}} if itemErr == nil { var subject trailerSubject if json.Unmarshal(item, &subject) == nil { manifest.Title = strings.TrimSpace(subject.Name) for _, remote := range subject.RemoteTrailers { provider := trailerProvider(remote.URL) if provider == "" { continue } manifest.Candidates = append(manifest.Candidates, trailerCandidate{ ID: trailerCandidateID(provider, remote.URL), Provider: provider, Name: strings.TrimSpace(remote.Name), SourceURL: strings.TrimSpace(remote.URL), Priority: remoteTrailerPriority(provider, remote.Name), }) } } } if localErr == nil && locals != nil { for _, raw := range locals.Items { summary, summaryErr := emby.Summarise(raw) if summaryErr != nil || summary.ID == "" { continue } manifest.Candidates = append(manifest.Candidates, trailerCandidate{ ID: trailerCandidateID("local", summary.ID), Provider: "local", Name: summary.Name, LocalItem: raw, Priority: 20, }) } } manifest.Candidates = uniqueTrailerCandidates(manifest.Candidates) sort.SliceStable(manifest.Candidates, func(i, j int) bool { return manifest.Candidates[i].Priority < manifest.Candidates[j].Priority }) if partial && len(manifest.Candidates) == 0 { return trailerManifest{}, errors.Join(localErr, itemErr) } if encoded, err := json.Marshal(manifest); err == nil && s.cache != nil && !partial { _ = s.cache.Set(ctx, key, encoded, s.cfg.ItemTTL) } return manifest, nil } func trailerProvider(raw string) string { value := strings.ToLower(raw) switch { case strings.Contains(value, "youtube.com/"), strings.Contains(value, "youtube-nocookie.com/"), strings.Contains(value, "youtu.be/"): return "youtube" case strings.Contains(value, "apple.com/"), strings.Contains(value, "apple.co/"): return "apple" default: return "" } } func remoteTrailerPriority(provider, name string) int { official := strings.Contains(strings.ToLower(name), "official") switch { case provider == "apple": // Apple-hosted trailer media is an official first-party source even when Emby // supplies no useful label for it. return 0 case provider == "youtube" && official: return 10 default: return 30 } } func trailerCandidateID(provider, source string) string { digest := sha256.Sum256([]byte(provider + "\x00" + strings.TrimSpace(source))) return provider + "-" + hex.EncodeToString(digest[:8]) } func uniqueTrailerCandidates(candidates []trailerCandidate) []trailerCandidate { seen := map[string]bool{} out := make([]trailerCandidate, 0, len(candidates)) for _, candidate := range candidates { if candidate.ID == "" || seen[candidate.ID] { continue } seen[candidate.ID] = true out = append(out, candidate) } return out } func trailerTitle(subject, candidate string) string { if strings.TrimSpace(subject) != "" { return strings.TrimSpace(subject) + " trailer" } if strings.TrimSpace(candidate) != "" { return strings.TrimSpace(candidate) } return "Trailer" }