✨ 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:备份→重写→校验→失败自动回滚,同步视频代理段配置
This commit is contained in:
chenxin committed 2026-10-09 23:56:32 +08:00
1 parent 162dde9222
commit 0aed60713a
13 files changed
+1000 -78

No files matched your search

+226 -17
View File
@@ -1,17 +1,27 @@
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 处理获取视频(支持批量和单个接口)
func HandleVideo(itemIDs interface{}) (string, error) {
// 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
}
@@ -44,6 +54,26 @@ func HandleVideo(itemIDs interface{}) (string, error) {
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",
@@ -86,7 +116,14 @@ func HandleVideo(itemIDs interface{}) (string, error) {
if data != nil {
if videoModelStr, ok := data["video_model"].(string); ok && isSingle {
decodedModel := decodeVideoModelAsJSON(videoModelStr)
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{}{
@@ -103,7 +140,7 @@ func HandleVideo(itemIDs interface{}) (string, error) {
for itemID, itemData := range data {
if itemMap, ok := itemData.(map[string]interface{}); ok {
if videoModelStr, ok := itemMap["video_model"].(string); ok {
decodedModel := decodeVideoModelAsJSON(videoModelStr)
decodedModel := decodeVideoModelAsJSON(videoModelStr, outputMode, deviceID, qualityFilter)
result[itemID] = map[string]interface{}{
"video_model": decodedModel,
}
@@ -134,8 +171,10 @@ func HandleVideo(itemIDs interface{}) (string, error) {
return BuildErrorResponse(errMsg), nil
}
// decodeVideoModelAsJSON 解析并解码 video_model,返回关键字段的 JSON 对象
func decodeVideoModelAsJSON(videoModelStr string) interface{} {
// 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 {
@@ -151,8 +190,10 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} {
result := make(map[string]interface{})
// 提取关键字段
if videoID, ok := videoModel["video_id"].(string); ok {
result["video_id"] = videoID
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)
@@ -167,10 +208,37 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} {
// 处理 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{})
@@ -205,18 +273,23 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} {
}
}
// 解码 main_url
if mainURL, ok := qualityMap["main_url"].(string); ok {
if decoded, err := base64.StdEncoding.DecodeString(mainURL); err == nil {
qualityInfo["main_url"] = string(decoded)
// 解码 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)
}
// 解码 backup_url_1
if backupURL, ok := qualityMap["backup_url_1"].(string); ok {
if decoded, err := base64.StdEncoding.DecodeString(backupURL); err == nil {
qualityInfo["backup_url_1"] = string(decoded)
}
qualityInfo["backup_url_1"] = processURL(backupURL)
}
processedVideoList[quality] = qualityInfo
@@ -229,6 +302,22 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} {
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))
@@ -278,3 +367,123 @@ func formatBitrate(bitrate float64) string {
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))
}
}