// 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 }