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)) } }