452 lines
14 KiB
Go
452 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type hlsAsset struct {
|
|
extension string
|
|
remote string
|
|
body []byte
|
|
playlist bool
|
|
fallbacks []hlsAsset
|
|
}
|
|
|
|
type hlsProxy struct {
|
|
downloader *Downloader
|
|
credentials *providerMediaCredentials
|
|
client *http.Client
|
|
server *http.Server
|
|
base string
|
|
referer string
|
|
key []byte
|
|
mediaKey []byte
|
|
mu sync.Mutex
|
|
assets map[string]hlsAsset
|
|
assetIDs map[string]string
|
|
failure error
|
|
root string
|
|
retries int
|
|
host string
|
|
diagnostic func(diagnosticEvent)
|
|
query string
|
|
acquire func(context.Context) (func(), error)
|
|
cache *downloadMediaCache
|
|
}
|
|
|
|
var hlsURIAttribute = regexp.MustCompile(`URI="([^"]+)"`)
|
|
|
|
func (d *Downloader) newHLSProxy(ctx context.Context, media providerMedia, key []byte) (*hlsProxy, error) {
|
|
nonce := randomHex(16)
|
|
if nonce == "" {
|
|
return nil, errors.New("无法创建本地下载会话")
|
|
}
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("创建本地 HLS 转发失败: %w", err)
|
|
}
|
|
proxy := &hlsProxy{
|
|
downloader: d,
|
|
credentials: media.credentials,
|
|
client: &http.Client{Transport: d.client.Transport, Jar: d.client.Jar, CheckRedirect: d.client.CheckRedirect},
|
|
base: "http://" + listener.Addr().String() + "/" + nonce + "/",
|
|
referer: media.Referer,
|
|
key: key,
|
|
mediaKey: media.HLSKey,
|
|
assets: map[string]hlsAsset{},
|
|
assetIDs: map[string]string{},
|
|
retries: d.cfg.Retries,
|
|
diagnostic: d.recordDiagnostic,
|
|
}
|
|
if remote, err := url.Parse(media.URL); err == nil {
|
|
proxy.host = remote.Hostname()
|
|
}
|
|
proxy.root = proxy.addAsset(playbackRootAsset(media))
|
|
proxy.server = &http.Server{
|
|
Handler: proxy,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
|
}
|
|
go func() {
|
|
if err := proxy.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
proxy.recordError(err)
|
|
}
|
|
}()
|
|
return proxy, nil
|
|
}
|
|
|
|
func playbackRootAsset(media providerMedia) hlsAsset {
|
|
asset := hlsAsset{remote: media.URL, body: []byte(media.Playlist), playlist: media.Playlist != ""}
|
|
for _, fallback := range mediaFallbackVariants(media) {
|
|
asset.fallbacks = append(asset.fallbacks, hlsAsset{remote: fallback.URL, body: []byte(fallback.Playlist), playlist: fallback.Playlist != ""})
|
|
}
|
|
if asset.playlist && !strings.Contains(media.Playlist, "#EXT-X-ENDLIST") && !strings.Contains(media.Playlist, "#EXT-X-STREAM-INF:") {
|
|
asset.body = nil
|
|
}
|
|
return asset
|
|
}
|
|
|
|
func (proxy *hlsProxy) Close() {
|
|
if proxy.server != nil {
|
|
_ = proxy.server.Close()
|
|
}
|
|
}
|
|
|
|
func (proxy *hlsProxy) Err() error {
|
|
proxy.mu.Lock()
|
|
defer proxy.mu.Unlock()
|
|
return proxy.failure
|
|
}
|
|
|
|
func (proxy *hlsProxy) recordError(err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
proxy.mu.Lock()
|
|
first := proxy.failure == nil
|
|
if proxy.failure == nil {
|
|
proxy.failure = publicError(err)
|
|
}
|
|
proxy.mu.Unlock()
|
|
if first && proxy.diagnostic != nil {
|
|
proxy.diagnostic(diagnosticEvent{Event: "media.failed", Host: proxy.host, Message: err.Error()})
|
|
}
|
|
}
|
|
|
|
func (proxy *hlsProxy) addAsset(asset hlsAsset) string {
|
|
proxy.mu.Lock()
|
|
defer proxy.mu.Unlock()
|
|
if existing := proxy.assetIDs[asset.remote]; existing != "" {
|
|
return existing
|
|
}
|
|
if len(proxy.assets) >= 16384 {
|
|
return ""
|
|
}
|
|
extension := asset.extension
|
|
if extension == "" {
|
|
remote, _ := url.Parse(asset.remote)
|
|
if remote != nil {
|
|
extension = strings.ToLower(path.Ext(remote.Path))
|
|
}
|
|
switch extension {
|
|
case ".m3u8":
|
|
asset.playlist = true
|
|
case ".mp4", ".m4s", ".m4a", ".aac", ".mp3", ".vtt", ".key", ".ts":
|
|
default:
|
|
extension = ".ts"
|
|
}
|
|
}
|
|
if asset.playlist {
|
|
extension = ".m3u8"
|
|
} else if len(asset.body) > 0 {
|
|
extension = ".key"
|
|
}
|
|
asset.extension = extension
|
|
local := proxy.base + strconv.Itoa(len(proxy.assets)+1) + extension
|
|
if proxy.query != "" {
|
|
local += "?" + proxy.query
|
|
}
|
|
parsed, _ := url.Parse(local)
|
|
proxy.assets[parsed.Path] = asset
|
|
proxy.assetIDs[asset.remote] = local
|
|
return local
|
|
}
|
|
|
|
func (proxy *hlsProxy) rewritePlaylist(raw, baseURL string) (string, error) {
|
|
base, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rewrite := func(reference string, playlist, key, initialization bool) (string, error) {
|
|
parsed, err := url.Parse(reference)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
remote := base.ResolveReference(parsed).String()
|
|
if !isProviderHTTPMediaURL(remote) {
|
|
return "", errors.New("HLS 包含非 HTTP/HTTPS 资源地址")
|
|
}
|
|
asset := hlsAsset{remote: remote, playlist: playlist || strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8")}
|
|
if key {
|
|
asset.extension = ".key"
|
|
} else if initialization {
|
|
asset.extension = ".mp4"
|
|
}
|
|
resolved, _ := url.Parse(remote)
|
|
if key && len(proxy.key) > 0 && strings.Contains(parsed.Path, "/api/app/vid/sec") {
|
|
asset.body = proxy.key
|
|
}
|
|
if key && len(proxy.mediaKey) == 16 && strings.HasSuffix(resolved.Path, "/enc.key") {
|
|
asset.body = proxy.mediaKey
|
|
}
|
|
local := proxy.addAsset(asset)
|
|
if local == "" {
|
|
return "", errors.New("HLS 资源数量超过限制")
|
|
}
|
|
return local, nil
|
|
}
|
|
lines := strings.Split(strings.TrimPrefix(raw, "\ufeff"), "\n")
|
|
nextPlaylist := false
|
|
for index, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF:") {
|
|
nextPlaylist = true
|
|
}
|
|
if strings.HasPrefix(trimmed, "#") {
|
|
var rewriteErr error
|
|
lines[index] = hlsURIAttribute.ReplaceAllStringFunc(line, func(attribute string) string {
|
|
reference := hlsURIAttribute.FindStringSubmatch(attribute)[1]
|
|
local, err := rewrite(reference, strings.HasPrefix(trimmed, "#EXT-X-MEDIA:") || strings.HasPrefix(trimmed, "#EXT-X-I-FRAME-STREAM-INF:"), strings.HasPrefix(trimmed, "#EXT-X-KEY:") || strings.HasPrefix(trimmed, "#EXT-X-SESSION-KEY:"), strings.HasPrefix(trimmed, "#EXT-X-MAP:"))
|
|
if err != nil {
|
|
rewriteErr = err
|
|
return attribute
|
|
}
|
|
return `URI="` + local + `"`
|
|
})
|
|
if rewriteErr != nil {
|
|
return "", rewriteErr
|
|
}
|
|
} else if trimmed != "" {
|
|
lines[index], err = rewrite(trimmed, nextPlaylist, false, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nextPlaylist = false
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
func hlsAssetContentType(extension string) string {
|
|
switch extension {
|
|
case ".mp4", ".m4s":
|
|
return "video/mp4"
|
|
case ".ts":
|
|
return "video/mp2t"
|
|
case ".m4a":
|
|
return "audio/mp4"
|
|
case ".aac":
|
|
return "audio/aac"
|
|
case ".mp3":
|
|
return "audio/mpeg"
|
|
case ".vtt":
|
|
return "text/vtt"
|
|
case ".key":
|
|
return "application/octet-stream"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (proxy *hlsProxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
|
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
|
writer.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
proxy.mu.Lock()
|
|
asset, found := proxy.assets[request.URL.Path]
|
|
proxy.mu.Unlock()
|
|
if !found {
|
|
http.NotFound(writer, request)
|
|
return
|
|
}
|
|
body := asset.body
|
|
if len(body) == 0 {
|
|
if proxy.acquire != nil {
|
|
release, err := proxy.acquire(request.Context())
|
|
if err != nil {
|
|
http.Error(writer, "媒体连接正在等待资源,请稍后重试", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer release()
|
|
}
|
|
method := request.Method
|
|
if asset.playlist {
|
|
method = http.MethodGet
|
|
}
|
|
buildRequest := func(candidate hlsAsset) (*http.Request, error) {
|
|
upstream, err := http.NewRequestWithContext(request.Context(), method, candidate.remote, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
upstream.Header.Set("User-Agent", userAgent)
|
|
upstream.Header.Set("Referer", proxy.referer)
|
|
upstream.Header.Set("Accept-Encoding", "identity")
|
|
if origin, err := url.Parse(proxy.referer); err == nil && origin.Host != "" {
|
|
upstream.Header.Set("Origin", origin.Scheme+"://"+origin.Host)
|
|
}
|
|
for _, header := range []string{"Range", "If-Range"} {
|
|
if value := request.Header.Get(header); value != "" && !candidate.playlist {
|
|
upstream.Header.Set(header, value)
|
|
}
|
|
}
|
|
return upstream, nil
|
|
}
|
|
candidates := append([]hlsAsset{asset}, asset.fallbacks...)
|
|
if proxy.cache != nil && !asset.playlist && request.Method == http.MethodGet && request.Header.Get("Range") == "" {
|
|
for _, candidate := range candidates {
|
|
file, size, cacheErr := proxy.cache.open(request.Context(), proxy, candidate, buildRequest)
|
|
if cacheErr == nil {
|
|
defer file.Close()
|
|
if contentType := hlsAssetContentType(candidate.extension); contentType != "" {
|
|
writer.Header().Set("Content-Type", contentType)
|
|
}
|
|
writer.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
|
http.ServeContent(writer, request, "media", time.Time{}, file)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
var upstream *http.Request
|
|
var response *http.Response
|
|
var err error
|
|
for index, candidate := range candidates {
|
|
upstream, err = buildRequest(candidate)
|
|
if err != nil {
|
|
break
|
|
}
|
|
response, err = proxy.fetch(upstream)
|
|
if err == nil && response.StatusCode >= 200 && response.StatusCode < 300 {
|
|
asset = candidate
|
|
break
|
|
}
|
|
if response != nil {
|
|
if response.StatusCode == http.StatusRequestedRangeNotSatisfiable || response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusUnauthorized && response.StatusCode != http.StatusForbidden && response.StatusCode != http.StatusNotFound && response.StatusCode != http.StatusGone {
|
|
break
|
|
}
|
|
_ = response.Body.Close()
|
|
}
|
|
if index == len(candidates)-1 {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
proxy.fail(writer, request, err)
|
|
return
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
_ = response.Body.Close()
|
|
if response.StatusCode == http.StatusRequestedRangeNotSatisfiable {
|
|
writer.Header().Set("Content-Range", response.Header.Get("Content-Range"))
|
|
writer.WriteHeader(response.StatusCode)
|
|
return
|
|
}
|
|
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone {
|
|
proxy.recordError(fmt.Errorf("媒体源 HTTP %d", response.StatusCode))
|
|
writer.WriteHeader(response.StatusCode)
|
|
return
|
|
}
|
|
proxy.fail(writer, request, fmt.Errorf("HLS 资源请求失败: %s HTTP %d", upstream.URL.Hostname(), response.StatusCode))
|
|
return
|
|
}
|
|
if asset.playlist || strings.Contains(response.Header.Get("Content-Type"), "mpegurl") {
|
|
defer response.Body.Close()
|
|
body, err = io.ReadAll(io.LimitReader(response.Body, providerMaxBodyBytes+1))
|
|
if err != nil || len(body) > providerMaxBodyBytes {
|
|
proxy.fail(writer, request, errors.New("读取 HLS 播放列表失败或内容过大"))
|
|
return
|
|
}
|
|
asset.playlist = true
|
|
if response.Request != nil && response.Request.URL != nil {
|
|
asset.remote = response.Request.URL.String()
|
|
}
|
|
} else {
|
|
reader := proxy.mediaReader(upstream, response)
|
|
defer reader.Close()
|
|
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "Content-Encoding", "ETag", "Last-Modified"} {
|
|
if value := response.Header.Get(header); value != "" {
|
|
writer.Header().Set(header, value)
|
|
}
|
|
}
|
|
if contentType := hlsAssetContentType(asset.extension); contentType != "" {
|
|
writer.Header().Set("Content-Type", contentType)
|
|
}
|
|
writer.WriteHeader(response.StatusCode)
|
|
if request.Method == http.MethodHead {
|
|
return
|
|
}
|
|
if _, err := io.Copy(writer, reader); err != nil && reader.failure != nil && request.Context().Err() == nil {
|
|
proxy.recordError(reader.failure)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if asset.playlist {
|
|
if !bytes.HasPrefix(bytes.TrimSpace(bytes.TrimPrefix(body, []byte("\ufeff"))), []byte("#EXTM3U")) {
|
|
proxy.fail(writer, request, errors.New("上游没有返回有效的 HLS 播放列表"))
|
|
return
|
|
}
|
|
rewritten, err := proxy.rewritePlaylist(string(body), asset.remote)
|
|
if err != nil {
|
|
proxy.fail(writer, request, err)
|
|
return
|
|
}
|
|
body = []byte(rewritten)
|
|
writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
|
} else {
|
|
writer.Header().Set("Content-Type", "application/octet-stream")
|
|
}
|
|
http.ServeContent(writer, request, "media", time.Time{}, bytes.NewReader(body))
|
|
}
|
|
|
|
func (proxy *hlsProxy) fetch(request *http.Request) (*http.Response, error) {
|
|
request = request.Clone(providerMediaContext(request.Context(), proxy.credentials))
|
|
attempts := mediaRequestAttempts(proxy.retries)
|
|
var lastErr error
|
|
for attempt := 0; attempt < attempts; attempt++ {
|
|
if attempt > 0 {
|
|
timer := time.NewTimer(time.Duration(attempt) * time.Second)
|
|
select {
|
|
case <-timer.C:
|
|
case <-request.Context().Done():
|
|
timer.Stop()
|
|
return nil, request.Context().Err()
|
|
}
|
|
}
|
|
var response *http.Response
|
|
var err error
|
|
if proxy.downloader != nil {
|
|
response, err = proxy.downloader.doMediaRequestWithClient(request.Clone(request.Context()), proxy.client)
|
|
} else {
|
|
upstream := request.Clone(request.Context())
|
|
mediaRequestHeaders(upstream, proxy.referer)
|
|
client := proxy.client
|
|
if proxy.credentials != nil {
|
|
if err := proxy.credentials.apply(upstream); err != nil {
|
|
return nil, err
|
|
}
|
|
client = proxy.credentials.client(client)
|
|
}
|
|
response, err = client.Do(upstream)
|
|
}
|
|
if err == nil {
|
|
return response, nil
|
|
}
|
|
if request.Context().Err() != nil {
|
|
return nil, request.Context().Err()
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, fmt.Errorf("媒体资源连接失败(%s,已尝试 %d 次;可在代理设置中检测连接): %w", request.URL.Hostname(), attempts, publicError(lastErr))
|
|
}
|
|
|
|
func (proxy *hlsProxy) fail(writer http.ResponseWriter, request *http.Request, err error) {
|
|
if request.Context().Err() != nil {
|
|
return
|
|
}
|
|
proxy.recordError(err)
|
|
http.Error(writer, "上游媒体请求失败", http.StatusBadGateway)
|
|
}
|