393 lines
12 KiB
Go
393 lines
12 KiB
Go
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"
|
|
}
|