0.2.56 - Reliable trailer playback
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
// Package trailer resolves remote trailer pages to native media streams.
|
||||
package trailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPageBytes = 2 << 20
|
||||
cacheTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("trailer unavailable")
|
||||
|
||||
type Source struct {
|
||||
Provider string
|
||||
URL string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
URL string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Supports(string) bool
|
||||
Resolve(context.Context, string) (Result, error)
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
result Result
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Resolver is an ordered provider chain with a short-lived successful mapping cache.
|
||||
// The cached value avoids repeating YouTube page resolution on Back → Trailer while its
|
||||
// signed media URL is still useful; failures are never cached.
|
||||
type Resolver struct {
|
||||
providers []Provider
|
||||
mu sync.Mutex
|
||||
cache map[string]cacheEntry
|
||||
}
|
||||
|
||||
func New(client *http.Client) *Resolver {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 8 * time.Second}
|
||||
}
|
||||
return &Resolver{
|
||||
providers: []Provider{newAppleProvider(client), newYouTubeProvider(client)},
|
||||
cache: map[string]cacheEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) Resolve(ctx context.Context, source Source) (Result, error) {
|
||||
key := strings.ToLower(strings.TrimSpace(source.Provider)) + "\x00" + strings.TrimSpace(source.URL)
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
if cached, ok := r.cache[key]; ok && cached.expiresAt.After(now) {
|
||||
r.mu.Unlock()
|
||||
return cached.result, nil
|
||||
}
|
||||
delete(r.cache, key)
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, provider := range r.providers {
|
||||
if source.Provider != "" && !strings.EqualFold(source.Provider, provider.Name()) {
|
||||
continue
|
||||
}
|
||||
if !provider.Supports(source.URL) {
|
||||
continue
|
||||
}
|
||||
result, err := provider.Resolve(ctx, source.URL)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.cache[key] = cacheEntry{result: result, expiresAt: now.Add(cacheTTL)}
|
||||
if len(r.cache) > 128 {
|
||||
for candidate, entry := range r.cache {
|
||||
if entry.expiresAt.Before(now) {
|
||||
delete(r.cache, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
return Result{}, ErrUnavailable
|
||||
}
|
||||
|
||||
func (r *Resolver) Invalidate(source Source) {
|
||||
key := strings.ToLower(strings.TrimSpace(source.Provider)) + "\x00" + strings.TrimSpace(source.URL)
|
||||
r.mu.Lock()
|
||||
delete(r.cache, key)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
type appleProvider struct{ client *http.Client }
|
||||
|
||||
func newAppleProvider(client *http.Client) Provider { return &appleProvider{client: client} }
|
||||
func (*appleProvider) Name() string { return "apple" }
|
||||
func (*appleProvider) Supports(raw string) bool {
|
||||
parsed, err := url.Parse(raw)
|
||||
return err == nil && (isHostOrSubdomain(parsed.Hostname(), "apple.com") ||
|
||||
isHostOrSubdomain(parsed.Hostname(), "apple.co"))
|
||||
}
|
||||
|
||||
func (p *appleProvider) Resolve(ctx context.Context, raw string) (Result, error) {
|
||||
if looksLikeMediaURL(raw) {
|
||||
return p.validate(ctx, raw)
|
||||
}
|
||||
body, err := fetchLimited(ctx, p.client, raw, maxPageBytes, "text/html")
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
links := mediaLinks(string(body))
|
||||
if len(links) == 0 {
|
||||
return Result{}, ErrUnavailable
|
||||
}
|
||||
sort.SliceStable(links, func(i, j int) bool { return mediaQuality(links[i]) > mediaQuality(links[j]) })
|
||||
for _, candidate := range links {
|
||||
if result, err := p.validate(ctx, candidate); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return Result{}, ErrUnavailable
|
||||
}
|
||||
|
||||
func (p *appleProvider) validate(ctx context.Context, raw string) (Result, error) {
|
||||
contentType, err := validateMediaURL(ctx, p.client, raw)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{URL: raw, MimeType: contentType}, nil
|
||||
}
|
||||
|
||||
var appleMediaURL = regexp.MustCompile(`https?:\\?/\\?/[^"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^"'<> ]*)?`)
|
||||
|
||||
func mediaLinks(body string) []string {
|
||||
matches := appleMediaURL.FindAllString(body, -1)
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
candidate := html.UnescapeString(strings.ReplaceAll(match, `\/`, `/`))
|
||||
if !seen[candidate] {
|
||||
seen[candidate] = true
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mediaQuality(raw string) int {
|
||||
lower := strings.ToLower(raw)
|
||||
for _, quality := range []int{2160, 1440, 1080, 720, 480, 360} {
|
||||
if strings.Contains(lower, fmt.Sprintf("%d", quality)) {
|
||||
return quality
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type youTubeProvider struct{ client *http.Client }
|
||||
|
||||
func newYouTubeProvider(client *http.Client) Provider { return &youTubeProvider{client: client} }
|
||||
func (*youTubeProvider) Name() string { return "youtube" }
|
||||
func (*youTubeProvider) Supports(raw string) bool { return youtubeVideoID(raw) != "" }
|
||||
|
||||
func (p *youTubeProvider) Resolve(ctx context.Context, raw string) (Result, error) {
|
||||
videoID := youtubeVideoID(raw)
|
||||
if videoID == "" {
|
||||
return Result{}, ErrUnavailable
|
||||
}
|
||||
responses := []func(context.Context, string) (youtubePlayer, error){
|
||||
p.innerTubePlayer,
|
||||
p.watchPagePlayer,
|
||||
}
|
||||
for _, load := range responses {
|
||||
player, err := load(ctx, videoID)
|
||||
if err != nil || !strings.EqualFold(player.PlayabilityStatus.Status, "OK") {
|
||||
continue
|
||||
}
|
||||
// YouTube's formats list contains progressive audio+video streams. AdaptiveFormats
|
||||
// are separate tracks and would begin silently if handed straight to Media3.
|
||||
formats := player.StreamingData.Formats
|
||||
sort.SliceStable(formats, func(i, j int) bool {
|
||||
return formats[i].Height > formats[j].Height ||
|
||||
(formats[i].Height == formats[j].Height && formats[i].Bitrate > formats[j].Bitrate)
|
||||
})
|
||||
for _, format := range formats {
|
||||
// Native playback needs one progressive stream carrying both tracks. Adaptive
|
||||
// video-only formats are deliberately skipped rather than starting silent.
|
||||
if format.URL == "" || !strings.Contains(format.MimeType, "video/") {
|
||||
continue
|
||||
}
|
||||
contentType, validationErr := validateMediaURL(ctx, p.client, format.URL)
|
||||
if validationErr == nil {
|
||||
return Result{URL: format.URL, MimeType: contentType}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return Result{}, ErrUnavailable
|
||||
}
|
||||
|
||||
type youtubePlayer struct {
|
||||
PlayabilityStatus struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"playabilityStatus"`
|
||||
StreamingData struct {
|
||||
Formats []youtubeFormat `json:"formats"`
|
||||
AdaptiveFormats []youtubeFormat `json:"adaptiveFormats"`
|
||||
} `json:"streamingData"`
|
||||
}
|
||||
|
||||
type youtubeFormat struct {
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Height int `json:"height"`
|
||||
Bitrate int `json:"bitrate"`
|
||||
}
|
||||
|
||||
func (p *youTubeProvider) innerTubePlayer(ctx context.Context, videoID string) (youtubePlayer, error) {
|
||||
payload := map[string]any{
|
||||
"videoId": videoID, "contentCheckOk": true, "racyCheckOk": true,
|
||||
"context": map[string]any{"client": map[string]any{
|
||||
"clientName": "ANDROID", "clientVersion": "20.10.38", "hl": "en", "gl": "NZ",
|
||||
}},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.youtube.com/youtubei/v1/player", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return youtubePlayer{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "com.google.android.youtube/20.10.38 (Linux; U; Android 12) gzip")
|
||||
return p.doPlayer(req)
|
||||
}
|
||||
|
||||
func (p *youTubeProvider) watchPagePlayer(ctx context.Context, videoID string) (youtubePlayer, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
"https://www.youtube.com/watch?v="+url.QueryEscape(videoID)+"&bpctr=9999999999&has_verified=1", nil)
|
||||
if err != nil {
|
||||
return youtubePlayer{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 Chrome/122 Safari/537.36")
|
||||
body, err := p.do(req)
|
||||
if err != nil {
|
||||
return youtubePlayer{}, err
|
||||
}
|
||||
for _, marker := range []string{"ytInitialPlayerResponse = ", `"playerResponse":`} {
|
||||
if raw := balancedJSONObject(body, marker); raw != "" {
|
||||
var player youtubePlayer
|
||||
if json.Unmarshal([]byte(raw), &player) == nil {
|
||||
return player, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return youtubePlayer{}, ErrUnavailable
|
||||
}
|
||||
|
||||
func (p *youTubeProvider) doPlayer(req *http.Request) (youtubePlayer, error) {
|
||||
body, err := p.do(req)
|
||||
if err != nil {
|
||||
return youtubePlayer{}, err
|
||||
}
|
||||
var player youtubePlayer
|
||||
if err := json.Unmarshal([]byte(body), &player); err != nil {
|
||||
return youtubePlayer{}, err
|
||||
}
|
||||
return player, nil
|
||||
}
|
||||
|
||||
func (p *youTubeProvider) do(req *http.Request) (string, error) {
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPageBytes+1))
|
||||
if err != nil || len(body) > maxPageBytes {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func youtubeVideoID(raw string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.")
|
||||
var id string
|
||||
switch {
|
||||
case host == "youtu.be":
|
||||
id = strings.Trim(parsed.Path, "/")
|
||||
case isHostOrSubdomain(host, "youtube.com"), isHostOrSubdomain(host, "youtube-nocookie.com"):
|
||||
id = parsed.Query().Get("v")
|
||||
if id == "" {
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
if len(parts) == 2 && (parts[0] == "embed" || parts[0] == "shorts") {
|
||||
id = parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(id) != 11 {
|
||||
return ""
|
||||
}
|
||||
for _, char := range id {
|
||||
if !(char == '-' || char == '_' || char >= 'a' && char <= 'z' ||
|
||||
char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func isHostOrSubdomain(host, root string) bool {
|
||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
root = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(root), "."))
|
||||
return host == root || strings.HasSuffix(host, "."+root)
|
||||
}
|
||||
|
||||
func balancedJSONObject(body, marker string) string {
|
||||
start := strings.Index(body, marker)
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
start += len(marker)
|
||||
for start < len(body) && body[start] != '{' {
|
||||
start++
|
||||
}
|
||||
if start == len(body) {
|
||||
return ""
|
||||
}
|
||||
depth, quoted, escaped := 0, false, false
|
||||
for index := start; index < len(body); index++ {
|
||||
char := body[index]
|
||||
if quoted {
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if char == '\\' {
|
||||
escaped = true
|
||||
} else if char == '"' {
|
||||
quoted = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch char {
|
||||
case '"':
|
||||
quoted = true
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return body[start : index+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeMediaURL(raw string) bool {
|
||||
path := strings.ToLower(strings.Split(raw, "?")[0])
|
||||
return strings.HasSuffix(path, ".mov") || strings.HasSuffix(path, ".mp4") || strings.HasSuffix(path, ".m3u8")
|
||||
}
|
||||
|
||||
func validateMediaURL(ctx context.Context, client *http.Client, raw string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-0")
|
||||
req.Header.Set("User-Agent", "Memby trailer resolver")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
contentType := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0]))
|
||||
if !strings.HasPrefix(contentType, "video/") && contentType != "application/vnd.apple.mpegurl" &&
|
||||
contentType != "application/x-mpegurl" && contentType != "application/octet-stream" {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
func fetchLimited(ctx context.Context, client *http.Client, raw string, limit int64, accept string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", accept)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android TV) AppleWebKit/537.36 Safari/537.36")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil || int64(len(body)) > limit {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package trailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return fn(request)
|
||||
}
|
||||
|
||||
func TestYouTubeVideoID(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"https://youtube.com/shorts/dQw4w9WgXcQ",
|
||||
} {
|
||||
if got := youtubeVideoID(raw); got != "dQw4w9WgXcQ" {
|
||||
t.Fatalf("youtubeVideoID(%q) = %q", raw, got)
|
||||
}
|
||||
}
|
||||
if got := youtubeVideoID("https://example.com/watch?v=dQw4w9WgXcQ"); got != "" {
|
||||
t.Fatalf("accepted a non-YouTube host: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderHostMatchingRejectsLookalikeDomains(t *testing.T) {
|
||||
if newAppleProvider(http.DefaultClient).Supports("https://notapple.com/trailer.mov") {
|
||||
t.Fatal("lookalike Apple host was accepted")
|
||||
}
|
||||
if youtubeVideoID("https://notyoutube.com/watch?v=dQw4w9WgXcQ") != "" {
|
||||
t.Fatal("lookalike YouTube host was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeResolverReturnsValidatedProgressiveStream(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
body := ""
|
||||
status := http.StatusOK
|
||||
headers := http.Header{}
|
||||
switch request.URL.Host {
|
||||
case "www.youtube.com":
|
||||
body = `{"playabilityStatus":{"status":"OK"},"streamingData":{"formats":[` +
|
||||
`{"url":"https://media.example/trailer.mp4","mimeType":"video/mp4; codecs=avc1,mp4a","height":720,"bitrate":1000}]}}`
|
||||
headers.Set("Content-Type", "application/json")
|
||||
case "media.example":
|
||||
status = http.StatusPartialContent
|
||||
headers.Set("Content-Type", "video/mp4")
|
||||
default:
|
||||
t.Fatalf("unexpected request to %s", request.URL)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: headers,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: request,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
resolver := New(client)
|
||||
result, err := resolver.Resolve(context.Background(), Source{
|
||||
Provider: "youtube",
|
||||
URL: "https://youtu.be/dQw4w9WgXcQ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.URL != "https://media.example/trailer.mp4" || result.MimeType != "video/mp4" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplePageChoosesBestValidatedStream(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
body := ""
|
||||
headers := http.Header{}
|
||||
status := http.StatusOK
|
||||
if request.URL.Path == "/page" {
|
||||
body = `<a href="https://trailers.apple.com/film_h720p.mov">720</a>` +
|
||||
`<a href="https://trailers.apple.com/film_h1080p.mov">1080</a>`
|
||||
headers.Set("Content-Type", "text/html")
|
||||
} else {
|
||||
status = http.StatusPartialContent
|
||||
headers.Set("Content-Type", "video/quicktime")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: headers,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: request,
|
||||
}, nil
|
||||
})}
|
||||
resolver := New(client)
|
||||
result, err := resolver.Resolve(context.Background(), Source{
|
||||
Provider: "apple", URL: "https://trailers.apple.com/page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.URL, "1080") {
|
||||
t.Fatalf("did not choose the best stream: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalancedJSONObjectIgnoresBracesInsideStrings(t *testing.T) {
|
||||
body := `before marker = {"value":"}" ,"nested":{"ok":true}} after`
|
||||
if got := balancedJSONObject(body, "marker = "); got != `{"value":"}" ,"nested":{"ok":true}}` {
|
||||
t.Fatalf("balanced object = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user