429 lines
12 KiB
Go
429 lines
12 KiB
Go
// 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
|
||
|
|
}
|