From d3713e54c736226661385bdbf682fb6848849e5a Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 31 Jul 2025 13:49:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=98=B2=E7=9B=97?= =?UTF-8?q?=E9=93=BE=E7=BB=95=E8=BF=87=E6=9C=BA=E5=88=B6=E8=A7=A3=E5=86=B3?= =?UTF-8?q?403=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题分析: - 火山云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-*: 安全策略头部 --- apps/desktop/src-tauri/src/lib.rs | 3 +- .../commands/volcano_video_commands.rs | 78 ++++++++++++++++++- .../src/components/VideoPreviewModal.tsx | 61 +++++++++++++-- 3 files changed, 132 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index bb12fec..30c5113 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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| { // 初始化日志系统 diff --git a/apps/desktop/src-tauri/src/presentation/commands/volcano_video_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/volcano_video_commands.rs index 35b3f15..0fb1701 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/volcano_video_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/volcano_video_commands.rs @@ -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 { + 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)) + } + } +} diff --git a/apps/desktop/src/components/VideoPreviewModal.tsx b/apps/desktop/src/components/VideoPreviewModal.tsx index 99d77a7..cb22826 100644 --- a/apps/desktop/src/components/VideoPreviewModal.tsx +++ b/apps/desktop/src/components/VideoPreviewModal.tsx @@ -50,9 +50,34 @@ export const VideoPreviewModal: React.FC = ({ const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isDownloading, setIsDownloading] = useState(false); + const [proxiedVideoUrl, setProxiedVideoUrl] = useState(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 = ({ } }, []); - const handleVideoError = useCallback(() => { - setIsLoading(false); - setError('视频加载失败'); - }, []); + const handleVideoError = useCallback(async () => { + console.log('视频加载失败,尝试使用代理服务'); + + // 如果还没有尝试过代理,则尝试使用代理服务 + if (proxiedVideoUrl === videoUrl) { + try { + setIsLoadingProxy(true); + setError(null); + + const proxiedData = await invoke('get_video_stream_base64', { + videoUrl: videoUrl + }); + + setProxiedVideoUrl(proxiedData); + console.log('成功获取代理视频数据'); + } catch (error) { + console.error('代理视频加载失败:', error); + setIsLoading(false); + 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 = ({ onPlay={handlePlay} onPause={handlePause} controls={false} + crossOrigin="anonymous" > - {videoUrl && ( - + {proxiedVideoUrl && ( + )} 您的浏览器不支持视频播放。