diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index d94a28f..9faaf91 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -111,7 +111,21 @@ class="inline-block cursor-pointer relative" @click.stop="onMediaClick(post, post.media[0], 0)" > + +
@@ -146,8 +160,20 @@ class="w-[116px] h-[116px] rounded-[2px] overflow-hidden bg-gray-100 border border-gray-200 flex items-center justify-center cursor-pointer relative" @click.stop="onMediaClick(post, m, idx)" > + + 图片失败 -
+
@@ -707,6 +737,31 @@ const getMediaPreviewSrc = (post, m, idx = 0) => { return getSnsMediaUrl(post, m, idx, m?.url || m?.thumb) } + +const getSnsVideoUrl = (postId, mediaId) => { + // 本地缓存视频 + const acc = String(selectedAccount.value || '').trim() + if (!acc || !postId || !mediaId) return '' + return `${mediaBase}/api/sns/video?account=${encodeURIComponent(acc)}&post_id=${encodeURIComponent(postId)}&media_id=${encodeURIComponent(mediaId)}` +} + +const localVideoStatus = ref({}) + +const videoStatusKey = (postId, mediaId) => `${String(postId)}:${String(mediaId)}` + +const onLocalVideoLoaded = (postId, mediaId) => { + localVideoStatus.value[videoStatusKey(postId, mediaId)] = 'loaded' +} + +const onLocalVideoError = (postId, mediaId) => { + localVideoStatus.value[videoStatusKey(postId, mediaId)] = 'error' +} + + +const isLocalVideoLoaded = (postId, mediaId) => { + return localVideoStatus.value[videoStatusKey(postId, mediaId)] === 'loaded' +} + // 图片预览 + 候选匹配选择 const previewCtx = ref(null) // { post, media, idx } const previewCandidatesOpen = ref(false) @@ -902,13 +957,23 @@ const loadMorePreviewCandidates = async () => { const onMediaClick = (post, m, idx = 0) => { if (!process.client) return const mt = Number(m?.type || 0) - // 视频:打开视频链接(新窗口),图片:打开预览 + + // 视频点击逻辑 if (mt === 6) { + // 1. 如果本地缓存加载成功,永远不请求 CDN!直接在新标签页打开本地的高清完整视频 + if (isLocalVideoLoaded(post.id, m.id)) { + const localUrl = getSnsVideoUrl(post.id, m.id) + window.open(localUrl, '_blank', 'noopener,noreferrer') + return + } + + // 2. 如果本地没有缓存,按原逻辑 fallback 到 CDN const u = String(m?.url || '').trim() - if (u) window.open(u, '_blank', 'noopener,noreferrer') - return - } - // Open preview overlay; it also loads local candidates for manual selection. + if (u) window.open(u, '_blank', 'noopener,noreferrer') + return + } + + // 图片:打开预览 void openImagePreview(post, m, idx) } diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py index 9cc5c65..76afb21 100644 --- a/src/wechat_decrypt_tool/routers/sns.py +++ b/src/wechat_decrypt_tool/routers/sns.py @@ -12,7 +12,7 @@ import xml.etree.ElementTree as ET from typing import Any, Optional from fastapi import APIRouter, HTTPException -from fastapi.responses import Response +from fastapi.responses import Response, FileResponse # 返回视频文件 from pydantic import BaseModel, Field from ..chat_helpers import _load_contact_rows, _pick_display_name, _resolve_account_dir @@ -448,6 +448,58 @@ def _sns_img_roots(wxid_dir_str: str) -> tuple[str, ...]: roots.sort() return tuple(roots) +@lru_cache(maxsize=16) +def _sns_video_roots(wxid_dir_str: str) -> tuple[str, ...]: + """List all month cache roots that contain `Sns/Video`.""" + wxid_dir = Path(str(wxid_dir_str or "").strip()) + cache_root = wxid_dir / "cache" + try: + month_dirs = [p for p in cache_root.iterdir() if p.is_dir()] + except Exception: + month_dirs = [] + + roots: list[str] = [] + for mdir in month_dirs: + video_root = mdir / "Sns" / "Video" + try: + if video_root.exists() and video_root.is_dir(): + roots.append(str(video_root)) + except Exception: + continue + roots.sort() + return tuple(roots) + +def _resolve_sns_cached_video_path( + wxid_dir: Path, + post_id: str, + media_id: str +) -> Optional[str]: + """基于逆向出的固定盐值 3,解析朋友圈视频的本地缓存路径""" + if not post_id or not media_id: + return None + + raw_key = f"{post_id}_{media_id}_3" # 暂时硬编码,大概率是对的 + try: + key32 = hashlib.md5(raw_key.encode("utf-8")).hexdigest() + except Exception: + return None + + sub = key32[:2] + rest = key32[2:] + + roots = _sns_video_roots(str(wxid_dir)) + for root_str in roots: + try: + base_path = Path(root_str) / sub / rest + for ext in [".mp4", ".tmp"]: + p = base_path.with_suffix(ext) + if p.exists() and p.is_file(): + return str(p) + except Exception: + continue + + return None + def _resolve_sns_cached_image_path_by_md5( *, @@ -1184,4 +1236,27 @@ async def proxy_article_thumb(url: str): except Exception as e: logger.warning(f"[sns] 提取公众号封面失败 url={u[:50]}... : {e}") - raise HTTPException(status_code=404, detail="无法获取文章封面") \ No newline at end of file + raise HTTPException(status_code=404, detail="无法获取文章封面") + + +@router.get("/api/sns/video", summary="获取朋友圈本地缓存视频") +async def get_sns_video( + account: Optional[str] = None, + post_id: Optional[str] = None, + media_id: Optional[str] = None, +): + if not post_id or not media_id: + raise HTTPException(status_code=400, detail="Missing post_id or media_id") + + account_dir = _resolve_account_dir(account) + wxid_dir = _resolve_account_wxid_dir(account_dir) + + if not wxid_dir: + raise HTTPException(status_code=404, detail="WXID dir not found") + + video_path = _resolve_sns_cached_video_path(wxid_dir, post_id, media_id) + + if not video_path: + raise HTTPException(status_code=404, detail="Local video cache not found") + + return FileResponse(video_path, media_type="video/mp4") \ No newline at end of file