- 逆向 SpadeA(黑桃A) PlayAuth 混淆,从 spade_a 还原 AES-128 内容密钥
- 实现 cenc-aes-ctr MP4 解密(senc 逐样本 CTR + stsd entry 修复),实测可播放
- 新增 /video/proxy/{key} 代理:稳定 key + 磁盘缓存(二次命中提速 130x)+
http.ServeContent 支持 Range/206/seek 流式播放
- /video 接口新增 output(url/proxy,默认 proxy) 与 quality(max/720p/档位key) 参数,
可由 config fanqie.video.* 配置默认值;无匹配清晰度时明确报错
- 代理映射复用 audio_cache 存储,抽取 loadMediaProxyEntry 共用;挂接 7 天缓存清理
- deploy.sh 新增 --sync-nginx:备份→重写→校验→失败自动回滚,同步视频代理段配置
490 lines
15 KiB
Go
490 lines
15 KiB
Go
package fqapi
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fanqie/internal/common"
|
||
"fanqie/internal/config"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
var videoLogger = common.NewLogger("video", "INFO", "")
|
||
|
||
// HandleVideo 处理获取视频(支持批量和单个接口)。
|
||
// output: url(默认,返回原始CDN链接,内容为 CENC 加密)/ proxy(返回本地解密代理链接,可直接播放)
|
||
// quality: 清晰度过滤,支持 max(最高档,默认)、definition(360p/480p/...)或档位 key(video_1..N),空为全部;
|
||
// 未传时回退 config fanqie.video.quality,output 为空时回退 fanqie.video.output_mode
|
||
func HandleVideo(itemIDs, output, quality interface{}) (string, error) {
|
||
if itemIDs == nil || itemIDs == "" {
|
||
return BuildSuccessResponse([]interface{}{}), nil
|
||
}
|
||
|
||
// 处理itemIDs - 使用ParseStringParam
|
||
itemIDStr := ParseStringParam(itemIDs, "")
|
||
|
||
// 判断是单个ID还是多个ID(用逗号分隔)
|
||
isSingle := true
|
||
if str, ok := itemIDs.(string); ok {
|
||
for _, ch := range str {
|
||
if ch == ',' {
|
||
isSingle = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 根据ID数量选择不同的接口
|
||
var url string
|
||
if isSingle {
|
||
// 单个视频接口 - 使用带签名的URL
|
||
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/novel/player/video_model/v1")
|
||
} else {
|
||
// 批量视频接口
|
||
url = BuildURL("https://reading.snssdk.com/novel/player/multi_video_model/v1/")
|
||
}
|
||
|
||
// 构建请求体,添加更多设备参数,请求所有清晰度
|
||
requestBody := fmt.Sprintf(`{"biz_param":{"device_level":3,"need_all_video_definition":true,"need_mp4_align":false,"use_os_player":false,"video_platform":1024},"video_id":"%s"}`,
|
||
itemIDStr)
|
||
|
||
outputMode := ParseStringParam(output, "")
|
||
qualityFilter := ParseStringParam(quality, "")
|
||
if cfg := config.GetAppConfig(); cfg != nil {
|
||
if outputMode == "" {
|
||
outputMode = cfg.Fanqie.Video.OutputMode
|
||
}
|
||
if qualityFilter == "" {
|
||
qualityFilter = cfg.Fanqie.Video.Quality
|
||
}
|
||
}
|
||
if outputMode == "" {
|
||
outputMode = "url"
|
||
}
|
||
deviceID := ""
|
||
if outputMode == "proxy" {
|
||
if dev := GetCurrentDevice(); dev != nil {
|
||
deviceID = dev.DeviceHeader
|
||
}
|
||
}
|
||
|
||
// 使用 GorgonRequest 添加签名和设备信息
|
||
resp, err := GorgonRequest(url, map[string]interface{}{
|
||
"method": "POST",
|
||
"body": requestBody,
|
||
"headers": map[string]string{
|
||
"User-Agent": "com.dragon.read",
|
||
"Content-Type": "application/json",
|
||
},
|
||
})
|
||
|
||
if err != nil {
|
||
videoLogger.Error(fmt.Sprintf("获取视频失败: %v", err))
|
||
return BuildErrorResponse("FAILED"), err
|
||
}
|
||
|
||
respJSON, err := resp.JSON()
|
||
if err != nil {
|
||
videoLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
|
||
return BuildErrorResponse("FAILED"), err
|
||
}
|
||
|
||
// 检查响应码(可能返回 Code 或 code)
|
||
var respCode float64
|
||
var okCode bool
|
||
|
||
if code, ok := respJSON["code"].(float64); ok {
|
||
respCode, okCode = code, code == 0
|
||
} else if code, ok := respJSON["Code"].(float64); ok {
|
||
respCode, okCode = code, code == 0
|
||
}
|
||
|
||
if okCode {
|
||
// 尝试获取 data(可能在不同位置)
|
||
var data map[string]interface{}
|
||
if d, ok := respJSON["data"].(map[string]interface{}); ok {
|
||
data = d
|
||
} else if d, ok := respJSON["Data"].(map[string]interface{}); ok {
|
||
data = d
|
||
}
|
||
|
||
if data != nil {
|
||
if videoModelStr, ok := data["video_model"].(string); ok && isSingle {
|
||
decodedModel := decodeVideoModelAsJSON(videoModelStr, outputMode, deviceID, qualityFilter)
|
||
|
||
// 清晰度过滤无匹配时报错(调用方能立即发现参数写错)
|
||
if qualityFilter != "" {
|
||
if vl, ok := decodedModel.(map[string]interface{})["video_list"].(map[string]interface{}); ok && len(vl) == 0 {
|
||
return BuildErrorResponse(fmt.Sprintf("无匹配清晰度: %s(可用档位见 video_list)", qualityFilter)), nil
|
||
}
|
||
}
|
||
|
||
// 单个接口直接返回 video_model
|
||
response := map[string]interface{}{
|
||
"code": 200,
|
||
"message": "SUCCESS",
|
||
"data": decodedModel,
|
||
}
|
||
|
||
return marshalNoEscape(response), nil
|
||
} else {
|
||
// 批量接口:data 是一个map,key是ID,value包含video_model
|
||
result := make(map[string]interface{})
|
||
|
||
for itemID, itemData := range data {
|
||
if itemMap, ok := itemData.(map[string]interface{}); ok {
|
||
if videoModelStr, ok := itemMap["video_model"].(string); ok {
|
||
decodedModel := decodeVideoModelAsJSON(videoModelStr, outputMode, deviceID, qualityFilter)
|
||
result[itemID] = map[string]interface{}{
|
||
"video_model": decodedModel,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 构建返回结果
|
||
response := map[string]interface{}{
|
||
"code": 200,
|
||
"message": "SUCCESS",
|
||
"data": result,
|
||
}
|
||
|
||
return marshalNoEscape(response), nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// 返回错误信息
|
||
errMsg := "获取视频失败"
|
||
if msg, ok := respJSON["message"].(string); ok {
|
||
errMsg = msg
|
||
} else if msg, ok := respJSON["Message"].(string); ok {
|
||
errMsg = msg
|
||
}
|
||
videoLogger.Error(fmt.Sprintf("获取视频失败: code=%v, msg=%s", respCode, errMsg))
|
||
return BuildErrorResponse(errMsg), nil
|
||
}
|
||
|
||
// decodeVideoModelAsJSON 解析并解码 video_model,返回关键字段的 JSON 对象。
|
||
// outputMode=proxy 时把 CDN 链接替换为本地解密代理链接(/video/proxy/{key})
|
||
// qualityFilter 非空时只保留匹配的清晰度(definition 或档位 key,大小写不敏感)
|
||
func decodeVideoModelAsJSON(videoModelStr, outputMode, deviceID, qualityFilter string) interface{} {
|
||
// 解析 video_model JSON 字符串
|
||
var videoModel map[string]interface{}
|
||
if err := json.Unmarshal([]byte(videoModelStr), &videoModel); err != nil {
|
||
// 解析失败,返回空对象
|
||
return map[string]interface{}{}
|
||
}
|
||
// 解包嵌套的 JSON 字符串字段(category_schema、recommend_info 等)
|
||
if unwrapped, ok := UnwrapJSONStrings(videoModel).(map[string]interface{}); ok {
|
||
videoModel = unwrapped
|
||
}
|
||
|
||
// 构建精简的返回结果,只包含关键字段
|
||
result := make(map[string]interface{})
|
||
|
||
// 提取关键字段
|
||
videoID := ""
|
||
if v, ok := videoModel["video_id"].(string); ok {
|
||
result["video_id"] = v
|
||
videoID = v
|
||
}
|
||
if duration, ok := videoModel["video_duration"].(float64); ok {
|
||
result["video_duration"] = formatDuration(duration)
|
||
}
|
||
if posterURL, ok := videoModel["poster_url"].(string); ok {
|
||
result["poster_url"] = posterURL
|
||
}
|
||
// 加密信息:视频为 CENC 加密(encrypt=true),解密需要 key_seed/kid
|
||
if keySeed, ok := videoModel["key_seed"].(string); ok {
|
||
result["key_seed"] = keySeed
|
||
}
|
||
|
||
// 处理 video_list
|
||
if videoList, ok := videoModel["video_list"].(map[string]interface{}); ok {
|
||
// max/best:解析为实际最高档位 key(不同视频可用档位不同,按 vheight/definition 取最大)
|
||
if strings.EqualFold(qualityFilter, "max") || strings.EqualFold(qualityFilter, "best") {
|
||
bestKey, bestH := "", 0
|
||
for q, qd := range videoList {
|
||
qm, ok := qd.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
if h := qualityHeight(qm); h > bestH {
|
||
bestH, bestKey = h, q
|
||
}
|
||
}
|
||
qualityFilter = bestKey
|
||
}
|
||
|
||
processedVideoList := make(map[string]interface{})
|
||
|
||
for quality, qualityData := range videoList {
|
||
if qualityMap, ok := qualityData.(map[string]interface{}); ok {
|
||
// 清晰度过滤:匹配档位 key(video_1)或 definition(720p)
|
||
if qualityFilter != "" {
|
||
def, _ := qualityMap["definition"].(string)
|
||
if !strings.EqualFold(quality, qualityFilter) && !strings.EqualFold(def, qualityFilter) {
|
||
continue
|
||
}
|
||
}
|
||
// spade_a(PlayAuth):解出 CENC 内容密钥的种子,proxy 模式随代理下发
|
||
spadeA := ""
|
||
if v, ok := qualityMap["spade_a"].(string); ok {
|
||
spadeA = v
|
||
}
|
||
// 只保留关键字段
|
||
qualityInfo := make(map[string]interface{})
|
||
|
||
if definition, ok := qualityMap["definition"].(string); ok {
|
||
qualityInfo["definition"] = definition
|
||
}
|
||
if vtype, ok := qualityMap["vtype"].(string); ok {
|
||
qualityInfo["vtype"] = vtype
|
||
}
|
||
if vwidth, ok := qualityMap["vwidth"].(float64); ok {
|
||
qualityInfo["vwidth"] = int(vwidth)
|
||
}
|
||
if vheight, ok := qualityMap["vheight"].(float64); ok {
|
||
qualityInfo["vheight"] = int(vheight)
|
||
}
|
||
if bitrate, ok := qualityMap["bitrate"].(float64); ok {
|
||
qualityInfo["bitrate"] = formatBitrate(bitrate)
|
||
}
|
||
if size, ok := qualityMap["size"].(float64); ok {
|
||
qualityInfo["size"] = formatFileSize(size)
|
||
}
|
||
if fps, ok := qualityMap["fps"].(float64); ok {
|
||
qualityInfo["fps"] = int(fps)
|
||
}
|
||
if encrypt, ok := qualityMap["encrypt"].(bool); ok && encrypt {
|
||
qualityInfo["encrypt"] = true
|
||
if em, ok := qualityMap["encryption_method"].(string); ok {
|
||
qualityInfo["encryption_method"] = em
|
||
}
|
||
if kid, ok := qualityMap["kid"].(string); ok {
|
||
qualityInfo["kid"] = kid
|
||
}
|
||
}
|
||
|
||
// 解码 main_url / backup_url_1(base64 的CDN链接),proxy 模式替换为解密代理。
|
||
// 代理 key 用 videoID+档位(稳定),同一视频重复调用复用磁盘缓存
|
||
processURL := func(b64 string) interface{} {
|
||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||
if err != nil {
|
||
return b64
|
||
}
|
||
if outputMode == "proxy" && spadeA != "" && videoID != "" {
|
||
return GenerateVideoProxyURL(videoID+"_"+quality, string(decoded), deviceID, spadeA)
|
||
}
|
||
return string(decoded)
|
||
}
|
||
if mainURL, ok := qualityMap["main_url"].(string); ok {
|
||
qualityInfo["main_url"] = processURL(mainURL)
|
||
}
|
||
if backupURL, ok := qualityMap["backup_url_1"].(string); ok {
|
||
qualityInfo["backup_url_1"] = processURL(backupURL)
|
||
}
|
||
|
||
processedVideoList[quality] = qualityInfo
|
||
}
|
||
}
|
||
|
||
result["video_list"] = processedVideoList
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// qualityHeight 取清晰度的比较高度:优先 vheight,缺失时从 definition(如 "720p")解析
|
||
func qualityHeight(qm map[string]interface{}) int {
|
||
if h, ok := qm["vheight"].(float64); ok && h > 0 {
|
||
return int(h)
|
||
}
|
||
def, _ := qm["definition"].(string)
|
||
n := 0
|
||
for _, c := range def {
|
||
if c < '0' || c > '9' {
|
||
break
|
||
}
|
||
n = n*10 + int(c-'0')
|
||
}
|
||
return n
|
||
}
|
||
|
||
// formatDuration 将秒数转换为可读的时间格式
|
||
func formatDuration(seconds float64) string {
|
||
totalSeconds := int(math.Round(seconds))
|
||
|
||
hours := totalSeconds / 3600
|
||
minutes := (totalSeconds % 3600) / 60
|
||
secs := totalSeconds % 60
|
||
|
||
if hours > 0 {
|
||
return fmt.Sprintf("%d小时%d分%d秒", hours, minutes, secs)
|
||
} else if minutes > 0 {
|
||
return fmt.Sprintf("%d分%d秒", minutes, secs)
|
||
}
|
||
return fmt.Sprintf("%d秒", secs)
|
||
}
|
||
|
||
// formatFileSize 将字节数转换为可读的文件大小格式
|
||
func formatFileSize(bytes float64) string {
|
||
const unit = 1024.0
|
||
if bytes < unit {
|
||
return fmt.Sprintf("%.0f B", bytes)
|
||
}
|
||
|
||
kb := bytes / unit
|
||
if kb < unit {
|
||
return fmt.Sprintf("%.2f KB", kb)
|
||
}
|
||
|
||
mb := kb / unit
|
||
if mb < unit {
|
||
return fmt.Sprintf("%.2f MB", mb)
|
||
}
|
||
|
||
gb := mb / unit
|
||
return fmt.Sprintf("%.2f GB", gb)
|
||
}
|
||
|
||
// formatBitrate 将比特率转换为可读的格式
|
||
func formatBitrate(bitrate float64) string {
|
||
const unit = 1000.0
|
||
kbps := bitrate / unit
|
||
|
||
if kbps < unit {
|
||
return fmt.Sprintf("%.0f Kbps", kbps)
|
||
}
|
||
|
||
mbps := kbps / unit
|
||
return fmt.Sprintf("%.2f Mbps", mbps)
|
||
}
|
||
|
||
// GenerateVideoProxyURL 生成视频解密代理URL。
|
||
// cacheKey 需稳定(videoID+档位):映射表覆盖式更新,磁盘缓存按 key 复用
|
||
func GenerateVideoProxyURL(cacheKey, originalURL, deviceID, spadeA string) string {
|
||
headers := map[string]string{"x-spade-a": spadeA}
|
||
if redisCache != nil {
|
||
saveAudioProxyEntry(cacheKey, &audioProxyEntry{
|
||
OriginalURL: originalURL,
|
||
DeviceID: deviceID,
|
||
Headers: headers,
|
||
})
|
||
return "/video/proxy/" + cacheKey
|
||
}
|
||
headersJSON, _ := json.Marshal(headers)
|
||
if cacheDB != nil {
|
||
cacheDB.Exec(q(`
|
||
INSERT INTO audio_cache (key, original_url, device_id, headers, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(key) DO UPDATE SET original_url = excluded.original_url,
|
||
device_id = excluded.device_id, headers = excluded.headers, updated_at = excluded.updated_at
|
||
`), cacheKey, originalURL, deviceID, string(headersJSON), time.Now().Unix(), time.Now().Unix())
|
||
}
|
||
return "/video/proxy/" + cacheKey
|
||
}
|
||
|
||
// videoCachePath 解密后视频的磁盘缓存路径;缓存未启用返回空串
|
||
func videoCachePath(key string) string {
|
||
cfg := config.GetAppConfig()
|
||
if cfg == nil || !cfg.Fanqie.Video.CacheEnabled || cfg.Fanqie.Video.CachePath == "" {
|
||
return ""
|
||
}
|
||
return filepath.Join(cfg.Fanqie.Video.CachePath, key+".mp4")
|
||
}
|
||
|
||
// HandleVideoProxy 处理视频代理请求:磁盘缓存命中直接返回;否则下载 CENC 加密 MP4 →
|
||
// SpadeA 解 key → 解密 → 写入缓存。返回 ReadSeeker 供 http.ServeContent(支持 Range/seek)。
|
||
func HandleVideoProxy(key string) (io.ReadSeeker, string, error) {
|
||
// 磁盘缓存命中:直接返回文件(映射记录即使过期也不影响已缓存的视频)
|
||
if p := videoCachePath(key); p != "" {
|
||
if f, err := os.Open(p); err == nil {
|
||
return f, "video/mp4", nil
|
||
}
|
||
}
|
||
|
||
entry, err := loadMediaProxyEntry(key)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
spadeA := entry.Headers["x-spade-a"]
|
||
if spadeA == "" {
|
||
return nil, "", fmt.Errorf("视频信息缺少 spade_a,请重新调用 /video 接口")
|
||
}
|
||
|
||
body, _, err := downloadMediaWithRetry(entry.OriginalURL, entry.DeviceID, entry.Headers, 3)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("下载视频失败: %w", err)
|
||
}
|
||
defer body.Close()
|
||
data, err := io.ReadAll(body)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("读取视频失败: %w", err)
|
||
}
|
||
|
||
contentKey, err := DecodeSpadeKey(spadeA)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("解密视频失败: %w", err)
|
||
}
|
||
if err := DecryptCENCMP4(data, contentKey); err != nil {
|
||
return nil, "", fmt.Errorf("解密视频失败: %w", err)
|
||
}
|
||
|
||
// 写入磁盘缓存(临时文件 + rename 原子替换;失败不影响本次返回)
|
||
if p := videoCachePath(key); p != "" {
|
||
if err := os.MkdirAll(filepath.Dir(p), 0755); err == nil {
|
||
if tmp, err := os.CreateTemp(filepath.Dir(p), ".tmp-*"); err == nil {
|
||
if _, werr := tmp.Write(data); werr == nil {
|
||
tmp.Close()
|
||
if err := os.Rename(tmp.Name(), p); err != nil {
|
||
os.Remove(tmp.Name())
|
||
}
|
||
} else {
|
||
tmp.Close()
|
||
os.Remove(tmp.Name())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return bytes.NewReader(data), "video/mp4", nil
|
||
}
|
||
|
||
// CleanupVideoCache 清理磁盘视频缓存中 mtime 过早的文件,挂接在缓存定期清理任务中
|
||
func CleanupVideoCache() {
|
||
cfg := config.GetAppConfig()
|
||
if cfg == nil || !cfg.Fanqie.Video.CacheEnabled {
|
||
return
|
||
}
|
||
dir := cfg.Fanqie.Video.CachePath
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return // 目录不存在等:无缓存可清
|
||
}
|
||
const maxAge = 7 * 24 * time.Hour // ponytail: 固定 7 天,需要更长保留时改配置语义
|
||
cutoff := time.Now().Add(-maxAge)
|
||
removed := 0
|
||
for _, e := range entries {
|
||
info, err := e.Info()
|
||
if err != nil || info.IsDir() {
|
||
continue
|
||
}
|
||
if info.ModTime().Before(cutoff) {
|
||
if os.Remove(filepath.Join(dir, e.Name())) == nil {
|
||
removed++
|
||
}
|
||
}
|
||
}
|
||
if removed > 0 {
|
||
videoLogger.Info(fmt.Sprintf("已清理 %d 个过期视频缓存文件", removed))
|
||
}
|
||
}
|
||
|