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" class="inline-block cursor-pointer relative"
@click.stop="onMediaClick(post, post.media[0], 0)" @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 <img
v-else
:src="getMediaThumbSrc(post, post.media[0], 0)" :src="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] object-cover" class="rounded-sm max-h-[360px] object-cover"
alt="" alt=""
@@ -120,7 +134,7 @@
@error="onMediaError(post.id, 0)" @error="onMediaError(post.id, 0)"
/> />
<div <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" 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"> <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" 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)" @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 <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)" :src="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover" class="w-full h-full object-cover"
alt="" alt=""
@@ -155,9 +181,13 @@
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
@error="onMediaError(post.id, idx)" @error="onMediaError(post.id, idx)"
/> />
<!-- 不知道微信朋友圈可不可以发多视频先这样写吧-->
<span v-else class="text-[10px] text-gray-400">图片失败</span> <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"> <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> <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div> </div>
@@ -707,6 +737,31 @@ const getMediaPreviewSrc = (post, m, idx = 0) => {
return getSnsMediaUrl(post, m, idx, m?.url || m?.thumb) 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 previewCtx = ref(null) // { post, media, idx }
const previewCandidatesOpen = ref(false) const previewCandidatesOpen = ref(false)
@@ -902,13 +957,23 @@ const loadMorePreviewCandidates = async () => {
const onMediaClick = (post, m, idx = 0) => { const onMediaClick = (post, m, idx = 0) => {
if (!process.client) return if (!process.client) return
const mt = Number(m?.type || 0) const mt = Number(m?.type || 0)
// 视频:打开视频链接(新窗口),图片:打开预览
// 视频点击逻辑
if (mt === 6) { 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() const u = String(m?.url || '').trim()
if (u) window.open(u, '_blank', 'noopener,noreferrer') if (u) window.open(u, '_blank', 'noopener,noreferrer')
return return
} }
// Open preview overlay; it also loads local candidates for manual selection.
// 图片:打开预览
void openImagePreview(post, m, idx) void openImagePreview(post, m, idx)
} }

View File

@@ -12,7 +12,7 @@ import xml.etree.ElementTree as ET
from typing import Any, Optional from typing import Any, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import Response from fastapi.responses import Response, FileResponse # 返回视频文件
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..chat_helpers import _load_contact_rows, _pick_display_name, _resolve_account_dir 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() roots.sort()
return tuple(roots) 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( def _resolve_sns_cached_image_path_by_md5(
*, *,
@@ -1184,4 +1236,27 @@ async def proxy_article_thumb(url: str):
except Exception as e: except Exception as e:
logger.warning(f"[sns] 提取公众号封面失败 url={u[:50]}... : {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")