0.2.56 - Reliable trailer playback

This commit is contained in:
ponzischeme89
2026-08-12 11:05:07 +12:00
parent 9777eb0952
commit f2d052dbf6
22 changed files with 1438 additions and 55 deletions
+9
View File
@@ -35,6 +35,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type Server struct {
@@ -85,6 +86,7 @@ type Server struct {
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
// a TV can show why playback stopped even if it missed the announcement.
embyHealth embyHealth
trailers *trailer.Resolver
}
// Deps are the collaborators the API needs. A struct rather than positional arguments:
@@ -105,6 +107,10 @@ type Deps struct {
}
func New(cfg config.Config, deps Deps) *Server {
trailerTimeout := cfg.UpstreamTimeout
if trailerTimeout <= 0 || trailerTimeout > 8*time.Second {
trailerTimeout = 8 * time.Second
}
return &Server{
cfg: cfg,
emby: deps.Emby,
@@ -119,6 +125,7 @@ func New(cfg config.Config, deps Deps) *Server {
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
trailers: trailer.New(&http.Client{Timeout: trailerTimeout}),
}
}
@@ -192,6 +199,8 @@ func (s *Server) Routes() http.Handler {
// fetching a sidecar sends none of Memby's headers.
v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
+360
View File
@@ -0,0 +1,360 @@
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"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
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"`
Title string `json:"title"`
ItemID string `json:"itemId,omitempty"`
MediaSourceID string `json:"mediaSourceId,omitempty"`
PlaySessionID string `json:"playSessionId,omitempty"`
PlayMethod string `json:"playMethod,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
}
resolver := s.trailers
if resolver == nil {
resolver = trailer.New(nil)
}
for _, candidate := range s.preferredTrailerCandidates(r.Context(), sess, manifest) {
if excluded[candidate.ID] {
if candidate.SourceURL != "" {
resolver.Invalidate(trailer.Source{Provider: candidate.Provider, URL: candidate.SourceURL})
}
continue
}
if len(candidate.LocalItem) > 0 {
if resolved, resolveErr := s.resolveLocalTrailer(r.Context(), sess, manifest, candidate); resolveErr == nil {
s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID)
writeJSON(w, http.StatusOK, resolved)
return
}
continue
}
resolved, resolveErr := resolver.Resolve(r.Context(), trailer.Source{
Provider: candidate.Provider,
URL: candidate.SourceURL,
})
if resolveErr != nil {
continue
}
s.rememberTrailerCandidate(r.Context(), sess, itemID, candidate.ID)
writeJSON(w, http.StatusOK, trailerPlaybackResponse{
CandidateID: candidate.ID,
Provider: candidate.Provider,
URL: resolved.URL,
Title: trailerTitle(manifest.Title, candidate.Name),
PlayMethod: "DirectPlay",
})
return
}
writeError(w, http.StatusNotFound, "no playable trailer is available")
}
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) {
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"
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trailer"
)
type trailerRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn trailerRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestResolveTrailerFallsThroughProviders(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/LocalTrailers"):
writeJSON(w, http.StatusOK, []any{})
case strings.Contains(r.URL.Path, "/Items/film-1"):
writeJSON(w, http.StatusOK, map[string]any{
"Id": "film-1", "Name": "A Film",
"RemoteTrailers": []map[string]string{
{"Name": "Official trailer", "Url": "https://trailers.apple.com/missing.mov"},
{"Name": "Official trailer", "Url": "https://youtu.be/dQw4w9WgXcQ"},
},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
resolverClient := &http.Client{Transport: trailerRoundTripFunc(func(request *http.Request) (*http.Response, error) {
status := http.StatusOK
body := ""
headers := http.Header{}
switch request.URL.Host {
case "trailers.apple.com":
status = http.StatusNotFound
case "www.youtube.com":
body = `{"playabilityStatus":{"status":"OK"},"streamingData":{"formats":[` +
`{"url":"https://media.example/trailer.mp4","mimeType":"video/mp4; codecs=avc1,mp4a","height":720}]}}`
headers.Set("Content-Type", "application/json")
case "media.example":
status = http.StatusPartialContent
headers.Set("Content-Type", "video/mp4")
default:
t.Fatalf("unexpected trailer request: %s", request.URL)
}
return &http.Response{
StatusCode: status, Header: headers,
Body: io.NopCloser(strings.NewReader(body)), Request: request,
}, nil
})}
server := &Server{
cfg: config.Config{ItemTTL: time.Minute},
emby: emby.New(upstream.URL, upstream.URL, "MbyATV", time.Second),
trailers: trailer.New(resolverClient),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/resolve", bytes.NewBufferString(`{}`))
request.SetPathValue("id", "film-1")
recorder := httptest.NewRecorder()
server.handleResolveTrailer(recorder, request, store.Session{EmbyUserID: "user", EmbyToken: "token"})
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response trailerPlaybackResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Provider != "youtube" || response.URL != "https://media.example/trailer.mp4" {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestRemoteTrailerPriorityPrefersOfficialAppleThenYouTube(t *testing.T) {
candidates := []trailerCandidate{
{ID: "youtube-other", Provider: "youtube", Priority: remoteTrailerPriority("youtube", "Trailer")},
{ID: "apple-official", Provider: "apple", Priority: remoteTrailerPriority("apple", "Trailer")},
{ID: "youtube-official", Provider: "youtube", Priority: remoteTrailerPriority("youtube", "Official trailer")},
{ID: "local", Provider: "local", Priority: 20},
}
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].Priority < candidates[j].Priority })
if candidates[0].ID != "apple-official" || candidates[1].ID != "youtube-official" || candidates[2].ID != "local" {
t.Fatalf("unexpected order: %+v", candidates)
}
}