mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-15 16:38:58 +08:00
fix: 增强视频定位能力,支持从 rawContent 提取备用 MD5
问题:部分视频消息的 videoMd5 字段为空或与实际文件名不匹配, 导致视频显示「不可用」。 变更内容: electron/services/videoService.ts - getVideoInfo 新增 rawContent 参数,从消息原始 XML 中提取 newmd5 / rawmd5 等备用 MD5 字段作为候选 - 返回 diagnostics 诊断信息(candidateMd5s、matchedMd5、reason、summary 等), 便于前端展示失败原因和调试 electron/services/chatService.ts - extractVideoMd5 新增对 newmd5 / rawmd5 XML 字段的提取支持 electron/services/exportService.ts / httpApiFacade.ts / httpApiService.ts - getVideoInfo 调用处透传 rawContent 参数 electron/services/mcp/readService.ts - getVideoLocalPath 支持 rawContent 为空时 videoMd5 也为空的情况 electron/services/imageDecryptService.ts - 新增 hdNotFoundCache,避免高清图重复查询 electron/main.ts / electron/preload.ts / src/types/electron.d.ts - IPC 接口同步更新,透传 rawContent 和 diagnostics src/pages/ChatPage.tsx - 视频缓存 key 改为 videoMd5 || local:localId,兼容无 MD5 的消息 - 视频不可用时展示 diagnostics.summary 诊断文案 - 详情面板新增关闭动画(closing 状态 + 220ms 延迟) - 视频播放按钮图标调整 src/pages/ChatPage.scss - 详情面板改为绝对定位浮层,新增 slideOutRight 关闭动画 - 工具栏按钮改为圆形,悬停加 scale 效果 - 视频不可用区域新增 .video-reason 样式 src/pages/VideoWindow.tsx - 播放图标尺寸调整
This commit is contained in:
+15
-2
@@ -2278,11 +2278,24 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
// 视频相关
|
||||
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
|
||||
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, rawContent?: string) => {
|
||||
try {
|
||||
const result = videoService.getVideoInfo(videoMd5)
|
||||
console.log('[VideoIPC] getVideoInfo request', {
|
||||
videoMd5,
|
||||
hasRawContent: Boolean(rawContent)
|
||||
})
|
||||
const result = videoService.getVideoInfo(videoMd5, rawContent)
|
||||
console.log('[VideoIPC] getVideoInfo response', {
|
||||
videoMd5,
|
||||
exists: result.exists,
|
||||
diagnostics: result.diagnostics
|
||||
})
|
||||
return { success: true, ...result }
|
||||
} catch (e) {
|
||||
console.error('[VideoIPC] getVideoInfo error', {
|
||||
videoMd5,
|
||||
error: String(e)
|
||||
})
|
||||
return { success: false, error: String(e), exists: false }
|
||||
}
|
||||
})
|
||||
|
||||
+1
-1
@@ -286,7 +286,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 视频
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||
getVideoInfo: (videoMd5: string, rawContent?: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, rawContent),
|
||||
readFile: (videoPath: string) => ipcRenderer.invoke('video:readFile', videoPath),
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content),
|
||||
parseChannelVideo: (content: string) => ipcRenderer.invoke('video:parseChannelVideo', content),
|
||||
|
||||
@@ -2682,6 +2682,10 @@ class ChatService extends EventEmitter {
|
||||
const md5 =
|
||||
this.extractXmlValue(content, 'md5') ||
|
||||
this.extractXmlAttribute(content, 'videomsg', 'md5') ||
|
||||
this.extractXmlValue(content, 'newmd5') ||
|
||||
this.extractXmlAttribute(content, 'videomsg', 'newmd5') ||
|
||||
this.extractXmlValue(content, 'rawmd5') ||
|
||||
this.extractXmlAttribute(content, 'videomsg', 'rawmd5') ||
|
||||
undefined
|
||||
|
||||
return md5?.toLowerCase()
|
||||
|
||||
@@ -2394,7 +2394,7 @@ class ExportService {
|
||||
try {
|
||||
const videoMd5 = videoService.parseVideoMd5(content)
|
||||
if (videoMd5) {
|
||||
const videoInfo = videoService.getVideoInfo(videoMd5)
|
||||
const videoInfo = videoService.getVideoInfo(videoMd5, content)
|
||||
if (videoInfo.exists && videoInfo.videoUrl) {
|
||||
const videoPath = videoInfo.videoUrl.replace(/^file:\/\/\//i, '').replace(/\//g, path.sep)
|
||||
if (fs.existsSync(videoPath)) {
|
||||
|
||||
@@ -667,7 +667,7 @@ export async function queryMessages(input: QueryMessagesInput) {
|
||||
|
||||
if (shouldResolveMediaPath && kind.messageKind === 'video' && base.videoMd5) {
|
||||
try {
|
||||
const videoInfo = videoService.getVideoInfo(String(base.videoMd5))
|
||||
const videoInfo = videoService.getVideoInfo(String(base.videoMd5), String(base.rawContent || ''))
|
||||
if (videoInfo.exists && videoInfo.videoUrl) {
|
||||
media.videoCachePath = fileUrlToPathMaybe(videoInfo.videoUrl)
|
||||
}
|
||||
|
||||
@@ -923,7 +923,7 @@ class HttpApiService {
|
||||
|
||||
if (shouldResolveMediaPath && kind.messageKind === 'video' && base.videoMd5) {
|
||||
try {
|
||||
const videoInfo = videoService.getVideoInfo(String(base.videoMd5))
|
||||
const videoInfo = videoService.getVideoInfo(String(base.videoMd5), String(base.rawContent || ''))
|
||||
if (videoInfo.exists && videoInfo.videoUrl) {
|
||||
media.videoCachePath = this.fileUrlToPathMaybe(videoInfo.videoUrl)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export class ImageDecryptService {
|
||||
private cacheIndexing: Promise<void> | null = null
|
||||
private updateFlags = new Map<string, boolean>()
|
||||
private notFoundCache = new Set<string>() // 失败缓存,避免重复查询
|
||||
private hdNotFoundCache = new Set<string>() // 高清图失败缓存
|
||||
private nativeLogged = false
|
||||
|
||||
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
@@ -138,6 +139,9 @@ export class ImageDecryptService {
|
||||
|
||||
// 即使 force=true,也先检查是否有高清图缓存
|
||||
if (payload.force) {
|
||||
if (this.hdNotFoundCache.has(cacheKey)) {
|
||||
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
|
||||
}
|
||||
// 快速查找高清图缓存
|
||||
const hdCached = this.findCachedOutputFast(cacheKey, payload.sessionId, true) ||
|
||||
this.findCachedOutput(cacheKey, payload.sessionId, true)
|
||||
@@ -220,6 +224,7 @@ export class ImageDecryptService {
|
||||
|
||||
// 如果要求高清图但没找到,直接返回提示
|
||||
if (!datPath && payload.force) {
|
||||
this.hdNotFoundCache.add(cacheKey)
|
||||
console.warn(`[ImageDecrypt] 未找到高清图: ${payload.imageDatName || payload.imageMd5}`)
|
||||
this.logDecryptTiming({
|
||||
cacheKey,
|
||||
|
||||
@@ -1047,10 +1047,10 @@ async function getImageLocalPath(sessionId: string, message: Message): Promise<s
|
||||
}
|
||||
|
||||
function getVideoLocalPath(message: Message): string | null {
|
||||
if (!message.videoMd5) return null
|
||||
if (!message.videoMd5 && !message.rawContent) return null
|
||||
|
||||
try {
|
||||
const info = videoService.getVideoInfo(String(message.videoMd5))
|
||||
const info = videoService.getVideoInfo(String(message.videoMd5 || ''), String(message.rawContent || ''))
|
||||
return info.exists ? info.videoUrl || null : null
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -14,6 +14,20 @@ export interface VideoInfo {
|
||||
coverUrl?: string // 封面 data URL
|
||||
thumbUrl?: string // 缩略�?data URL
|
||||
exists: boolean
|
||||
diagnostics?: VideoLookupDiagnostics
|
||||
}
|
||||
|
||||
export interface VideoLookupDiagnostics {
|
||||
requestedMd5?: string
|
||||
candidateMd5s?: string[]
|
||||
searchedFileKeys?: string[]
|
||||
matchedMd5?: string
|
||||
hardlinkMatchedMd5?: string
|
||||
hardlinkDbPath?: string
|
||||
accountDir?: string
|
||||
videoBaseDir?: string
|
||||
reason?: 'missing_input' | 'missing_config' | 'account_dir_not_found' | 'video_dir_missing' | 'local_file_missing'
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export interface ChannelVideoInfo {
|
||||
@@ -50,6 +64,21 @@ class VideoService {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private logVideoLookup(stage: string, payload: Record<string, unknown> = {}): void {
|
||||
console.log(`[VideoLookup] ${stage}`, payload)
|
||||
}
|
||||
|
||||
private warnVideoLookup(stage: string, payload: Record<string, unknown> = {}): void {
|
||||
console.warn(`[VideoLookup] ${stage}`, payload)
|
||||
}
|
||||
|
||||
private previewRawContent(content?: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
const normalized = content.replace(/\s+/g, ' ').trim()
|
||||
if (!normalized) return undefined
|
||||
return normalized.slice(0, 220)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库根目录
|
||||
*/
|
||||
@@ -95,10 +124,147 @@ class VideoService {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private normalizeMd5(value?: string | null): string | undefined {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (!normalized || !/^[a-f0-9]{8,}$/.test(normalized)) return undefined
|
||||
return normalized
|
||||
}
|
||||
|
||||
private addMd5Candidate(candidates: string[], value?: string | null): void {
|
||||
const normalized = this.normalizeMd5(value)
|
||||
if (!normalized || candidates.includes(normalized)) return
|
||||
candidates.push(normalized)
|
||||
}
|
||||
|
||||
private extractVideoMsgAttribute(content: string, attrName: string): string | undefined {
|
||||
const match = new RegExp(`<videomsg[^>]*\\s${attrName}\\s*=\\s*['"]([a-fA-F0-9]+)['"]`, 'i').exec(content)
|
||||
return this.normalizeMd5(match?.[1])
|
||||
}
|
||||
|
||||
private extractVideoXmlValue(content: string, tagName: string): string | undefined {
|
||||
const match = new RegExp(`<${tagName}>\\s*([a-fA-F0-9]+)\\s*<\\/${tagName}>`, 'i').exec(content)
|
||||
return this.normalizeMd5(match?.[1])
|
||||
}
|
||||
|
||||
private collectVideoMd5Candidates(content?: string, preferredMd5?: string): string[] {
|
||||
const candidates: string[] = []
|
||||
|
||||
this.addMd5Candidate(candidates, preferredMd5)
|
||||
if (!content) return candidates
|
||||
|
||||
this.addMd5Candidate(candidates, this.extractVideoMsgAttribute(content, 'newmd5'))
|
||||
this.addMd5Candidate(candidates, this.extractVideoMsgAttribute(content, 'md5'))
|
||||
this.addMd5Candidate(candidates, this.extractVideoMsgAttribute(content, 'rawmd5'))
|
||||
this.addMd5Candidate(candidates, this.extractVideoXmlValue(content, 'newmd5'))
|
||||
this.addMd5Candidate(candidates, this.extractVideoXmlValue(content, 'md5'))
|
||||
this.addMd5Candidate(candidates, this.extractVideoXmlValue(content, 'rawmd5'))
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
private formatMd5CandidateSummary(values: string[]): string {
|
||||
return values
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
.map(value => value.slice(0, 8))
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
private buildLookupSummary(
|
||||
reason: VideoLookupDiagnostics['reason'],
|
||||
diagnostics: Pick<VideoLookupDiagnostics, 'candidateMd5s' | 'searchedFileKeys' | 'hardlinkMatchedMd5'>
|
||||
): string {
|
||||
switch (reason) {
|
||||
case 'missing_input':
|
||||
return '缺少视频 MD5'
|
||||
case 'missing_config':
|
||||
return '缺少数据库路径或微信账号'
|
||||
case 'account_dir_not_found':
|
||||
return '未找到账号目录'
|
||||
case 'video_dir_missing':
|
||||
return '未找到 msg/video 目录'
|
||||
case 'local_file_missing': {
|
||||
if (diagnostics.hardlinkMatchedMd5) {
|
||||
const resolved = this.formatMd5CandidateSummary(diagnostics.searchedFileKeys || [])
|
||||
return resolved ? `hardlink 已命中,但本地文件缺失 ${resolved}` : 'hardlink 已命中,但本地文件缺失'
|
||||
}
|
||||
|
||||
const summary = this.formatMd5CandidateSummary(diagnostics.candidateMd5s || [])
|
||||
return summary ? `未命中本地缓存,候选 ${summary}` : '未命中本地缓存'
|
||||
}
|
||||
default:
|
||||
return '视频不可用'
|
||||
}
|
||||
}
|
||||
|
||||
private isDirectory(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isAccountDir(path: string): boolean {
|
||||
return (
|
||||
existsSync(join(path, 'msg')) ||
|
||||
existsSync(join(path, 'db_storage')) ||
|
||||
existsSync(join(path, 'hardlink.db'))
|
||||
)
|
||||
}
|
||||
|
||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
const directCandidates = new Set<string>([
|
||||
normalized,
|
||||
join(normalized, wxid)
|
||||
])
|
||||
|
||||
if (cleanedWxid !== wxid) {
|
||||
directCandidates.add(join(normalized, cleanedWxid))
|
||||
}
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (this.isAccountDir(candidate)) return candidate
|
||||
}
|
||||
|
||||
if (!this.isDirectory(normalized)) return null
|
||||
|
||||
try {
|
||||
const entries = readdirSync(normalized)
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(normalized, entry)
|
||||
if (!this.isDirectory(entryPath)) continue
|
||||
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
const cleanedEntry = this.cleanWxid(entry).toLowerCase()
|
||||
if (
|
||||
lowerEntry === wxidLower ||
|
||||
lowerEntry === cleanedWxidLower ||
|
||||
lowerEntry.startsWith(`${wxidLower}_`) ||
|
||||
lowerEntry.startsWith(`${cleanedWxidLower}_`) ||
|
||||
cleanedEntry === wxidLower ||
|
||||
cleanedEntry === cleanedWxidLower
|
||||
) {
|
||||
if (this.isAccountDir(entryPath)) return entryPath
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* �?video_hardlink_info_v4 表查询视频文件名
|
||||
*/
|
||||
private queryVideoFileName(md5: string): string | undefined {
|
||||
private resolveHardlinkDbPath(): string | undefined {
|
||||
const cachePath = this.getCachePath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
@@ -135,29 +301,62 @@ class VideoService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!hardlinkDbPath) return undefined
|
||||
return hardlinkDbPath
|
||||
}
|
||||
|
||||
try {
|
||||
const db = new Database(hardlinkDbPath, { readonly: true })
|
||||
|
||||
// 查询视频文件�?
|
||||
const row = db.prepare(`
|
||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||
WHERE md5 = ?
|
||||
LIMIT 1
|
||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
||||
|
||||
db.close()
|
||||
|
||||
if (row?.file_name) {
|
||||
// 提取不带扩展名的文件名作�?MD5
|
||||
return row.file_name.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
private queryVideoFileNames(md5Candidates: string[]): {
|
||||
fileKeys: string[]
|
||||
hardlinkDbPath?: string
|
||||
hardlinkMatchedMd5?: string
|
||||
} {
|
||||
const hardlinkDbPath = this.resolveHardlinkDbPath()
|
||||
if (!hardlinkDbPath || md5Candidates.length === 0) {
|
||||
return { fileKeys: [], hardlinkDbPath }
|
||||
}
|
||||
|
||||
return undefined
|
||||
let db: Database.Database | null = null
|
||||
|
||||
try {
|
||||
db = new Database(hardlinkDbPath, { readonly: true })
|
||||
const stmt = db.prepare(`
|
||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||
WHERE md5 = ?
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const fileKeys: string[] = []
|
||||
let hardlinkMatchedMd5: string | undefined
|
||||
|
||||
for (const md5 of md5Candidates) {
|
||||
const row = stmt.get(md5) as { file_name?: string; md5?: string } | undefined
|
||||
const normalizedFileKey = this.normalizeMd5(row?.file_name?.replace(/\.[^.]+$/, ''))
|
||||
if (!normalizedFileKey) continue
|
||||
|
||||
if (!hardlinkMatchedMd5) {
|
||||
hardlinkMatchedMd5 = this.normalizeMd5(row?.md5) || md5
|
||||
}
|
||||
|
||||
this.addMd5Candidate(fileKeys, normalizedFileKey)
|
||||
}
|
||||
|
||||
this.logVideoLookup('hardlink-query', {
|
||||
hardlinkDbPath,
|
||||
md5Candidates,
|
||||
fileKeys,
|
||||
hardlinkMatchedMd5
|
||||
})
|
||||
|
||||
return { fileKeys, hardlinkDbPath, hardlinkMatchedMd5 }
|
||||
} catch (error) {
|
||||
this.warnVideoLookup('hardlink-query-error', {
|
||||
hardlinkDbPath,
|
||||
md5Candidates,
|
||||
error: String(error)
|
||||
})
|
||||
return { fileKeys: [], hardlinkDbPath }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,21 +377,83 @@ class VideoService {
|
||||
* 视频存放�? {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
getVideoInfo(videoMd5: string): VideoInfo {
|
||||
getVideoInfo(videoMd5: string, rawContent?: string): VideoInfo {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
if (!dbPath || !wxid || !videoMd5) {
|
||||
return { exists: false }
|
||||
const requestedMd5 = this.normalizeMd5(videoMd5)
|
||||
const candidateMd5s = this.collectVideoMd5Candidates(rawContent, requestedMd5)
|
||||
const diagnostics: VideoLookupDiagnostics = {
|
||||
requestedMd5,
|
||||
candidateMd5s
|
||||
}
|
||||
|
||||
// 先尝试从数据库查询真正的视频文件�?
|
||||
const realVideoMd5 = this.queryVideoFileName(videoMd5) || videoMd5
|
||||
this.logVideoLookup('request', {
|
||||
requestedMd5,
|
||||
candidateMd5s,
|
||||
hasRawContent: Boolean(rawContent),
|
||||
rawPreview: this.previewRawContent(rawContent),
|
||||
dbPath,
|
||||
wxid
|
||||
})
|
||||
|
||||
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
||||
if (candidateMd5s.length === 0) {
|
||||
diagnostics.reason = 'missing_input'
|
||||
diagnostics.summary = this.buildLookupSummary('missing_input', diagnostics)
|
||||
this.warnVideoLookup('missing-input', diagnostics)
|
||||
return { exists: false, diagnostics }
|
||||
}
|
||||
|
||||
if (!dbPath || !wxid) {
|
||||
diagnostics.reason = 'missing_config'
|
||||
diagnostics.summary = this.buildLookupSummary('missing_config', diagnostics)
|
||||
this.warnVideoLookup('missing-config', diagnostics)
|
||||
return { exists: false, diagnostics }
|
||||
}
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
diagnostics.accountDir = accountDir || undefined
|
||||
|
||||
this.logVideoLookup('account-dir', {
|
||||
requestedMd5,
|
||||
accountDir,
|
||||
dbPath,
|
||||
wxid
|
||||
})
|
||||
|
||||
if (!accountDir) {
|
||||
diagnostics.reason = 'account_dir_not_found'
|
||||
diagnostics.summary = this.buildLookupSummary('account_dir_not_found', diagnostics)
|
||||
this.warnVideoLookup('account-dir-not-found', diagnostics)
|
||||
return { exists: false, diagnostics }
|
||||
}
|
||||
|
||||
const videoBaseDir = join(accountDir, 'msg', 'video')
|
||||
diagnostics.videoBaseDir = videoBaseDir
|
||||
|
||||
const hardlinkResult = this.queryVideoFileNames(candidateMd5s)
|
||||
diagnostics.hardlinkDbPath = hardlinkResult.hardlinkDbPath
|
||||
diagnostics.hardlinkMatchedMd5 = hardlinkResult.hardlinkMatchedMd5
|
||||
|
||||
const fileKeys = [...candidateMd5s]
|
||||
for (const fileKey of hardlinkResult.fileKeys) {
|
||||
this.addMd5Candidate(fileKeys, fileKey)
|
||||
}
|
||||
diagnostics.searchedFileKeys = fileKeys
|
||||
|
||||
this.logVideoLookup('search-plan', {
|
||||
requestedMd5,
|
||||
candidateMd5s,
|
||||
hardlinkDbPath: diagnostics.hardlinkDbPath,
|
||||
hardlinkMatchedMd5: diagnostics.hardlinkMatchedMd5,
|
||||
searchedFileKeys: diagnostics.searchedFileKeys,
|
||||
videoBaseDir
|
||||
})
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
return { exists: false }
|
||||
diagnostics.reason = 'video_dir_missing'
|
||||
diagnostics.summary = this.buildLookupSummary('video_dir_missing', diagnostics)
|
||||
this.warnVideoLookup('video-dir-missing', diagnostics)
|
||||
return { exists: false, diagnostics }
|
||||
}
|
||||
|
||||
// 遍历年月目录查找视频文件
|
||||
@@ -210,25 +471,45 @@ class VideoService {
|
||||
for (const yearMonth of yearMonthDirs) {
|
||||
const dirPath = join(videoBaseDir, yearMonth)
|
||||
|
||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
||||
for (const fileKey of fileKeys) {
|
||||
const videoPath = join(dirPath, `${fileKey}.mp4`)
|
||||
const coverPath = join(dirPath, `${fileKey}.jpg`)
|
||||
const thumbPath = join(dirPath, `${fileKey}_thumb.jpg`)
|
||||
|
||||
// 检查视频文件是否存�?
|
||||
if (existsSync(videoPath)) {
|
||||
return {
|
||||
videoUrl: `file:///${videoPath.replace(/\\/g, '/')}`, // 转换为 file:// 协议
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||
exists: true
|
||||
// 检查视频文件是否存�?
|
||||
if (existsSync(videoPath)) {
|
||||
diagnostics.matchedMd5 = fileKey
|
||||
this.logVideoLookup('local-file-hit', {
|
||||
requestedMd5,
|
||||
matchedMd5: fileKey,
|
||||
yearMonth,
|
||||
videoPath,
|
||||
coverExists: existsSync(coverPath),
|
||||
thumbExists: existsSync(thumbPath)
|
||||
})
|
||||
return {
|
||||
videoUrl: `file:///${videoPath.replace(/\\/g, '/')}`, // 转换为 file:// 协议
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||
exists: true,
|
||||
diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
} catch (error) {
|
||||
this.warnVideoLookup('scan-error', {
|
||||
requestedMd5,
|
||||
videoBaseDir,
|
||||
searchedFileKeys: diagnostics.searchedFileKeys,
|
||||
error: String(error)
|
||||
})
|
||||
}
|
||||
|
||||
return { exists: false }
|
||||
diagnostics.reason = 'local_file_missing'
|
||||
diagnostics.summary = this.buildLookupSummary('local_file_missing', diagnostics)
|
||||
this.warnVideoLookup('local-file-missing', diagnostics)
|
||||
return { exists: false, diagnostics }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,23 +519,14 @@ class VideoService {
|
||||
if (!content) return undefined
|
||||
|
||||
try {
|
||||
// 尝试从XML中提取md5
|
||||
// 格式可能�? <md5>xxx</md5> �?md5="xxx"
|
||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||
if (md5Match) {
|
||||
return md5Match[1].toLowerCase()
|
||||
}
|
||||
|
||||
const attrMatch = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (attrMatch) {
|
||||
return attrMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 尝试从videomsg标签中提�?
|
||||
const videoMsgMatch = /<videomsg[^>]*md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (videoMsgMatch) {
|
||||
return videoMsgMatch[1].toLowerCase()
|
||||
}
|
||||
return (
|
||||
this.extractVideoXmlValue(content, 'md5') ||
|
||||
this.extractVideoMsgAttribute(content, 'md5') ||
|
||||
this.extractVideoXmlValue(content, 'newmd5') ||
|
||||
this.extractVideoMsgAttribute(content, 'newmd5') ||
|
||||
this.extractVideoXmlValue(content, 'rawmd5') ||
|
||||
this.extractVideoMsgAttribute(content, 'rawmd5')
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('解析视频MD5失败:', e)
|
||||
}
|
||||
|
||||
+37
-13
@@ -311,17 +311,20 @@
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.active {
|
||||
@@ -351,6 +354,7 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
@@ -2741,14 +2745,23 @@
|
||||
|
||||
// 会话详情面板
|
||||
.detail-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
background: var(--card-bg);
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideInRight 0.2s ease;
|
||||
animation: slideInRight 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
z-index: 10;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.15), -2px 0 8px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&.closing {
|
||||
animation: slideOutRight 0.22s cubic-bezier(0.55, 0.06, 0.68, 0.19) forwards;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
@@ -2942,7 +2955,7 @@
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transform: translateX(40px);
|
||||
}
|
||||
|
||||
to {
|
||||
@@ -2951,6 +2964,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 消息中的链接样式
|
||||
.message-link {
|
||||
@@ -3051,6 +3076,13 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.video-reason {
|
||||
max-width: 176px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.video-action {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
@@ -3068,7 +3100,6 @@
|
||||
|
||||
&:hover {
|
||||
.video-play-button {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
@@ -3103,18 +3134,11 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
margin-left: 4px; // 视觉居中调整
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
-23
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users, Mic, CheckCircle, XCircle, Download, Phone, Aperture, MapPin, UserRound } from 'lucide-react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, PlayCircle, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users, Mic, CheckCircle, XCircle, Download, Phone, Aperture, MapPin, UserRound } from 'lucide-react'
|
||||
import { Qwen } from '@lobehub/icons'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useUpdateStatusStore } from '../stores/updateStatusStore'
|
||||
@@ -299,6 +299,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(260)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [showDetailPanel, setShowDetailPanel] = useState(false)
|
||||
const [isDetailClosing, setIsDetailClosing] = useState(false)
|
||||
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
||||
const [hasImageKey, setHasImageKey] = useState<boolean | null>(null)
|
||||
@@ -527,12 +528,22 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}, [])
|
||||
|
||||
// 切换详情面板
|
||||
const closeDetailPanel = useCallback(() => {
|
||||
setIsDetailClosing(true)
|
||||
setTimeout(() => {
|
||||
setShowDetailPanel(false)
|
||||
setIsDetailClosing(false)
|
||||
}, 220)
|
||||
}, [])
|
||||
|
||||
const toggleDetailPanel = useCallback(() => {
|
||||
if (!showDetailPanel && currentSessionId) {
|
||||
loadSessionDetail(currentSessionId)
|
||||
if (showDetailPanel) {
|
||||
closeDetailPanel()
|
||||
} else {
|
||||
if (currentSessionId) loadSessionDetail(currentSessionId)
|
||||
setShowDetailPanel(true)
|
||||
}
|
||||
setShowDetailPanel(!showDetailPanel)
|
||||
}, [showDetailPanel, currentSessionId, loadSessionDetail])
|
||||
}, [showDetailPanel, currentSessionId, loadSessionDetail, closeDetailPanel])
|
||||
|
||||
// 连接数据库
|
||||
const connect = useCallback(async () => {
|
||||
@@ -2136,10 +2147,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
{/* 会话详情面板 */}
|
||||
{showDetailPanel && (
|
||||
<div className="detail-panel">
|
||||
<div className={`detail-panel${isDetailClosing ? ' closing' : ''}`}>
|
||||
<div className="detail-header">
|
||||
<h4>会话详情</h4>
|
||||
<button className="close-btn" onClick={() => setShowDetailPanel(false)}>
|
||||
<button className="close-btn" onClick={closeDetailPanel}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -2791,13 +2802,29 @@ function enqueueDecrypt(fn: () => Promise<void>) {
|
||||
}
|
||||
|
||||
// 视频信息缓存(带时间戳)
|
||||
const videoInfoCache = new Map<string, {
|
||||
type VideoLookupDiagnostics = {
|
||||
requestedMd5?: string
|
||||
candidateMd5s?: string[]
|
||||
searchedFileKeys?: string[]
|
||||
matchedMd5?: string
|
||||
hardlinkMatchedMd5?: string
|
||||
hardlinkDbPath?: string
|
||||
accountDir?: string
|
||||
videoBaseDir?: string
|
||||
reason?: 'missing_input' | 'missing_config' | 'account_dir_not_found' | 'video_dir_missing' | 'local_file_missing'
|
||||
summary?: string
|
||||
}
|
||||
|
||||
type CachedVideoInfo = {
|
||||
videoUrl?: string
|
||||
coverUrl?: string
|
||||
thumbUrl?: string
|
||||
exists: boolean
|
||||
cachedAt: number // 缓存时间戳
|
||||
}>()
|
||||
diagnostics?: VideoLookupDiagnostics
|
||||
}
|
||||
|
||||
const videoInfoCache = new Map<string, CachedVideoInfo>()
|
||||
|
||||
// 最后一次增量更新时间戳
|
||||
let lastIncrementalUpdateTime = 0
|
||||
@@ -2938,9 +2965,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 视频相关状态
|
||||
const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null)
|
||||
const [videoInfo, setVideoInfo] = useState<CachedVideoInfo | null>(null)
|
||||
const [videoLoading, setVideoLoading] = useState(false)
|
||||
const videoContainerRef = useRef<HTMLDivElement>(null)
|
||||
const videoCacheKey = message.videoMd5 || `local:${message.localId}`
|
||||
|
||||
// 从缓存获取表情包 data URL
|
||||
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
|
||||
@@ -3136,48 +3164,93 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
// 加载视频信息
|
||||
useEffect(() => {
|
||||
if (!isVideo || !isVisible || videoInfo || videoLoading) return
|
||||
if (!message.videoMd5) return
|
||||
if (!message.videoMd5 && !message.rawContent) return
|
||||
|
||||
// 先检查缓存
|
||||
const cached = videoInfoCache.get(message.videoMd5)
|
||||
const cached = videoInfoCache.get(videoCacheKey)
|
||||
if (cached) {
|
||||
// 智能缓存失效:如果视频不存在,且缓存时间早于最后一次增量更新,则重新获取
|
||||
const shouldRefetch = !cached.exists && cached.cachedAt < lastIncrementalUpdateTime
|
||||
|
||||
console.log('[Video][Renderer] cache-check', {
|
||||
localId: message.localId,
|
||||
sessionId: session.username,
|
||||
videoCacheKey,
|
||||
videoMd5: message.videoMd5,
|
||||
hasCached: true,
|
||||
cachedExists: cached.exists,
|
||||
shouldRefetch,
|
||||
diagnostics: cached.diagnostics
|
||||
})
|
||||
|
||||
if (!shouldRefetch) {
|
||||
setVideoInfo(cached)
|
||||
return
|
||||
}
|
||||
|
||||
// 需要重新获取,清除旧缓存
|
||||
videoInfoCache.delete(message.videoMd5)
|
||||
videoInfoCache.delete(videoCacheKey)
|
||||
}
|
||||
|
||||
setVideoLoading(true)
|
||||
window.electronAPI.video.getVideoInfo(message.videoMd5).then((result) => {
|
||||
console.log('[Video][Renderer] request-start', {
|
||||
localId: message.localId,
|
||||
sessionId: session.username,
|
||||
videoCacheKey,
|
||||
videoMd5: message.videoMd5,
|
||||
rawPreview: String(message.rawContent || '').replace(/\s+/g, ' ').slice(0, 220)
|
||||
})
|
||||
window.electronAPI.video.getVideoInfo(message.videoMd5 || '', message.rawContent).then((result) => {
|
||||
if (result && result.success) {
|
||||
const info = {
|
||||
exists: result.exists,
|
||||
videoUrl: result.videoUrl,
|
||||
coverUrl: result.coverUrl,
|
||||
thumbUrl: result.thumbUrl,
|
||||
diagnostics: result.diagnostics,
|
||||
cachedAt: Date.now() // 记录缓存时间
|
||||
}
|
||||
videoInfoCache.set(message.videoMd5!, info)
|
||||
videoInfoCache.set(videoCacheKey, info)
|
||||
setVideoInfo(info)
|
||||
console.log('[Video][Renderer] request-success', {
|
||||
localId: message.localId,
|
||||
sessionId: session.username,
|
||||
videoCacheKey,
|
||||
exists: result.exists,
|
||||
videoUrl: result.videoUrl,
|
||||
diagnostics: result.diagnostics
|
||||
})
|
||||
if (!result.exists && result.diagnostics) {
|
||||
console.warn('[Video] 视频定位失败:', {
|
||||
localId: message.localId,
|
||||
diagnostics: result.diagnostics
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const info = { exists: false, cachedAt: Date.now() }
|
||||
videoInfoCache.set(message.videoMd5!, info)
|
||||
videoInfoCache.set(videoCacheKey, info)
|
||||
setVideoInfo(info)
|
||||
console.warn('[Video][Renderer] request-unsuccessful', {
|
||||
localId: message.localId,
|
||||
sessionId: session.username,
|
||||
videoCacheKey,
|
||||
result
|
||||
})
|
||||
}
|
||||
}).catch(() => {
|
||||
}).catch((error) => {
|
||||
const info = { exists: false, cachedAt: Date.now() }
|
||||
videoInfoCache.set(message.videoMd5!, info)
|
||||
videoInfoCache.set(videoCacheKey, info)
|
||||
setVideoInfo(info)
|
||||
console.error('[Video][Renderer] request-error', {
|
||||
localId: message.localId,
|
||||
sessionId: session.username,
|
||||
videoCacheKey,
|
||||
error: String(error)
|
||||
})
|
||||
}).finally(() => {
|
||||
setVideoLoading(false)
|
||||
})
|
||||
}, [isVideo, isVisible, videoInfo, videoLoading, message.videoMd5])
|
||||
}, [isVideo, isVisible, videoInfo, videoLoading, message.videoMd5, message.rawContent, message.localId, videoCacheKey])
|
||||
|
||||
// 播放视频 - 打开独立窗口
|
||||
const handlePlayVideo = useCallback(async () => {
|
||||
@@ -3996,11 +4069,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
<button
|
||||
className="video-unavailable"
|
||||
ref={videoContainerRef as unknown as React.RefObject<HTMLButtonElement>}
|
||||
title={videoInfo?.diagnostics?.summary || '点击重试'}
|
||||
onClick={() => {
|
||||
// 清除缓存并重新加载
|
||||
if (message.videoMd5) {
|
||||
videoInfoCache.delete(message.videoMd5)
|
||||
}
|
||||
console.log('[Video][Renderer] retry-click', {
|
||||
localId: message.localId,
|
||||
sessionId: session.username,
|
||||
videoCacheKey,
|
||||
diagnostics: videoInfo?.diagnostics
|
||||
})
|
||||
videoInfoCache.delete(videoCacheKey)
|
||||
setVideoInfo(null)
|
||||
setVideoLoading(false)
|
||||
}}
|
||||
@@ -4008,6 +4086,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
>
|
||||
<Video size={24} />
|
||||
<span>视频不可用</span>
|
||||
{videoInfo?.diagnostics?.summary && (
|
||||
<span className="video-reason">{videoInfo.diagnostics.summary}</span>
|
||||
)}
|
||||
<span className="video-action">点击重试</span>
|
||||
</button>
|
||||
)
|
||||
@@ -4025,7 +4106,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
</div>
|
||||
)}
|
||||
<div className="video-play-button">
|
||||
<Play size={32} fill="white" />
|
||||
<Play size={36} fill="currentColor" />
|
||||
</div>
|
||||
{message.videoDuration && message.videoDuration > 0 && (
|
||||
<span className="video-duration-tag">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Play, Pause, Volume2, VolumeX, RotateCcw } from 'lucide-react'
|
||||
import { Play, Pause, PlayCircle, Volume2, VolumeX, RotateCcw } from 'lucide-react'
|
||||
import './VideoWindow.scss'
|
||||
|
||||
export default function VideoWindow() {
|
||||
@@ -147,7 +147,7 @@ export default function VideoWindow() {
|
||||
/>
|
||||
{!isPlaying && !isLoading && !error && (
|
||||
<div className="play-overlay">
|
||||
<Play size={64} fill="white" />
|
||||
<Play size={48} fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Vendored
+13
-1
@@ -409,13 +409,25 @@ export interface ElectronAPI {
|
||||
countThumbnails: () => Promise<{ success: boolean; count: number; error?: string }>
|
||||
}
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => Promise<{
|
||||
getVideoInfo: (videoMd5: string, rawContent?: string) => Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
exists: boolean
|
||||
videoUrl?: string
|
||||
coverUrl?: string
|
||||
thumbUrl?: string
|
||||
diagnostics?: {
|
||||
requestedMd5?: string
|
||||
candidateMd5s?: string[]
|
||||
searchedFileKeys?: string[]
|
||||
matchedMd5?: string
|
||||
hardlinkMatchedMd5?: string
|
||||
hardlinkDbPath?: string
|
||||
accountDir?: string
|
||||
videoBaseDir?: string
|
||||
reason?: 'missing_input' | 'missing_config' | 'account_dir_not_found' | 'video_dir_missing' | 'local_file_missing'
|
||||
summary?: string
|
||||
}
|
||||
}>
|
||||
readFile: (videoPath: string) => Promise<{
|
||||
success: boolean
|
||||
|
||||
Reference in New Issue
Block a user