@@ -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