图片与视频索引优化 #786;修复 #786;修复导出页面打开目录缺失路径的问题;完善朋友圈卡片封面解析

This commit is contained in:
cc
2026-04-18 12:54:14 +08:00
parent 74012ab252
commit 6c84e0c35a
15 changed files with 1250 additions and 573 deletions

View File

@@ -4336,9 +4336,9 @@ class ChatService {
encrypVer = imageInfo.encrypVer
cdnThumbUrl = imageInfo.cdnThumbUrl
imageDatName = this.parseImageDatNameFromRow(row)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = this.parseVideoMd5(content)
} else if (localType === 43) {
// 视频消息:优先从 packed_info_data 提取真实文件名32位十六进制再回退 XML
videoMd5 = this.parseVideoFileNameFromRow(row, content)
} else if (localType === 34 && content) {
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
} else if (localType === 42 && content) {
@@ -4876,7 +4876,20 @@ class ChatService {
}
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
const packed = row.packed_info_data
const packed = this.getRowField(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
const buffer = this.decodePackedInfo(packed)
if (!buffer || buffer.length === 0) return undefined
const printable: number[] = []
@@ -4894,6 +4907,81 @@ class ChatService {
return hexMatch?.[1]?.toLowerCase()
}
private parseVideoFileNameFromRow(row: Record<string, any>, content?: string): string | undefined {
const packed = this.getRowField(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
const packedToken = this.extractVideoTokenFromPackedRaw(packed)
if (packedToken) return packedToken
const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [
'video_md5',
'videoMd5',
'raw_md5',
'rawMd5',
'video_file_name',
'videoFileName'
]))
if (byColumn) return byColumn
return this.normalizeVideoFileToken(this.parseVideoMd5(content || ''))
}
private normalizeVideoFileToken(value: unknown): string | undefined {
let text = String(value || '').trim().toLowerCase()
if (!text) return undefined
text = text.replace(/^.*[\\/]/, '')
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
text = text.replace(/_thumb$/, '')
const directMatch = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
if (directMatch) {
const suffix = /_raw$/i.test(text) ? '_raw' : ''
return `${directMatch[1].toLowerCase()}${suffix}`
}
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const generic = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
return generic?.[1]?.toLowerCase()
}
private extractVideoTokenFromPackedRaw(raw: unknown): string | undefined {
const buffer = this.decodePackedInfo(raw)
if (!buffer || buffer.length === 0) return undefined
const candidates: string[] = []
let current = ''
for (const byte of buffer) {
const isHex =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66)
if (isHex) {
current += String.fromCharCode(byte)
continue
}
if (current.length >= 16) candidates.push(current)
current = ''
}
if (current.length >= 16) candidates.push(current)
if (candidates.length === 0) return undefined
const exact32 = candidates.find((item) => item.length === 32)
if (exact32) return exact32.toLowerCase()
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
return fallback?.toLowerCase()
}
private decodePackedInfo(raw: any): Buffer | null {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
@@ -4901,9 +4989,10 @@ class ChatService {
if (Array.isArray(raw)) return Buffer.from(raw)
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
const compactHex = trimmed.replace(/\s+/g, '')
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
try {
return Buffer.from(trimmed, 'hex')
return Buffer.from(compactHex, 'hex')
} catch { }
}
try {
@@ -10490,6 +10579,8 @@ class ChatService {
const imgInfo = this.parseImageInfo(rawContent)
Object.assign(msg, imgInfo)
msg.imageDatName = this.parseImageDatNameFromRow(row)
} else if (msg.localType === 43) { // Video
msg.videoMd5 = this.parseVideoFileNameFromRow(row, rawContent)
} else if (msg.localType === 47) { // Emoji
const emojiInfo = this.parseEmojiInfo(rawContent)
msg.emojiCdnUrl = emojiInfo.cdnUrl

View File

@@ -3780,7 +3780,6 @@ class ExportService {
const md5Pattern = /^[a-f0-9]{32}$/i
const imageMd5Set = new Set<string>()
const videoMd5Set = new Set<string>()
let scanIndex = 0
for (const msg of messages) {
@@ -3800,19 +3799,12 @@ class ExportService {
}
}
if (options.exportVideos && msg?.localType === 43) {
const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase()
if (videoMd5) videoMd5Set.add(videoMd5)
}
}
const preloadTasks: Array<Promise<void>> = []
if (imageMd5Set.size > 0) {
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
}
if (videoMd5Set.size > 0) {
preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set)))
}
if (preloadTasks.length === 0) return
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
@@ -4102,6 +4094,95 @@ class ExportService {
return tagMatch?.[1]?.toLowerCase()
}
private decodePackedInfoBuffer(raw: unknown): Buffer | null {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
if (raw instanceof Uint8Array) return Buffer.from(raw)
if (Array.isArray(raw)) return Buffer.from(raw)
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed) return null
const compactHex = trimmed.replace(/\s+/g, '')
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
try {
return Buffer.from(compactHex, 'hex')
} catch { }
}
try {
const decoded = Buffer.from(trimmed, 'base64')
if (decoded.length > 0) return decoded
} catch { }
return null
}
if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
return Buffer.from((raw as any).data)
}
return null
}
private normalizeVideoFileToken(value: unknown): string | undefined {
let text = String(value || '').trim().toLowerCase()
if (!text) return undefined
text = text.replace(/^.*[\\/]/, '')
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
text = text.replace(/_thumb$/, '')
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
if (direct) {
const suffix = /_raw$/i.test(text) ? '_raw' : ''
return `${direct[1].toLowerCase()}${suffix}`
}
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
return fallback?.[1]?.toLowerCase()
}
private extractVideoFileNameFromPackedRaw(raw: unknown): string | undefined {
const buffer = this.decodePackedInfoBuffer(raw)
if (!buffer || buffer.length === 0) return undefined
const candidates: string[] = []
let current = ''
for (const byte of buffer) {
const isHex =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66)
if (isHex) {
current += String.fromCharCode(byte)
continue
}
if (current.length >= 16) candidates.push(current)
current = ''
}
if (current.length >= 16) candidates.push(current)
if (candidates.length === 0) return undefined
const exact32 = candidates.find((item) => item.length === 32)
if (exact32) return exact32.toLowerCase()
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
return fallback?.toLowerCase()
}
private extractVideoFileNameFromRow(row: Record<string, any>, content?: string): string | undefined {
const packedRaw = this.getRowField(row, [
'packed_info_data', 'packedInfoData',
'packed_info_blob', 'packedInfoBlob',
'packed_info', 'packedInfo',
'BytesExtra', 'bytes_extra',
'WCDB_CT_packed_info',
'reserved0', 'Reserved0', 'WCDB_CT_Reserved0'
])
const byPacked = this.extractVideoFileNameFromPackedRaw(packedRaw)
if (byPacked) return byPacked
const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [
'video_md5', 'videoMd5', 'raw_md5', 'rawMd5', 'video_file_name', 'videoFileName'
]))
if (byColumn) return byColumn
return this.normalizeVideoFileToken(this.extractVideoMd5(content || ''))
}
private resolveFileAttachmentRoots(): string[] {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const rawWxid = String(this.configService.get('myWxid') || '').trim()
@@ -4567,7 +4648,7 @@ class ExportService {
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined
videoMd5 = this.extractVideoFileNameFromRow(row, content)
if (localType === 3 && content) {
// 图片消息
@@ -4575,7 +4656,7 @@ class ExportService {
imageDatName = imageDatName || this.extractImageDatName(content)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = videoMd5 || this.extractVideoMd5(content)
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
} else if (collectMode === 'full' && content && (localType === 49 || content.includes('<appmsg') || content.includes('&lt;appmsg'))) {
// 检查是否是聊天记录消息type=19兼容大 localType 的 appmsg
const normalizedContent = this.normalizeAppMessageContent(content)
@@ -4720,7 +4801,7 @@ class ExportService {
}
if (msg.localType === 43) {
const videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || this.extractVideoMd5(content)
const videoMd5 = this.extractVideoFileNameFromRow(row, content)
if (videoMd5) msg.videoMd5 = videoMd5
}
} catch (error) {
@@ -8941,12 +9022,14 @@ class ExportService {
pendingSessionIds?: string[]
successSessionIds?: string[]
failedSessionIds?: string[]
sessionOutputPaths?: Record<string, string>
error?: string
}> {
let successCount = 0
let failCount = 0
const successSessionIds: string[] = []
const failedSessionIds: string[] = []
const sessionOutputPaths: Record<string, string> = {}
const progressEmitter = this.createProgressEmitter(onProgress)
let attachMediaTelemetry = false
const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => {
@@ -9144,7 +9227,8 @@ class ExportService {
stopped: true,
pendingSessionIds: [...queue],
successSessionIds,
failedSessionIds
failedSessionIds,
sessionOutputPaths
}
}
if (pauseRequested) {
@@ -9155,7 +9239,8 @@ class ExportService {
paused: true,
pendingSessionIds: [...queue],
successSessionIds,
failedSessionIds
failedSessionIds,
sessionOutputPaths
}
}
@@ -9266,6 +9351,7 @@ class ExportService {
if (hasNoDataChange) {
successCount++
successSessionIds.push(sessionId)
sessionOutputPaths[sessionId] = preferredOutputPath
activeSessionRatios.delete(sessionId)
completedCount++
emitProgress({
@@ -9311,6 +9397,7 @@ class ExportService {
if (result.success) {
successCount++
successSessionIds.push(sessionId)
sessionOutputPaths[sessionId] = outputPath
if (typeof messageCountHint === 'number' && messageCountHint >= 0) {
exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, {
sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0
@@ -9401,7 +9488,8 @@ class ExportService {
stopped: true,
pendingSessionIds,
successSessionIds,
failedSessionIds
failedSessionIds,
sessionOutputPaths
}
}
if (pauseRequested && pendingSessionIds.length > 0) {
@@ -9412,7 +9500,8 @@ class ExportService {
paused: true,
pendingSessionIds,
successSessionIds,
failedSessionIds
failedSessionIds,
sessionOutputPaths
}
}
@@ -9425,7 +9514,7 @@ class ExportService {
}, { force: true })
progressEmitter.flush()
return { success: true, successCount, failCount, successSessionIds, failedSessionIds }
return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths }
} catch (e) {
progressEmitter.flush()
return { success: false, successCount, failCount, error: String(e) }

View File

@@ -144,14 +144,14 @@ export class ImageDecryptService {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const upgraded = this.isThumbnailPath(cached)
const upgraded = !this.isHdPath(cached)
? await this.tryPromoteThumbnailCache(payload, key, cached)
: null
const finalPath = upgraded || cached
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(finalPath)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
const isNonHd = !this.isHdPath(finalPath)
const hasUpdate = isNonHd ? (this.updateFlags.get(key) ?? false) : false
if (isNonHd) {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, key, finalPath)
}
@@ -184,15 +184,15 @@ export class ImageDecryptService {
if (datPath) {
const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false)
if (existing) {
const upgraded = this.isThumbnailPath(existing)
const upgraded = !this.isHdPath(existing)
? await this.tryPromoteThumbnailCache(payload, cacheKey, existing)
: null
const finalPath = upgraded || existing
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath)
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(finalPath)
const hasUpdate = isThumb ? (this.updateFlags.get(cacheKey) ?? false) : false
if (isThumb) {
const isNonHd = !this.isHdPath(finalPath)
const hasUpdate = isNonHd ? (this.updateFlags.get(cacheKey) ?? false) : false
if (isNonHd) {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, cacheKey, finalPath)
}
@@ -219,7 +219,7 @@ export class ImageDecryptService {
if (payload.force) {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) {
if (cached && existsSync(cached) && this.isImageFile(cached) && this.isHdPath(cached)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
@@ -237,7 +237,7 @@ export class ImageDecryptService {
if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const upgraded = this.isThumbnailPath(cached)
const upgraded = !this.isHdPath(cached)
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
: null
const finalPath = upgraded || cached
@@ -280,22 +280,13 @@ export class ImageDecryptService {
if (!accountDir) return
try {
const ready = await this.ensureWcdbReady()
if (!ready) return
const requests = normalizedList.map((md5) => ({ md5, accountDir }))
const result = await wcdbService.resolveImageHardlinkBatch(requests)
if (!result.success || !Array.isArray(result.rows)) return
for (const row of result.rows) {
const md5 = String(row?.md5 || '').trim().toLowerCase()
if (!md5) continue
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
const fullPath = String(row?.data?.full_path || '').trim()
if (!fileName || !fullPath) continue
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
if (!selectedPath || !existsSync(selectedPath)) continue
for (const md5 of normalizedList) {
if (!this.looksLikeMd5(md5)) continue
const selectedPath = this.selectBestDatPathByBase(accountDir, md5, undefined, undefined, true)
if (!selectedPath) continue
this.cacheDatPath(accountDir, md5, selectedPath)
this.cacheDatPath(accountDir, fileName, selectedPath)
const fileName = basename(selectedPath).toLowerCase()
if (fileName) this.cacheDatPath(accountDir, fileName, selectedPath)
}
} catch {
// ignore preload failures
@@ -477,8 +468,10 @@ export class ImageDecryptService {
this.logInfo('解密成功', { outputPath, size: decrypted.length })
const isThumb = this.isThumbnailPath(datPath)
const isHdCache = this.isHdPath(outputPath)
this.removeDuplicateCacheCandidates(datPath, payload.sessionId, outputPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) {
if (isHdCache) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
} else {
if (this.shouldCheckImageUpdate(payload)) {
@@ -625,95 +618,49 @@ export class ImageDecryptService {
allowDatNameScanFallback
})
const lookupMd5s = this.collectHardlinkLookupMd5s(imageMd5, imageDatName)
const fallbackDatName = String(imageDatName || imageMd5 || '').trim().toLowerCase() || undefined
if (lookupMd5s.length === 0) {
if (!allowDatNameScanFallback) {
this.logInfo('[ImageDecrypt] resolveDatPath skip datName scan (no hardlink md5)', {
imageMd5,
imageDatName,
sessionId,
createTime
})
return null
}
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
if (packedDatFallback) {
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
const normalizedFileName = basename(packedDatFallback).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
this.logInfo('[ImageDecrypt] datName fallback hit (no hardlink md5)', {
imageMd5,
imageDatName,
selectedPath: packedDatFallback
})
return packedDatFallback
}
this.logInfo('[ImageDecrypt] resolveDatPath miss (no hardlink md5)', { imageMd5, imageDatName })
const lookupBases = this.collectLookupBasesForScan(imageMd5, imageDatName, allowDatNameScanFallback)
if (lookupBases.length === 0) {
this.logInfo('[ImageDecrypt] resolveDatPath miss (no lookup base)', { imageMd5, imageDatName })
return null
}
if (!skipResolvedCache) {
const cacheCandidates = Array.from(new Set([
...lookupMd5s,
...lookupBases,
String(imageMd5 || '').trim().toLowerCase(),
String(imageDatName || '').trim().toLowerCase()
].filter(Boolean)))
for (const cacheKey of cacheCandidates) {
const scopedKey = `${accountDir}|${cacheKey}`
const cached = this.resolvedCache.get(scopedKey)
if (!cached) continue
if (!existsSync(cached)) continue
if (!allowThumbnail && this.isThumbnailPath(cached)) continue
if (!cached || !existsSync(cached)) continue
if (!allowThumbnail && !this.isHdDatPath(cached)) continue
return cached
}
}
for (const lookupMd5 of lookupMd5s) {
this.logInfo('[ImageDecrypt] hardlink lookup', { lookupMd5, sessionId, hardlinkOnly })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, lookupMd5, sessionId)
if (!hardlinkPath) continue
if (!allowThumbnail && this.isThumbnailPath(hardlinkPath)) continue
for (const baseMd5 of lookupBases) {
const selectedPath = this.selectBestDatPathByBase(accountDir, baseMd5, sessionId, createTime, allowThumbnail)
if (!selectedPath) continue
this.cacheDatPath(accountDir, lookupMd5, hardlinkPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
const normalizedFileName = basename(hardlinkPath).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, hardlinkPath)
return hardlinkPath
}
if (!allowDatNameScanFallback) {
this.logInfo('[ImageDecrypt] resolveDatPath skip datName fallback after hardlink miss', {
imageMd5,
imageDatName,
sessionId,
createTime,
lookupMd5s
this.cacheDatPath(accountDir, baseMd5, selectedPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, selectedPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, selectedPath)
const normalizedFileName = basename(selectedPath).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, selectedPath)
this.logInfo('[ImageDecrypt] dat scan selected', {
baseMd5,
selectedPath,
allowThumbnail
})
return null
return selectedPath
}
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
if (packedDatFallback) {
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
const normalizedFileName = basename(packedDatFallback).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
this.logInfo('[ImageDecrypt] datName fallback hit (hardlink miss)', {
imageMd5,
imageDatName,
lookupMd5s,
selectedPath: packedDatFallback
})
return packedDatFallback
}
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink + datName fallback)', {
this.logInfo('[ImageDecrypt] resolveDatPath miss (dat scan)', {
imageMd5,
imageDatName,
lookupMd5s
lookupBases,
allowThumbnail
})
return null
}
@@ -724,23 +671,46 @@ export class ImageDecryptService {
cachedPath: string
): Promise<boolean> {
if (!cachedPath || !existsSync(cachedPath)) return false
const isThumbnail = this.isThumbnailPath(cachedPath)
if (!isThumbnail) return false
if (this.isHdPath(cachedPath)) return false
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) return false
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) return false
const hdPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false }
)
return Boolean(hdPath)
const lookupBases = this.collectLookupBasesForScan(payload.imageMd5, payload.imageDatName, true)
if (lookupBases.length === 0) return false
let currentTier = this.getCachedPathTier(cachedPath)
let bestDatPath: string | null = null
let bestDatTier = -1
for (const baseMd5 of lookupBases) {
const candidate = this.selectBestDatPathByBase(accountDir, baseMd5, payload.sessionId, payload.createTime, true)
if (!candidate) continue
const candidateTier = this.getDatTier(candidate, baseMd5)
if (candidateTier <= 0) continue
if (!bestDatPath) {
bestDatPath = candidate
bestDatTier = candidateTier
continue
}
if (candidateTier > bestDatTier) {
bestDatPath = candidate
bestDatTier = candidateTier
continue
}
if (candidateTier === bestDatTier) {
const candidateSize = this.fileSizeSafe(candidate)
const bestSize = this.fileSizeSafe(bestDatPath)
if (candidateSize > bestSize) {
bestDatPath = candidate
bestDatTier = candidateTier
}
}
}
if (!bestDatPath || bestDatTier <= 0) return false
if (currentTier < 0) currentTier = 1
return bestDatTier > currentTier
}
private async tryPromoteThumbnailCache(
@@ -750,7 +720,7 @@ export class ImageDecryptService {
): Promise<string | null> {
if (!cachedPath || !existsSync(cachedPath)) return null
if (!this.isImageFile(cachedPath)) return null
if (!this.isThumbnailPath(cachedPath)) return null
if (this.isHdPath(cachedPath)) return null
const accountDir = this.resolveCurrentAccountDir()
if (!accountDir) return null
@@ -766,7 +736,7 @@ export class ImageDecryptService {
if (!hdDatPath) return null
const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true)
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && !this.isThumbnailPath(existingHd)) {
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && this.isHdPath(existingHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.removeThumbnailCacheFile(cachedPath, existingHd)
@@ -796,7 +766,7 @@ export class ImageDecryptService {
? cachedResult
: String(upgraded.localPath || '').trim()
if (!upgradedPath || !existsSync(upgradedPath)) return null
if (!this.isImageFile(upgradedPath) || this.isThumbnailPath(upgradedPath)) return null
if (!this.isImageFile(upgradedPath) || !this.isHdPath(upgradedPath)) return null
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
@@ -814,24 +784,73 @@ export class ImageDecryptService {
if (!oldPath) return
if (keepPath && oldPath === keepPath) return
if (!existsSync(oldPath)) return
if (!this.isThumbnailPath(oldPath)) return
if (this.isHdPath(oldPath)) return
void rm(oldPath, { force: true }).catch(() => { })
}
private triggerUpdateCheck(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; disableUpdateCheck?: boolean; suppressEvents?: boolean },
payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
disableUpdateCheck?: boolean
suppressEvents?: boolean
},
cacheKey: string,
cachedPath: string
): void {
if (!this.shouldCheckImageUpdate(payload)) return
if (this.updateFlags.get(cacheKey)) return
void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => {
void this.checkHasUpdate(payload, cacheKey, cachedPath).then(async (hasUpdate) => {
if (!hasUpdate) return
this.updateFlags.set(cacheKey, true)
const upgradedPath = await this.tryAutoRefreshBetterCache(payload, cacheKey, cachedPath)
if (upgradedPath) {
this.updateFlags.delete(cacheKey)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(upgradedPath, payload.preferFilePath))
return
}
this.emitImageUpdate(payload, cacheKey)
}).catch(() => { })
}
private async tryAutoRefreshBetterCache(
payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
disableUpdateCheck?: boolean
suppressEvents?: boolean
},
cacheKey: string,
cachedPath: string
): Promise<string | null> {
if (!cachedPath || !existsSync(cachedPath)) return null
if (this.isHdPath(cachedPath)) return null
const refreshed = await this.decryptImage({
sessionId: payload.sessionId,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
createTime: payload.createTime,
preferFilePath: true,
force: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
if (!refreshed.success || !refreshed.localPath) return null
const refreshedPath = String(refreshed.localPath || '').trim()
if (!refreshedPath || !existsSync(refreshedPath)) return null
if (!this.isImageFile(refreshedPath)) return null
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, refreshedPath)
this.removeThumbnailCacheFile(cachedPath, refreshedPath)
return refreshedPath
}
private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] {
@@ -854,6 +873,111 @@ export class ImageDecryptService {
return keys
}
private collectLookupBasesForScan(imageMd5?: string, imageDatName?: string, allowDatNameScanFallback = true): string[] {
const bases = this.collectHardlinkLookupMd5s(imageMd5, imageDatName)
if (!allowDatNameScanFallback) return bases
const fallbackRaw = String(imageDatName || imageMd5 || '').trim().toLowerCase()
if (!fallbackRaw) return bases
const fallbackNoExt = fallbackRaw.endsWith('.dat') ? fallbackRaw.slice(0, -4) : fallbackRaw
const fallbackBase = this.normalizeDatBase(fallbackNoExt)
if (this.looksLikeMd5(fallbackBase) && !bases.includes(fallbackBase)) {
bases.push(fallbackBase)
}
return bases
}
private collectAllDatCandidatesForBase(
accountDir: string,
baseMd5: string,
sessionId?: string,
createTime?: number
): string[] {
const sessionMonth = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime)
return Array.from(new Set(sessionMonth.filter((item) => {
const path = String(item || '').trim()
return path && existsSync(path) && path.toLowerCase().endsWith('.dat')
})))
}
private isImgScopedDatPath(filePath: string): boolean {
const lower = String(filePath || '').toLowerCase()
return /[\\/](img|image|msgimg)[\\/]/.test(lower)
}
private fileSizeSafe(filePath: string): number {
try {
return statSync(filePath).size || 0
} catch {
return 0
}
}
private fileMtimeSafe(filePath: string): number {
try {
return statSync(filePath).mtimeMs || 0
} catch {
return 0
}
}
private pickLargestDatPath(paths: string[]): string | null {
const list = Array.from(new Set(paths.filter(Boolean)))
if (list.length === 0) return null
list.sort((a, b) => {
const sizeDiff = this.fileSizeSafe(b) - this.fileSizeSafe(a)
if (sizeDiff !== 0) return sizeDiff
const mtimeDiff = this.fileMtimeSafe(b) - this.fileMtimeSafe(a)
if (mtimeDiff !== 0) return mtimeDiff
return a.localeCompare(b)
})
return list[0] || null
}
private selectBestDatPathByBase(
accountDir: string,
baseMd5: string,
sessionId?: string,
createTime?: number,
allowThumbnail = true
): string | null {
const candidates = this.collectAllDatCandidatesForBase(accountDir, baseMd5, sessionId, createTime)
if (candidates.length === 0) return null
const imgCandidates = candidates.filter((item) => this.isImgScopedDatPath(item))
const imgHdCandidates = imgCandidates.filter((item) => this.isHdDatPath(item))
const hdInImg = this.pickLargestDatPath(imgHdCandidates)
if (hdInImg) return hdInImg
if (!allowThumbnail) {
// 高清优先仅认 img/image/msgimg 路径中的 H 变体;
// 若该范围没有,则交由 allowThumbnail=true 的回退分支按 base.dat/_t 继续挑选。
return null
}
// 无 H 时,优先尝试原始无后缀 DAT{md5}.dat
const baseDatInImg = this.pickLargestDatPath(
imgCandidates.filter((item) => this.isBaseDatPath(item, baseMd5))
)
if (baseDatInImg) return baseDatInImg
const baseDatAny = this.pickLargestDatPath(
candidates.filter((item) => this.isBaseDatPath(item, baseMd5))
)
if (baseDatAny) return baseDatAny
const thumbDatInImg = this.pickLargestDatPath(
imgCandidates.filter((item) => this.isTVariantDat(item))
)
if (thumbDatInImg) return thumbDatInImg
const thumbDatAny = this.pickLargestDatPath(
candidates.filter((item) => this.isTVariantDat(item))
)
if (thumbDatAny) return thumbDatAny
return null
}
private resolveDatPathFromParsedDatName(
accountDir: string,
imageDatName?: string,
@@ -878,7 +1002,7 @@ export class ImageDecryptService {
if (sessionMonthCandidates.length > 0) {
const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5)
for (const candidatePath of orderedSessionMonth) {
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
if (!allowThumbnail && !this.isHdDatPath(candidatePath)) continue
this.datNameScanMissAt.delete(missKey)
this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', {
accountDir,
@@ -894,54 +1018,17 @@ export class ImageDecryptService {
}
}
const hasPreciseContext = Boolean(String(sessionId || '').trim() && monthKey)
if (hasPreciseContext) {
this.datNameScanMissAt.set(missKey, Date.now())
this.logInfo('[ImageDecrypt] datName fallback precise scan miss', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail
})
return null
}
const candidates = this.collectDatCandidatesFromAccountDir(accountDir, baseMd5)
if (candidates.length === 0) {
this.datNameScanMissAt.set(missKey, Date.now())
this.logInfo('[ImageDecrypt] datName fallback scan miss', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail
})
return null
}
const ordered = this.sortDatCandidatePaths(candidates, baseMd5)
for (const candidatePath of ordered) {
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
this.datNameScanMissAt.delete(missKey)
this.logInfo('[ImageDecrypt] datName fallback selected', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail,
selectedPath: candidatePath
})
return candidatePath
}
// 新策略:只扫描会话月目录,不做 account-wide 根目录回退。
this.datNameScanMissAt.set(missKey, Date.now())
this.logInfo('[ImageDecrypt] datName fallback precise scan miss', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail
})
return null
}
@@ -966,27 +1053,14 @@ export class ImageDecryptService {
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
if (!normalizedSessionId || !monthKey) return []
const attachRoots = this.getAttachScanRoots(accountDir)
const cacheRoots = this.getMessageCacheScanRoots(accountDir)
const sessionDirs = this.getAttachSessionDirCandidates(normalizedSessionId)
const sessionDir = this.resolveSessionDirForStorage(normalizedSessionId)
if (!sessionDir) return []
const candidates = new Set<string>()
const budget = { remaining: 600 }
const targetDirs: Array<{ dir: string; depth: number }> = []
for (const root of attachRoots) {
for (const sessionDir of sessionDirs) {
targetDirs.push({ dir: join(root, sessionDir, monthKey), depth: 2 })
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Img'), depth: 1 })
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Image'), depth: 1 })
}
}
for (const root of cacheRoots) {
for (const sessionDir of sessionDirs) {
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir, 'Bubble'), depth: 1 })
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir), depth: 2 })
}
}
const budget = { remaining: 240 }
const targetDirs: Array<{ dir: string; depth: number }> = [
// 1) accountDir/msg/attach/{sessionMd5}/{yyyy-MM}/Img
{ dir: join(accountDir, 'msg', 'attach', sessionDir, monthKey, 'Img'), depth: 1 }
]
for (const target of targetDirs) {
if (budget.remaining <= 0) break
@@ -996,98 +1070,13 @@ export class ImageDecryptService {
return Array.from(candidates)
}
private getAttachScanRoots(accountDir: string): string[] {
const roots: string[] = []
const push = (value: string) => {
const normalized = String(value || '').trim()
if (!normalized) return
if (!roots.includes(normalized)) roots.push(normalized)
}
push(join(accountDir, 'msg', 'attach'))
push(join(accountDir, 'attach'))
const parent = dirname(accountDir)
if (parent && parent !== accountDir) {
push(join(parent, 'msg', 'attach'))
push(join(parent, 'attach'))
}
return roots
}
private getMessageCacheScanRoots(accountDir: string): string[] {
const roots: string[] = []
const push = (value: string) => {
const normalized = String(value || '').trim()
if (!normalized) return
if (!roots.includes(normalized)) roots.push(normalized)
}
push(join(accountDir, 'cache'))
const parent = dirname(accountDir)
if (parent && parent !== accountDir) {
push(join(parent, 'cache'))
}
return roots
}
private getAttachSessionDirCandidates(sessionId: string): string[] {
const normalized = String(sessionId || '').trim()
if (!normalized) return []
const lower = normalized.toLowerCase()
const cleaned = this.cleanAccountDirName(normalized)
const inputs = Array.from(new Set([normalized, lower, cleaned, cleaned.toLowerCase()].filter(Boolean)))
const results: string[] = []
const push = (value: string) => {
if (!value) return
if (!results.includes(value)) results.push(value)
}
for (const item of inputs) {
push(item)
const md5 = crypto.createHash('md5').update(item).digest('hex').toLowerCase()
push(md5)
push(md5.slice(0, 16))
}
return results
}
private collectDatCandidatesFromAccountDir(accountDir: string, baseMd5: string): string[] {
const roots = this.getDatScanRoots(accountDir)
const candidates = new Set<string>()
const budget = { remaining: 1400 }
for (const item of roots) {
if (budget.remaining <= 0) break
this.scanDatCandidatesUnderRoot(item.root, baseMd5, item.maxDepth, candidates, budget)
}
if (candidates.size === 0 && budget.remaining <= 0) {
this.logInfo('[ImageDecrypt] datName fallback budget exhausted', {
accountDir,
baseMd5,
roots: roots.map((item) => item.root)
})
}
return Array.from(candidates)
}
private getDatScanRoots(accountDir: string): Array<{ root: string; maxDepth: number }> {
const roots: Array<{ root: string; maxDepth: number }> = []
const push = (root: string, maxDepth: number) => {
const normalized = String(root || '').trim()
if (!normalized) return
if (roots.some((item) => item.root === normalized)) return
roots.push({ root: normalized, maxDepth })
}
push(join(accountDir, 'attach'), 4)
push(join(accountDir, 'msg', 'attach'), 4)
push(join(accountDir, 'FileStorage', 'Image'), 3)
push(join(accountDir, 'FileStorage', 'Image2'), 3)
push(join(accountDir, 'FileStorage', 'MsgImg'), 3)
return roots
private resolveSessionDirForStorage(sessionId: string): string {
const normalized = String(sessionId || '').trim().toLowerCase()
if (!normalized) return ''
if (this.looksLikeMd5(normalized)) return normalized
const cleaned = this.cleanAccountDirName(normalized).toLowerCase()
if (this.looksLikeMd5(cleaned)) return cleaned
return crypto.createHash('md5').update(cleaned || normalized).digest('hex').toLowerCase()
}
private scanDatCandidatesUnderRoot(
@@ -1296,17 +1285,60 @@ export class ImageDecryptService {
}
}
private getCacheVariantSuffixFromDat(datPath: string): string {
if (this.isHdDatPath(datPath)) return '_hd'
const name = basename(datPath)
const lower = name.toLowerCase()
const stem = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const base = this.normalizeDatBase(stem)
const rawSuffix = stem.slice(base.length)
if (!rawSuffix) return ''
const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '')
if (!safe) return ''
if (safe.startsWith('_') || safe.startsWith('.')) return safe
return `_${safe}`
}
private getCacheVariantSuffixFromCachedPath(cachePath: string): string {
const raw = String(cachePath || '').split('?')[0]
const name = basename(raw)
const ext = extname(name).toLowerCase()
const stem = (ext ? name.slice(0, -ext.length) : name).toLowerCase()
const base = this.normalizeDatBase(stem)
const rawSuffix = stem.slice(base.length)
if (!rawSuffix) return ''
const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '')
if (!safe) return ''
if (safe.startsWith('_') || safe.startsWith('.')) return safe
return `_${safe}`
}
private buildCacheSuffixSearchOrder(primarySuffix: string, preferHd: boolean): string[] {
const fallbackSuffixes = [
'_hd',
'_thumb',
'_t',
'.t',
'_b',
'.b',
'_w',
'.w',
'_c',
'.c',
''
]
const ordered = preferHd
? ['_hd', primarySuffix, ...fallbackSuffixes]
: [primarySuffix, '_hd', ...fallbackSuffixes]
return Array.from(new Set(ordered.map((item) => String(item || '').trim()).filter((item) => item.length >= 0)))
}
private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string {
const name = basename(datPath)
const lower = name.toLowerCase()
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
// 提取基础名称(去掉 _t, _h 等后缀)
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const normalizedBase = this.normalizeDatBase(base)
// 判断是缩略图还是高清图
const isThumb = this.isThumbnailDat(lower)
const suffix = isThumb ? '_thumb' : '_hd'
const suffix = this.getCacheVariantSuffixFromDat(datPath)
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
const timeDir = this.resolveTimeDir(datPath)
@@ -1319,9 +1351,10 @@ export class ImageDecryptService {
private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] {
const name = basename(datPath)
const lower = name.toLowerCase()
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const normalizedBase = this.normalizeDatBase(base)
const suffixes = preferHd ? ['_hd', '_thumb'] : ['_thumb', '_hd']
const primarySuffix = this.getCacheVariantSuffixFromDat(datPath)
const suffixes = this.buildCacheSuffixSearchOrder(primarySuffix, preferHd)
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
const root = this.getCacheRoot()
@@ -1354,6 +1387,20 @@ export class ImageDecryptService {
return candidates
}
private removeDuplicateCacheCandidates(datPath: string, sessionId: string | undefined, keepPath: string): void {
const candidateSets = [
...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, false),
...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, true)
]
const candidates = Array.from(new Set(candidateSets))
for (const candidate of candidates) {
if (!candidate || candidate === keepPath) continue
if (!existsSync(candidate)) continue
if (!this.isImageFile(candidate)) continue
void rm(candidate, { force: true }).catch(() => { })
}
}
private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null {
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd)
for (const candidate of candidates) {
@@ -1786,8 +1833,54 @@ export class ImageDecryptService {
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
}
private isHdDatPath(datPath: string): boolean {
const name = basename(String(datPath || '')).toLowerCase()
if (!name.endsWith('.dat')) return false
const stem = name.slice(0, -4)
return (
stem.endsWith('_h') ||
stem.endsWith('.h') ||
stem.endsWith('_hd') ||
stem.endsWith('.hd')
)
}
private isTVariantDat(datPath: string): boolean {
const name = basename(String(datPath || '')).toLowerCase()
return this.isThumbnailDat(name)
}
private isBaseDatPath(datPath: string, baseMd5: string): boolean {
const normalizedBase = String(baseMd5 || '').trim().toLowerCase()
if (!normalizedBase) return false
const name = basename(String(datPath || '')).toLowerCase()
return name === `${normalizedBase}.dat`
}
private getDatTier(datPath: string, baseMd5: string): number {
if (this.isHdDatPath(datPath)) return 3
if (this.isBaseDatPath(datPath, baseMd5)) return 2
if (this.isTVariantDat(datPath)) return 1
return 0
}
private getCachedPathTier(cachePath: string): number {
if (this.isHdPath(cachePath)) return 3
const suffix = this.getCacheVariantSuffixFromCachedPath(cachePath)
if (!suffix) return 2
const normalized = suffix.toLowerCase()
if (normalized === '_t' || normalized === '.t' || normalized === '_thumb' || normalized === '.thumb') {
return 1
}
return 1
}
private isHdPath(p: string): boolean {
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
const raw = String(p || '').split('?')[0]
const name = basename(raw).toLowerCase()
const ext = extname(name).toLowerCase()
const stem = ext ? name.slice(0, -ext.length) : name
return stem.endsWith('_hd')
}
private isThumbnailPath(p: string): boolean {

View File

@@ -2,6 +2,10 @@ import { ConfigService } from './config'
import { chatService, type ChatSession, type Message } from './chatService'
import { wcdbService } from './wcdbService'
import { httpService } from './httpService'
import { promises as fs } from 'fs'
import path from 'path'
import { createHash } from 'crypto'
import { pathToFileURL } from 'url'
interface SessionBaseline {
lastTimestamp: number
@@ -33,6 +37,8 @@ class MessagePushService {
private readonly sessionBaseline = new Map<string, SessionBaseline>()
private readonly recentMessageKeys = new Map<string, number>()
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
private readonly pushAvatarCacheDir: string
private readonly pushAvatarDataCache = new Map<string, string>()
private readonly debounceMs = 350
private readonly recentMessageTtlMs = 10 * 60 * 1000
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
@@ -45,6 +51,7 @@ class MessagePushService {
constructor() {
this.configService = ConfigService.getInstance()
this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files')
}
start(): void {
@@ -310,12 +317,13 @@ class MessagePushService {
const groupInfo = await chatService.getContactAvatar(sessionId)
const groupName = session.displayName || groupInfo?.displayName || sessionId
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl)
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
avatarUrl,
groupName,
sourceName,
content
@@ -323,17 +331,63 @@ class MessagePushService {
}
const contactInfo = await chatService.getContactAvatar(sessionId)
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl)
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
avatarUrl,
sourceName: session.displayName || contactInfo?.displayName || sessionId,
content
}
}
private async normalizePushAvatarUrl(avatarUrl?: string): Promise<string | undefined> {
const normalized = String(avatarUrl || '').trim()
if (!normalized) return undefined
if (!normalized.startsWith('data:image/')) {
return normalized
}
const cached = this.pushAvatarDataCache.get(normalized)
if (cached) return cached
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized)
if (!match) return undefined
try {
const mimeType = match[1].toLowerCase()
const base64Data = match[2]
const imageBuffer = Buffer.from(base64Data, 'base64')
if (!imageBuffer.length) return undefined
const ext = this.getImageExtFromMime(mimeType)
const hash = createHash('sha1').update(normalized).digest('hex')
const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`)
await fs.mkdir(this.pushAvatarCacheDir, { recursive: true })
try {
await fs.access(filePath)
} catch {
await fs.writeFile(filePath, imageBuffer)
}
const fileUrl = pathToFileURL(filePath).toString()
this.pushAvatarDataCache.set(normalized, fileUrl)
return fileUrl
} catch {
return undefined
}
}
private getImageExtFromMime(mimeType: string): string {
if (mimeType === 'image/png') return 'png'
if (mimeType === 'image/gif') return 'gif'
if (mimeType === 'image/webp') return 'webp'
return 'jpg'
}
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
if (sessionId.endsWith('@chatroom')) {
return 'group'

View File

@@ -2,7 +2,7 @@ import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService'
import { app } from 'electron'
import { existsSync, mkdirSync } from 'fs'
import { existsSync, mkdirSync, unlinkSync } from 'fs'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { basename, join } from 'path'
import crypto from 'crypto'
@@ -174,8 +174,17 @@ const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
// BMP
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
// ISO BMFF 家族:优先识别 AVIF/HEIF避免误判为 MP4
if (buf.length > 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return 'image/avif'
if (
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
) return 'image/heic'
return 'video/mp4'
}
// Fallback logic for video
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
@@ -1231,7 +1240,19 @@ class SnsService {
const cacheKey = `${url}|${key ?? ''}`
if (this.imageCache.has(cacheKey)) {
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
const base64Part = cachedDataUrl.split(',')[1] || ''
if (base64Part) {
try {
const cachedBuf = Buffer.from(base64Part, 'base64')
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
return { success: true, dataUrl: cachedDataUrl }
}
} catch {
// ignore and fall through to refetch
}
}
this.imageCache.delete(cacheKey)
}
const result = await this.fetchAndDecryptImage(url, key)
@@ -1244,6 +1265,9 @@ class SnsService {
}
if (result.data && result.contentType) {
if (!detectImageMime(result.data, '').startsWith('image/')) {
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
}
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
this.imageCache.set(cacheKey, dataUrl)
return { success: true, dataUrl }
@@ -1853,8 +1877,13 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
}
const data = await readFile(cachePath)
const contentType = detectImageMime(data)
return { success: true, data, contentType, cachePath }
if (!detectImageMime(data, '').startsWith('image/')) {
// 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。
try { unlinkSync(cachePath) } catch { }
} else {
const contentType = detectImageMime(data)
return { success: true, data, contentType, cachePath }
}
} catch (e) {
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
}
@@ -2006,6 +2035,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const xEnc = String(res.headers['x-enc'] || '').trim()
let decoded = raw
const rawMagicMime = detectImageMime(raw, '')
// 图片逻辑
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
@@ -2023,13 +2053,24 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
decrypted[i] = raw[i] ^ keystream[i]
}
decoded = decrypted
const decryptedMagicMime = detectImageMime(decrypted, '')
if (decryptedMagicMime.startsWith('image/')) {
decoded = decrypted
} else if (!rawMagicMime.startsWith('image/')) {
decoded = decrypted
}
}
} catch (e) {
console.error('[SnsService] TS Decrypt Error:', e)
}
}
const decodedMagicMime = detectImageMime(decoded, '')
if (!decodedMagicMime.startsWith('image/')) {
resolve({ success: false, error: '图片解密失败:无法识别图片格式' })
return
}
// 写入磁盘缓存
try {
await writeFile(cachePath, decoded)
@@ -2063,6 +2104,15 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true
if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return true
if (
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
) return true
}
return false
}

View File

@@ -1,8 +1,6 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs'
import { spawn } from 'child_process'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { pathToFileURL } from 'url'
import crypto from 'crypto'
import { app } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
@@ -27,48 +25,15 @@ interface VideoIndexEntry {
type PosterFormat = 'dataUrl' | 'fileUrl'
function getStaticFfmpegPath(): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static')
if (typeof ffmpegStatic === 'string') {
let fixedPath = ffmpegStatic
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
}
if (existsSync(fixedPath)) return fixedPath
}
} catch {
// ignore
}
const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName)
if (existsSync(devPath)) return devPath
if (app.isPackaged) {
const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName)
if (existsSync(packedPath)) return packedPath
}
return null
}
class VideoService {
private configService: ConfigService
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
private pendingPosterExtract = new Map<string, Promise<string | null>>()
private extractedPosterCache = new Map<string, TimedCacheEntry<string | null>>()
private posterExtractRunning = 0
private posterExtractQueue: Array<() => void> = []
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
private readonly videoIndexCacheTtlMs = 90 * 1000
private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000
private readonly maxPosterExtractConcurrency = 1
private readonly maxCacheEntries = 2000
private readonly maxIndexEntries = 6
@@ -287,11 +252,9 @@ class VideoService {
}
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
if (!dbPath || !wxid) return
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
// 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。
// 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。
void md5List
}
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
@@ -429,6 +392,23 @@ class VideoService {
return null
}
private normalizeVideoLookupKey(value: string): string {
let text = String(value || '').trim().toLowerCase()
if (!text) return ''
text = text.replace(/^.*[\\/]/, '')
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
text = text.replace(/_thumb$/, '')
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
if (direct) {
const suffix = /_raw$/i.test(text) ? '_raw' : ''
return `${direct[1].toLowerCase()}${suffix}`
}
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
return String(fallback?.[1] || '').toLowerCase()
}
private fallbackScanVideo(
videoBaseDir: string,
realVideoMd5: string,
@@ -473,154 +453,10 @@ class VideoService {
return null
}
private getFfmpegPath(): string {
const staticPath = getStaticFfmpegPath()
if (staticPath) return staticPath
return 'ffmpeg'
}
private async withPosterExtractSlot<T>(run: () => Promise<T>): Promise<T> {
if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) {
await new Promise<void>((resolve) => {
this.posterExtractQueue.push(resolve)
})
}
this.posterExtractRunning += 1
try {
return await run()
} finally {
this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1)
const next = this.posterExtractQueue.shift()
if (next) next()
}
}
private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise<string | null> {
const normalizedPath = String(videoPath || '').trim()
if (!normalizedPath || !existsSync(normalizedPath)) return null
const cacheKey = `${normalizedPath}|format=${posterFormat}`
const cached = this.readTimedCache(this.extractedPosterCache, cacheKey)
if (cached !== undefined) return cached
const pending = this.pendingPosterExtract.get(cacheKey)
if (pending) return pending
const task = this.withPosterExtractSlot(() => new Promise<string | null>((resolve) => {
const tmpDir = join(app.getPath('temp'), 'weflow_video_frames')
try {
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
} catch {
resolve(null)
return
}
const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24)
const outputPath = join(tmpDir, `frame_${stableHash}.jpg`)
if (posterFormat === 'fileUrl' && existsSync(outputPath)) {
resolve(pathToFileURL(outputPath).toString())
return
}
const ffmpegPath = this.getFfmpegPath()
const args = [
'-hide_banner', '-loglevel', 'error', '-y',
'-ss', '0',
'-i', normalizedPath,
'-frames:v', '1',
'-q:v', '3',
outputPath
]
const errChunks: Buffer[] = []
let done = false
const finish = (value: string | null) => {
if (done) return
done = true
if (posterFormat === 'dataUrl') {
try {
if (existsSync(outputPath)) unlinkSync(outputPath)
} catch {
// ignore
}
}
resolve(value)
}
const proc = spawn(ffmpegPath, args, {
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
})
const timer = setTimeout(() => {
try { proc.kill('SIGKILL') } catch { /* ignore */ }
finish(null)
}, 12000)
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
proc.on('error', () => {
clearTimeout(timer)
finish(null)
})
proc.on('close', (code: number) => {
clearTimeout(timer)
if (code !== 0 || !existsSync(outputPath)) {
if (errChunks.length > 0) {
this.log('extractFirstFrameDataUrl failed', {
videoPath: normalizedPath,
error: Buffer.concat(errChunks).toString().slice(0, 240)
})
}
finish(null)
return
}
try {
const jpgBuf = readFileSync(outputPath)
if (!jpgBuf.length) {
finish(null)
return
}
if (posterFormat === 'fileUrl') {
finish(pathToFileURL(outputPath).toString())
return
}
finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`)
} catch {
finish(null)
}
})
}))
this.pendingPosterExtract.set(cacheKey, task)
try {
const result = await task
this.writeTimedCache(
this.extractedPosterCache,
cacheKey,
result,
this.extractedPosterCacheTtlMs,
this.maxCacheEntries
)
return result
} finally {
this.pendingPosterExtract.delete(cacheKey)
}
}
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
void posterFormat
if (!includePoster) return info
if (!info.exists || !info.videoUrl) return info
if (info.coverUrl || info.thumbUrl) return info
const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat)
if (!extracted) return info
return {
...info,
coverUrl: extracted,
thumbUrl: extracted
}
return info
}
/**
@@ -652,7 +488,7 @@ class VideoService {
if (pending) return pending
const task = (async (): Promise<VideoInfo> => {
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
if (!existsSync(videoBaseDir)) {
@@ -678,7 +514,7 @@ class VideoService {
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 })
return miss
})()

View File

@@ -2011,6 +2011,14 @@ export class WcdbCore {
}
return ''
}
const pickRaw = (row: Record<string, any>, keys: string[]): unknown => {
for (const key of keys) {
const value = row[key]
if (value === null || value === undefined) continue
return value
}
return undefined
}
const extractXmlValue = (xml: string, tag: string): string => {
if (!xml) return ''
const regex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i')
@@ -2096,25 +2104,37 @@ export class WcdbCore {
const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase)
return String(md5Like?.[1] || fileBase || '').trim().toLowerCase()
}
const decodePackedToPrintable = (raw: string): string => {
const text = String(raw || '').trim()
if (!text) return ''
let buf: Buffer | null = null
if (/^[a-fA-F0-9]+$/.test(text) && text.length % 2 === 0) {
try {
buf = Buffer.from(text, 'hex')
} catch {
buf = null
const decodePackedInfoBuffer = (raw: unknown): Buffer | null => {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
if (raw instanceof Uint8Array) return Buffer.from(raw)
if (Array.isArray(raw)) return Buffer.from(raw as any[])
if (typeof raw === 'string') {
const text = raw.trim()
if (!text) return null
const compactHex = text.replace(/\s+/g, '')
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
try {
return Buffer.from(compactHex, 'hex')
} catch {
// ignore
}
}
}
if (!buf) {
try {
const base64 = Buffer.from(text, 'base64')
if (base64.length > 0) buf = base64
if (base64.length > 0) return base64
} catch {
buf = null
// ignore
}
return null
}
if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
return Buffer.from((raw as any).data)
}
return null
}
const decodePackedToPrintable = (raw: unknown): string => {
const buf = decodePackedInfoBuffer(raw)
if (!buf || buf.length === 0) return ''
const printable: number[] = []
for (const byte of buf) {
@@ -2129,6 +2149,46 @@ export class WcdbCore {
const match = /([a-fA-F0-9]{32})/.exec(input)
return String(match?.[1] || '').toLowerCase()
}
const normalizeVideoFileToken = (value: unknown): string => {
let text = String(value || '').trim().toLowerCase()
if (!text) return ''
text = text.replace(/^.*[\\/]/, '')
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
text = text.replace(/_thumb$/, '')
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
if (direct) {
const suffix = /_raw$/i.test(text) ? '_raw' : ''
return `${direct[1].toLowerCase()}${suffix}`
}
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
return String(fallback?.[1] || '').toLowerCase()
}
const extractVideoFileNameFromPackedRaw = (raw: unknown): string => {
const buf = decodePackedInfoBuffer(raw)
if (!buf || buf.length === 0) return ''
const candidates: string[] = []
let current = ''
for (const byte of buf) {
const isHex =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66)
if (isHex) {
current += String.fromCharCode(byte)
continue
}
if (current.length >= 16) candidates.push(current)
current = ''
}
if (current.length >= 16) candidates.push(current)
if (candidates.length === 0) return ''
const exact32 = candidates.find((item) => item.length === 32)
if (exact32) return exact32.toLowerCase()
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
return String(fallback || '').toLowerCase()
}
const extractImageDatName = (row: Record<string, any>, content: string): string => {
const direct = pickString(row, [
'image_path',
@@ -2147,7 +2207,7 @@ export class WcdbCore {
const normalizedXml = normalizeDatBase(xmlCandidate)
if (normalizedXml) return normalizedXml
const packedRaw = pickString(row, [
const packedRaw = pickRaw(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
@@ -2172,7 +2232,7 @@ export class WcdbCore {
return ''
}
const extractPackedPayload = (row: Record<string, any>): string => {
const packedRaw = pickString(row, [
const packedRaw = pickRaw(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
@@ -2327,6 +2387,20 @@ export class WcdbCore {
const packedPayload = extractPackedPayload(row)
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
const packedRaw = pickRaw(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
let content = ''
let imageMd5: string | undefined
@@ -2342,10 +2416,17 @@ export class WcdbCore {
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
}
} else if (localType === 43) {
videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined
videoMd5 =
extractVideoFileNameFromPackedRaw(packedRaw) ||
normalizeVideoFileToken(videoMd5ByColumn) ||
extractHexMd5(packedPayload) ||
undefined
if (!videoMd5) {
content = decodeContentIfNeeded()
videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined
videoMd5 =
normalizeVideoFileToken(extractVideoMd5(content)) ||
extractHexMd5(packedPayload) ||
undefined
} else if (useRawMessageContent) {
// 占位态标题只依赖简单 XML已带 md5 时不做额外解压
content = rawMessageContent