Files
fanqie-go/internal/fqapi/audio_proxy.go
T
chenxin 49d0a371fb ✨ 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-10 12:58:25 +08:00

140 lines
4.1 KiB
Go

package fqapi
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"time"
)
// AudioCacheItem 音频缓存项
type AudioCacheItem struct {
OriginalURL string `json:"original_url"` // 原始CDN URL
DeviceID string `json:"device_id"` // 关联的设备ID
Headers map[string]string `json:"headers"` // 请求头信息
CachedAt time.Time `json:"cached_at"` // 缓存时间
}
// GenerateAudioProxyURL 生成音频代理URL
func GenerateAudioProxyURL(originalURL, deviceID string, headers map[string]string) string {
// 生成缓存key
key := generateRandomKey(16)
// Redis 模式:TTL 自动过期
if redisCache != nil {
saveAudioProxyEntry(key, &audioProxyEntry{
OriginalURL: originalURL,
DeviceID: deviceID,
Headers: headers,
})
return fmt.Sprintf("/audio/proxy/%s", key)
}
// 将headers序列化为JSON字符串
headersJSON, _ := json.Marshal(headers)
// 存储到数据库
if cacheDB != nil {
_, err := 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
`), key, originalURL, deviceID, string(headersJSON), time.Now().Unix(), time.Now().Unix())
if err != nil {
audioLogger.Error("保存音频缓存失败: " + err.Error())
}
}
// 返回代理URL
return fmt.Sprintf("/audio/proxy/%s", key)
}
// loadMediaProxyEntry 读取媒体代理映射(audio/video 共用;Redis 优先,回退 SQL)
type mediaProxyEntry struct {
OriginalURL string
DeviceID string
Headers map[string]string
}
func loadMediaProxyEntry(key string) (*mediaProxyEntry, error) {
if redisCache != nil {
entry, err := loadAudioProxyEntry(key)
if err != nil {
return nil, err
}
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(entry.OriginalURL, entry.DeviceID, entry.Headers, 3)
if err != nil {
return nil, "", fmt.Errorf("下载音频失败: %w", err)
}
return body, contentType, nil
}
// CleanExpiredAudioCache 清理过期的音频缓存
func CleanExpiredAudioCache() {
// Redis 模式下由 TTL 自动过期,无需清理
if redisCache != nil {
return
}
if cacheDB == nil {
return
}
// 删除2小时前的缓存
result, err := cacheDB.Exec(q("DELETE FROM audio_cache WHERE created_at < ?"), time.Now().Unix()-7200)
if err != nil {
audioLogger.Error("清理过期音频缓存失败: " + err.Error())
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
audioLogger.Info(fmt.Sprintf("已清理 %d 条过期音频缓存", rowsAffected))
}
}