diff --git a/electron/main.ts b/electron/main.ts index ab9128d..f9d3bcd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -852,9 +852,9 @@ function registerIpcHandlers() { }) // 视频相关 - ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => { + ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, sessionId?: string) => { try { - const result = await videoService.getVideoInfo(videoMd5) + const result = await videoService.getVideoInfo(videoMd5, sessionId) return { success: true, ...result } } catch (e) { return { success: false, error: String(e), exists: false } diff --git a/electron/preload.ts b/electron/preload.ts index 4cf585b..d03b94e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -196,7 +196,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // 视频 video: { - getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), + getVideoInfo: (videoMd5: string, sessionId?: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, sessionId), parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index be86d54..845a1bd 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -4478,27 +4478,77 @@ class ChatService { } private resolveAccountDir(dbPath: string, wxid: string): string | null { - const normalized = dbPath.replace(/[\\\\/]+$/, '') + const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase() + const normalized = dbPath.replace(/[\\/]+$/, '') - // 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件) - // 则向上回溯到账号目录 - if (basename(normalized).toLowerCase() === 'db_storage') { - return dirname(normalized) - } - const dir = dirname(normalized) - if (basename(dir).toLowerCase() === 'db_storage') { - return dirname(dir) + const candidates: { path: string; mtime: number }[] = [] + + // 检查直接路径 + const direct = join(normalized, cleanedWxid) + if (existsSync(direct) && this.isAccountDir(direct)) { + candidates.push({ path: direct, mtime: this.getDirMtime(direct) }) } - // 否则,dbPath 应该是数据库根目录(如 xwechat_files) - // 账号目录应该是 {dbPath}/{wxid} - const accountDirWithWxid = join(normalized, wxid) - if (existsSync(accountDirWithWxid)) { - return accountDirWithWxid + // 检查 dbPath 本身是否就是账号目录 + if (this.isAccountDir(normalized)) { + candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) }) } - // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) - return normalized + // 扫描 dbPath 下的所有子目录寻找匹配的 wxid + 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}_`)) { + if (this.isAccountDir(entryPath)) { + if (!candidates.some(c => c.path === entryPath)) { + candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) }) + } + } + } + } + } + } catch { } + + if (candidates.length === 0) return null + + // 按修改时间降序排序,取最新的 + candidates.sort((a, b) => b.mtime - a.mtime) + return candidates[0].path + } + + private isAccountDir(dirPath: string): boolean { + return ( + existsSync(join(dirPath, 'db_storage')) || + existsSync(join(dirPath, 'FileStorage', 'Image')) || + existsSync(join(dirPath, 'FileStorage', 'Image2')) || + existsSync(join(dirPath, 'msg', 'attach')) + ) + } + + private getDirMtime(dirPath: string): number { + try { + const stat = statSync(dirPath) + let mtime = stat.mtimeMs + const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] + for (const sub of subDirs) { + const fullPath = join(dirPath, sub) + if (existsSync(fullPath)) { + try { + mtime = Math.max(mtime, statSync(fullPath).mtimeMs) + } catch { } + } + } + return mtime + } catch { + return 0 + } } private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise { diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index ee15b02..122c33a 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -77,7 +77,8 @@ export class DbPathService { return ( existsSync(join(entryPath, 'db_storage')) || existsSync(join(entryPath, 'FileStorage', 'Image')) || - existsSync(join(entryPath, 'FileStorage', 'Image2')) + existsSync(join(entryPath, 'FileStorage', 'Image2')) || + existsSync(join(entryPath, 'msg', 'attach')) ) } @@ -94,22 +95,21 @@ export class DbPathService { const accountStat = statSync(entryPath) let latest = accountStat.mtimeMs - const dbPath = join(entryPath, 'db_storage') - if (existsSync(dbPath)) { - const dbStat = statSync(dbPath) - latest = Math.max(latest, dbStat.mtimeMs) - } + const checkSubDirs = [ + 'db_storage', + join('FileStorage', 'Image'), + join('FileStorage', 'Image2'), + join('msg', 'attach') + ] - const imagePath = join(entryPath, 'FileStorage', 'Image') - if (existsSync(imagePath)) { - const imageStat = statSync(imagePath) - latest = Math.max(latest, imageStat.mtimeMs) - } - - const image2Path = join(entryPath, 'FileStorage', 'Image2') - if (existsSync(image2Path)) { - const image2Stat = statSync(image2Path) - latest = Math.max(latest, image2Stat.mtimeMs) + for (const sub of checkSubDirs) { + const fullPath = join(entryPath, sub) + if (existsSync(fullPath)) { + try { + const s = statSync(fullPath) + latest = Math.max(latest, s.mtimeMs) + } catch { } + } } return latest diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 7a8c043..20f034b 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -329,28 +329,78 @@ export class ImageDecryptService { } private resolveAccountDir(dbPath: string, wxid: string): string | null { - const cleanedWxid = this.cleanAccountDirName(wxid) + const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase() const normalized = dbPath.replace(/[\\/]+$/, '') + const candidates: { path: string; mtime: number }[] = [] + + // 检查直接路径 const direct = join(normalized, cleanedWxid) - if (existsSync(direct)) return direct + if (existsSync(direct) && this.isAccountDir(direct)) { + candidates.push({ path: direct, mtime: this.getDirMtime(direct) }) + } - if (this.isAccountDir(normalized)) return normalized + // 检查 dbPath 本身是否就是账号目录 + if (this.isAccountDir(normalized)) { + candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) }) + } + // 扫描 dbPath 下的所有子目录寻找匹配的 wxid try { - const entries = readdirSync(normalized) - const lowerWxid = cleanedWxid.toLowerCase() - for (const entry of entries) { - const entryPath = join(normalized, entry) - if (!this.isDirectory(entryPath)) continue - const lowerEntry = entry.toLowerCase() - if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { - if (this.isAccountDir(entryPath)) return entryPath + if (existsSync(normalized) && this.isDirectory(normalized)) { + const entries = readdirSync(normalized) + for (const entry of entries) { + const entryPath = join(normalized, entry) + if (!this.isDirectory(entryPath)) continue + + const lowerEntry = entry.toLowerCase() + // 匹配原 wxid 或带有后缀的 wxid (如 wxid_xxx_1234) + if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) { + if (this.isAccountDir(entryPath)) { + if (!candidates.some(c => c.path === entryPath)) { + candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) }) + } + } + } } } } catch { } - return null + if (candidates.length === 0) return null + + // 按修改时间降序排序,取最新的(最可能是当前活跃的) + candidates.sort((a, b) => b.mtime - a.mtime) + + if (candidates.length > 1) { + this.logInfo('找到多个候选账号目录,选择最新修改的一个', { + selected: candidates[0].path, + all: candidates.map(c => c.path) + }) + } + + return candidates[0].path + } + + private getDirMtime(dirPath: string): number { + try { + const stat = statSync(dirPath) + let mtime = stat.mtimeMs + + // 检查几个关键子目录的修改时间,以更准确地反映活动状态 + const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] + for (const sub of subDirs) { + const fullPath = join(dirPath, sub) + if (existsSync(fullPath)) { + try { + mtime = Math.max(mtime, statSync(fullPath).mtimeMs) + } catch { } + } + } + + return mtime + } catch { + return 0 + } } /** diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 34e7bd0..fb21b2f 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -744,7 +744,8 @@ export class KeyService { return ( existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) + existsSync(join(dirPath, 'FileStorage', 'Image2')) || + existsSync(join(dirPath, 'msg', 'attach')) ) } @@ -761,8 +762,8 @@ export class KeyService { private listAccountDirs(rootDir: string): string[] { try { const entries = readdirSync(rootDir) - const high: string[] = [] - const low: string[] = [] + const candidates: { path: string; mtime: number; isAccount: boolean }[] = [] + for (const entry of entries) { const fullPath = join(rootDir, entry) try { @@ -775,18 +776,48 @@ export class KeyService { continue } - if (this.isAccountDir(fullPath)) { - high.push(fullPath) - } else { - low.push(fullPath) - } + const isAccount = this.isAccountDir(fullPath) + candidates.push({ + path: fullPath, + mtime: this.getDirMtime(fullPath), + isAccount + }) } - return high.length ? high.sort() : low.sort() + + // 优先选择有效账号目录,然后按修改时间从新到旧排序 + return candidates + .sort((a, b) => { + if (a.isAccount !== b.isAccount) return a.isAccount ? -1 : 1 + return b.mtime - a.mtime + }) + .map(c => c.path) } catch { return [] } } + private getDirMtime(dirPath: string): number { + try { + const stat = statSync(dirPath) + let mtime = stat.mtimeMs + + // 检查几个关键子目录的修改时间,以更准确地反映活动状态 + const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] + for (const sub of subDirs) { + const fullPath = join(dirPath, sub) + if (existsSync(fullPath)) { + try { + mtime = Math.max(mtime, statSync(fullPath).mtimeMs) + } catch { } + } + } + + return mtime + } catch { + return 0 + } + } + private normalizeExistingDir(inputPath: string): string | null { const trimmed = inputPath.replace(/[\\\\/]+$/, '') if (!existsSync(trimmed)) return null diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 6b44c2e..089142a 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,11 +1,13 @@ -import { join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { join, basename, extname, dirname } from 'path' +import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, writeFileSync } from 'fs' +import { app } from 'electron' import { ConfigService } from './config' import Database from 'better-sqlite3' import { wcdbService } from './wcdbService' +import crypto from 'crypto' export interface VideoInfo { - videoUrl?: string // 视频文件路径(用于 readFile) + videoUrl?: string // 视频文件路径 coverUrl?: string // 封面 data URL thumbUrl?: string // 缩略图 data URL exists: boolean @@ -13,266 +15,379 @@ export interface VideoInfo { class VideoService { private configService: ConfigService + private resolvedCache = new Map() // md5 -> localPath constructor() { this.configService = new ConfigService() } - /** - * 获取数据库根目录 - */ + private logInfo(message: string, meta?: Record): 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): 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 { return this.configService.get('dbPath') || '' } - /** - * 获取当前用户的wxid - */ private getMyWxid(): string { return this.configService.get('myWxid') || '' } - /** - * 获取缓存目录(解密后的数据库存放位置) - */ - private getCachePath(): string { + private getCacheBasePath(): string { return this.configService.getCacheBasePath() } - /** - * 清理 wxid 目录名(去掉后缀) - */ private cleanWxid(wxid: string): string { 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 } - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) if (suffixMatch) return suffixMatch[1] - return trimmed } - /** - * 从 video_hardlink_info_v4 表查询视频文件名 - * 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3) - * 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db - */ - private async queryVideoFileName(md5: string): Promise { - const cachePath = this.getCachePath() - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - const cleanedWxid = this.cleanWxid(wxid) + 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 }[] = [] - if (!wxid) return undefined - - // 方法1:优先在 cachePath 下查找解密后的 hardlink.db - if (cachePath) { - const cacheDbPaths = [ - join(cachePath, cleanedWxid, 'hardlink.db'), - join(cachePath, wxid, 'hardlink.db'), - join(cachePath, 'hardlink.db'), - join(cachePath, 'databases', cleanedWxid, 'hardlink.db'), - join(cachePath, 'databases', wxid, 'hardlink.db') - ] - - for (const p of cacheDbPaths) { - if (existsSync(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(/\.[^.]+$/, '') - return realMd5 - } - } catch (e) { - // 忽略错误 - } - } + 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) }) } } - // 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db - if (dbPath) { - // 检查 dbPath 是否已经包含 wxid - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxidLower = cleanedWxid.toLowerCase() - const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) + checkDir(join(normalized, wxid)) + checkDir(join(normalized, cleanedWxid)) + checkDir(normalized) - const encryptedDbPaths: string[] = [] - if (dbPathContainsWxid) { - // dbPath 已包含 wxid,不需要再拼接 - encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')) - } else { - // dbPath 不包含 wxid,需要拼接 - encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db')) - encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')) - } - - for (const p of encryptedDbPaths) { - if (existsSync(p)) { + try { + if (existsSync(normalized) && statSync(normalized).isDirectory()) { + const entries = readdirSync(normalized) + for (const entry of entries) { + const entryPath = join(normalized, entry) try { - const escapedMd5 = md5.replace(/'/g, "''") - - // 用 md5 字段查询,获取 file_name - const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` - - const result = await wcdbService.execQuery('media', p, sql) - - 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(/\.[^.]+$/, '') - return realMd5 - } - } - } catch (e) { - // 忽略错误 + 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 { + 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) + } + + /** + * 计算会话哈希(对应磁盘目录名) + */ + private md5Hash(text: string): string { + return crypto.createHash('md5').update(text).digest('hex') + } + + private async resolveHardlinkPath(accountDir: string, md5: string): Promise { + const dbPath = join(accountDir, 'db_storage', 'hardlink', 'hardlink.db') + if (!existsSync(dbPath)) { + this.logInfo('hardlink.db 不存在', { dbPath }) + return null + } + + try { + const ready = await this.ensureWcdbReady() + if (!ready) return null + + const tableResult = await wcdbService.execQuery('media', dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'video_hardlink_info%' ORDER BY name DESC LIMIT 1") + + if (!tableResult.success || !tableResult.rows?.length) return null + const tableName = tableResult.rows[0].name + + const escapedMd5 = md5.replace(/'/g, "''") + const rowResult = await wcdbService.execQuery('media', dbPath, + `SELECT dir1, dir2, file_name FROM ${tableName} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`) + + if (!rowResult.success || !rowResult.rows?.length) return null + + const row = rowResult.rows[0] + const dir1 = row.dir1 ?? row.DIR1 + 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) { + if (existsSync(p)) { + this.logInfo('hardlink 命中', { path: p }) + return p + } + } + } catch (e) { + this.logError('resolveHardlinkPath 异常', e) + } + return null + } + + private async searchVideoFile(accountDir: string, md5: string, sessionId?: string): Promise { + 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 个月) + 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 } - /** - * 将文件转换为 data URL - */ - private fileToDataUrl(filePath: string, mimeType: string): string | undefined { - try { - if (!existsSync(filePath)) return undefined - const buffer = readFileSync(filePath) - return `data:${mimeType};base64,${buffer.toString('base64')}` - } catch { - return undefined + private isEncrypted(buffer: Buffer, xorKey: number, type: 'video' | 'image'): boolean { + if (buffer.length < 8) return false + const first = buffer[0] ^ xorKey + const second = buffer[1] ^ xorKey + + 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 开头 } } - /** - * 根据视频MD5获取视频文件信息 - * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ - * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg - */ - async getVideoInfo(videoMd5: string): Promise { - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - - if (!dbPath || !wxid || !videoMd5) { - return { exists: false } - } - - // 先尝试从数据库查询真正的视频文件名 - const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 - - // 检查 dbPath 是否已经包含 wxid,避免重复拼接 - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxid = this.cleanWxid(wxid) - - let videoBaseDir: string - if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) { - // dbPath 已经包含 wxid,直接使用 - videoBaseDir = join(dbPath, 'msg', 'video') - } else { - // dbPath 不包含 wxid,需要拼接 - videoBaseDir = join(dbPath, wxid, 'msg', 'video') - } - - if (!existsSync(videoBaseDir)) { - return { exists: false } - } - - // 遍历年月目录查找视频文件 + private filePathToUrl(filePath: string): string { try { - const allDirs = readdirSync(videoBaseDir) + 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, '/')}` + } + } - // 支持多种目录格式: YYYY-MM, YYYYMM, 或其他 - const yearMonthDirs = allDirs - .filter(dir => { - const dirPath = join(videoBaseDir, dir) - return statSync(dirPath).isDirectory() - }) - .sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找 + private handleFile(filePath: string, type: 'video' | 'image', sessionId?: string): string | undefined { + if (!existsSync(filePath)) return undefined + const xorKey = this.getXorKey() + + try { + const buffer = readFileSync(filePath) + const isEnc = xorKey !== undefined && !Number.isNaN(xorKey) && this.isEncrypted(buffer, xorKey, type) - 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`) - - // 检查视频文件是否存在 - if (existsSync(videoPath)) { - return { - videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 - coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), - thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), - exists: true + if (isEnc) { + 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')}` + } + return this.filePathToUrl(filePath) } catch (e) { - // 忽略错误 + this.logError(`处理${type}文件异常: ${filePath}`, e) + return type === 'image' ? undefined : this.filePathToUrl(filePath) + } + } + + async getVideoInfo(videoMd5: string, sessionId?: string): Promise { + this.logInfo('获取视频信息', { videoMd5, sessionId }) + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + if (!dbPath || !wxid || !videoMd5) return { exists: false } + + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) { + this.logError('未找到账号目录', undefined, { dbPath, wxid }) + return { exists: false } } + // 1. 通过 hardlink 映射 + let videoPath = await this.resolveHardlinkPath(accountDir, videoMd5) + + // 2. 启发式搜索 + if (!videoPath) { + videoPath = await this.searchVideoFile(accountDir, videoMd5, sessionId) + } + + if (videoPath && existsSync(videoPath)) { + this.logInfo('定位成功', { videoPath }) + const base = videoPath.slice(0, -4) + const coverPath = `${base}.jpg` + const thumbPath = `${base}_thumb.jpg` + + return { + videoUrl: this.handleFile(videoPath, 'video', sessionId), + coverUrl: this.handleFile(coverPath, 'image', sessionId), + thumbUrl: this.handleFile(thumbPath, 'image', sessionId), + exists: true + } + } + + this.logInfo('定位失败', { videoMd5 }) return { exists: false } } - /** - * 根据消息内容解析视频MD5 - */ parseVideoMd5(content: string): string | undefined { - - // 打印前500字符看看 XML 结构 - if (!content) return undefined - try { - // 提取所有可能的 md5 值进行日志 - const allMd5s: string[] = [] - const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi - let match - while ((match = md5Regex.exec(content)) !== null) { - allMd5s.push(`${match[0]}`) - } - - // 提取 md5(用于查询 hardlink.db) - // 注意:不是 rawmd5,rawmd5 是另一个值 - // 格式: md5="xxx" 或 xxx - - // 尝试从videomsg标签中提取md5 - const videoMsgMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (videoMsgMatch) { - return videoMsgMatch[1].toLowerCase() - } - - const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (attrMatch) { - return attrMatch[1].toLowerCase() - } - - const md5Match = /([a-fA-F0-9]+)<\/md5>/i.exec(content) - if (md5Match) { - return md5Match[1].toLowerCase() - } - } catch (e) { - console.error('[VideoService] 解析视频MD5失败:', e) - } - - return undefined + const m = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || + /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || + /([a-fA-F0-9]+)<\/md5>/i.exec(content) + return m ? m[1].toLowerCase() : undefined + } catch { return undefined } } } diff --git a/package-lock.json b/package-lock.json index 92d10ba..4ac3dc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2909,6 +2910,7 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3055,6 +3057,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3994,6 +3997,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5103,6 +5107,7 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5290,6 +5295,7 @@ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "5.6.1" @@ -5376,7 +5382,6 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5390,7 +5395,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5406,7 +5410,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5420,7 +5423,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -9150,6 +9152,7 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9159,6 +9162,7 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9593,6 +9597,7 @@ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -10434,6 +10439,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10881,6 +10887,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10970,7 +10977,8 @@ "resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -10996,6 +11004,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b5ec3df..af71340 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3543,7 +3543,7 @@ function MessageBubble({ videoLoadingRef.current = true setVideoLoading(true) try { - const result = await window.electronAPI.video.getVideoInfo(videoMd5) + const result = await window.electronAPI.video.getVideoInfo(videoMd5, session.username) if (result && result.success && result.exists) { setVideoInfo({ exists: result.exists, @@ -3560,7 +3560,7 @@ function MessageBubble({ videoLoadingRef.current = false setVideoLoading(false) } - }, [videoMd5]) + }, [videoMd5, session.username]) // 视频进入视野时自动加载 useEffect(() => {