⚡️ perf(media): 音频代理改为流式转发
- downloadMedia 拿到响应头即返回 Body,音频不再全量驻留内存,改由 io.Copy 流式写出 - 重试仅覆盖响应阶段;Body 读取中途失败由客户端自行重试 - 请求头设置提取为 setMediaHeaders 复用;data_uri 模式保持全量读取(本就需驻留内存) - 漫画代理因需解密后输出,维持原有全量方式
This commit is contained in:
1 parent
f89bee8ddc
commit
ad6d28fd85
3 files changed
+54
-47
No files matched your search
@@ -4,15 +4,16 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AudioCacheItem 音频缓存项
|
// AudioCacheItem 音频缓存项
|
||||||
type AudioCacheItem struct {
|
type AudioCacheItem struct {
|
||||||
OriginalURL string `json:"original_url"` // 原始CDN URL
|
OriginalURL string `json:"original_url"` // 原始CDN URL
|
||||||
DeviceID string `json:"device_id"` // 关联的设备ID
|
DeviceID string `json:"device_id"` // 关联的设备ID
|
||||||
Headers map[string]string `json:"headers"` // 请求头信息
|
Headers map[string]string `json:"headers"` // 请求头信息
|
||||||
CachedAt time.Time `json:"cached_at"` // 缓存时间
|
CachedAt time.Time `json:"cached_at"` // 缓存时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateAudioProxyURL 生成音频代理URL
|
// GenerateAudioProxyURL 生成音频代理URL
|
||||||
@@ -49,8 +50,8 @@ func GenerateAudioProxyURL(originalURL, deviceID string, headers map[string]stri
|
|||||||
return fmt.Sprintf("/audio/proxy/%s", key)
|
return fmt.Sprintf("/audio/proxy/%s", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleAudioProxy 处理音频代理请求
|
// HandleAudioProxy 处理音频代理请求(流式返回,调用方负责 Close)
|
||||||
func HandleAudioProxy(key string) ([]byte, string, error) {
|
func HandleAudioProxy(key string) (io.ReadCloser, string, error) {
|
||||||
var originalURL, deviceID string
|
var originalURL, deviceID string
|
||||||
var headers map[string]string
|
var headers map[string]string
|
||||||
|
|
||||||
@@ -95,13 +96,13 @@ func HandleAudioProxy(key string) ([]byte, string, error) {
|
|||||||
headers = make(map[string]string)
|
headers = make(map[string]string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载音频内容,使用关联的设备信息
|
// 下载音频内容(流式),使用关联的设备信息
|
||||||
data, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 3)
|
body, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("下载音频失败: %w", err)
|
return nil, "", fmt.Errorf("下载音频失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, contentType, nil
|
return body, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanExpiredAudioCache 清理过期的音频缓存
|
// CleanExpiredAudioCache 清理过期的音频缓存
|
||||||
|
|||||||
@@ -32,15 +32,15 @@ func extractDeviceParam(deviceHeader, paramName string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadMediaWithRetry 带重试的媒体下载,自动切换设备
|
// downloadMediaWithRetry 带重试的流式媒体下载,自动切换设备(仅响应阶段可重试)
|
||||||
func downloadMediaWithRetry(url, deviceID string, headers map[string]string, maxRetries int) ([]byte, string, error) {
|
func downloadMediaWithRetry(url, deviceID string, headers map[string]string, maxRetries int) (io.ReadCloser, string, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
currentDeviceID := deviceID
|
currentDeviceID := deviceID
|
||||||
|
|
||||||
for i := 0; i <= maxRetries; i++ {
|
for i := 0; i <= maxRetries; i++ {
|
||||||
data, contentType, err := downloadMedia(url, currentDeviceID, headers)
|
body, contentType, err := downloadMedia(url, currentDeviceID, headers)
|
||||||
if err == nil && len(data) > 0 {
|
if err == nil {
|
||||||
return data, contentType, nil
|
return body, contentType, nil
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
@@ -58,9 +58,10 @@ func downloadMediaWithRetry(url, deviceID string, headers map[string]string, max
|
|||||||
return nil, "", fmt.Errorf("下载失败(已重试%d次): %w", maxRetries, lastErr)
|
return nil, "", fmt.Errorf("下载失败(已重试%d次): %w", maxRetries, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadMedia 下载媒体内容
|
// downloadMedia 流式下载媒体内容:拿到响应头即返回,Body 由调用方消费
|
||||||
func downloadMedia(url, deviceID string, headers map[string]string) ([]byte, string, error) {
|
// 大文件不再整体驻留内存;Body 读取中途的错误无法重试,由客户端自行重试
|
||||||
// 创建专用的媒体下载客户端,超时时间更长
|
func downloadMedia(url, deviceID string, headers map[string]string) (io.ReadCloser, string, error) {
|
||||||
|
// 创建专用的媒体下载客户端,超时时间更长(Timeout 覆盖整个响应周期含 Body 传输)
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 300 * time.Second, // 5分钟超时,适合大文件
|
Timeout: 300 * time.Second, // 5分钟超时,适合大文件
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,30 @@ func downloadMedia(url, deviceID string, headers map[string]string) ([]byte, str
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMediaHeaders(req, deviceID, headers)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("下载失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, "", fmt.Errorf("HTTP状态码: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取Content-Type
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
// 根据文件后缀推断
|
||||||
|
contentType = getContentTypeFromURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Body, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setMediaHeaders 设置媒体下载请求头(CDN 伪装 + 设备信息 + 自定义头)
|
||||||
|
func setMediaHeaders(req *http.Request, deviceID string, headers map[string]string) {
|
||||||
// 设置CDN请求头,模拟安卓客户端请求
|
// 设置CDN请求头,模拟安卓客户端请求
|
||||||
req.Header.Set("User-Agent", "com.dragon.read/7.9.9.32 (Linux; U; Android 13; zh_CN; M2102K1AC; Build/TKQ1.220807.001; Cronet/113.0.5672.62)")
|
req.Header.Set("User-Agent", "com.dragon.read/7.9.9.32 (Linux; U; Android 13; zh_CN; M2102K1AC; Build/TKQ1.220807.001; Cronet/113.0.5672.62)")
|
||||||
req.Header.Set("Accept", "*/*")
|
req.Header.Set("Accept", "*/*")
|
||||||
@@ -89,31 +114,6 @@ func downloadMedia(url, deviceID string, headers map[string]string) ([]byte, str
|
|||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("下载失败: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return nil, "", fmt.Errorf("HTTP状态码: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取内容
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("读取内容失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取Content-Type
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
|
||||||
if contentType == "" {
|
|
||||||
// 根据文件后缀推断
|
|
||||||
contentType = getContentTypeFromURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, contentType, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getContentTypeFromURL 根据URL推断Content-Type
|
// getContentTypeFromURL 根据URL推断Content-Type
|
||||||
@@ -179,11 +179,16 @@ func processMediaURL(originalURL, deviceID string, headers map[string]string, ou
|
|||||||
// 其他类型返回原始URL
|
// 其他类型返回原始URL
|
||||||
return originalURL
|
return originalURL
|
||||||
case "data_uri":
|
case "data_uri":
|
||||||
// 直接下载并返回base64
|
// 直接下载并返回base64(data_uri 模式本就要求全量驻留内存)
|
||||||
data, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 2)
|
body, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return originalURL
|
return originalURL
|
||||||
}
|
}
|
||||||
|
data, readErr := io.ReadAll(body)
|
||||||
|
body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return originalURL
|
||||||
|
}
|
||||||
return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data))
|
return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data))
|
||||||
default:
|
default:
|
||||||
// 直接返回原始URL
|
// 直接返回原始URL
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fanqie/internal/fqapi"
|
"fanqie/internal/fqapi"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -31,9 +32,9 @@ func HandleAudio(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Fprint(w, result)
|
fmt.Fprint(w, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleAudioProxy 音频代理处理
|
// HandleAudioProxy 音频代理处理(流式转发,音频不全量驻留内存)
|
||||||
func HandleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
func HandleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
incrementStat("total")
|
incrementStat("total", "audio")
|
||||||
|
|
||||||
// 从URL路径提取key: /audio/proxy/{key}
|
// 从URL路径提取key: /audio/proxy/{key}
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
@@ -44,11 +45,12 @@ func HandleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用代理处理函数
|
// 调用代理处理函数
|
||||||
data, contentType, err := fqapi.HandleAudioProxy(key)
|
body, contentType, err := fqapi.HandleAudioProxy(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer body.Close()
|
||||||
|
|
||||||
// 设置正确的Content-Type
|
// 设置正确的Content-Type
|
||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
@@ -59,7 +61,7 @@ func HandleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// 直接播放,不强制下载
|
// 直接播放,不强制下载
|
||||||
// 浏览器会根据Content-Type决定是播放还是下载
|
// 浏览器会根据Content-Type决定是播放还是下载
|
||||||
w.Write(data)
|
io.Copy(w, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleVideo 视频处理
|
// HandleVideo 视频处理
|
||||||
@@ -133,4 +135,3 @@ func HandleManga(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Fprint(w, string(jsonData))
|
fmt.Fprint(w, string(jsonData))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user