feat: calc sns video file path

This commit is contained in:
H3CoF6
2026-02-13 20:41:48 +08:00
parent 372deaf060
commit 7cc7ff8628
2 changed files with 150 additions and 10 deletions

View File

@@ -111,7 +111,21 @@
class="inline-block cursor-pointer relative"
@click.stop="onMediaClick(post, post.media[0], 0)"
>
<video
v-if="Number(post.media[0]?.type || 0) === 6"
:src="getSnsVideoUrl(post.id, post.media[0].id)"
:poster="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] max-w-full object-cover"
autoplay
loop
muted
playsinline
@loadeddata="onLocalVideoLoaded(post.id, post.media[0].id)"
@error="onLocalVideoError(post.id, post.media[0].id)"
></video>
<img
v-else
:src="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] object-cover"
alt=""
@@ -120,7 +134,7 @@
@error="onMediaError(post.id, 0)"
/>
<div
v-if="Number(post.media[0]?.type || 0) === 6"
v-if="Number(post.media[0]?.type || 0) === 6 && !isLocalVideoLoaded(post.id, post.media[0].id)"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
@@ -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)"
>
<video
v-if="!hasMediaError(post.id, idx) && Number(m?.type || 0) === 6"
:src="getSnsVideoUrl(post.id, m.id)"
:poster="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover"
autoplay
loop
muted
playsinline
@loadeddata="onLocalVideoLoaded(post.id, m.id)"
@error="onLocalVideoError(post.id, m.id)"
></video>
<img
v-if="!hasMediaError(post.id, idx) && getMediaThumbSrc(post, m, idx)"
v-else-if="!hasMediaError(post.id, idx) && getMediaThumbSrc(post, m, idx)"
:src="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover"
alt=""
@@ -155,9 +181,13 @@
referrerpolicy="no-referrer"
@error="onMediaError(post.id, idx)"
/>
<!-- 不知道微信朋友圈可不可以发多视频先这样写吧-->
<span v-else class="text-[10px] text-gray-400">图片失败</span>
<div v-if="Number(m?.type || 0) === 6" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div
v-if="Number(m?.type || 0) === 6 && !isLocalVideoLoaded(post.id, m.id)"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="w-10 h-10 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
@@ -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)
}

View File

@@ -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="无法获取文章封面")
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")