From 49d0a371fbb5ecf82389d7354c5b8fb119bcdc04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Fri, 9 Oct 2026 23:56:32 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(video):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=A7=86=E9=A2=91=20CENC=20=E8=A7=A3=E5=AF=86=E4=B8=8E?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E4=BB=A3=E7=90=86=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 逆向 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:备份→重写→校验→失败自动回滚,同步视频代理段配置 --- .gitignore | 1 + cmd/fanqie/main.go | 1 + config/config.yaml | 15 ++ deploy.sh | 104 ++++++-- internal/config/config.go | 15 ++ internal/config/video_config_test.go | 46 ++++ internal/fqapi/audio_proxy.go | 81 +++--- internal/fqapi/cache_manager.go | 3 + internal/fqapi/store_redis.go | 2 +- internal/fqapi/video.go | 243 ++++++++++++++++-- internal/fqapi/video_decrypt.go | 360 +++++++++++++++++++++++++++ internal/fqapi/videodecrypt_test.go | 178 +++++++++++++ internal/handlers/media.go | 29 ++- 13 files changed, 1000 insertions(+), 78 deletions(-) create mode 100644 internal/config/video_config_test.go create mode 100644 internal/fqapi/video_decrypt.go create mode 100644 internal/fqapi/videodecrypt_test.go diff --git a/.gitignore b/.gitignore index b19979e..57ea76e 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ __pycache__ /build/ cache.db +video_cache/ # Build output fanqie-* diff --git a/cmd/fanqie/main.go b/cmd/fanqie/main.go index 328b407..daf85cf 100644 --- a/cmd/fanqie/main.go +++ b/cmd/fanqie/main.go @@ -172,6 +172,7 @@ func setupRoutes() { http.HandleFunc("/audio", wrap(handlers.HandleAudio)) http.HandleFunc("/audio/proxy/", wrap(handlers.HandleAudioProxy)) http.HandleFunc("/video", wrap(handlers.HandleVideo)) + http.HandleFunc("/video/proxy/", wrap(handlers.HandleVideoProxy)) http.HandleFunc("/manga", wrap(handlers.HandleManga)) http.HandleFunc("/manga/image/", wrap(handlers.HandleMangaImageProxy)) diff --git a/config/config.yaml b/config/config.yaml index abe1dfe..7fb6c27 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -51,6 +51,21 @@ fanqie: # 是否启用段评功能(默认true,启用段评) enable_review: true + # 视频处理配置 + video: + # 输出模式:proxy / url + # proxy: 返回本地解密代理URL /video/proxy/{key}(已解密,可直接播放,推荐) + # url: 返回原始CDN链接(内容为 CENC 加密,需自行解密) + output_mode: "proxy" + + # 默认清晰度:max(最高档)/ 360p/480p/540p/720p / 档位 key(video_1..N) + # 留空返回全部清晰度;/video 接口的 quality 参数优先于此配置 + quality: "max" + + # 解密后视频的磁盘缓存(同一视频重复播放直接读本地文件,无需重新下载解密) + cache_enabled: true + cache_path: "./video_cache" + # 漫画处理配置 manga: # 输出模式:proxy / data_uri / url diff --git a/deploy.sh b/deploy.sh index c754e6b..43d1b8a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -11,6 +11,7 @@ # ./deploy.sh --rollback # 交互式回滚到指定版本 # ./deploy.sh --rollback v1.0.0 # 直接回滚到指定版本 # ./deploy.sh --list # 列出所有已部署版本 +# ./deploy.sh --sync-nginx # 更新线上 nginx 配置(备份+校验+失败回滚) # ./deploy.sh --help # 显示帮助 # ============================================================ @@ -61,6 +62,7 @@ ROLLBACK=false ROLLBACK_VERSION="" LIST=false FORCE=false +SYNC_NGINX=false print_help() { cat </dev/null </dev/null || $SUDO nginx -s reload 2>/dev/null || true + log "nginx 配置已更新并重载" + else + err "nginx -t 校验失败" + if [ -n "${backup}" ] && [ -f "${backup}" ]; then + $SUDO cp "${backup}" "${NGINX_CONF}" + if $SUDO nginx -t >/dev/null 2>&1; then + $SUDO systemctl reload nginx 2>/dev/null || $SUDO nginx -s reload 2>/dev/null || true + warn "已回滚到备份配置: ${backup}" + fi + fi + exit 1 + fi +} + # ==================== --list ==================== if [ "$LIST" = true ]; then list_versions exit 0 fi +# ==================== --sync-nginx ==================== +# 只更新 nginx 配置(给已部署服务器同步模板变更用),不动二进制与服务 +if [ "$SYNC_NGINX" = true ]; then + command -v nginx >/dev/null 2>&1 || { err "未找到 nginx"; exit 1; } + sync_nginx_conf + exit 0 +fi + # ==================== --rollback ==================== if [ "$ROLLBACK" = true ]; then if [ ! -d "${BIN_DIR}" ]; then @@ -326,28 +407,7 @@ WantedBy=multi-user.target EOF log "初始化 Nginx 配置: ${NGINX_CONF}" - $SUDO tee "${NGINX_CONF}" >/dev/null < 7200 { // 2小时 = 7200秒 - // 清理过期缓存 - cacheDB.Exec(q("DELETE FROM audio_cache WHERE key = ?"), key) - return nil, "", fmt.Errorf("音频信息已过期,请重新调用 /audio 接口") - } - - // 解析headers - json.Unmarshal([]byte(headersJSON), &headers) + return &mediaProxyEntry{OriginalURL: entry.OriginalURL, DeviceID: entry.DeviceID, Headers: entry.Headers}, nil } + if cacheDB == nil { + return nil, fmt.Errorf("数据库未初始化") + } + + var originalURL, deviceID, headersJSON string + var createdAt int64 + err := cacheDB.QueryRow(q(` + SELECT original_url, device_id, headers, created_at + FROM audio_cache WHERE key = ? + `), key).Scan(&originalURL, &deviceID, &headersJSON, &createdAt) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("媒体信息不存在,请先调用 /audio 或 /video 接口") + } + if err != nil { + return nil, fmt.Errorf("查询媒体缓存失败: %w", err) + } + // 过期清理(2小时,匹配CDN链接有效期) + if time.Now().Unix()-createdAt > 7200 { + cacheDB.Exec(q("DELETE FROM audio_cache WHERE key = ?"), key) + return nil, fmt.Errorf("媒体信息已过期,请重新调用接口") + } + + var headers map[string]string + json.Unmarshal([]byte(headersJSON), &headers) if headers == nil { headers = make(map[string]string) } + return &mediaProxyEntry{OriginalURL: originalURL, DeviceID: deviceID, Headers: headers}, nil +} + +// HandleAudioProxy 处理音频代理请求(流式返回,调用方负责 Close) +func HandleAudioProxy(key string) (io.ReadCloser, string, error) { + entry, err := loadMediaProxyEntry(key) + if err != nil { + return nil, "", err + } // 下载音频内容(流式),使用关联的设备信息 - body, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 3) + body, contentType, err := downloadMediaWithRetry(entry.OriginalURL, entry.DeviceID, entry.Headers, 3) if err != nil { return nil, "", fmt.Errorf("下载音频失败: %w", err) } diff --git a/internal/fqapi/cache_manager.go b/internal/fqapi/cache_manager.go index 756fc77..e0ae5ad 100644 --- a/internal/fqapi/cache_manager.go +++ b/internal/fqapi/cache_manager.go @@ -483,6 +483,9 @@ func CleanupExpiredCache() error { cacheLogger.Info(fmt.Sprintf("已清理 %d 条过期缓存(创建时间早于 %d 秒前)", rowsAffected, cfg.Cache.CleanupSeconds)) } + // 顺带清理磁盘视频缓存(7 天未访问的解密文件) + CleanupVideoCache() + return nil } diff --git a/internal/fqapi/store_redis.go b/internal/fqapi/store_redis.go index c87d107..2b811cf 100644 --- a/internal/fqapi/store_redis.go +++ b/internal/fqapi/store_redis.go @@ -77,7 +77,7 @@ func loadAudioProxyEntry(key string) (*audioProxyEntry, error) { val, err := redisCache.Get(ctx, redisAudioKeyPrefix+key).Result() if err != nil { - return nil, fmt.Errorf("音频信息不存在或已过期,请重新调用 /audio 接口") + return nil, fmt.Errorf("媒体信息不存在或已过期,请重新调用 /audio 或 /video 接口") } var entry audioProxyEntry diff --git a/internal/fqapi/video.go b/internal/fqapi/video.go index fdd98aa..a799357 100644 --- a/internal/fqapi/video.go +++ b/internal/fqapi/video.go @@ -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)) + } +} + diff --git a/internal/fqapi/video_decrypt.go b/internal/fqapi/video_decrypt.go new file mode 100644 index 0000000..7e0d182 --- /dev/null +++ b/internal/fqapi/video_decrypt.go @@ -0,0 +1,360 @@ +package fqapi + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/binary" + "fmt" +) + +// ============ SpadeA(黑桃A)PlayAuth 解码 ============ +// +// 番茄短剧/汽水音乐同源的本地 DRM:video_model 每个清晰度的 spade_a +// (PlayAuth)经混淆变换直接还原 16 字节 AES-128 内容密钥(32 位 hex 字符串)。 +// 算法逆向自 SodaMusic v3.5.1(qishui-decrypt-tool),与番茄短剧样本实测一致。 + +func bitCount32(v uint32) int { + c := v + c -= (c >> 1) & 0x55555555 + c = (c & 0x33333333) + ((c >> 2) & 0x33333333) + return int(((c + (c>>4)) & 0x0f0f0f0f) * 0x01010101 >> 24) +} + +func decodeBase36Byte(v byte) int { + switch { + case v >= '0' && v <= '9': + return int(v - '0') + case v >= 'a' && v <= 'z': + return int(v-'a') + 10 + } + return 0xff +} + +// decodeSpadeInner 逐字节去混淆:与前一字节异或后减去索引 popcount 与常数 +func decodeSpadeInner(data []byte) []byte { + result := make([]byte, len(data)) + buff := append([]byte{0xfa, 0x55}, data...) + for i := 0; i < len(data); i++ { + raw := int(data[i]^buff[i]) - bitCount32(uint32(i)) - 21 + if raw >= 0 { + result[i] = byte(raw) + } else { + result[i] = byte(((raw % 255) + 255) % 255) + } + } + return result +} + +// DecodeSpadeKey 从 spade_a(PlayAuth base64)解出 16 字节 AES 内容密钥 +func DecodeSpadeKey(playAuthB64 string) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(playAuthB64) + if err != nil { + return nil, fmt.Errorf("spade_a base64 解码失败: %w", err) + } + if len(raw) < 4 { + return nil, fmt.Errorf("spade_a 长度不足: %d", len(raw)) + } + padding := int(raw[0]^raw[1]^raw[2]) - 48 + if padding < 0 || padding >= len(raw)-3 { + return nil, fmt.Errorf("spade_a padding 非法: %d", padding) + } + body := raw[1 : len(raw)-padding] + tmp := decodeSpadeInner(body) + keyLen := decodeBase36Byte(tmp[0]) + if keyLen == 0xff { + return nil, fmt.Errorf("spade_a key 长度字符非法: %q", tmp[0]) + } + end := len(body) - keyLen + if end < 1 || end > len(tmp) { + return nil, fmt.Errorf("spade_a key 范围非法: end=%d len=%d", end, len(tmp)) + } + keyHex := string(tmp[1:end]) + if len(keyHex) != 32 { + return nil, fmt.Errorf("spade_a key 长度异常: %d", len(keyHex)) + } + key := make([]byte, 16) + for i := 0; i < 16; i++ { + if _, err := fmt.Sscanf(keyHex[i*2:i*2+2], "%02x", &key[i]); err != nil { + return nil, fmt.Errorf("spade_a key hex 非法: %s", keyHex) + } + } + return key, nil +} + +// ============ CENC (cenc-aes-ctr) MP4 解密 ============ +// +// 容器为标准 CENC MP4:moov 明文;加密 sample entry(encv/enca)+ senc box +// (version 0,每个 sample 8 字节 IV,整样本 AES-128-CTR,无 subsample)。 +// 解密后把 entry 类型改回原始 codec(sinf/frma 指示),得到可播放文件。 + +type mp4Box struct { + typ string + contentStar int + end int +} + +// walkBoxes 迭代 [start,end) 内的顶层 box,fn 返回 false 时停止迭代 +func walkBoxes(data []byte, start, end int, fn func(mp4Box) bool) error { + p := start + for p+8 <= end { + size := int(binary.BigEndian.Uint32(data[p : p+4])) + typ := string(data[p+4 : p+8]) + if size == 1 { // largesize + if p+16 > end { + return fmt.Errorf("box %s largesize 越界", typ) + } + size = int(binary.BigEndian.Uint64(data[p+8 : p+16])) + // ponytail: >2GB 文件不支持,截断风险可接受 + if size > len(data) { + return fmt.Errorf("box %s 过大", typ) + } + } + if size < 8 || p+size > end { + return fmt.Errorf("box %s 尺寸非法 size=%d at %d", typ, size, p) + } + if !fn(mp4Box{typ: typ, contentStar: p + 8, end: p + size}) { + return nil + } + p += size + } + return nil +} + +// DecryptCENCMP4 原地解密 CENC 加密的 MP4 字节并修复 stsd entry 类型。 +// 仅支持 senc version 0 + 无 subsample 标志(番茄实测形态),其他形态返回错误。 +func DecryptCENCMP4(data []byte, key []byte) error { + block, err := aes.NewCipher(key) + if err != nil { + return fmt.Errorf("内容密钥非法: %w", err) + } + + decryptStream := func(ct []byte, iv8 []byte) error { + if len(iv8) != 8 { + return fmt.Errorf("senc IV 长度异常: %d", len(iv8)) + } + iv := make([]byte, 16) + copy(iv, iv8) // 8 字节 IV 置于高位,低 8 字节为块计数器(CENC 标准) + cipher.NewCTR(block, iv).XORKeyStream(ct, ct) + return nil + } + + // 找 moov 并遍历其下所有 trak,逐 trak 解密 + var trakErr error + foundMoov := false + if err := walkBoxes(data, 0, len(data), func(b mp4Box) bool { + if b.typ != "moov" { + return true + } + foundMoov = true + _ = walkBoxes(data, b.contentStar, b.end, func(trak mp4Box) bool { + if trak.typ != "trak" { + return true + } + if err := decryptTrak(data, trak, decryptStream); err != nil { + trakErr = err + return false + } + return true + }) + return false + }); err != nil { + return err + } + if !foundMoov { + return fmt.Errorf("moov box 未找到") + } + return trakErr +} + +// decryptTrak 处理单个 trak:解析 sample 表 + senc,逐样本解密,修补 stsd +func decryptTrak(data []byte, trak mp4Box, decryptStream func([]byte, []byte) error) error { + // 定位 mdia→minf→stbl + stbl, err := findBox(data, trak, "mdia", "minf", "stbl") + if err != nil || stbl == nil { + return err // 无 stbl 的 trak 跳过 + } + + var ( + sizes []int + chunkOffsets []int + stscEntries [][2]int // {first_chunk, samples_per_chunk} + ivs [][]byte + stsd *mp4Box + haveSizes bool + haveOffsets bool + haveStsc bool + parseErr error + ) + + if err := walkBoxes(data, stbl.contentStar, stbl.end, func(b mp4Box) bool { + switch b.typ { + case "stsd": + stsd = &b + case "stsz": + constSize := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8])) + count := int(binary.BigEndian.Uint32(data[b.contentStar+8 : b.contentStar+12])) + if constSize != 0 { + for i := 0; i < count; i++ { + sizes = append(sizes, constSize) + } + } else { + sizes = make([]int, count) + for i := 0; i < count; i++ { + sizes[i] = int(binary.BigEndian.Uint32(data[b.contentStar+12+i*4 : b.contentStar+16+i*4])) + } + } + haveSizes = true + case "stco": + count := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8])) + for i := 0; i < count; i++ { + chunkOffsets = append(chunkOffsets, int(binary.BigEndian.Uint32(data[b.contentStar+8+i*4:b.contentStar+12+i*4]))) + } + haveOffsets = true + case "co64": + count := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8])) + for i := 0; i < count; i++ { + v := binary.BigEndian.Uint64(data[b.contentStar+8+i*8 : b.contentStar+16+i*8]) + if v > uint64(len(data)) { + parseErr = fmt.Errorf("co64 偏移越界") + return false + } + chunkOffsets = append(chunkOffsets, int(v)) + } + haveOffsets = true + case "stsc": + count := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8])) + for i := 0; i < count; i++ { + first := int(binary.BigEndian.Uint32(data[b.contentStar+8+i*12 : b.contentStar+12+i*12])) + spc := int(binary.BigEndian.Uint32(data[b.contentStar+12+i*12 : b.contentStar+16+i*12])) + stscEntries = append(stscEntries, [2]int{first, spc}) + } + haveStsc = true + case "senc": + verFlags := binary.BigEndian.Uint32(data[b.contentStar : b.contentStar+4]) + if verFlags>>24 != 0 { + parseErr = fmt.Errorf("senc version %d 不支持", verFlags>>24) + return false + } + if verFlags&0b10 != 0 { + parseErr = fmt.Errorf("senc subsample 加密模式不支持") + return false + } + count := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8])) + p := b.contentStar + 8 + if p+8*count > b.end { + parseErr = fmt.Errorf("senc IV 表越界") + return false + } + for i := 0; i < count; i++ { + ivs = append(ivs, data[p+8*i:p+8*i+8]) + } + } + return true + }); err != nil { + return fmt.Errorf("stbl 解析失败: %w", err) + } + if parseErr != nil { + return parseErr + } + + if len(ivs) == 0 { + return nil // 本 trak 未加密 + } + if !haveSizes || !haveOffsets || !haveStsc { + return fmt.Errorf("sample 表不完整(stsz=%v stco=%v stsc=%v)", haveSizes, haveOffsets, haveStsc) + } + + // stsc+stco+stsz 重建每样本绝对偏移(标准 sample table 遍历) + sampleIdx := 0 + for ci, off := range chunkOffsets { + spc := stscEntries[len(stscEntries)-1][1] + for _, e := range stscEntries { + if e[0] <= ci+1 { + spc = e[1] + } else { + break + } + } + pos := off + for s := 0; s < spc && sampleIdx < len(sizes); s++ { + size := sizes[sampleIdx] + if sampleIdx >= len(ivs) { + break + } + if pos+size > len(data) { + return fmt.Errorf("sample %d 越界 pos=%d size=%d", sampleIdx, pos, size) + } + if err := decryptStream(data[pos:pos+size], ivs[sampleIdx]); err != nil { + return err + } + pos += size + sampleIdx++ + } + } + + if stsd != nil { + patchStsdEntry(data, *stsd) + } + return nil +} + +// findBox 沿 path 逐层查找唯一子 box;任意一层缺失返回 (nil, nil) +func findBox(data []byte, parent mp4Box, path ...string) (*mp4Box, error) { + cur := parent + for _, name := range path { + var next *mp4Box + if err := walkBoxes(data, cur.contentStar, cur.end, func(b mp4Box) bool { + if b.typ == name { + next = &b + return false + } + return true + }); err != nil { + return nil, err + } + if next == nil { + return nil, nil + } + cur = *next + } + return &cur, nil +} + +// patchStsdEntry 把加密 sample entry(encv/enca)的类型改回 sinf/frma 指示的原始 codec。 +// 番茄实测单 entry;entry 头部长度因音/视频而异,直接在 entry 字节里定位 "sinf"。 +func patchStsdEntry(data []byte, stsd mp4Box) { + entryCount := int(binary.BigEndian.Uint32(data[stsd.contentStar+4 : stsd.contentStar+8])) + if entryCount != 1 { + return // ponytail: 多 entry 形态按需扩展 + } + entryStart := stsd.contentStar + 8 + entryEnd := stsd.end + + // 在 entry 内定位 "sinf"(box 类型魔数),再验证其 box 头合法性 + sinfOff := bytes.Index(data[entryStart:entryEnd], []byte("sinf")) + for sinfOff >= 0 { + boxStart := entryStart + sinfOff - 4 // size 字段起点 + if boxStart >= entryStart+8 { + size := int(binary.BigEndian.Uint32(data[boxStart : boxStart+4])) + if size >= 12 && boxStart+size <= entryEnd { + sinfContent, sinfEnd := boxStart+8, boxStart+size + frmaOff := bytes.Index(data[sinfContent:sinfEnd], []byte("frma")) + if frmaOff >= 0 { + frmaContent := sinfContent + frmaOff + 4 + if frmaContent+4 <= sinfEnd { + // entry 类型字段在 box 头的 type 位置(entryStart+4) + copy(data[entryStart+4:entryStart+8], data[frmaContent:frmaContent+4]) + return + } + } + } + } + next := bytes.Index(data[entryStart+sinfOff+1:entryEnd], []byte("sinf")) + if next < 0 { + return + } + sinfOff = sinfOff + 1 + next + } +} diff --git a/internal/fqapi/videodecrypt_test.go b/internal/fqapi/videodecrypt_test.go new file mode 100644 index 0000000..c46d3cd --- /dev/null +++ b/internal/fqapi/videodecrypt_test.go @@ -0,0 +1,178 @@ +package fqapi + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/binary" + "bytes" + "encoding/base64" + "encoding/hex" + "io" + "os" + "strings" + "testing" +) + +// 番茄短剧真实样本(video_model video_1..4 共用同一 spade_a) +const testSpadeA = "kbwfwFiKI8RuvCnHV4ss9lK6HMFmvS/2f7wv7H+mAfRRvxmXlw==" + +func TestDecodeSpadeKey(t *testing.T) { + key, err := DecodeSpadeKey(testSpadeA) + if err != nil { + t.Fatal(err) + } + want := "4f04d75b0df5cee67dbe13738872e965" + if got := hex.EncodeToString(key); got != want { + t.Fatalf("key mismatch: got %s want %s", got, want) + } +} + +// 端到端:下载真实 360p 视频 → DecryptCENCMP4 → 输出文件(ffprobe 验证) +func TestDecryptRealVideo(t *testing.T) { + mainURLB64 := os.Getenv("FQ_VIDEO_URL") + spadeA := strings.TrimSpace(os.Getenv("FQ_VIDEO_SPADE")) + if mainURLB64 == "" || spadeA == "" { + t.Skip("need FQ_VIDEO_URL (main_url base64) and FQ_VIDEO_SPADE") + } + url, err := base64.StdEncoding.DecodeString(mainURLB64) + if err != nil { + t.Fatal(err) + } + body, _, err := downloadMedia(string(bytes.TrimSpace(url)), "", map[string]string{}) + if err != nil { + t.Fatal(err) + } + defer body.Close() + data, err := io.ReadAll(body) + if err != nil { + t.Fatal(err) + } + t.Logf("downloaded %d bytes", len(data)) + + key, err := DecodeSpadeKey(spadeA) + if err != nil { + t.Fatal(err) + } + t.Logf("key=%x", key) + if err := DecryptCENCMP4(data, key); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("/tmp/video_decrypted.mp4", data, 0644); err != nil { + t.Fatal(err) + } + t.Log("written /tmp/video_decrypted.mp4") +} + +// ---- 合成 CENC MP4 单元测试:加密 → 解密 → 比对明文 ---- + +func box(typ string, payload ...[]byte) []byte { + var body []byte + for _, p := range payload { + body = append(body, p...) + } + b := make([]byte, 8+len(body)) + binary.BigEndian.PutUint32(b[:4], uint32(len(b))) + copy(b[4:8], typ) + copy(b[8:], body) + return b +} + +func u32(v uint32) []byte { b := make([]byte, 4); binary.BigEndian.PutUint32(b, v); return b } + +func TestDecryptCENCMP4Synthetic(t *testing.T) { + key := []byte("0123456789abcdef") + plain1 := []byte("HELLO_CENC_SAMPLE_1_PADPADP") + plain2 := []byte("SECOND_SAMPLE") + plain3 := []byte("THIRD") + + enc := func(pt []byte, iv8 []byte) []byte { + iv := make([]byte, 16) + copy(iv, iv8) + block, _ := aes.NewCipher(key) + ct := make([]byte, len(pt)) + cipher.NewCTR(block, iv).XORKeyStream(ct, pt) + return ct + } + iv1 := []byte{1, 2, 3, 4, 5, 6, 7, 8} + iv2 := []byte{8, 7, 6, 5, 4, 3, 2, 1} + iv3 := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22} + ct1, ct2, ct3 := enc(plain1, iv1), enc(plain2, iv2), enc(plain3, iv3) + + // buildFile(off1, off2):chunk 绝对偏移写入 stco;偏移依赖 moov 长度,先占位测长再重建 + buildFile := func(off1, off2 uint32) []byte { + entry := box("enca", make([]byte, 8), box("sinf", box("frma", []byte("mp4a")))) + stsd := box("stsd", u32(0), u32(1), entry) + stsz := box("stsz", u32(0), u32(0), u32(3), u32(uint32(len(plain1))), u32(uint32(len(plain2))), u32(uint32(len(plain3)))) + stsc := box("stsc", u32(0), u32(2), + u32(1), u32(2), u32(1), // entry1: chunk1 起,每 chunk 2 样本 + u32(2), u32(1), u32(1)) // entry2: chunk2 起,每 chunk 1 样本 + stco := box("stco", u32(0), u32(2), u32(off1), u32(off2)) + sencBody := append(append(append(append(u32(0), u32(3)...), iv1...), iv2...), iv3...) + senc := box("senc", sencBody) + moov := box("moov", box("trak", box("mdia", box("minf", box("stbl", stsd, stsz, stsc, stco, senc))))) + ftyp := box("ftyp", []byte("isom")) + return append(append(ftyp, moov...), box("mdat", ct1, ct2, ct3)...) + } + + // 占位构造测 moov 总长,得到真实 chunk 偏移后重建 + probe := buildFile(0, 0) + chunk1Off := uint32(len(probe) - len(ct1) - len(ct2) - len(ct3)) + chunk2Off := chunk1Off + uint32(len(ct1)+len(ct2)) + data := buildFile(chunk1Off, chunk2Off) + + if err := DecryptCENCMP4(data, key); err != nil { + t.Fatal(err) + } + if got := data[chunk1Off : chunk1Off+uint32(len(plain1))]; string(got) != string(plain1) { + t.Errorf("sample1 解密不符: %q", got) + } + s2 := chunk1Off + uint32(len(plain1)) + if got := data[s2 : s2+uint32(len(plain2))]; string(got) != string(plain2) { + t.Errorf("sample2 解密不符: %q", got) + } + if got := data[chunk2Off : chunk2Off+uint32(len(plain3))]; string(got) != string(plain3) { + t.Errorf("sample3 解密不符: %q", got) + } + // stsd entry 类型应被 patch 为 frma 指示的 mp4a(原 enca 四字已不存在) + if bytes.Index(data, []byte("enca")) >= 0 || bytes.Index(data, []byte("mp4a")) < 0 { + t.Error("stsd entry 类型未修复为 mp4a") + } +} + +// 清晰度过滤:按 definition 与档位 key 均可命中;无匹配返回空列表由上层报错 +func TestDecodeVideoModelQualityFilter(t *testing.T) { + vm := `{"video_id":"v123","video_duration":1.5,"video_list":{ + "video_1":{"definition":"360p","vtype":"mp4","main_url":"` + base64.StdEncoding.EncodeToString([]byte("https://a/360")) + `"}, + "video_4":{"definition":"720p","vtype":"mp4","main_url":"` + base64.StdEncoding.EncodeToString([]byte("https://a/720")) + `"}}}` + got := decodeVideoModelAsJSON(vm, "url", "", "720p").(map[string]interface{}) + vl := got["video_list"].(map[string]interface{}) + if len(vl) != 1 || vl["video_4"] == nil { + t.Fatalf("definition 过滤失败: %v", vl) + } + got = decodeVideoModelAsJSON(vm, "url", "", "VIDEO_1").(map[string]interface{}) + vl = got["video_list"].(map[string]interface{}) + if len(vl) != 1 || vl["video_1"] == nil { + t.Fatalf("档位key过滤失败: %v", vl) + } + if u := vl["video_1"].(map[string]interface{})["main_url"].(string); u != "https://a/360" { + t.Fatalf("main_url 未解码: %s", u) + } + got = decodeVideoModelAsJSON(vm, "url", "", "1080p").(map[string]interface{}) + if len(got["video_list"].(map[string]interface{})) != 0 { + t.Fatal("无匹配应返回空列表") + } + // max 取最高档(vheight 缺失时按 definition 数字比较) + got = decodeVideoModelAsJSON(vm, "url", "", "max").(map[string]interface{}) + vl = got["video_list"].(map[string]interface{}) + if len(vl) != 1 || vl["video_4"] == nil { + t.Fatalf("max 应只保留最高档: %v", vl) + } + // vheight 存在时优先按 vheight + vm2 := `{"video_list":{ + "video_1":{"definition":"360p","vwidth":360,"vheight":640}, + "video_2":{"definition":"540p","vwidth":540,"vheight":960}}}` + got = decodeVideoModelAsJSON(vm2, "url", "", "max").(map[string]interface{}) + if len(got["video_list"].(map[string]interface{})) != 1 || got["video_list"].(map[string]interface{})["video_2"] == nil { + t.Fatal("max 应按 vheight 取 video_2") + } +} diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 6a68990..db0f029 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strings" + "time" ) // HandleAudio 音频处理 @@ -75,7 +76,9 @@ func HandleVideo(w http.ResponseWriter, r *http.Request) { return } - result, err := fqapi.HandleVideo(params["item_ids"]) + output := r.URL.Query().Get("output") + quality := r.URL.Query().Get("quality") + result, err := fqapi.HandleVideo(params["item_ids"], output, quality) if err != nil { respondError(w, "获取视频失败: "+err.Error(), false) return @@ -84,6 +87,30 @@ func HandleVideo(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, result) } +// HandleVideoProxy 视频解密代理处理(磁盘缓存优先;http.ServeContent 支持 Range/seek/HEAD, +// 播放器拖动进度条时按需分片返回) +func HandleVideoProxy(w http.ResponseWriter, r *http.Request) { + incrementStat("total", "video") + + path := r.URL.Path + key := strings.TrimPrefix(path, "/video/proxy/") + if key == "" || key == path { + http.Error(w, "缺少视频key", http.StatusBadRequest) + return + } + + content, contentType, err := fqapi.HandleVideoProxy(key) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if closer, ok := content.(io.Closer); ok { + defer closer.Close() + } + w.Header().Set("Content-Type", contentType) + http.ServeContent(w, r, key+".mp4", time.Time{}, content) +} + // HandleManga 漫画处理 func HandleManga(w http.ResponseWriter, r *http.Request) { incrementStat("total", "manga")