恢复了原有的视频解密逻辑

This commit is contained in:
ace
2026-02-27 16:07:27 +08:00
parent ec0eb64ffd
commit 5e96cdb1d6
5 changed files with 381 additions and 692 deletions

View File

@@ -852,9 +852,9 @@ function registerIpcHandlers() {
}) })
// 视频相关 // 视频相关
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, sessionId?: string) => { ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
try { try {
const result = await videoService.getVideoInfo(videoMd5, sessionId) const result = await videoService.getVideoInfo(videoMd5)
return { success: true, ...result } return { success: true, ...result }
} catch (e) { } catch (e) {
return { success: false, error: String(e), exists: false } return { success: false, error: String(e), exists: false }

View File

@@ -196,10 +196,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 视频 // 视频
video: { video: {
getVideoInfo: (videoMd5: string, sessionId?: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, sessionId), getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
}, },
// 数据分析 // 数据分析
analytics: { analytics: {
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,11 @@
import { join, basename, extname, dirname } from 'path' import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, writeFileSync } from 'fs' import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import crypto from 'crypto'
export interface VideoInfo { export interface VideoInfo {
videoUrl?: string // 视频文件路径 videoUrl?: string // 视频文件路径(用于 readFile
coverUrl?: string // 封面 data URL coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略图 data URL thumbUrl?: string // 缩略图 data URL
exists: boolean exists: boolean
@@ -15,379 +13,276 @@ export interface VideoInfo {
class VideoService { class VideoService {
private configService: ConfigService private configService: ConfigService
private resolvedCache = new Map<string, string>() // md5 -> localPath
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
} }
private logInfo(message: string, meta?: Record<string, unknown>): void { /**
if (!this.configService.get('logEnabled')) return * 获取数据库根目录
const timestamp = new Date().toISOString() */
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [VideoService] ${message}${metaStr}\n`
this.writeLog(logLine)
}
private logError(message: string, error?: unknown, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
const timestamp = new Date().toISOString()
const errorStr = error ? ` Error: ${String(error)}` : ''
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [VideoService] ERROR: ${message}${errorStr}${metaStr}\n`
console.error(`[VideoService] ${message}`, error, meta)
this.writeLog(logLine)
}
private writeLog(line: string): void {
try {
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true })
}
appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' })
} catch (err) {
console.error('写入日志失败:', err)
}
}
private getDbPath(): string { private getDbPath(): string {
return this.configService.get('dbPath') || '' return this.configService.get('dbPath') || ''
} }
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string { private getMyWxid(): string {
return this.configService.get('myWxid') || '' return this.configService.get('myWxid') || ''
} }
private getCacheBasePath(): string { /**
return this.configService.getCacheBasePath() * 获取缓存目录(解密后的数据库存放位置)
} */
private getCachePath(): string {
private cleanWxid(wxid: string): string { return this.configService.get('cachePath') || ''
const trimmed = wxid.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
private resolveAccountDir(dbPath: string, wxid: string): string | null {
if (!dbPath || !wxid) return null
const cleanedWxid = this.cleanWxid(wxid).toLowerCase()
const normalized = dbPath.replace(/[\\/]+$/, '')
const candidates: { path: string; mtime: number }[] = []
const checkDir = (p: string) => {
if (existsSync(p) && (existsSync(join(p, 'db_storage')) || existsSync(join(p, 'msg', 'video')) || existsSync(join(p, 'msg', 'attach')))) {
candidates.push({ path: p, mtime: this.getDirMtime(p) })
}
}
checkDir(join(normalized, wxid))
checkDir(join(normalized, cleanedWxid))
checkDir(normalized)
try {
if (existsSync(normalized) && statSync(normalized).isDirectory()) {
const entries = readdirSync(normalized)
for (const entry of entries) {
const entryPath = join(normalized, entry)
try {
if (!statSync(entryPath).isDirectory()) continue
} catch { continue }
const lowerEntry = entry.toLowerCase()
if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) {
checkDir(entryPath)
}
}
}
} catch { }
if (candidates.length === 0) return null
candidates.sort((a, b) => b.mtime - a.mtime)
return candidates[0].path
}
private getDirMtime(dirPath: string): number {
try {
let mtime = statSync(dirPath).mtimeMs
const subs = ['db_storage', 'msg/video', 'msg/attach']
for (const sub of subs) {
const p = join(dirPath, sub)
if (existsSync(p)) mtime = Math.max(mtime, statSync(p).mtimeMs)
}
return mtime
} catch { return 0 }
}
private async ensureWcdbReady(): Promise<boolean> {
if (wcdbService.isReady()) return true
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
const wxid = this.configService.get('myWxid')
if (!dbPath || !decryptKey || !wxid) return false
const cleanedWxid = this.cleanWxid(wxid)
return await wcdbService.open(dbPath, decryptKey, cleanedWxid)
} }
/** /**
* 计算会话哈希(对应磁盘目录名 * 清理 wxid 目录名(去掉后缀
*/ */
private md5Hash(text: string): string { private cleanWxid(wxid: string): string {
return crypto.createHash('md5').update(text).digest('hex') const trimmed = wxid.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
} }
private async resolveHardlinkPath(accountDir: string, md5: string): Promise<string | null> { const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const dbPath = join(accountDir, 'db_storage', 'hardlink', 'hardlink.db') if (suffixMatch) return suffixMatch[1]
if (!existsSync(dbPath)) {
this.logInfo('hardlink.db 不存在', { dbPath }) return trimmed
return null
} }
try { /**
const ready = await this.ensureWcdbReady() * 从 video_hardlink_info_v4 表查询视频文件名
if (!ready) return null * 优先使用 cachePath 中解密后的 hardlink.db使用 better-sqlite3
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
*/
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const cachePath = this.getCachePath()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
const tableResult = await wcdbService.execQuery('media', dbPath, console.log('[VideoService] queryVideoFileName called with MD5:', md5)
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'video_hardlink_info%' ORDER BY name DESC LIMIT 1") console.log('[VideoService] cachePath:', cachePath, 'dbPath:', dbPath, 'wxid:', wxid, 'cleanedWxid:', cleanedWxid)
if (!tableResult.success || !tableResult.rows?.length) return null if (!wxid) return undefined
const tableName = tableResult.rows[0].name
const escapedMd5 = md5.replace(/'/g, "''") // 方法1优先在 cachePath 下查找解密后的 hardlink.db
const rowResult = await wcdbService.execQuery('media', dbPath, if (cachePath) {
`SELECT dir1, dir2, file_name FROM ${tableName} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`) const cacheDbPaths = [
join(cachePath, cleanedWxid, 'hardlink.db'),
if (!rowResult.success || !rowResult.rows?.length) return null join(cachePath, wxid, 'hardlink.db'),
join(cachePath, 'hardlink.db'),
const row = rowResult.rows[0] join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
const dir1 = row.dir1 ?? row.DIR1 join(cachePath, 'databases', wxid, 'hardlink.db')
const dir2 = row.dir2 ?? row.DIR2
const file_name = row.file_name ?? row.fileName ?? row.FILE_NAME
if (dir1 === undefined || dir2 === undefined || !file_name) return null
const dirTableResult = await wcdbService.execQuery('media', dbPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1")
if (!dirTableResult.success || !dirTableResult.rows?.length) return null
const dirTable = dirTableResult.rows[0].name
const getDirName = async (id: number) => {
const res = await wcdbService.execQuery('media', dbPath, `SELECT username FROM ${dirTable} WHERE rowid = ${id} LIMIT 1`)
return res.success && res.rows?.length ? String(res.rows[0].username) : null
}
const dir1Name = await getDirName(Number(dir1))
const dir2Name = await getDirName(Number(dir2))
if (!dir1Name || !dir2Name) return null
const candidates = [
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Video', file_name),
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, file_name),
join(accountDir, 'msg', 'video', dir2Name, file_name)
] ]
for (const p of candidates) { for (const p of cacheDbPaths) {
if (existsSync(p)) { if (existsSync(p)) {
this.logInfo('hardlink 命中', { path: p }) console.log('[VideoService] Found decrypted hardlink.db at:', p)
return p try {
const db = new Database(p, { 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) {
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
console.log('[VideoService] Found video filename via cache:', realMd5)
return realMd5
}
} catch (e) {
console.log('[VideoService] Failed to query cached hardlink.db:', e)
}
}
}
}
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
const encryptedDbPaths = [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
for (const p of encryptedDbPaths) {
if (existsSync(p)) {
console.log('[VideoService] Found encrypted hardlink.db at:', p)
try {
const escapedMd5 = md5.replace(/'/g, "''")
// 用 md5 字段查询,获取 file_name
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
console.log('[VideoService] Query SQL:', sql)
const result = await wcdbService.execQuery('media', p, sql)
console.log('[VideoService] Query result:', result)
if (result.success && result.rows && result.rows.length > 0) {
const row = result.rows[0]
if (row?.file_name) {
// 提取不带扩展名的文件名作为实际视频 MD5
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
console.log('[VideoService] Found video filename:', realMd5)
return realMd5
} }
} }
} catch (e) { } catch (e) {
this.logError('resolveHardlinkPath 异常', e) console.log('[VideoService] Failed to query encrypted hardlink.db via wcdbService:', e)
}
return null
}
private async searchVideoFile(accountDir: string, md5: string, sessionId?: string): Promise<string | null> {
const lowerMd5 = md5.toLowerCase()
// 策略 1: 基于 sessionId 哈希的精准搜索 (XWeChat 核心逻辑)
if (sessionId) {
const sessHash = this.md5Hash(sessionId)
const attachRoot = join(accountDir, 'msg', 'attach', sessHash)
if (existsSync(attachRoot)) {
try {
const monthDirs = readdirSync(attachRoot).filter(d => /^\d{4}-\d{2}$/.test(d))
for (const m of monthDirs) {
const videoDir = join(attachRoot, m, 'Video')
if (existsSync(videoDir)) {
// 尝试精确名和带数字后缀的名
const files = readdirSync(videoDir)
const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4'))
if (match) return join(videoDir, match)
} }
} }
} catch { }
} }
} }
// 策略 2: 概率搜索所有 session 目录 (针对最近 3 个月) console.log('[VideoService] No matching video found in hardlink.db')
const attachRoot = join(accountDir, 'msg', 'attach')
if (existsSync(attachRoot)) {
try {
const sessionDirs = readdirSync(attachRoot).filter(d => d.length === 32)
const now = new Date()
const months = []
for (let i = 0; i < 3; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
for (const sess of sessionDirs) {
for (const month of months) {
const videoDir = join(attachRoot, sess, month, 'Video')
if (existsSync(videoDir)) {
const files = readdirSync(videoDir)
const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4'))
if (match) return join(videoDir, match)
}
}
}
} catch { }
}
// 策略 3: 传统 msg/video 目录
const videoRoot = join(accountDir, 'msg', 'video')
if (existsSync(videoRoot)) {
try {
const monthDirs = readdirSync(videoRoot).sort().reverse()
for (const m of monthDirs) {
const dirPath = join(videoRoot, m)
const files = readdirSync(dirPath)
const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4'))
if (match) return join(dirPath, match)
}
} catch { }
}
return null
}
private getXorKey(): number | undefined {
const raw = this.configService.get('imageXorKey')
if (typeof raw === 'number') return raw
if (typeof raw === 'string') {
const t = raw.trim()
return t.toLowerCase().startsWith('0x') ? parseInt(t, 16) : parseInt(t, 10)
}
return undefined return undefined
} }
private isEncrypted(buffer: Buffer, xorKey: number, type: 'video' | 'image'): boolean { /**
if (buffer.length < 8) return false * 将文件转换为 data URL
const first = buffer[0] ^ xorKey */
const second = buffer[1] ^ xorKey private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
if (type === 'image') {
return (first === 0xFF && second === 0xD8) || (first === 0x89 && second === 0x50) || (first === 0x47 && second === 0x49)
} else {
// MP4 头部通常包含 'ftyp'
const f = buffer[4] ^ xorKey
const t = buffer[5] ^ xorKey
const y = buffer[6] ^ xorKey
const p = buffer[7] ^ xorKey
return (f === 0x66 && t === 0x74 && y === 0x79 && p === 0x70) || // 'ftyp'
(buffer[0] ^ xorKey) === 0x00 && (buffer[1] ^ xorKey) === 0x00 // 一些 mp4 以 00 00 开头
}
}
private filePathToUrl(filePath: string): string {
try { try {
const { pathToFileURL } = require('url')
const url = pathToFileURL(filePath).toString()
const s = statSync(filePath)
return `${url}?v=${Math.floor(s.mtimeMs)}`
} catch {
return `file:///${filePath.replace(/\\/g, '/')}`
}
}
private handleFile(filePath: string, type: 'video' | 'image', sessionId?: string): string | undefined {
if (!existsSync(filePath)) return undefined if (!existsSync(filePath)) return undefined
const xorKey = this.getXorKey()
try {
const buffer = readFileSync(filePath) const buffer = readFileSync(filePath)
const isEnc = xorKey !== undefined && !Number.isNaN(xorKey) && this.isEncrypted(buffer, xorKey, type) return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
if (isEnc) { return undefined
const decrypted = Buffer.alloc(buffer.length)
for (let i = 0; i < buffer.length; i++) decrypted[i] = buffer[i] ^ xorKey!
if (type === 'image') {
return `data:image/jpeg;base64,${decrypted.toString('base64')}`
} else {
const cacheDir = join(this.getCacheBasePath(), 'Videos', this.cleanWxid(sessionId || 'unknown'))
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true })
const outPath = join(cacheDir, `${basename(filePath)}`)
if (!existsSync(outPath) || statSync(outPath).size !== decrypted.length) {
writeFileSync(outPath, decrypted)
}
return this.filePathToUrl(outPath)
} }
} }
if (type === 'image') { /**
return `data:image/jpeg;base64,${buffer.toString('base64')}` * 根据视频MD5获取视频文件信息
} * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
return this.filePathToUrl(filePath) * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
} catch (e) { */
this.logError(`处理${type}文件异常: ${filePath}`, e) async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
return type === 'image' ? undefined : this.filePathToUrl(filePath) console.log('[VideoService] getVideoInfo called with MD5:', videoMd5)
}
}
async getVideoInfo(videoMd5: string, sessionId?: string): Promise<VideoInfo> {
this.logInfo('获取视频信息', { videoMd5, sessionId })
const dbPath = this.getDbPath() const dbPath = this.getDbPath()
const wxid = this.getMyWxid() const wxid = this.getMyWxid()
if (!dbPath || !wxid || !videoMd5) return { exists: false }
const accountDir = this.resolveAccountDir(dbPath, wxid) console.log('[VideoService] Config - dbPath:', dbPath, 'wxid:', wxid)
if (!accountDir) {
this.logError('未找到账号目录', undefined, { dbPath, wxid }) if (!dbPath || !wxid || !videoMd5) {
console.log('[VideoService] Missing required params')
return { exists: false } return { exists: false }
} }
// 1. 通过 hardlink 映射 // 先尝试从数据库查询真正的视频文件名
let videoPath = await this.resolveHardlinkPath(accountDir, videoMd5) const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
console.log('[VideoService] Real video MD5:', realVideoMd5)
// 2. 启发式搜索 const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
if (!videoPath) { console.log('[VideoService] Video base dir:', videoBaseDir)
videoPath = await this.searchVideoFile(accountDir, videoMd5, sessionId)
if (!existsSync(videoBaseDir)) {
console.log('[VideoService] Video base dir does not exist')
return { exists: false }
} }
if (videoPath && existsSync(videoPath)) { // 遍历年月目录查找视频文件
this.logInfo('定位成功', { videoPath }) try {
const base = videoPath.slice(0, -4) const allDirs = readdirSync(videoBaseDir)
const coverPath = `${base}.jpg` console.log('[VideoService] Found year-month dirs:', allDirs)
const thumbPath = `${base}_thumb.jpg`
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
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`)
console.log('[VideoService] Checking:', videoPath)
// 检查视频文件是否存在
if (existsSync(videoPath)) {
console.log('[VideoService] Video file found!')
return { return {
videoUrl: this.handleFile(videoPath, 'video', sessionId), videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
coverUrl: this.handleFile(coverPath, 'image', sessionId), coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.handleFile(thumbPath, 'image', sessionId), thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true exists: true
} }
} }
}
console.log('[VideoService] Video file not found in any directory')
} catch (e) {
console.error('[VideoService] Error searching for video:', e)
}
this.logInfo('定位失败', { videoMd5 })
return { exists: false } return { exists: false }
} }
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined { parseVideoMd5(content: string): string | undefined {
console.log('[VideoService] parseVideoMd5 called, content length:', content?.length)
// 打印前500字符看看 XML 结构
console.log('[VideoService] XML preview:', content?.substring(0, 500))
if (!content) return undefined if (!content) return undefined
try { try {
const m = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || // 提取所有可能的 md5 值进行日志
/\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || const allMd5s: string[] = []
/<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content) const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
return m ? m[1].toLowerCase() : undefined let match
} catch { return undefined } while ((match = md5Regex.exec(content)) !== null) {
allMd5s.push(`${match[0]}`)
}
console.log('[VideoService] All MD5 attributes found:', allMd5s)
// 提取 md5用于查询 hardlink.db
// 注意:不是 rawmd5rawmd5 是另一个值
// 格式: md5="xxx" 或 <md5>xxx</md5>
// 尝试从videomsg标签中提取md5
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {
console.log('[VideoService] Found MD5 via videomsg:', videoMsgMatch[1])
return videoMsgMatch[1].toLowerCase()
}
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
console.log('[VideoService] Found MD5 via attribute:', attrMatch[1])
return attrMatch[1].toLowerCase()
}
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5Match) {
console.log('[VideoService] Found MD5 via <md5> tag:', md5Match[1])
return md5Match[1].toLowerCase()
}
console.log('[VideoService] No MD5 found in content')
} catch (e) {
console.error('[VideoService] 解析视频MD5失败:', e)
}
return undefined
} }
} }

View File

@@ -3543,10 +3543,9 @@ function MessageBubble({
videoLoadingRef.current = true videoLoadingRef.current = true
setVideoLoading(true) setVideoLoading(true)
try { try {
const result = await window.electronAPI.video.getVideoInfo(videoMd5, session.username) const result = await window.electronAPI.video.getVideoInfo(videoMd5)
if (result && result.success && result.exists) { if (result && result.success && result.exists) {
setVideoInfo({ setVideoInfo({ exists: result.exists,
exists: result.exists,
videoUrl: result.videoUrl, videoUrl: result.videoUrl,
coverUrl: result.coverUrl, coverUrl: result.coverUrl,
thumbUrl: result.thumbUrl thumbUrl: result.thumbUrl
@@ -3560,7 +3559,7 @@ function MessageBubble({
videoLoadingRef.current = false videoLoadingRef.current = false
setVideoLoading(false) setVideoLoading(false)
} }
}, [videoMd5, session.username]) }, [videoMd5])
// 视频进入视野时自动加载 // 视频进入视野时自动加载
useEffect(() => { useEffect(() => {