Files
MeBox/internal/service/cloud115_hls_proxy.go
T
2026-09-14 17:57:51 +08:00

362 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
var (
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
)
const (
cloud115HLSSessionTTL = 30 * time.Minute
cloud115HLSMaxSessions = 256
cloud115HLSMaxManifest = 8 << 20
)
type cloud115HLSSession struct {
ID string
MediaID string
Definition int
CreatedAt time.Time
ExpiresAt time.Time
mu sync.Mutex
entries map[string]string
next int
}
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
//
// 浏览器不能直接请求 115 的 m3u8:master/variant/分片的 CORS 只允许
// https://115.com,且 master 还是 HTTP。代理在服务端拉取并重写播放列表,
// 分片按 Range 流式转发。
type Cloud115HLSProxy struct {
service *Cloud115PlaybackService
client *http.Client
mu sync.Mutex
sessions map[string]*cloud115HLSSession
}
func newCloud115HLSProxy(service *Cloud115PlaybackService) *Cloud115HLSProxy {
return &Cloud115HLSProxy{
service: service,
client: &http.Client{
Timeout: 0,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 6 {
return errors.New("stopped after 6 redirects")
}
return nil
},
},
sessions: make(map[string]*cloud115HLSSession),
}
}
// ServeMaster 解析指定清晰度并返回重写后的 master.m3u8。
func (p *Cloud115HLSProxy) ServeMaster(ctx context.Context, w http.ResponseWriter, r *http.Request, mediaID string, definition int) error {
if p == nil || p.service == nil {
return ErrCloud115NotApplicable
}
upstream, _, err := p.service.ResolveCloud115URL(ctx, mediaID, definition)
if err != nil {
return err
}
session := p.newSession(mediaID, definition)
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := p.fetchUpstream(fetchCtx, upstream, "")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("115 云端播放列表返回 HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
if err != nil {
return err
}
baseURL := upstream
if resp.Request != nil && resp.Request.URL != nil {
baseURL = resp.Request.URL.String()
}
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
p.storeSession(session)
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
if r.Method != http.MethodHead {
_, err = io.WriteString(w, rewritten)
}
return err
}
// ServeChild 代理 variant/分片;variant 播放列表会继续重写为同源地址。
func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter, r *http.Request, sessionID, key string) error {
session, upstream, ok := p.lookup(sessionID, key)
if !ok {
return ErrCloud115HLSSessionNotFound
}
resp, err := p.fetchUpstream(ctx, upstream, r.Header.Get("Range"))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusGone {
p.deleteSession(sessionID)
return ErrCloud115HLSUpstreamExpired
}
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
isPlaylist := strings.Contains(contentType, "mpegurl") ||
strings.Contains(contentType, "application/vnd.apple.mpegurl")
if !isPlaylist {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
return readErr
}
if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") {
isPlaylist = true
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
} else {
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
}
copyUpstreamHeaders(w, resp, isPlaylist)
if isPlaylist {
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
if err != nil {
return err
}
baseURL := upstream
if resp.Request != nil && resp.Request.URL != nil {
baseURL = resp.Request.URL.String()
}
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
w.WriteHeader(resp.StatusCode)
if r.Method != http.MethodHead {
_, err = io.WriteString(w, rewritten)
}
return err
}
w.WriteHeader(resp.StatusCode)
if r.Method != http.MethodHead {
_, err = io.Copy(w, resp.Body)
}
return err
}
func (p *Cloud115HLSProxy) newSession(mediaID string, definition int) *cloud115HLSSession {
now := time.Now()
return &cloud115HLSSession{
ID: cloud115.RandomString(24),
MediaID: mediaID,
Definition: definition,
CreatedAt: now,
ExpiresAt: now.Add(cloud115HLSSessionTTL),
entries: make(map[string]string),
}
}
func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) {
if p == nil || session == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
if len(p.sessions) >= cloud115HLSMaxSessions {
for id, existing := range p.sessions {
if now.After(existing.ExpiresAt) {
delete(p.sessions, id)
}
}
}
if len(p.sessions) >= cloud115HLSMaxSessions {
for id := range p.sessions {
delete(p.sessions, id)
break
}
}
session.ExpiresAt = now.Add(cloud115HLSSessionTTL)
p.sessions[session.ID] = session
}
func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, string, bool) {
if p == nil {
return nil, "", false
}
p.mu.Lock()
session := p.sessions[sessionID]
if session != nil && time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
}
p.mu.Unlock()
if session == nil {
return nil, "", false
}
session.mu.Lock()
upstream, ok := session.entries[key]
session.mu.Unlock()
return session, upstream, ok
}
func (p *Cloud115HLSProxy) deleteSession(sessionID string) {
if p == nil {
return
}
p.mu.Lock()
delete(p.sessions, sessionID)
p.mu.Unlock()
}
func (p *Cloud115HLSProxy) fetchUpstream(ctx context.Context, rawURL, rangeHeader string) (*http.Response, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed == nil || !isAllowed115UpstreamHost(parsed.Hostname()) {
return nil, fmt.Errorf("115 hls upstream host not allowed: %s", rawURL)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", cloud115.DefaultUA)
if strings.TrimSpace(rangeHeader) != "" {
req.Header.Set("Range", rangeHeader)
}
return p.client.Do(req)
}
func isAllowed115UpstreamHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
for _, suffix := range []string{".115.com", ".115cdn.com", ".115cdn.net"} {
if strings.HasSuffix(host, suffix) {
return true
}
}
return host == "115.com" || host == "115cdn.com" || host == "115cdn.net"
}
func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, baseURL, rawQuery string) string {
if session == nil {
return text
}
lines := strings.SplitAfter(text, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
if strings.HasPrefix(trimmed, "#EXT-X-MEDIA:") ||
strings.HasPrefix(trimmed, "#EXT-X-KEY:") ||
strings.HasPrefix(trimmed, "#EXT-X-MAP:") {
lines[i] = replaceManifestURI(line, func(uri string) string {
return p.proxyURL(session, resolveManifestURL(baseURL, uri), rawQuery)
})
}
continue
}
lines[i] = p.proxyURL(session, resolveManifestURL(baseURL, trimmed), rawQuery) + lineEnding(line)
}
return strings.Join(lines, "")
}
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
session.mu.Lock()
session.next++
key := "e" + strconv.Itoa(session.next)
session.entries[key] = upstream
mediaID := session.MediaID
session.mu.Unlock()
query := childProxyQuery(rawQuery, mediaID)
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
}
func childProxyQuery(rawQuery, mediaID string) string {
values, _ := url.ParseQuery(rawQuery)
keep := url.Values{}
for _, key := range []string{"token", "api_key", "apiKey", "ApiKey", "profile_id", "profile_pin_token"} {
if value := strings.TrimSpace(values.Get(key)); value != "" {
keep.Set(key, value)
}
}
if strings.TrimSpace(mediaID) != "" {
keep.Set("media_id", mediaID)
}
return keep.Encode()
}
func replaceManifestURI(line string, replace func(string) string) string {
const marker = `URI="`
idx := strings.Index(line, marker)
if idx < 0 {
return line
}
start := idx + len(marker)
end := strings.Index(line[start:], `"`)
if end < 0 {
return line
}
end += start
return line[:start] + replace(line[start:end]) + line[end:]
}
func resolveManifestURL(baseURL, raw string) string {
base, baseErr := url.Parse(strings.TrimSpace(baseURL))
ref, refErr := url.Parse(strings.TrimSpace(raw))
if baseErr != nil || refErr != nil || base == nil || ref == nil {
return raw
}
return base.ResolveReference(ref).String()
}
func lineEnding(line string) string {
if strings.HasSuffix(line, "\r\n") {
return "\r\n"
}
if strings.HasSuffix(line, "\n") {
return "\n"
}
return ""
}
func copyUpstreamHeaders(w http.ResponseWriter, resp *http.Response, playlist bool) {
for _, key := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
if value := resp.Header.Get(key); value != "" {
w.Header().Set(key, value)
}
}
if playlist {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
} else if w.Header().Get("Cache-Control") == "" {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
}