- downloadMedia 拿到响应头即返回 Body,音频不再全量驻留内存,改由 io.Copy 流式写出 - 重试仅覆盖响应阶段;Body 读取中途失败由客户端自行重试 - 请求头设置提取为 setMediaHeaders 复用;data_uri 模式保持全量读取(本就需驻留内存) - 漫画代理因需解密后输出,维持原有全量方式
198 lines
5.6 KiB
Go
198 lines
5.6 KiB
Go
package fqapi
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// generateRandomKey 生成随机key
|
|
func generateRandomKey(length int) string {
|
|
b := make([]byte, length/2)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// extractDeviceParam 从deviceHeader字符串中提取指定参数
|
|
func extractDeviceParam(deviceHeader, paramName string) string {
|
|
// deviceHeader 格式: device_id=xxx&device_type=xxx&iid=xxx&version_name=xxx
|
|
parts := strings.Split(deviceHeader, "&")
|
|
for _, part := range parts {
|
|
kv := strings.Split(part, "=")
|
|
if len(kv) == 2 && kv[0] == paramName {
|
|
return kv[1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// downloadMediaWithRetry 带重试的流式媒体下载,自动切换设备(仅响应阶段可重试)
|
|
func downloadMediaWithRetry(url, deviceID string, headers map[string]string, maxRetries int) (io.ReadCloser, string, error) {
|
|
var lastErr error
|
|
currentDeviceID := deviceID
|
|
|
|
for i := 0; i <= maxRetries; i++ {
|
|
body, contentType, err := downloadMedia(url, currentDeviceID, headers)
|
|
if err == nil {
|
|
return body, contentType, nil
|
|
}
|
|
lastErr = err
|
|
|
|
if i < maxRetries {
|
|
// 重试时切换到新的有效设备
|
|
newDevice, err := GetValidDevice()
|
|
if err == nil {
|
|
currentDeviceID = newDevice.DeviceHeader
|
|
}
|
|
// 重试间隔递增(2秒、4秒、6秒)
|
|
time.Sleep(time.Duration(i*2+2) * time.Second)
|
|
}
|
|
}
|
|
|
|
return nil, "", fmt.Errorf("下载失败(已重试%d次): %w", maxRetries, lastErr)
|
|
}
|
|
|
|
// downloadMedia 流式下载媒体内容:拿到响应头即返回,Body 由调用方消费
|
|
// 大文件不再整体驻留内存;Body 读取中途的错误无法重试,由客户端自行重试
|
|
func downloadMedia(url, deviceID string, headers map[string]string) (io.ReadCloser, string, error) {
|
|
// 创建专用的媒体下载客户端,超时时间更长(Timeout 覆盖整个响应周期含 Body 传输)
|
|
client := &http.Client{
|
|
Timeout: 300 * time.Second, // 5分钟超时,适合大文件
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
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请求头,模拟安卓客户端请求
|
|
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-Encoding", "identity")
|
|
req.Header.Set("Connection", "keep-alive")
|
|
req.Header.Set("Host", req.URL.Host)
|
|
|
|
// 添加设备信息(如果提供了deviceID)
|
|
if deviceID != "" {
|
|
req.Header.Set("X-Device-ID", extractDeviceParam(deviceID, "device_id"))
|
|
req.Header.Set("X-Device-Type", extractDeviceParam(deviceID, "device_type"))
|
|
req.Header.Set("X-IID", extractDeviceParam(deviceID, "iid"))
|
|
req.Header.Set("X-Version-Name", extractDeviceParam(deviceID, "version_name"))
|
|
}
|
|
|
|
// 添加自定义请求头
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
}
|
|
|
|
// getContentTypeFromURL 根据URL推断Content-Type
|
|
func getContentTypeFromURL(url string) string {
|
|
ext := strings.ToLower(filepath.Ext(url))
|
|
switch ext {
|
|
case ".mp3", ".m4a", ".aac":
|
|
return "audio/mpeg"
|
|
case ".mp4", ".m4v", ".mov":
|
|
return "video/mp4"
|
|
case ".flv":
|
|
return "video/x-flv"
|
|
case ".ts":
|
|
return "video/MP2T"
|
|
case ".webm":
|
|
return "video/webm"
|
|
case ".ogg":
|
|
return "audio/ogg"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// DetectMediaType 检测媒体类型
|
|
func DetectMediaType(data []byte) string {
|
|
if len(data) < 4 {
|
|
return "unknown"
|
|
}
|
|
|
|
// 检测音频格式
|
|
if data[0] == 0xFF && (data[1] == 0xFB || data[1] == 0xF3 || data[1] == 0xF2) {
|
|
return "audio/mpeg" // MP3
|
|
}
|
|
if string(data[:4]) == "ftyp" || string(data[:4]) == "moov" || string(data[:4]) == "mdat" {
|
|
return "video/mp4" // MP4/M4A
|
|
}
|
|
if string(data[:4]) == "OggS" {
|
|
return "audio/ogg" // OGG
|
|
}
|
|
if string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE" {
|
|
return "audio/wav" // WAV
|
|
}
|
|
|
|
// 检测视频格式
|
|
if string(data[:3]) == "FLV" {
|
|
return "video/x-flv" // FLV
|
|
}
|
|
if string(data[:4]) == "webm" {
|
|
return "video/webm" // WebM
|
|
}
|
|
|
|
return "unknown"
|
|
}
|
|
|
|
// processMediaURL 处理媒体URL,根据output模式返回不同的URL(仅支持音频)
|
|
func processMediaURL(originalURL, deviceID string, headers map[string]string, outputMode string, mediaType string) interface{} {
|
|
switch outputMode {
|
|
case "proxy":
|
|
// 生成代理URL
|
|
if mediaType == "audio" {
|
|
return GenerateAudioProxyURL(originalURL, deviceID, headers)
|
|
}
|
|
// 其他类型返回原始URL
|
|
return originalURL
|
|
case "data_uri":
|
|
// 直接下载并返回base64(data_uri 模式本就要求全量驻留内存)
|
|
body, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 2)
|
|
if err != nil {
|
|
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))
|
|
default:
|
|
// 直接返回原始URL
|
|
return originalURL
|
|
}
|
|
}
|