feat: 实现防盗链绕过机制解决403错误

问题分析:
- 火山云CDN配置了防盗链保护,直接访问返回403 Forbidden
- 需要特定的Referer和User-Agent头部才能正常访问
- 跨域访问限制导致浏览器无法直接播放视频

解决方案:
1. 添加crossOrigin='anonymous'属性到video标签
2. 创建代理服务绕过防盗链限制
3. 实现fallback机制:直接访问失败时自动使用代理

技术实现:
- 新增get_video_stream_base64命令,返回Base64编码的视频数据
- 添加完整的HTTP头部模拟真实浏览器请求
- 包含Referer、Origin、User-Agent等关键头部信息
- 实现自动fallback:直接播放失败时转为代理模式

用户体验:
- 透明的错误处理,用户无感知切换
- 保持原有的播放控制功能
- 支持大文件的Base64编码传输
- 提供详细的错误日志便于调试

HTTP头部配置:
- User-Agent: 模拟Chrome浏览器
- Referer: https://www.volcengine.com/
- Origin: https://www.volcengine.com
- Accept: 视频MIME类型
- Sec-Fetch-*: 安全策略头部
This commit is contained in:
imeepos 2025-07-31 13:49:25 +08:00
parent fb8b5b90ee
commit d3713e54c7
3 changed files with 132 additions and 10 deletions

View File

@ -497,7 +497,8 @@ pub fn run() {
commands::volcano_video_commands::batch_delete_volcano_video_generations,
commands::volcano_video_commands::download_volcano_video,
commands::volcano_video_commands::download_video_to_directory,
commands::volcano_video_commands::batch_download_volcano_videos
commands::volcano_video_commands::batch_download_volcano_videos,
commands::volcano_video_commands::get_video_stream_base64
])
.setup(|app| {
// 初始化日志系统

View File

@ -276,10 +276,26 @@ pub async fn download_video_to_directory(
Err(_) => return Err("文件选择对话框超时".to_string()),
};
// 下载视频文件
let client = reqwest::Client::new();
// 创建带有防盗链绕过的HTTP客户端
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.build()
.map_err(|e| format!("创建HTTP客户端失败: {}", e))?;
match client.get(&video_url).send().await {
// 构建请求,添加必要的头部信息绕过防盗链
let request = client
.get(&video_url)
.header("Referer", "https://www.volcengine.com/")
.header("Origin", "https://www.volcengine.com")
.header("Accept", "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5")
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.header("Cache-Control", "no-cache")
.header("Pragma", "no-cache")
.header("Sec-Fetch-Dest", "video")
.header("Sec-Fetch-Mode", "no-cors")
.header("Sec-Fetch-Site", "cross-site");
match request.send().await {
Ok(response) => {
if response.status().is_success() {
match response.bytes().await {
@ -371,3 +387,59 @@ pub async fn batch_download_volcano_videos(
Ok(results)
}
/// 获取视频流的Base64数据绕过防盗链限制
#[tauri::command]
pub async fn get_video_stream_base64(
video_url: String,
) -> Result<String, String> {
info!("获取视频流Base64数据: {}", video_url);
// 创建带有防盗链绕过的HTTP客户端
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| format!("创建HTTP客户端失败: {}", e))?;
// 构建请求,添加必要的头部信息
let request = client
.get(&video_url)
.header("Referer", "https://www.volcengine.com/")
.header("Origin", "https://www.volcengine.com")
.header("Accept", "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5")
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.header("Cache-Control", "no-cache")
.header("Pragma", "no-cache")
.header("Sec-Fetch-Dest", "video")
.header("Sec-Fetch-Mode", "no-cors")
.header("Sec-Fetch-Site", "cross-site");
match request.send().await {
Ok(response) => {
if response.status().is_success() {
match response.bytes().await {
Ok(bytes) => {
use base64::{Engine as _, engine::general_purpose};
let base64_data = general_purpose::STANDARD.encode(&bytes);
let data_url = format!("data:video/mp4;base64,{}", base64_data);
info!("成功获取视频流Base64数据大小: {} bytes", bytes.len());
Ok(data_url)
}
Err(e) => {
error!("读取视频流数据失败: {}", e);
Err(format!("读取视频流数据失败: {}", e))
}
}
} else {
let error_msg = format!("获取视频流失败HTTP状态码: {}", response.status());
error!("{}", error_msg);
Err(error_msg)
}
}
Err(e) => {
error!("获取视频流请求失败: {}", e);
Err(format!("获取视频流请求失败: {}", e))
}
}
}

View File

@ -50,9 +50,34 @@ export const VideoPreviewModal: React.FC<VideoPreviewModalProps> = ({
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isDownloading, setIsDownloading] = useState(false);
const [proxiedVideoUrl, setProxiedVideoUrl] = useState<string | null>(null);
const [isLoadingProxy, setIsLoadingProxy] = useState(false);
const { addNotification } = useNotifications();
// 加载代理视频
useEffect(() => {
if (!videoUrl || !isOpen) return;
const loadProxiedVideo = async () => {
setIsLoadingProxy(true);
setError(null);
try {
// 首先尝试直接加载
setProxiedVideoUrl(videoUrl);
setIsLoading(true);
} catch (error) {
console.error('加载视频失败:', error);
setError('视频加载失败');
} finally {
setIsLoadingProxy(false);
}
};
loadProxiedVideo();
}, [videoUrl, isOpen]);
// 重置状态当视频URL改变时
useEffect(() => {
if (videoUrl) {
@ -166,10 +191,33 @@ export const VideoPreviewModal: React.FC<VideoPreviewModalProps> = ({
}
}, []);
const handleVideoError = useCallback(() => {
const handleVideoError = useCallback(async () => {
console.log('视频加载失败,尝试使用代理服务');
// 如果还没有尝试过代理,则尝试使用代理服务
if (proxiedVideoUrl === videoUrl) {
try {
setIsLoadingProxy(true);
setError(null);
const proxiedData = await invoke<string>('get_video_stream_base64', {
videoUrl: videoUrl
});
setProxiedVideoUrl(proxiedData);
console.log('成功获取代理视频数据');
} catch (error) {
console.error('代理视频加载失败:', error);
setIsLoading(false);
setError('视频加载失败');
}, []);
setError('视频加载失败,请检查网络连接或视频链接');
} finally {
setIsLoadingProxy(false);
}
} else {
setIsLoading(false);
setError('视频加载失败,请检查网络连接或视频链接');
}
}, [videoUrl, proxiedVideoUrl]);
const handleTimeUpdate = useCallback(() => {
if (videoRef.current) {
@ -243,9 +291,10 @@ export const VideoPreviewModal: React.FC<VideoPreviewModalProps> = ({
onPlay={handlePlay}
onPause={handlePause}
controls={false}
crossOrigin="anonymous"
>
{videoUrl && (
<source src={videoUrl} type="video/mp4" />
{proxiedVideoUrl && (
<source src={proxiedVideoUrl} type="video/mp4" />
)}
</video>