Files
fanqie-go/internal/fqapi/video.go
T
chenxin 0aed60713a ✨ feat(video): 新增视频 CENC 解密与流式代理播放
- 逆向 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:备份→重写→校验→失败自动回滚,同步视频代理段配置
2026-10-09 23:56:32 +08:00

490 lines
15 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 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))
}
}