feat(app): add internal components batch 2
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user