mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 14:20:51 +08:00
feat: calc sns video file path
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user