mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
@@ -194,12 +194,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
}
|
||||
},
|
||||
|
||||
// 视频
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||
},
|
||||
|
||||
// 视频
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||
},
|
||||
// 数据分析
|
||||
analytics: {
|
||||
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||
|
||||
@@ -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<string | null> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||
@@ -11,16 +11,7 @@ import { wcdbService } from './wcdbService'
|
||||
// 获取 ffmpeg-static 的路径
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
try {
|
||||
// 优先处理打包后的路径
|
||||
if (app.isPackaged) {
|
||||
const resourcesPath = process.resourcesPath
|
||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(packedPath)) {
|
||||
return packedPath
|
||||
}
|
||||
}
|
||||
|
||||
// 方法1: 直接 require ffmpeg-static(开发环境)
|
||||
// 方法1: 直接 require ffmpeg-static
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
|
||||
@@ -28,12 +19,21 @@ function getStaticFfmpegPath(): string | null {
|
||||
return ffmpegStatic
|
||||
}
|
||||
|
||||
// 方法2: 手动构建路径(开发环境备用)
|
||||
// 方法2: 手动构建路径(开发环境)
|
||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(devPath)) {
|
||||
return devPath
|
||||
}
|
||||
|
||||
// 方法3: 打包后的路径
|
||||
if (app.isPackaged) {
|
||||
const resourcesPath = process.resourcesPath
|
||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(packedPath)) {
|
||||
return packedPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
@@ -45,7 +45,6 @@ type DecryptResult = {
|
||||
localPath?: string
|
||||
error?: string
|
||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||
liveVideoPath?: string // 实况照片的视频路径
|
||||
}
|
||||
|
||||
type HardlinkState = {
|
||||
@@ -62,7 +61,6 @@ export class ImageDecryptService {
|
||||
private cacheIndexed = false
|
||||
private cacheIndexing: Promise<void> | null = null
|
||||
private updateFlags = new Map<string, boolean>()
|
||||
private noLiveSet = new Set<string>() // 已确认无 live 视频的图片路径
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
@@ -118,9 +116,8 @@ export class ImageDecryptService {
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached)
|
||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath }
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
this.resolvedCache.delete(key)
|
||||
@@ -139,9 +136,8 @@ export class ImageDecryptService {
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
|
||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath }
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
@@ -155,25 +151,13 @@ export class ImageDecryptService {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
}
|
||||
|
||||
if (payload.force) {
|
||||
const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId)
|
||||
if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) {
|
||||
const dataUrl = this.fileToDataUrl(hdCached)
|
||||
const localPath = dataUrl || this.filePathToUrl(hdCached)
|
||||
const liveVideoPath = this.checkLiveVideoCache(hdCached)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, isThumb: false, liveVideoPath }
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.force) {
|
||||
const cached = this.resolvedCache.get(cacheKey)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const dataUrl = this.fileToDataUrl(cached)
|
||||
const localPath = dataUrl || this.filePathToUrl(cached)
|
||||
const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, liveVideoPath }
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
this.resolvedCache.delete(cacheKey)
|
||||
@@ -251,9 +235,8 @@ export class ImageDecryptService {
|
||||
const dataUrl = this.fileToDataUrl(existing)
|
||||
const localPath = dataUrl || this.filePathToUrl(existing)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, isThumb, liveVideoPath }
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,14 +280,14 @@ export class ImageDecryptService {
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
// 对于 hevc 格式,返回错误提示
|
||||
if (finalExt === '.hevc') {
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
||||
error: '此图片为微信新格式 (wxgf),需要安装 ffmpeg 才能显示',
|
||||
isThumb: this.isThumbnailPath(datPath)
|
||||
}
|
||||
}
|
||||
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
if (!isThumb) {
|
||||
@@ -313,15 +296,7 @@ export class ImageDecryptService {
|
||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
|
||||
// 检测实况照片(Motion Photo)
|
||||
let liveVideoPath: string | undefined
|
||||
if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) {
|
||||
const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted)
|
||||
if (videoPath) liveVideoPath = this.filePathToUrl(videoPath)
|
||||
}
|
||||
|
||||
return { success: true, localPath, isThumb, liveVideoPath }
|
||||
return { success: true, localPath, isThumb }
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: String(e) }
|
||||
@@ -357,37 +332,23 @@ export class ImageDecryptService {
|
||||
* 获取解密后的缓存目录(用于查找 hardlink.db)
|
||||
*/
|
||||
private getDecryptedCacheDir(wxid: string): string | null {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
if (!cachePath) return null
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const configured = this.configService.get('cachePath')
|
||||
const documentsPath = app.getPath('documents')
|
||||
const baseCandidates = Array.from(new Set([
|
||||
configured || '',
|
||||
join(documentsPath, 'WeFlow'),
|
||||
join(documentsPath, 'WeFlowData'),
|
||||
this.configService.getCacheBasePath()
|
||||
].filter(Boolean)))
|
||||
const cacheAccountDir = join(cachePath, cleanedWxid)
|
||||
|
||||
for (const base of baseCandidates) {
|
||||
const accountCandidates = Array.from(new Set([
|
||||
join(base, wxid),
|
||||
join(base, cleanedWxid),
|
||||
join(base, 'databases', wxid),
|
||||
join(base, 'databases', cleanedWxid)
|
||||
]))
|
||||
for (const accountDir of accountCandidates) {
|
||||
if (existsSync(join(accountDir, 'hardlink.db'))) {
|
||||
return accountDir
|
||||
}
|
||||
const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink')
|
||||
if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) {
|
||||
return hardlinkSubdir
|
||||
}
|
||||
}
|
||||
if (existsSync(join(base, 'hardlink.db'))) {
|
||||
return base
|
||||
}
|
||||
// 检查缓存目录下是否有 hardlink.db
|
||||
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
|
||||
return cacheAccountDir
|
||||
}
|
||||
if (existsSync(join(cachePath, 'hardlink.db'))) {
|
||||
return cachePath
|
||||
}
|
||||
const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink')
|
||||
if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) {
|
||||
return cacheHardlinkDir
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -396,8 +357,7 @@ export class ImageDecryptService {
|
||||
existsSync(join(dirPath, 'hardlink.db')) ||
|
||||
existsSync(join(dirPath, 'db_storage')) ||
|
||||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
||||
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
|
||||
existsSync(join(dirPath, 'msg', 'attach'))
|
||||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -435,14 +395,35 @@ export class ImageDecryptService {
|
||||
const allowThumbnail = options?.allowThumbnail ?? true
|
||||
const skipResolvedCache = options?.skipResolvedCache ?? false
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
||||
accountDir,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
allowThumbnail,
|
||||
skipResolvedCache
|
||||
})
|
||||
|
||||
if (!skipResolvedCache) {
|
||||
if (imageMd5) {
|
||||
const cached = this.resolvedCache.get(imageMd5)
|
||||
if (cached && existsSync(cached)) return cached
|
||||
}
|
||||
if (imageDatName) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) return cached
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
||||
if (imageMd5) {
|
||||
const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// 优先通过 hardlink.db 查询
|
||||
if (imageMd5) {
|
||||
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
||||
@@ -463,12 +444,6 @@ export class ImageDecryptService {
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false)
|
||||
if (hdInDir) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdInDir)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||
return hdInDir
|
||||
}
|
||||
// 没找到高清图,返回 null(不进行全局搜索)
|
||||
return null
|
||||
}
|
||||
@@ -486,16 +461,9 @@ export class ImageDecryptService {
|
||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false)
|
||||
if (hdInDir) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdInDir)
|
||||
this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||
return hdInDir
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
@@ -518,17 +486,15 @@ export class ImageDecryptService {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false)
|
||||
if (hdInDir) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||
return hdInDir
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
}
|
||||
|
||||
// force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图
|
||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
||||
if (!allowThumbnail) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!imageDatName) return null
|
||||
if (!skipResolvedCache) {
|
||||
@@ -538,8 +504,6 @@ export class ImageDecryptService {
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(cached)
|
||||
if (hdPath) return hdPath
|
||||
const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false)
|
||||
if (hdInDir) return hdInDir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,9 +604,7 @@ export class ImageDecryptService {
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
private looksLikeMd5(value: string): boolean {
|
||||
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
||||
}
|
||||
|
||||
|
||||
private resolveHardlinkDbPath(accountDir: string): string | null {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
@@ -858,7 +820,7 @@ export class ImageDecryptService {
|
||||
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
||||
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
||||
*/
|
||||
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> {
|
||||
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
|
||||
const { promises: fs } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
@@ -894,7 +856,7 @@ export class ImageDecryptService {
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// --- 策略 B: 新版 Session 哈希路径猜测 ---
|
||||
// --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 ---
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||
const sessionDirs = entries
|
||||
@@ -947,7 +909,7 @@ export class ImageDecryptService {
|
||||
|
||||
/**
|
||||
* 在同一目录下查找高清图变体
|
||||
* 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat
|
||||
* 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat
|
||||
*/
|
||||
private findHdVariantInSameDir(thumbPath: string): string | null {
|
||||
try {
|
||||
@@ -1029,55 +991,6 @@ export class ImageDecryptService {
|
||||
})
|
||||
}
|
||||
|
||||
private matchesDatName(fileName: string, datName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
const normalizedBase = this.normalizeDatBase(base)
|
||||
const normalizedTarget = this.normalizeDatBase(datName.toLowerCase())
|
||||
if (normalizedBase === normalizedTarget) return true
|
||||
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i')
|
||||
if (pattern.test(lower)) return true
|
||||
return lower.endsWith('.dat') && lower.includes(datName)
|
||||
}
|
||||
|
||||
private scoreDatName(fileName: string): number {
|
||||
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
|
||||
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
private isThumbnailDat(fileName: string): boolean {
|
||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
||||
}
|
||||
|
||||
private hasXVariant(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
}
|
||||
|
||||
private isThumbnailPath(filePath: string): boolean {
|
||||
const lower = basename(filePath).toLowerCase()
|
||||
if (this.isThumbnailDat(lower)) return true
|
||||
const ext = extname(lower)
|
||||
const base = ext ? lower.slice(0, -ext.length) : lower
|
||||
// 支持新命名 _thumb 和旧命名 _t
|
||||
return base.endsWith('_t') || base.endsWith('_thumb')
|
||||
}
|
||||
|
||||
private isHdPath(filePath: string): boolean {
|
||||
const lower = basename(filePath).toLowerCase()
|
||||
const ext = extname(lower)
|
||||
const base = ext ? lower.slice(0, -ext.length) : lower
|
||||
return base.endsWith('_hd') || base.endsWith('_h')
|
||||
}
|
||||
|
||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
}
|
||||
|
||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
|
||||
}
|
||||
|
||||
private normalizeDatBase(name: string): string {
|
||||
let base = name.toLowerCase()
|
||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||
@@ -1089,27 +1002,16 @@ export class ImageDecryptService {
|
||||
return base
|
||||
}
|
||||
|
||||
private sanitizeDirName(name: string): string {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return 'unknown'
|
||||
return trimmed.replace(/[<>:"/\\|?*]/g, '_')
|
||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
}
|
||||
|
||||
private resolveTimeDir(datPath: string): string {
|
||||
const parts = datPath.split(/[\\/]+/)
|
||||
for (const part of parts) {
|
||||
if (/^\d{4}-\d{2}$/.test(part)) return part
|
||||
}
|
||||
try {
|
||||
const stat = statSync(datPath)
|
||||
const year = stat.mtime.getFullYear()
|
||||
const month = String(stat.mtime.getMonth() + 1).padStart(2, '0')
|
||||
return `${year}-${month}`
|
||||
} catch {
|
||||
return 'unknown-time'
|
||||
}
|
||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
|
||||
}
|
||||
|
||||
|
||||
|
||||
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
||||
@@ -1344,14 +1246,14 @@ export class ImageDecryptService {
|
||||
private async ensureCacheIndexed(): Promise<void> {
|
||||
if (this.cacheIndexed) return
|
||||
if (this.cacheIndexing) return this.cacheIndexing
|
||||
this.cacheIndexing = new Promise((resolve) => {
|
||||
this.cacheIndexing = (async () => {
|
||||
// 扫描所有可能的缓存根目录
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
||||
|
||||
for (const root of allRoots) {
|
||||
try {
|
||||
this.indexCacheDir(root, 3, 0) // 增加深度到3,支持 sessionId/YYYY-MM 结构
|
||||
this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构
|
||||
} catch (e) {
|
||||
this.logError('索引目录失败', e, { root })
|
||||
}
|
||||
@@ -1360,8 +1262,7 @@ export class ImageDecryptService {
|
||||
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
|
||||
this.cacheIndexed = true
|
||||
this.cacheIndexing = null
|
||||
resolve()
|
||||
})
|
||||
})()
|
||||
return this.cacheIndexing
|
||||
}
|
||||
|
||||
@@ -1564,14 +1465,14 @@ export class ImageDecryptService {
|
||||
|
||||
private bytesToInt32(bytes: Buffer): number {
|
||||
if (bytes.length !== 4) {
|
||||
throw new Error('需要4个字节')
|
||||
throw new Error('需要 4 个字节')
|
||||
}
|
||||
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
||||
}
|
||||
|
||||
asciiKey16(keyString: string): Buffer {
|
||||
if (keyString.length < 16) {
|
||||
throw new Error('AES密钥至少需要16个字符')
|
||||
throw new Error('AES密钥至少需要 16 个字符')
|
||||
}
|
||||
return Buffer.from(keyString, 'ascii').subarray(0, 16)
|
||||
}
|
||||
@@ -1738,76 +1639,6 @@ export class ImageDecryptService {
|
||||
return mostCommonKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined
|
||||
* 已确认无 live 的路径会被记录,下次直接跳过
|
||||
*/
|
||||
private checkLiveVideoCache(imagePath: string): string | undefined {
|
||||
if (this.noLiveSet.has(imagePath)) return undefined
|
||||
const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
|
||||
if (existsSync(livePath)) return this.filePathToUrl(livePath)
|
||||
this.noLiveSet.add(imagePath)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测并分离 Motion Photo(实况照片)
|
||||
* Google Motion Photo = JPEG + MP4 拼接在一起
|
||||
* 返回视频文件路径,如果不是实况照片则返回 null
|
||||
*/
|
||||
private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise<string | null> {
|
||||
// 只处理 JPEG 文件
|
||||
if (imageBuffer.length < 8) return null
|
||||
if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null
|
||||
|
||||
// 从末尾向前搜索 MP4 ftyp 原子签名
|
||||
// ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...]
|
||||
// 实际起始位置在 ftyp 前4字节(大小字段)
|
||||
const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp'
|
||||
let videoOffset: number | null = null
|
||||
|
||||
const searchEnd = Math.max(0, imageBuffer.length - 8)
|
||||
for (let i = searchEnd; i > 0; i--) {
|
||||
if (imageBuffer[i] === ftypSig[0] &&
|
||||
imageBuffer[i + 1] === ftypSig[1] &&
|
||||
imageBuffer[i + 2] === ftypSig[2] &&
|
||||
imageBuffer[i + 3] === ftypSig[3]) {
|
||||
// ftyp 前4字节是 box size,实际 MP4 从这里开始
|
||||
videoOffset = i - 4
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 备用:从 XMP 元数据中读取偏移量
|
||||
if (videoOffset === null || videoOffset <= 0) {
|
||||
try {
|
||||
const text = imageBuffer.toString('latin1')
|
||||
const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i)
|
||||
if (match) {
|
||||
const offset = parseInt(match[1], 10)
|
||||
if (offset > 0 && offset < imageBuffer.length) {
|
||||
videoOffset = imageBuffer.length - offset
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (videoOffset === null || videoOffset <= 100) return null
|
||||
|
||||
// 验证视频部分确实以有效 MP4 数据开头
|
||||
const videoStart = imageBuffer[videoOffset + 4] === 0x66 &&
|
||||
imageBuffer[videoOffset + 5] === 0x74 &&
|
||||
imageBuffer[videoOffset + 6] === 0x79 &&
|
||||
imageBuffer[videoOffset + 7] === 0x70
|
||||
if (!videoStart) return null
|
||||
|
||||
// 写出视频文件
|
||||
const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
|
||||
const videoBuffer = imageBuffer.slice(videoOffset)
|
||||
await writeFile(videoPath, videoBuffer)
|
||||
return videoPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包 wxgf 格式
|
||||
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||
@@ -1851,7 +1682,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||
* 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦
|
||||
*/
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
const nalUnits: Buffer[] = []
|
||||
@@ -1961,6 +1792,44 @@ export class ImageDecryptService {
|
||||
})
|
||||
}
|
||||
|
||||
private looksLikeMd5(s: string): boolean {
|
||||
return /^[a-f0-9]{32}$/i.test(s)
|
||||
}
|
||||
|
||||
private isThumbnailDat(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
|
||||
}
|
||||
|
||||
private hasXVariant(base: string): boolean {
|
||||
const lower = base.toLowerCase()
|
||||
return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t')
|
||||
}
|
||||
|
||||
private isHdPath(p: string): boolean {
|
||||
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
|
||||
}
|
||||
|
||||
private isThumbnailPath(p: string): boolean {
|
||||
const lower = p.toLowerCase()
|
||||
return lower.includes('_thumb') || lower.includes('_t') || lower.includes('.t.')
|
||||
}
|
||||
|
||||
private sanitizeDirName(s: string): string {
|
||||
return s.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unknown'
|
||||
}
|
||||
|
||||
private resolveTimeDir(filePath: string): string {
|
||||
try {
|
||||
const stats = statSync(filePath)
|
||||
const d = new Date(stats.mtime)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
} catch {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原有的解密到文件方法(用于兼容)
|
||||
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||
const version = this.getDatVersion(inputPath)
|
||||
@@ -1973,7 +1842,7 @@ export class ImageDecryptService {
|
||||
decrypted = this.decryptDatV4(inputPath, xorKey, key)
|
||||
} else {
|
||||
if (!aesKey || aesKey.length !== 16) {
|
||||
throw new Error('V4版本需要16字节AES密钥')
|
||||
throw new Error('V4版本需要 16 字节 AES 密钥')
|
||||
}
|
||||
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join } from 'path'
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
import Database from 'better-sqlite3'
|
||||
@@ -36,7 +36,7 @@ class VideoService {
|
||||
* 获取缓存目录(解密后的数据库存放位置)
|
||||
*/
|
||||
private getCachePath(): string {
|
||||
return this.configService.getCacheBasePath()
|
||||
return this.configService.get('cachePath') || ''
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +69,9 @@ class VideoService {
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
console.log('[VideoService] queryVideoFileName called with MD5:', md5)
|
||||
console.log('[VideoService] cachePath:', cachePath, 'dbPath:', dbPath, 'wxid:', wxid, 'cleanedWxid:', cleanedWxid)
|
||||
|
||||
if (!wxid) return undefined
|
||||
|
||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
||||
@@ -83,6 +86,7 @@ class VideoService {
|
||||
|
||||
for (const p of cacheDbPaths) {
|
||||
if (existsSync(p)) {
|
||||
console.log('[VideoService] Found decrypted hardlink.db at:', p)
|
||||
try {
|
||||
const db = new Database(p, { readonly: true })
|
||||
const row = db.prepare(`
|
||||
@@ -94,10 +98,11 @@ class VideoService {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,46 +110,41 @@ class VideoService {
|
||||
|
||||
// 方法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)
|
||||
|
||||
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'))
|
||||
}
|
||||
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) {
|
||||
// 忽略错误
|
||||
console.log('[VideoService] Failed to query encrypted hardlink.db via wcdbService:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[VideoService] No matching video found in hardlink.db')
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -167,37 +167,34 @@ class VideoService {
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
||||
console.log('[VideoService] getVideoInfo called with MD5:', videoMd5)
|
||||
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
console.log('[VideoService] Config - dbPath:', dbPath, 'wxid:', wxid)
|
||||
|
||||
if (!dbPath || !wxid || !videoMd5) {
|
||||
console.log('[VideoService] Missing required params')
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
// 先尝试从数据库查询真正的视频文件名
|
||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||
console.log('[VideoService] Real video MD5:', realVideoMd5)
|
||||
|
||||
// 检查 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')
|
||||
}
|
||||
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
||||
console.log('[VideoService] Video base dir:', videoBaseDir)
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
console.log('[VideoService] Video base dir does not exist')
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
// 遍历年月目录查找视频文件
|
||||
try {
|
||||
const allDirs = readdirSync(videoBaseDir)
|
||||
console.log('[VideoService] Found year-month dirs:', allDirs)
|
||||
|
||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
||||
const yearMonthDirs = allDirs
|
||||
@@ -214,8 +211,11 @@ class VideoService {
|
||||
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 {
|
||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
@@ -224,8 +224,10 @@ class VideoService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[VideoService] Video file not found in any directory')
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
console.error('[VideoService] Error searching for video:', e)
|
||||
}
|
||||
|
||||
return { exists: false }
|
||||
@@ -235,8 +237,10 @@ class VideoService {
|
||||
* 根据消息内容解析视频MD5
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -248,6 +252,7 @@ class VideoService {
|
||||
while ((match = md5Regex.exec(content)) !== null) {
|
||||
allMd5s.push(`${match[0]}`)
|
||||
}
|
||||
console.log('[VideoService] All MD5 attributes found:', allMd5s)
|
||||
|
||||
// 提取 md5(用于查询 hardlink.db)
|
||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
||||
@@ -256,18 +261,23 @@ class VideoService {
|
||||
// 尝试从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)
|
||||
}
|
||||
|
||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -3540,13 +3540,12 @@ function MessageBubble({
|
||||
const requestVideoInfo = useCallback(async () => {
|
||||
if (!videoMd5 || videoLoadingRef.current) return
|
||||
|
||||
videoLoadingRef.current = true
|
||||
setVideoLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.video.getVideoInfo(videoMd5)
|
||||
if (result && result.success && result.exists) {
|
||||
setVideoInfo({
|
||||
exists: result.exists,
|
||||
videoLoadingRef.current = true
|
||||
setVideoLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.video.getVideoInfo(videoMd5)
|
||||
if (result && result.success && result.exists) {
|
||||
setVideoInfo({ exists: result.exists,
|
||||
videoUrl: result.videoUrl,
|
||||
coverUrl: result.coverUrl,
|
||||
thumbUrl: result.thumbUrl
|
||||
|
||||
Reference in New Issue
Block a user