Merge pull request #27 from H3CoF6/feat/sns-media

feat(sns): complete moments viewer with exact cache path resolution and rich media support
This commit is contained in:
2977094657
2026-02-14 12:25:10 +08:00
committed by GitHub
2 changed files with 637 additions and 394 deletions

View File

@@ -2,25 +2,45 @@
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<!-- 右侧朋友圈区域 -->
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
<div class="flex-1 overflow-auto min-h-0">
<div class="flex-1 overflow-auto min-h-0 bg-white" @scroll="onScroll">
<div class="max-w-2xl mx-auto px-4 py-4">
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-2">{{ error }}</div>
<div v-else-if="isLoading && posts.length === 0" class="text-sm text-gray-500 py-2">加载中</div>
<div v-else-if="posts.length === 0" class="text-sm text-gray-500 py-2">暂无朋友圈数据</div>
<div class="relative w-full mb-12 -mt-4 bg-white">
<div class="h-64 w-full bg-[#333333] relative overflow-hidden">
<img
v-if="coverData && coverData.media && coverData.media.length > 0"
:src="getSnsMediaUrl(coverData, coverData.media[0], 0, coverData.media[0].url)"
class="w-full h-full object-cover"
alt="朋友圈封面"
/>
</div>
<div class="absolute right-4 -bottom-6 flex items-end gap-4">
<div class="text-white font-bold text-xl mb-7 drop-shadow-md">
{{ selfInfo.nickname || '获取中...' }}
</div>
<div class="w-[72px] h-[72px] rounded-lg bg-white p-[2px] shadow-sm">
<img
v-if="selfInfo.wxid"
:src="postAvatarUrl(selfInfo.wxid)"
class="w-full h-full rounded-md object-cover bg-gray-100"
:alt="selfInfo.nickname"
referrerpolicy="no-referrer"
/>
<div v-else class="w-full h-full rounded-md bg-gray-300 flex items-center justify-center text-gray-500 text-xs">
...
</div>
</div>
</div>
</div>
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-4 text-center">{{ error }}</div>
<div v-else-if="isLoading && posts.length === 0" class="flex flex-col items-center justify-center py-16">
<div class="w-8 h-8 border-[3px] border-gray-200 border-t-[#576b95] rounded-full animate-spin"></div>
<div class="mt-4 text-sm text-gray-400">正在前往朋友圈...</div>
</div>
<div v-else-if="posts.length === 0" class="text-sm text-gray-400 py-16 text-center">暂无朋友圈数据</div>
<!-- 图片匹配提示实验功能 -->
<div v-if="!error" class="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<div class="font-medium">图片匹配实验功能</div>
<div class="mt-1 leading-5">
图片可能会出现错配或无法显示点击图片进入预览可在候选匹配中手动选择你的选择会保存在本机并在下次优先使用
</div>
<label class="mt-2 flex items-start gap-2 select-none">
<input v-model="snsAvoidOtherPicked" type="checkbox" class="mt-[2px]" />
<span class="leading-5">
自动匹配时避开已被你手动指定到其他动态的图片降低重复
</span>
</label>
</div>
<div v-for="post in posts" :key="post.id" class="bg-white rounded-sm px-4 py-4 mb-3">
<div class="flex items-start gap-3" @contextmenu.prevent="openPostContextMenu($event, post)">
@@ -47,31 +67,95 @@
</div>
<div
v-if="post.contentDesc"
class="mt-1 text-sm text-gray-900 leading-6 whitespace-pre-wrap break-words"
:class="{ 'privacy-blur': privacyMode }"
v-if="post.contentDesc"
class="mt-1 text-sm text-gray-900 leading-6 whitespace-pre-wrap break-words"
:class="{ 'privacy-blur': privacyMode }"
>
{{ post.contentDesc }}
</div>
<div v-if="post.media && post.media.length > 0" class="mt-2" :class="{ 'privacy-blur': privacyMode }">
<div v-if="post.type === 3" class="mt-2 max-w-[360px]" :class="{ 'privacy-blur': privacyMode }">
<a :href="post.contentUrl" target="_blank" class="block bg-gray-100 p-2 rounded-sm border border-gray-200 no-underline hover:bg-gray-200 transition-colors">
<div class="flex items-center gap-3">
<img
v-if="post.contentUrl && !hasArticleThumbError(post.id)"
:src="getArticleThumbProxyUrl(post.contentUrl)"
class="w-12 h-12 object-cover flex-shrink-0 bg-white"
alt=""
@error="onArticleThumbError(post.id)"
/>
<div v-else class="w-12 h-12 flex items-center justify-center bg-gray-200 text-gray-400 flex-shrink-0 text-xs">
文章
</div>
<div class="flex-1 flex flex-col justify-between overflow-hidden h-12">
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2">{{ post.title }}</div>
</div>
</div>
<div class="text-[11px] text-[#576b95] mt-1 pt-1 border-t border-gray-200/50">
公众号文章分享
</div>
</a>
</div>
<div v-else-if="post.type === 28 && post.finderFeed && Object.keys(post.finderFeed).length > 0" class="mt-2 max-w-[360px]" :class="{ 'privacy-blur': privacyMode }">
<div class="block bg-gray-100 p-2 rounded-sm border border-gray-200 no-underline hover:bg-gray-200 transition-colors">
<!-- 浏览器没有看微信视频号的环境暂时不进行跳转-->
<div class="flex items-start gap-3">
<div class="relative w-14 h-16 flex-shrink-0 bg-black overflow-hidden rounded-sm">
<img
v-if="post.finderFeed.thumbUrl"
:src="getProxyExternalUrl(post.finderFeed.thumbUrl)"
class="w-full h-full object-cover opacity-80"
alt="finder cover"
/>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg class="w-5 h-5 text-white/90" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="flex-1 flex flex-col overflow-hidden">
<div class="text-xs text-gray-500 truncate">{{ post.finderFeed.nickname }}</div>
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2 mt-[2px]">{{ post.finderFeed.desc || post.title }}</div>
</div>
</div>
<div class="text-[11px] text-[#576b95] mt-1 pt-1 border-t border-gray-200/50">
视频号 · 动态
</div>
</div>
</div>
<div v-else-if="post.media && post.media.length > 0" class="mt-2" :class="{ 'privacy-blur': privacyMode }">
<div v-if="post.media.length === 1" class="max-w-[360px]">
<div
v-if="!hasMediaError(post.id, 0) && getMediaThumbSrc(post, post.media[0], 0)"
class="inline-block cursor-pointer relative"
@click.stop="onMediaClick(post, post.media[0], 0)"
v-if="!hasMediaError(post.id, 0) && getMediaThumbSrc(post, post.media[0], 0)"
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
:src="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onMediaError(post.id, 0)"
v-else
:src="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onMediaError(post.id, 0)"
/>
<div
v-if="Number(post.media[0]?.type || 0) === 6"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
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">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
@@ -79,11 +163,11 @@
</div>
</div>
<div
v-else
class="w-[240px] h-[180px] rounded-sm bg-gray-100 border border-gray-200 flex items-center justify-center text-xs text-gray-400"
title="图片加载失败"
@click.stop="onMediaClick(post, post.media[0], 0)"
style="cursor: pointer;"
v-else
class="w-[240px] h-[180px] rounded-sm bg-gray-100 border border-gray-200 flex items-center justify-center text-xs text-gray-400"
title="图片加载失败"
@click.stop="onMediaClick(post, post.media[0], 0)"
style="cursor: pointer;"
>
图片加载失败
</div>
@@ -91,24 +175,39 @@
<div v-else class="grid grid-cols-3 gap-1 max-w-[360px]">
<div
v-for="(m, idx) in post.media.slice(0, 9)"
:key="idx"
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)"
v-for="(m, idx) in post.media.slice(0, 9)"
:key="idx"
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)"
:src="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onMediaError(post.id, 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=""
loading="lazy"
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>
@@ -171,16 +270,12 @@
</div>
</div>
<div v-if="hasMore" class="py-2">
<button
type="button"
class="w-full text-sm text-gray-600 py-2 rounded bg-white hover:bg-gray-50 border border-gray-200"
:disabled="isLoading"
@click="loadPosts({ reset: false })"
>
{{ isLoading ? '加载中…' : '加载更多' }}
</button>
</div>
<div v-if="isLoading && posts.length > 0" class="py-4 flex justify-center items-center">
<div class="w-5 h-5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-if="!hasMore && posts.length > 0" class="py-6 text-center text-xs text-gray-400">
到底了
</div>
</div>
</div>
</div>
@@ -209,68 +304,6 @@
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
<img :src="previewSrc" alt="预览" class="max-w-[90vw] max-h-[70vh] object-contain" />
<!-- 候选匹配面板仅在本地缓存匹配时有意义 -->
<div class="mt-3 w-full max-w-[90vw] rounded bg-black/35 text-white text-xs px-3 py-2">
<div class="flex items-center justify-between gap-2">
<div class="truncate">
候选匹配
<span v-if="previewCandidates.loading">加载中</span>
<span v-else-if="previewCandidates.count > 0"> {{ previewCandidates.count }} </span>
<span v-else>未找到本地候选可能仅能显示占位图</span>
<span v-if="previewEffectiveIdx != null" class="ml-2 text-white/80">当前#{{ Number(previewEffectiveIdx) + 1 }}</span>
<span v-if="previewHasUserOverride" class="ml-2 text-emerald-200">(已保存)</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button
type="button"
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
@click="toggleCandidatePanel"
>
{{ previewCandidatesOpen ? '收起' : '展开' }}
</button>
<button
v-if="previewHasUserOverride"
type="button"
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
@click="clearUserOverrideForPreview"
>
恢复自动
</button>
</div>
</div>
<div v-if="previewCandidates.error" class="mt-2 text-red-200 whitespace-pre-wrap">
{{ previewCandidates.error }}
</div>
<div v-if="previewCandidatesOpen && previewCandidates.count > 0" class="mt-2">
<div class="flex gap-2 overflow-x-auto pb-1">
<button
v-for="cand in previewCandidates.items"
:key="cand.idx"
type="button"
class="flex-shrink-0 w-24"
@click="selectCandidateForPreview(cand.idx)"
>
<div class="w-24 h-24 rounded bg-black/20 overflow-hidden border border-white/10">
<img :src="getPreviewCandidateSrc(cand.idx)" class="w-full h-full object-cover" alt="" />
</div>
<div class="mt-1 text-[11px] text-white/80">#{{ Number(cand.idx) + 1 }}</div>
</button>
</div>
<div v-if="previewCandidates.hasMore" class="mt-2">
<button
type="button"
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
:disabled="previewCandidates.loadingMore"
@click="loadMorePreviewCandidates"
>
{{ previewCandidates.loadingMore ? '加载中…' : '加载更多候选' }}
</button>
</div>
</div>
</div>
</div>
<button
@@ -295,7 +328,7 @@ useHead({ title: '朋友圈 - 微信数据分析助手' })
const api = useApi()
const chatAccounts = useChatAccountsStore()
const { selectedAccount, accounts: availableAccounts } = storeToRefs(chatAccounts)
const { selectedAccount } = storeToRefs(chatAccounts)
const privacyStore = usePrivacyStore()
const { privacyMode } = storeToRefs(privacyStore)
@@ -305,114 +338,12 @@ const hasMore = ref(true)
const isLoading = ref(false)
const error = ref('')
const coverData = ref(null)
const pageSize = 20
const mediaBase = process.client ? 'http://localhost:8000' : ''
// User overrides for SNS image matching (account-local, stored in localStorage).
const SNS_MEDIA_OVERRIDE_PREFIX = 'sns_media_override:v1:'
const SNS_MEDIA_OVERRIDE_REV_PREFIX = 'sns_media_override_rev:v1:'
const snsMediaOverrides = ref({})
const snsMediaOverrideRev = ref('0')
const snsOverrideStorageKey = (account) => `${SNS_MEDIA_OVERRIDE_PREFIX}${String(account || '').trim()}`
const snsOverrideRevStorageKey = (account) => `${SNS_MEDIA_OVERRIDE_REV_PREFIX}${String(account || '').trim()}`
const snsOverrideMediaKey = (postId, idx) => `${String(postId || '')}:${String(Number(idx) || 0)}`
const loadSnsMediaOverrides = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) {
snsMediaOverrides.value = {}
snsMediaOverrideRev.value = '0'
return
}
try {
const raw = localStorage.getItem(snsOverrideStorageKey(acc))
const parsed = raw ? JSON.parse(raw) : {}
snsMediaOverrides.value = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
snsMediaOverrides.value = {}
}
try {
const rev = localStorage.getItem(snsOverrideRevStorageKey(acc))
snsMediaOverrideRev.value = String(rev || '0')
} catch {
snsMediaOverrideRev.value = '0'
}
}
const saveSnsMediaOverrides = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
localStorage.setItem(snsOverrideStorageKey(acc), JSON.stringify(snsMediaOverrides.value || {}))
} catch {}
try {
localStorage.setItem(snsOverrideRevStorageKey(acc), String(snsMediaOverrideRev.value || '0'))
} catch {}
}
// Settings: avoid auto-using an image that was manually pinned to another SNS post.
const SNS_SNS_SETTINGS_PREFIX = 'sns_settings:v1:'
const snsAvoidOtherPicked = ref(true)
const snsAvoidOtherPickedStorageKey = (account) => `${SNS_SNS_SETTINGS_PREFIX}${String(account || '').trim()}:avoid_other_picked`
const loadSnsSettings = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
const raw = localStorage.getItem(snsAvoidOtherPickedStorageKey(acc))
if (raw == null || raw === '') return
snsAvoidOtherPicked.value = raw === '1' || raw === 'true'
} catch {}
}
const saveSnsSettings = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
localStorage.setItem(snsAvoidOtherPickedStorageKey(acc), snsAvoidOtherPicked.value ? '1' : '0')
} catch {}
}
const syncSnsMediaPicksToBackend = async () => {
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
await api.saveSnsMediaPicks({ account: acc, picks: snsMediaOverrides.value || {} })
} catch {}
}
const getSnsMediaOverridePick = (postId, idx) => {
const key = snsOverrideMediaKey(postId, idx)
const v = snsMediaOverrides.value?.[key]
return String(v || '').trim()
}
const setSnsMediaOverridePick = (postId, idx, pick) => {
if (!process.client) return
const key = snsOverrideMediaKey(postId, idx)
const v = String(pick || '').trim()
if (!v) {
if (snsMediaOverrides.value && Object.prototype.hasOwnProperty.call(snsMediaOverrides.value, key)) {
delete snsMediaOverrides.value[key]
}
} else {
snsMediaOverrides.value[key] = v
}
saveSnsMediaOverrides()
// Keep backend in sync so it can apply duplicate-avoidance logic.
// Then bump `pv` so other auto-matched images reload using the updated picks.
void syncSnsMediaPicksToBackend().finally(() => {
snsMediaOverrideRev.value = String(Date.now())
saveSnsMediaOverrides()
})
}
// Track failed images per-post, per-index to render placeholders instead of broken <img>.
const mediaErrors = ref({})
@@ -422,6 +353,34 @@ const onMediaError = (postId, idx) => {
mediaErrors.value[mediaErrorKey(postId, idx)] = true
}
const articleThumbErrors = ref({})
const hasArticleThumbError = (postId) => !!articleThumbErrors.value[postId]
const onArticleThumbError = (postId) => {
articleThumbErrors.value[postId] = true
}
const selfInfo = ref({ wxid: '', nickname: '' })
const loadSelfInfo = async () => {
if (!selectedAccount.value) return
try {
const resp = await $fetch(`${mediaBase}/api/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`)
if (resp && resp.wxid) {
selfInfo.value = resp
}
} catch (e) {
console.error('获取个人信息失败', e)
}
}
const getArticleThumbProxyUrl = (contentUrl) => {
const u = String(contentUrl || '').trim()
if (!u) return ''
return `${mediaBase}/api/sns/article_thumb?url=${encodeURIComponent(u)}`
}
// Right-click context menu (copy text / JSON) to help debug SNS parsing issues.
const contextMenu = ref({ visible: false, x: 0, y: 0, post: null })
@@ -506,6 +465,15 @@ const onCopyPostJsonClick = async () => {
}
}
const onScroll = (e) => {
const { scrollTop, clientHeight, scrollHeight } = e.target
if (scrollTop + clientHeight >= scrollHeight - 200) {
if (hasMore.value && !isLoading.value) {
loadPosts({ reset: false })
}
}
}
const postAvatarUrl = (username) => {
const acc = String(selectedAccount.value || '').trim()
const u = String(username || '').trim()
@@ -596,7 +564,7 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
const h = String(m?.size?.height || m?.size?.h || '').trim()
const ts = String(m?.size?.totalSize || m?.size?.total_size || m?.size?.total || '').trim()
const sizeIdx = mediaSizeGroupIndex(post, m, idx)
const pick = getSnsMediaOverridePick(post?.id, idx)
// const pick = getSnsMediaOverridePick(post?.id, idx)
let md5 = normalizeHex32(m?.urlAttrs?.md5 || m?.thumbAttrs?.md5 || m?.urlAttrs?.MD5 || m?.thumbAttrs?.MD5)
if (!md5) {
const match = /[?&]md5=([0-9a-fA-F]{16,32})/.exec(raw)
@@ -615,14 +583,13 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
const mid = String(m?.id || '').trim()
if (mid) parts.set('media_id', mid)
const mtype = String(m?.type || '').trim()
if (mtype) parts.set('media_type', mtype)
const postType = String(post?.type || '1').trim()
if (postType) parts.set('post_type', postType)
const mediaType = String(m?.type || '2').trim()
if (mediaType) parts.set('media_type', mediaType)
if (pick) parts.set('pick', pick)
if (!pick && snsAvoidOtherPicked.value) {
parts.set('avoid_picked', '1')
parts.set('pv', String(snsMediaOverrideRev.value || '0'))
}
if (md5) parts.set('md5', md5)
// Bump this when changing backend matching logic to avoid stale cached wrong images.
parts.set('v', '7')
@@ -643,6 +610,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)
@@ -670,52 +662,6 @@ const previewSrc = computed(() => {
return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx)
})
const previewHasUserOverride = computed(() => {
const ctx = previewCtx.value
if (!ctx) return false
return !!getSnsMediaOverridePick(ctx.post?.id, ctx.idx)
})
const previewEffectiveIdx = computed(() => {
const ctx = previewCtx.value
if (!ctx) return null
const pick = getSnsMediaOverridePick(ctx.post?.id, ctx.idx)
if (pick) {
const found = (previewCandidates.items || []).find((c) => String(c?.key || '') === pick)
if (found) return Number(found.idx)
return null
}
const baseIdx = mediaSizeGroupIndex(ctx.post, ctx.media, ctx.idx)
if (!snsAvoidOtherPicked.value) return baseIdx
const curPid = String(ctx.post?.id || '').trim()
if (!curPid) return baseIdx
// Mirror backend logic: skip candidates that were manually pinned to other posts.
const reserved = new Set()
try {
for (const [k, v] of Object.entries(snsMediaOverrides.value || {})) {
const pid = String(k || '').split(':', 1)[0].trim()
if (!pid || pid === curPid) continue
const key = String(v || '').trim()
if (key) reserved.add(key)
}
} catch {}
const items = Array.isArray(previewCandidates.items) ? [...previewCandidates.items] : []
items.sort((a, b) => Number(a?.idx || 0) - Number(b?.idx || 0))
for (const c of items) {
const i = Number(c?.idx)
const key = String(c?.key || '').trim()
if (!Number.isFinite(i) || i < baseIdx) continue
if (!key) continue
if (!reserved.has(key)) return i
}
return baseIdx
})
const toggleCandidatePanel = () => {
previewCandidatesOpen.value = !previewCandidatesOpen.value
}
const loadPreviewCandidates = async ({ reset }) => {
const ctx = previewCtx.value
@@ -787,64 +733,26 @@ const closeImagePreview = () => {
document.body.style.overflow = ''
}
const getPreviewCandidateSrc = (candIdx) => {
const ctx = previewCtx.value
const acc = String(selectedAccount.value || '').trim()
if (!ctx || !acc) return ''
const idxNum = Number(candIdx)
const cand = (previewCandidates.items || []).find((c) => Number(c?.idx) === idxNum)
const key = String(cand?.key || '').trim()
if (!key) return ''
const parts = new URLSearchParams()
parts.set('account', acc)
parts.set('pick', key)
const ct = String(ctx.post?.createTime || '').trim()
if (ct) parts.set('create_time', ct)
parts.set('v', '7')
return `${mediaBase}/api/sns/media?${parts.toString()}`
}
const selectCandidateForPreview = (candIdx) => {
const ctx = previewCtx.value
if (!ctx) return
const idxNum = Number(candIdx)
const cand = (previewCandidates.items || []).find((c) => Number(c?.idx) === idxNum)
const key = String(cand?.key || '').trim()
if (!key) return
setSnsMediaOverridePick(ctx.post?.id, ctx.idx, key)
// Allow <img> to retry after user switches candidates.
try {
delete mediaErrors.value[mediaErrorKey(ctx.post?.id, ctx.idx)]
} catch {}
}
const clearUserOverrideForPreview = () => {
const ctx = previewCtx.value
if (!ctx) return
setSnsMediaOverridePick(ctx.post?.id, ctx.idx, '')
try {
delete mediaErrors.value[mediaErrorKey(ctx.post?.id, ctx.idx)]
} catch {}
}
const loadMorePreviewCandidates = async () => {
if (previewCandidates.loading || previewCandidates.loadingMore) return
if (!previewCandidates.hasMore) return
await loadPreviewCandidates({ reset: false })
}
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)
}
@@ -887,10 +795,12 @@ const loadPosts = async ({ reset }) => {
offset
})
const items = resp?.timeline || []
if (reset) {
posts.value = items
posts.value = items.filter(p => p.type !== 7)
coverData.value = resp?.cover || null
} else {
posts.value = [...posts.value, ...items]
posts.value = [...posts.value, ...items.filter(p => p.type !== 7)]
}
hasMore.value = !!resp?.hasMore
} catch (e) {
@@ -900,35 +810,23 @@ const loadPosts = async ({ reset }) => {
}
}
watch(
() => selectedAccount.value,
async (v, oldV) => {
if (v && v !== oldV) {
// Account switch: reload overrides and reset preview state.
loadSnsMediaOverrides()
loadSnsSettings()
void syncSnsMediaPicksToBackend()
if (previewCtx.value) closeImagePreview()
await loadPosts({ reset: true })
} else if (!v) {
snsMediaOverrides.value = {}
}
}
)
watch(
() => snsAvoidOtherPicked.value,
() => {
saveSnsSettings()
}
() => selectedAccount.value,
async (v, oldV) => {
if (v && v !== oldV) {
if (previewCtx.value) closeImagePreview()
await loadSelfInfo()
await loadPosts({ reset: true })
}
},
{ immediate: true }
)
onMounted(async () => {
privacyStore.init()
await loadAccounts()
loadSnsMediaOverrides()
loadSnsSettings()
void syncSnsMediaPicksToBackend()
})
const onGlobalClick = () => {
@@ -954,4 +852,13 @@ onUnmounted(() => {
document.removeEventListener('click', onGlobalClick)
document.removeEventListener('keydown', onGlobalKeyDown)
})
const getProxyExternalUrl = (url) => {
// 目前难以计算enc代理获取封面图thumbnail
const u = String(url || '').trim()
if (!u) return ''
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}`
}
</script>

View File

@@ -4,13 +4,15 @@ from pathlib import Path
import hashlib
import json
import re
import httpx
import html # 修复&amp;转义的问题!!!
import sqlite3
import time
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
@@ -93,12 +95,17 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any]
"media": [],
"likes": [],
"comments": [],
"type": 1, # 默认类型
"title": "",
"contentUrl": "",
"finderFeed": {}
}
xml_str = str(xml_text or "").strip()
if not xml_str:
return out
try:
root = ET.fromstring(xml_str)
except Exception:
@@ -113,54 +120,72 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any]
if isinstance(v, str) and v.strip():
return v.strip()
return ""
# &amp转义
def _clean_url(u: str) -> str:
if not u:
return ""
out["username"] = (
_find_text(".//TimelineObject/username", ".//TimelineObject/user_name", ".//TimelineObject/userName", ".//username")
or fallback_username
)
cleaned = html.unescape(u)
cleaned = cleaned.replace("&amp;", "&")
return cleaned.strip()
out["username"] = _find_text(".//TimelineObject/username", ".//TimelineObject/user_name",
".//username") or fallback_username
out["createTime"] = _safe_int(_find_text(".//TimelineObject/createTime", ".//createTime"))
out["contentDesc"] = _find_text(".//TimelineObject/contentDesc", ".//contentDesc")
out["location"] = _build_location_text(root.find(".//location"))
# --- 提取内容类型 ---
post_type = _safe_int(_find_text(".//ContentObject/type", ".//type"))
out["type"] = post_type
# --- 如果是公众号文章 (Type 3) ---
if post_type == 3:
out["title"] = _find_text(".//ContentObject/title")
out["contentUrl"] = _clean_url(_find_text(".//ContentObject/contentUrl"))
# --- 如果是视频号 (Type 28) ---
if post_type == 28:
out["title"] = _find_text(".//ContentObject/title")
out["contentUrl"] = _clean_url(_find_text(".//ContentObject/contentUrl"))
out["finderFeed"] = {
"nickname": _find_text(".//finderFeed/nickname"),
"desc": _find_text(".//finderFeed/desc"),
"thumbUrl": _clean_url(
_find_text(".//finderFeed/mediaList/media/thumbUrl", ".//finderFeed/mediaList/media/coverUrl")),
"url": _clean_url(_find_text(".//finderFeed/mediaList/media/url"))
}
media: list[dict[str, Any]] = []
try:
for m in root.findall(".//mediaList//media"):
mt = _safe_int(m.findtext("type"))
url_el = m.find("url") if m.find("url") is not None else m.find("urlV")
thumb_el = m.find("thumb") if m.find("thumb") is not None else m.find("thumbV")
# WeChat stores important download/auth hints in attributes (key/enc_idx/token/md5...).
# NOTE: xml.etree.ElementTree.Element is falsy when it has no children.
# So we must check `is None` instead of using `or`, otherwise `<url>` would be treated as missing.
url_el = m.find("url")
if url_el is None:
url_el = m.find("urlV")
thumb_el = m.find("thumb")
if thumb_el is None:
thumb_el = m.find("thumbV")
url = str((url_el.text if url_el is not None else "") or "").strip()
thumb = str((thumb_el.text if thumb_el is not None else "") or "").strip()
url = _clean_url(url_el.text if url_el is not None else "")
thumb = _clean_url(thumb_el.text if thumb_el is not None else "")
url_attrs = dict(url_el.attrib) if url_el is not None and url_el.attrib else {}
thumb_attrs = dict(thumb_el.attrib) if thumb_el is not None and thumb_el.attrib else {}
media_id = str(m.findtext("id") or "").strip()
size_el = m.find("size")
size = dict(size_el.attrib) if size_el is not None and size_el.attrib else {}
if not url and not thumb:
continue
media.append(
{
"type": mt,
"id": media_id,
"url": url,
"thumb": thumb,
"urlAttrs": url_attrs,
"thumbAttrs": thumb_attrs,
"size": size,
}
)
media.append({
"type": mt,
"id": media_id,
"url": url,
"thumb": thumb,
"urlAttrs": url_attrs,
"thumbAttrs": thumb_attrs,
"size": size,
})
except Exception:
media = []
pass
out["media"] = media
likes: list[str] = []
@@ -424,6 +449,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(
*,
@@ -686,6 +763,133 @@ def _list_sns_cached_image_candidate_keys(
return tuple(out)
def _get_sns_cover(account_dir: Path, target_wxid: str) -> Optional[dict[str, Any]]:
"""无论多古老,强行揪出用户最近的一次朋友圈封面 (type=7)"""
cover_sql = f"SELECT tid, content FROM SnsTimeLine WHERE user_name = '{target_wxid}' AND content LIKE '%<type>7</type>%' ORDER BY tid DESC LIMIT 1"
cover_xml = None
cover_tid = None
try:
if WCDB_REALTIME.is_connected(account_dir.name):
conn = WCDB_REALTIME.ensure_connected(account_dir)
with conn.lock:
sns_db_path = conn.db_storage_dir / "sns" / "sns.db"
if not sns_db_path.exists():
sns_db_path = conn.db_storage_dir / "sns.db"
# 利用 exec_query 强行查
rows = _wcdb_exec_query(conn.handle, kind="media", path=str(sns_db_path), sql=cover_sql)
if rows:
cover_xml = rows[0].get("content")
cover_tid = rows[0].get("tid")
except Exception as e:
logger.warning(f"[sns] WCDB cover fetch failed: {e}")
# 2. 如果没查到,降级从本地解密的 sns.db 查
if not cover_xml:
sns_db_path = account_dir / "sns.db"
if sns_db_path.exists():
try:
# 只读模式防止锁死
conn_sq = sqlite3.connect(f"file:{sns_db_path}?mode=ro", uri=True)
conn_sq.row_factory = sqlite3.Row
row = conn_sq.execute(cover_sql).fetchone()
if row:
cover_xml = str(row["content"] or "")
cover_tid = row["tid"]
conn_sq.close()
except Exception as e:
logger.warning(f"[sns] SQLite cover fetch failed: {e}")
if cover_xml:
parsed = _parse_timeline_xml(cover_xml, target_wxid)
return {
"id": str(cover_tid or ""),
"media": parsed.get("media", []),
"type": 7
}
return None
@router.get("/api/sns/self_info", summary="获取个人信息wxid和nickname")
def api_sns_self_info(account: Optional[str] = None):
account_dir = _resolve_account_dir(account)
wxid = account_dir.name
logger.info(f"[self_info] 开始获取账号信息, 预设 wxid: {wxid}")
nickname = wxid
source = "wxid_dir"
try:
status = WCDB_REALTIME.get_status(account_dir)
if status.get("dll_present") and status.get("key_present"):
rt_conn = WCDB_REALTIME.ensure_connected(account_dir)
with rt_conn.lock:
names_map = _wcdb_get_display_names(rt_conn.handle, [wxid])
if names_map and names_map.get(wxid):
nickname = names_map[wxid]
source = "wcdb_realtime"
logger.info(f"[self_info] 从 WCDB 实时连接获取成功: {nickname}")
return {"wxid": wxid, "nickname": nickname, "source": source}
except Exception as e:
logger.debug(f"[self_info] WCDB 路径跳过或失败: {e}")
contact_db_path = account_dir / "contact.db"
if contact_db_path.exists():
conn = None
try:
db_uri = f"file:{contact_db_path}?mode=ro"
conn = sqlite3.connect(db_uri, uri=True, timeout=5)
conn.row_factory = sqlite3.Row
cursor = conn.execute("PRAGMA table_info(contact)")
cols = {row["name"].lower() for row in cursor.fetchall()}
logger.debug(f"[self_info] contact 表现有字段: {cols}")
target_nick_col = "nick_name" if "nick_name" in cols else ("nickname" if "nickname" in cols else None)
if target_nick_col:
sql = f"SELECT remark, {target_nick_col} as nickname_val, alias FROM contact WHERE username = ? LIMIT 1"
row = conn.execute(sql, (wxid,)).fetchone()
if row:
raw_remark = str(row["remark"] or "").strip() if "remark" in row.keys() else ""
raw_nick = str(row["nickname_val"] or "").strip()
raw_alias = str(row["alias"] or "").strip() if "alias" in row.keys() else ""
if raw_remark:
nickname = raw_remark
source = "contact_db_remark"
elif raw_nick:
nickname = raw_nick
source = "contact_db_nickname"
elif raw_alias:
nickname = raw_alias
source = "contact_db_alias"
logger.info(f"[self_info] 从数据库提取成功: {nickname} (src: {source})")
else:
logger.warning("[self_info] contact 表中找不到任何昵称相关字段")
except sqlite3.OperationalError as e:
logger.error(f"[self_info] 数据库繁忙或锁定: {e}")
except Exception as e:
logger.exception(f"[self_info] 查询异常: {e}")
finally:
if conn: conn.close()
else:
logger.warning(f"[self_info] 找不到 contact.db: {contact_db_path}")
return {
"wxid": wxid,
"nickname": nickname,
"source": source
}
@router.get("/api/sns/timeline", summary="获取朋友圈时间线")
def list_sns_timeline(
@@ -708,6 +912,11 @@ def list_sns_timeline(
users = _parse_csv_list(usernames)
kw = str(keyword or "").strip()
cover_data = None
if offset == 0:
target_wxid = users[0] if users else account_dir.name
cover_data = _get_sns_cover(account_dir, target_wxid)
# Prefer real-time WCDB access (reads the latest encrypted db_storage/sns/sns.db).
# Fallback to the decrypted sqlite copy in output/{account}/sns.db.
try:
@@ -789,6 +998,11 @@ def list_sns_timeline(
# Enrich with parsed XML when available.
location = str(r.get("location") or "")
post_type = 1
title = ""
content_url = ""
finder_feed = {}
try:
tid_u = int(r.get("id") or 0)
tid_s = (tid_u & 0xFFFFFFFFFFFFFFFF)
@@ -799,6 +1013,16 @@ def list_sns_timeline(
parsed = _parse_timeline_xml(xml, uname)
if parsed.get("location"):
location = str(parsed.get("location") or "")
post_type = parsed.get("type", 1)
if post_type == 7: # 朋友圈封面
continue
title = parsed.get("title", "")
content_url = parsed.get("contentUrl", "")
finder_feed = parsed.get("finderFeed", {})
pmedia = parsed.get("media") or []
if isinstance(pmedia, list) and isinstance(media, list) and pmedia:
# Merge by index (best-effort).
@@ -835,10 +1059,21 @@ def list_sns_timeline(
"media": media,
"likes": likes,
"comments": comments,
"type": post_type,
"title": title,
"contentUrl": content_url,
"finderFeed": finder_feed,
}
)
return {"timeline": timeline, "hasMore": has_more, "limit": limit, "offset": offset, "source": "wcdb"}
return {
"timeline": timeline,
"hasMore": has_more,
"limit": limit,
"offset": offset,
"source": "wcdb",
"cover": cover_data,
}
except WCDBRealtimeError as e:
logger.info("[sns] wcdb realtime unavailable: %s", e)
except Exception as e:
@@ -911,10 +1146,20 @@ def list_sns_timeline(
"media": parsed.get("media") or [],
"likes": parsed.get("likes") or [],
"comments": parsed.get("comments") or [],
"type": parsed.get("type", 1),
"title": parsed.get("title", ""),
"contentUrl": parsed.get("contentUrl", ""),
"finderFeed": parsed.get("finderFeed", {}),
}
)
return {"timeline": timeline, "hasMore": has_more, "limit": limit, "offset": offset}
return {
"timeline": timeline,
"hasMore": has_more,
"limit": limit,
"offset": offset,
"cover": cover_data,
}
class SnsMediaPicksSaveRequest(BaseModel):
@@ -969,6 +1214,7 @@ async def get_sns_media(
avoid_picked: int = 0,
post_id: Optional[str] = None,
media_id: Optional[str] = None,
post_type: int = 1,
media_type: int = 2,
pick: Optional[str] = None,
md5: Optional[str] = None,
@@ -978,25 +1224,58 @@ async def get_sns_media(
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir and post_id and media_id:
deterministic_key = _generate_sns_cache_key(post_id, media_id, media_type)
if int(post_type) == 7:
raw_key = f"{post_id}_{media_id}_4" # 硬编码
md5_str = hashlib.md5(raw_key.encode("utf-8")).hexdigest()
bkg_path = wxid_dir / "business" / "sns" / "bkg" / md5_str[:2] / md5_str
if bkg_path.exists() and bkg_path.is_file():
print(f"===== Hit Bkg Cover ======= {bkg_path}")
return FileResponse(bkg_path, media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=31536000"})
exact_match_path = None
hit_type = ""
# 尝试 1: 使用 post_type 计算 MD5
key_post = _generate_sns_cache_key(post_id, media_id, post_type)
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=deterministic_key,
cache_key=key_post,
create_time=0
)
if exact_match_path:
hit_type = "post_type"
# 尝试 2: 如果没找到,并且 media_type 和 post_type 不一样,再试一次
if not exact_match_path and post_type != media_type:
key_media = _generate_sns_cache_key(post_id, media_id, media_type)
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=key_media,
create_time=0
)
if exact_match_path:
hit_type = "media_type"
# 如果通过这两种精确定位找到了文件,直接返回
if exact_match_path:
print(f"=====exact_match_path======={exact_match_path}============= (Hit: {hit_type})")
try:
payload, mtype = _read_and_maybe_decrypt_media(Path(exact_match_path), account_dir)
if payload and str(mtype or "").startswith("image/"):
resp = Response(content=payload, media_type=str(mtype or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=31536000" # 确定性缓存可以设置很久
resp.headers["Cache-Control"] = "public, max-age=31536000"
resp.headers["X-SNS-Source"] = "deterministic-hash"
# 在 Header 里塞入到底是哪个 type 命中的,方便 F12 调试
resp.headers["X-SNS-Hit-Type"] = hit_type
return resp
except Exception:
pass
print("no exact match path, falling back...")
# 0) User-picked cache key override (stable across candidate ordering).
pick_key = _normalize_hex32(pick)
if pick_key:
@@ -1105,3 +1384,60 @@ async def get_sns_media(
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Fetch sns media failed: {e}")
@router.get("/api/sns/article_thumb", summary="提取公众号文章封面图")
async def proxy_article_thumb(url: str):
u = str(url or "").strip()
if not u.startswith("http"):
raise HTTPException(status_code=400, detail="Invalid URL")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
resp = await client.get(u, headers=headers)
resp.raise_for_status()
html_text = resp.text
match = re.search(r'["\'](https?://[^"\']*?mmbiz_[a-zA-Z]+[^"\']*?)["\']', html_text)
if not match:
raise HTTPException(status_code=404, detail="未在 HTML 中找到图片 URL")
img_url = match.group(1)
img_url = html.unescape(img_url).replace("&amp;", "&")
img_resp = await client.get(img_url, headers=headers)
img_resp.raise_for_status()
return Response(
content=img_resp.content,
media_type=img_resp.headers.get("Content-Type", "image/jpeg")
)
except Exception as e:
logger.warning(f"[sns] 提取公众号封面失败 url={u[:50]}... : {e}")
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")