新增资源管理并修复了朋友圈的资源缓存路径

This commit is contained in:
cc
2026-04-06 23:32:59 +08:00
parent 20c5381211
commit d128bedffa
23 changed files with 3860 additions and 86 deletions

View File

@@ -4,7 +4,7 @@ import { Worker } from 'worker_threads'
import { randomUUID } from 'crypto'
import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises'
import { readFile, writeFile, mkdir, rm, readdir, copyFile } from 'fs/promises'
import { existsSync } from 'fs'
import { ConfigService } from './services/config'
import { dbPathService } from './services/dbPathService'
@@ -1371,6 +1371,225 @@ const removeMatchedEntriesInDir = async (
}
}
const normalizeFsPathForCompare = (value: string): string => {
const normalized = String(value || '').replace(/\\/g, '/').replace(/\/+$/, '')
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
type SnsCacheMigrationCandidate = {
label: string
sourceDir: string
targetDir: string
fileCount: number
}
type SnsCacheMigrationPlan = {
legacyBaseDir: string
currentBaseDir: string
candidates: SnsCacheMigrationCandidate[]
totalFiles: number
}
type SnsCacheMigrationProgressPayload = {
status: 'running' | 'done' | 'error'
phase: 'copying' | 'cleanup' | 'done' | 'error'
current: number
total: number
copied: number
skipped: number
remaining: number
message?: string
currentItemLabel?: string
}
let snsCacheMigrationInProgress = false
const countFilesInDir = async (dirPath: string): Promise<number> => {
if (!dirPath || !existsSync(dirPath)) return 0
try {
const entries = await readdir(dirPath, { withFileTypes: true })
let count = 0
for (const entry of entries) {
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
count += await countFilesInDir(fullPath)
continue
}
if (entry.isFile()) count += 1
}
return count
} catch {
return 0
}
}
const migrateDirectoryPreserveNewFiles = async (
sourceDir: string,
targetDir: string,
onFileProcessed?: (payload: { copied: boolean }) => void
): Promise<{ copied: number; skipped: number; processed: number }> => {
let copied = 0
let skipped = 0
let processed = 0
if (!existsSync(sourceDir)) return { copied, skipped, processed }
await mkdir(targetDir, { recursive: true })
const entries = await readdir(sourceDir, { withFileTypes: true })
for (const entry of entries) {
const sourcePath = join(sourceDir, entry.name)
const targetPath = join(targetDir, entry.name)
if (entry.isDirectory()) {
const nested = await migrateDirectoryPreserveNewFiles(sourcePath, targetPath, onFileProcessed)
copied += nested.copied
skipped += nested.skipped
processed += nested.processed
continue
}
if (!entry.isFile()) continue
if (existsSync(targetPath)) {
skipped += 1
processed += 1
onFileProcessed?.({ copied: false })
continue
}
await mkdir(dirname(targetPath), { recursive: true })
await copyFile(sourcePath, targetPath)
copied += 1
processed += 1
onFileProcessed?.({ copied: true })
}
return { copied, skipped, processed }
}
const collectLegacySnsCacheMigrationPlan = async (): Promise<SnsCacheMigrationPlan | null> => {
if (!configService) return null
const legacyBaseDir = configService.getCacheBasePath()
const configuredCachePath = String(configService.get('cachePath') || '').trim()
const currentBaseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
if (!legacyBaseDir || !currentBaseDir) return null
const candidates = [
{
label: '朋友圈媒体缓存',
sourceDir: join(legacyBaseDir, 'sns_cache'),
targetDir: join(currentBaseDir, 'sns_cache')
},
{
label: '朋友圈表情缓存(合并到 Emojis',
sourceDir: join(legacyBaseDir, 'sns_emoji_cache'),
targetDir: join(currentBaseDir, 'Emojis')
},
{
label: '朋友圈表情缓存(当前目录残留)',
sourceDir: join(currentBaseDir, 'sns_emoji_cache'),
targetDir: join(currentBaseDir, 'Emojis')
}
]
const pendingKeys = new Set<string>()
const pending: SnsCacheMigrationCandidate[] = []
for (const item of candidates) {
const sourceKey = normalizeFsPathForCompare(item.sourceDir)
const targetKey = normalizeFsPathForCompare(item.targetDir)
if (!sourceKey || sourceKey === targetKey) continue
const dedupeKey = `${sourceKey}=>${targetKey}`
if (pendingKeys.has(dedupeKey)) continue
const fileCount = await countFilesInDir(item.sourceDir)
if (fileCount <= 0) continue
pendingKeys.add(dedupeKey)
pending.push({ ...item, fileCount })
}
if (pending.length === 0) return null
const totalFiles = pending.reduce((sum, item) => sum + item.fileCount, 0)
return {
legacyBaseDir,
currentBaseDir,
candidates: pending,
totalFiles
}
}
const runLegacySnsCacheMigration = async (
plan: SnsCacheMigrationPlan,
onProgress: (payload: SnsCacheMigrationProgressPayload) => void
): Promise<{ copied: number; skipped: number; totalFiles: number }> => {
let processed = 0
let copied = 0
let skipped = 0
const total = plan.totalFiles
const emitProgress = (patch?: Partial<SnsCacheMigrationProgressPayload>) => {
onProgress({
status: 'running',
phase: 'copying',
current: processed,
total,
copied,
skipped,
remaining: Math.max(0, total - processed),
...patch
})
}
emitProgress({ message: '准备迁移缓存...' })
for (const item of plan.candidates) {
emitProgress({ currentItemLabel: item.label, message: `正在迁移:${item.label}` })
const result = await migrateDirectoryPreserveNewFiles(item.sourceDir, item.targetDir, ({ copied: copiedThisFile }) => {
processed += 1
if (copiedThisFile) copied += 1
else skipped += 1
emitProgress({ currentItemLabel: item.label })
})
// 兜底对齐计数,防止回调未触发造成偏差
const expectedProcessed = copied + skipped
if (processed !== expectedProcessed) {
processed = expectedProcessed
copied = Math.max(copied, result.copied)
skipped = Math.max(skipped, result.skipped)
emitProgress({ currentItemLabel: item.label })
}
}
emitProgress({ phase: 'cleanup', message: '正在清理旧目录...' })
for (const item of plan.candidates) {
await rm(item.sourceDir, { recursive: true, force: true })
}
if (existsSync(plan.legacyBaseDir)) {
try {
const remaining = await readdir(plan.legacyBaseDir)
if (remaining.length === 0) {
await rm(plan.legacyBaseDir, { recursive: true, force: true })
}
} catch {
// 忽略旧目录清理失败,不影响迁移结果
}
}
onProgress({
status: 'done',
phase: 'done',
current: processed,
total,
copied,
skipped,
remaining: Math.max(0, total - processed),
message: '迁移完成'
})
return { copied, skipped, totalFiles: total }
}
// 注册 IPC 处理器
function registerIpcHandlers() {
registerNotificationHandlers()
@@ -1764,9 +1983,9 @@ function registerIpcHandlers() {
})
// 视频相关
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => {
try {
const result = await videoService.getVideoInfo(videoMd5)
const result = await videoService.getVideoInfo(videoMd5, options)
return { success: true, ...result }
} catch (e) {
return { success: false, error: String(e), exists: false }
@@ -2088,6 +2307,28 @@ function registerIpcHandlers() {
ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => {
return chatService.getMessageDateCounts(sessionId)
})
ipcMain.handle('chat:getResourceMessages', async (_, options?: {
sessionId?: string
types?: Array<'image' | 'video' | 'voice' | 'file'>
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => {
return chatService.getResourceMessages(options)
})
ipcMain.handle('chat:getMediaStream', async (_, options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => {
return wcdbService.getMediaStream(options)
})
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
return chatService.resolveVoiceCache(sessionId, msgId)
})
@@ -2222,17 +2463,121 @@ function registerIpcHandlers() {
return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey)
})
ipcMain.handle('sns:getCacheMigrationStatus', async () => {
try {
const plan = await collectLegacySnsCacheMigrationPlan()
if (!plan) {
return {
success: true,
needed: false,
inProgress: snsCacheMigrationInProgress,
totalFiles: 0,
items: []
}
}
return {
success: true,
needed: true,
inProgress: snsCacheMigrationInProgress,
totalFiles: plan.totalFiles,
legacyBaseDir: plan.legacyBaseDir,
currentBaseDir: plan.currentBaseDir,
items: plan.candidates
}
} catch (error) {
return { success: false, needed: false, error: String((error as Error)?.message || error || '') }
}
})
ipcMain.handle('sns:startCacheMigration', async (event) => {
if (snsCacheMigrationInProgress) {
return { success: false, error: '迁移任务正在进行中' }
}
const sender = event.sender
let lastProgress: SnsCacheMigrationProgressPayload = {
status: 'running',
phase: 'copying',
current: 0,
total: 0,
copied: 0,
skipped: 0,
remaining: 0
}
const emitProgress = (payload: SnsCacheMigrationProgressPayload) => {
lastProgress = payload
if (!sender.isDestroyed()) {
sender.send('sns:cacheMigrationProgress', payload)
}
}
try {
const plan = await collectLegacySnsCacheMigrationPlan()
if (!plan) {
emitProgress({
status: 'done',
phase: 'done',
current: 0,
total: 0,
copied: 0,
skipped: 0,
remaining: 0,
message: '无需迁移'
})
return { success: true, copied: 0, skipped: 0, totalFiles: 0, message: '无需迁移' }
}
snsCacheMigrationInProgress = true
const result = await runLegacySnsCacheMigration(plan, emitProgress)
return { success: true, ...result }
} catch (error) {
const message = String((error as Error)?.message || error || '')
emitProgress({
...lastProgress,
status: 'error',
phase: 'error',
message
})
return { success: false, error: message }
} finally {
snsCacheMigrationInProgress = false
}
})
// 私聊克隆
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
return imageDecryptService.decryptImage(payload)
})
ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => {
ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => {
return imageDecryptService.resolveCachedImage(payload)
})
ipcMain.handle('image:preload', async (_, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => {
imagePreloadService.enqueue(payloads || [])
ipcMain.handle(
'image:resolveCacheBatch',
async (
_,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean }
) => {
const list = Array.isArray(payloads) ? payloads : []
const rows = await Promise.all(list.map(async (payload) => {
return imageDecryptService.resolveCachedImage({
...payload,
disableUpdateCheck: options?.disableUpdateCheck === true
})
}))
return { success: true, rows }
}
)
ipcMain.handle(
'image:preload',
async (
_,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean }
) => {
imagePreloadService.enqueue(payloads || [], options)
return true
})

View File

@@ -226,6 +226,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
getResourceMessages: (options?: {
sessionId?: string
types?: Array<'image' | 'video' | 'voice' | 'file'>
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => ipcRenderer.invoke('chat:getResourceMessages', options),
getMediaStream: (options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => ipcRenderer.invoke('chat:getMediaStream', options),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
@@ -250,10 +266,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) =>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) =>
ipcRenderer.invoke('image:resolveCache', payload),
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
ipcRenderer.invoke('image:preload', payloads),
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean }
) => ipcRenderer.invoke('image:preload', payloads, options),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
ipcRenderer.on('image:updateAvailable', listener)
@@ -263,12 +285,33 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
ipcRenderer.on('image:cacheResolved', listener)
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
},
onDecryptProgress: (callback: (payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => void) => {
const listener = (_: unknown, payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => callback(payload)
ipcRenderer.on('image:decryptProgress', listener)
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
}
},
// 视频
video: {
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
@@ -418,7 +461,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params),
getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'),
startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'),
onCacheMigrationProgress: (callback: (payload: any) => void) => {
const listener = (_event: unknown, payload: any) => callback(payload)
ipcRenderer.on('sns:cacheMigrationProgress', listener)
return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener)
}
},
biz: {

View File

@@ -142,6 +142,14 @@ export interface Message {
_db_path?: string // 内部字段:记录消息所属数据库路径
}
type ResourceMessageType = 'image' | 'video' | 'voice' | 'file'
interface ResourceMessageItem extends Message {
sessionId: string
sessionDisplayName?: string
resourceType: ResourceMessageType
}
export interface Contact {
username: string
alias: string
@@ -7544,6 +7552,152 @@ class ChatService {
}
}
private resolveResourceType(message: Message): ResourceMessageType | null {
if (message.localType === 3) return 'image'
if (message.localType === 43) return 'video'
if (message.localType === 34) return 'voice'
if (
message.localType === 49 ||
message.localType === 34359738417 ||
message.localType === 103079215153 ||
message.localType === 25769803825
) {
if (message.appMsgKind === 'file' || message.xmlType === '6') return 'file'
if (message.localType !== 49) return 'file'
}
return null
}
async getResourceMessages(options?: {
sessionId?: string
types?: ResourceMessageType[]
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}): Promise<{
success: boolean
items?: ResourceMessageItem[]
total?: number
hasMore?: boolean
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
const requestedTypes = Array.isArray(options?.types)
? options.types.filter((type): type is ResourceMessageType => ['image', 'video', 'voice', 'file'].includes(type))
: []
const typeSet = new Set<ResourceMessageType>(requestedTypes.length > 0 ? requestedTypes : ['image', 'video', 'voice', 'file'])
const beginTimestamp = Number(options?.beginTimestamp || 0)
const endTimestamp = Number(options?.endTimestamp || 0)
const offset = Math.max(0, Number(options?.offset || 0))
const limitRaw = Number(options?.limit || 0)
const limit = Number.isFinite(limitRaw) ? Math.min(2000, Math.max(1, Math.floor(limitRaw || 300))) : 300
const sessionsResult = await this.getSessions()
if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) {
return { success: false, error: sessionsResult.error || '获取会话失败' }
}
const sessionNameMap = new Map<string, string>()
sessionsResult.sessions.forEach((session) => {
sessionNameMap.set(session.username, session.displayName || session.username)
})
const requestedSessionId = String(options?.sessionId || '').trim()
const sortedSessions = [...sessionsResult.sessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const targetSessionIds = requestedSessionId
? [requestedSessionId]
: sortedSessions.map((session) => session.username)
const localTypes: number[] = []
if (typeSet.has('image')) localTypes.push(3)
if (typeSet.has('video')) localTypes.push(43)
if (typeSet.has('voice')) localTypes.push(34)
if (typeSet.has('file')) {
localTypes.push(49, 34359738417, 103079215153, 25769803825)
}
const uniqueLocalTypes = Array.from(new Set(localTypes))
const allItems: ResourceMessageItem[] = []
const dedup = new Set<string>()
const targetCount = offset + limit
const candidateBuffer = Math.max(180, limit)
const perTypeFetch = requestedSessionId
? Math.min(2000, Math.max(200, targetCount * 2))
: (beginTimestamp > 0 || endTimestamp > 0 ? 140 : 90)
const maxSessionScan = requestedSessionId
? 1
: (beginTimestamp > 0 || endTimestamp > 0 ? 240 : 80)
const scanSessionIds = targetSessionIds.slice(0, maxSessionScan)
let maybeHasMore = targetSessionIds.length > scanSessionIds.length
let stopEarly = false
for (const sessionId of scanSessionIds) {
const batchRows = await Promise.all(
uniqueLocalTypes.map((localType) =>
wcdbService.getMessagesByType(sessionId, localType, false, perTypeFetch, 0)
)
)
for (const result of batchRows) {
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) continue
if (result.rows.length >= perTypeFetch) maybeHasMore = true
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
for (const message of mapped) {
const resourceType = this.resolveResourceType(message)
if (!resourceType || !typeSet.has(resourceType)) continue
if (beginTimestamp > 0 && message.createTime < beginTimestamp) continue
if (endTimestamp > 0 && message.createTime > endTimestamp) continue
const dedupKey = `${sessionId}:${message.localId}:${message.serverId}:${message.createTime}:${message.localType}`
if (dedup.has(dedupKey)) continue
dedup.add(dedupKey)
allItems.push({
...message,
sessionId,
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
resourceType
})
}
}
if (allItems.length >= targetCount + candidateBuffer) {
stopEarly = true
maybeHasMore = true
break
}
}
allItems.sort((a, b) => {
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
if (timeDiff !== 0) return timeDiff
return (b.localId || 0) - (a.localId || 0)
})
const total = allItems.length
const start = Math.min(offset, total)
const end = Math.min(start + limit, total)
return {
success: true,
items: allItems.slice(start, end),
total,
hasMore: end < total || maybeHasMore || stopEarly
}
} catch (e) {
console.error('[ChatService] 获取资源消息失败:', e)
return { success: false, error: String(e) }
}
}
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()

View File

@@ -55,11 +55,14 @@ type DecryptResult = {
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
}
type DecryptProgressStage = 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
type CachedImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
preferFilePath?: boolean
disableUpdateCheck?: boolean
}
type DecryptImagePayload = CachedImagePayload & {
@@ -126,7 +129,9 @@ export class ImageDecryptService {
const isThumb = this.isThumbnailPath(cached)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
this.triggerUpdateCheck(payload, key, cached)
if (!payload.disableUpdateCheck) {
this.triggerUpdateCheck(payload, key, cached)
}
} else {
this.updateFlags.delete(key)
}
@@ -146,7 +151,9 @@ export class ImageDecryptService {
const isThumb = this.isThumbnailPath(existing)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
this.triggerUpdateCheck(payload, key, existing)
if (!payload.disableUpdateCheck) {
this.triggerUpdateCheck(payload, key, existing)
}
} else {
this.updateFlags.delete(key)
}
@@ -167,6 +174,7 @@ export class ImageDecryptService {
if (!cacheKey) {
return { success: false, error: '缺少图片标识' }
}
this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running')
if (payload.force) {
for (const key of cacheKeys) {
@@ -176,6 +184,7 @@ export class ImageDecryptService {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
@@ -191,6 +200,7 @@ export class ImageDecryptService {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath }
}
}
@@ -201,6 +211,7 @@ export class ImageDecryptService {
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
@@ -209,7 +220,10 @@ export class ImageDecryptService {
}
const pending = this.pending.get(cacheKey)
if (pending) return pending
if (pending) {
this.emitDecryptProgress(payload, cacheKey, 'queued', 8, 'running')
return pending
}
const task = this.decryptImageInternal(payload, cacheKey)
this.pending.set(cacheKey, task)
@@ -261,49 +275,93 @@ export class ImageDecryptService {
cacheKey: string
): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
try {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) {
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
return { success: false, error: '未配置账号或数据库路径' }
}
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) {
this.logError('未找到账号目录', undefined, { dbPath, wxid })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失')
return { success: false, error: '未找到账号目录' }
}
const datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{
allowThumbnail: !payload.force,
skipResolvedCache: Boolean(payload.force),
hardlinkOnly: payload.hardlinkOnly === true
}
)
let datPath: string | null = null
let usedHdAttempt = false
let fallbackToThumbnail = false
// 如果要求高清图但没找到,直接返回提示
if (!datPath && payload.force) {
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
// force=true 时先尝试高清;若高清缺失则回退到缩略图,避免直接失败。
if (payload.force) {
usedHdAttempt = true
datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{
allowThumbnail: false,
skipResolvedCache: true,
hardlinkOnly: payload.hardlinkOnly === true
}
)
if (!datPath) {
datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{
allowThumbnail: true,
skipResolvedCache: true,
hardlinkOnly: payload.hardlinkOnly === true
}
)
fallbackToThumbnail = Boolean(datPath)
if (fallbackToThumbnail) {
this.logInfo('高清缺失,回退解密缩略图', {
md5: payload.imageMd5,
datName: payload.imageDatName
})
}
}
} else {
datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true
}
)
}
if (!datPath) {
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件')
if (usedHdAttempt) {
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试' }
}
return { success: false, error: '未找到图片文件' }
}
this.logInfo('找到DAT文件', { datPath })
this.emitDecryptProgress(payload, cacheKey, 'locating', 34, 'running')
if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(datPath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb }
}
@@ -319,6 +377,7 @@ export class ImageDecryptService {
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb }
}
}
@@ -340,6 +399,7 @@ export class ImageDecryptService {
}
}
if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥')
return { success: false, error: '未配置图片解密密钥' }
}
@@ -347,7 +407,9 @@ export class ImageDecryptService {
const aesKey = this.resolveAesKey(aesKeyRaw)
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
const wxgfResult = await this.unwrapWxgf(decrypted)
@@ -363,10 +425,12 @@ export class ImageDecryptService {
const finalExt = ext || '.jpg'
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length })
if (finalExt === '.hevc') {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败')
return {
success: false,
error: '此图片为微信新格式(wxgf)ffmpeg 转换失败,请检查日志',
@@ -378,15 +442,19 @@ export class ImageDecryptService {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
} else {
this.triggerUpdateCheck(payload, cacheKey, outputPath)
}
const localPath = payload.preferFilePath
? outputPath
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, emitPath)
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb }
} catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e))
return { success: false, error: String(e) }
}
}
@@ -1288,6 +1356,31 @@ export class ImageDecryptService {
}
}
private emitDecryptProgress(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
cacheKey: string,
stage: DecryptProgressStage,
progress: number,
status: 'running' | 'done' | 'error',
message?: string
): void {
const safeProgress = Math.max(0, Math.min(100, Math.floor(progress)))
const event = {
cacheKey,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
stage,
progress: safeProgress,
status,
message: message || ''
}
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('image:decryptProgress', event)
}
}
}
private async ensureCacheIndexed(): Promise<void> {
if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing

View File

@@ -6,36 +6,57 @@ type PreloadImagePayload = {
imageDatName?: string
}
type PreloadOptions = {
allowDecrypt?: boolean
}
type PreloadTask = PreloadImagePayload & {
key: string
allowDecrypt: boolean
}
export class ImagePreloadService {
private queue: PreloadTask[] = []
private pending = new Set<string>()
private active = 0
private readonly maxConcurrent = 2
private activeCache = 0
private activeDecrypt = 0
private readonly maxCacheConcurrent = 8
private readonly maxDecryptConcurrent = 2
private readonly maxQueueSize = 320
enqueue(payloads: PreloadImagePayload[]): void {
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
if (!Array.isArray(payloads) || payloads.length === 0) return
const allowDecrypt = options?.allowDecrypt !== false
for (const payload of payloads) {
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
const cacheKey = payload.imageMd5 || payload.imageDatName
if (!cacheKey) continue
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
if (this.pending.has(key)) continue
this.pending.add(key)
this.queue.push({ ...payload, key })
this.queue.push({ ...payload, key, allowDecrypt })
}
this.processQueue()
}
private processQueue(): void {
while (this.active < this.maxConcurrent && this.queue.length > 0) {
const task = this.queue.shift()
while (this.queue.length > 0) {
const taskIndex = this.queue.findIndex((task) => (
task.allowDecrypt
? this.activeDecrypt < this.maxDecryptConcurrent
: this.activeCache < this.maxCacheConcurrent
))
if (taskIndex < 0) return
const task = this.queue.splice(taskIndex, 1)[0]
if (!task) return
this.active += 1
if (task.allowDecrypt) this.activeDecrypt += 1
else this.activeCache += 1
void this.handleTask(task).finally(() => {
this.active -= 1
if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
else this.activeCache = Math.max(0, this.activeCache - 1)
this.pending.delete(task.key)
this.processQueue()
})
@@ -49,9 +70,11 @@ export class ImagePreloadService {
const cached = await imageDecryptService.resolveCachedImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName
imageDatName: task.imageDatName,
disableUpdateCheck: !task.allowDecrypt
})
if (cached.success) return
if (!task.allowDecrypt) return
await imageDecryptService.decryptImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,

View File

@@ -17,9 +17,9 @@ export class KeyServiceLinux {
constructor() {
try {
this.sudo = require('sudo-prompt');
this.sudo = require('@vscode/sudo-prompt');
} catch (e) {
console.error('Failed to load sudo-prompt', e);
console.error('Failed to load @vscode/sudo-prompt', e);
}
}
@@ -361,4 +361,4 @@ export class KeyServiceLinux {
return { ciphertext, xorKey }
}
}
}

View File

@@ -1,6 +1,7 @@
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService'
import { app } from 'electron'
import { existsSync, mkdirSync } from 'fs'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { basename, join } from 'path'
@@ -801,14 +802,25 @@ class SnsService {
}
private getSnsCacheDir(): string {
const cachePath = this.configService.getCacheBasePath()
const snsCacheDir = join(cachePath, 'sns_cache')
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
const snsCacheDir = join(baseDir, 'sns_cache')
if (!existsSync(snsCacheDir)) {
mkdirSync(snsCacheDir, { recursive: true })
}
return snsCacheDir
}
private getEmojiCacheDir(): string {
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
const emojiDir = join(baseDir, 'Emojis')
if (!existsSync(emojiDir)) {
mkdirSync(emojiDir, { recursive: true })
}
return emojiDir
}
private getCacheFilePath(url: string): string {
const hash = crypto.createHash('md5').update(url).digest('hex')
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
@@ -1832,7 +1844,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const isVideo = isVideoUrl(url)
const cachePath = this.getCacheFilePath(url)
// 1. 尝试从磁盘缓存读取
// 1. 优先尝试从当前缓存目录读取
if (existsSync(cachePath)) {
try {
// 对于视频,不读取整个文件到内存,只确认存在即可
@@ -2293,9 +2305,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const fs = require('fs')
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
const cachePath = this.configService.getCacheBasePath()
const emojiDir = join(cachePath, 'sns_emoji_cache')
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
const emojiDir = this.getEmojiCacheDir()
// 检查本地缓存
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {

View File

@@ -1,5 +1,8 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs'
import { spawn } from 'child_process'
import { pathToFileURL } from 'url'
import crypto from 'crypto'
import { app } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
@@ -22,15 +25,50 @@ interface VideoIndexEntry {
thumbPath?: string
}
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
@@ -256,12 +294,10 @@ class VideoService {
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
}
/**
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
try {
if (!filePath || !existsSync(filePath)) return undefined
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
@@ -355,7 +391,12 @@ class VideoService {
return index
}
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
private getVideoInfoFromIndex(
index: Map<string, VideoIndexEntry>,
md5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!normalizedMd5) return null
@@ -379,8 +420,8 @@ class VideoService {
}
return {
videoUrl: entry.videoPath,
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
exists: true
}
}
@@ -388,7 +429,12 @@ class VideoService {
return null
}
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
private fallbackScanVideo(
videoBaseDir: string,
realVideoMd5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
@@ -416,8 +462,8 @@ class VideoService {
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
return {
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
exists: true
}
}
@@ -427,14 +473,165 @@ 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> {
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
}
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise<VideoInfo> {
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
const includePoster = options?.includePoster !== false
const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl'
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
@@ -446,7 +643,7 @@ class VideoService {
}
const scopeKey = this.getScopeKey(dbPath, wxid)
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}`
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
if (cachedInfo) return cachedInfo
@@ -465,16 +662,18 @@ class VideoService {
}
const index = this.getOrBuildVideoIndex(videoBaseDir)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
if (indexed) {
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return indexed
const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat)
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return withPoster
}
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat)
if (fallback) {
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return fallback
const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return withPoster
}
const miss = { exists: false }

View File

@@ -1,6 +1,7 @@
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os'
import * as fzstd from 'fzstd'
//数据服务初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
@@ -80,6 +81,7 @@ export class WcdbCore {
private wcdbGetSessionMessageDateCounts: any = null
private wcdbGetSessionMessageDateCountsBatch: any = null
private wcdbGetMessagesByType: any = null
private wcdbScanMediaStream: any = null
private wcdbGetHeadImageBuffers: any = null
private wcdbSearchMessages: any = null
private wcdbGetSnsTimeline: any = null
@@ -1013,6 +1015,11 @@ export class WcdbCore {
} catch {
this.wcdbGetMessagesByType = null
}
try {
this.wcdbScanMediaStream = this.lib.func('int32 wcdb_scan_media_stream(int64 handle, const char* sessionIdsJson, int32 mediaType, int32 beginTimestamp, int32 endTimestamp, int32 limit, int32 offset, _Out_ void** outJson, _Out_ int32* outHasMore)')
} catch {
this.wcdbScanMediaStream = null
}
try {
this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
} catch {
@@ -1921,6 +1928,397 @@ export class WcdbCore {
}
}
async getMediaStream(options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}): Promise<{
success: boolean
items?: Array<{
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}>
hasMore?: boolean
nextOffset?: number
error?: string
}> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持媒体流扫描,请先更新 wcdb 数据服务' }
try {
const toInt = (value: unknown): number => {
const n = Number(value || 0)
if (!Number.isFinite(n)) return 0
return Math.floor(n)
}
const pickString = (row: Record<string, any>, keys: string[]): string => {
for (const key of keys) {
const value = row[key]
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
const extractXmlValue = (xml: string, tag: string): string => {
if (!xml) return ''
const regex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i')
const match = regex.exec(xml)
if (!match) return ''
return String(match[1] || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
const looksLikeHex = (text: string): boolean => {
if (!text || text.length < 2 || text.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(text)
}
const looksLikeBase64 = (text: string): boolean => {
if (!text || text.length < 16 || text.length % 4 !== 0) return false
return /^[A-Za-z0-9+/]+={0,2}$/.test(text)
}
const decodeBinaryContent = (data: Buffer, fallbackValue?: string): string => {
if (!data || data.length === 0) return ''
try {
if (data.length >= 4) {
const magicLE = data.readUInt32LE(0)
const magicBE = data.readUInt32BE(0)
if (magicLE === 0xFD2FB528 || magicBE === 0xFD2FB528) {
try {
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
} catch {
// ignore
}
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
if (fallbackValue && replacementCount > 0) return fallbackValue
return data.toString('latin1')
} catch {
return fallbackValue || ''
}
}
const decodeMaybeCompressed = (raw: unknown): string => {
if (raw === null || raw === undefined) return ''
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
return decodeBinaryContent(Buffer.from(raw as any), String(raw))
}
const text = String(raw).trim()
if (!text) return ''
if (text.length > 16 && looksLikeHex(text)) {
try {
const bytes = Buffer.from(text, 'hex')
if (bytes.length > 0) return decodeBinaryContent(bytes, text)
} catch {
// ignore
}
}
if (text.length > 16 && looksLikeBase64(text)) {
try {
const bytes = Buffer.from(text, 'base64')
if (bytes.length > 0) return decodeBinaryContent(bytes, text)
} catch {
// ignore
}
}
return text
}
const decodeMessageContent = (messageContent: unknown, compressContent: unknown): string => {
const compressedDecoded = decodeMaybeCompressed(compressContent)
if (compressedDecoded) return compressedDecoded
return decodeMaybeCompressed(messageContent)
}
const extractImageMd5 = (xml: string): string => {
const byTag = extractXmlValue(xml, 'md5') || extractXmlValue(xml, 'imgmd5')
if (byTag) return byTag
const byAttr = /(?:md5|imgmd5)\s*=\s*['"]?([a-fA-F0-9]{16,64})['"]?/i.exec(xml)
return byAttr?.[1] || ''
}
const normalizeDatBase = (value: string): string => {
const input = String(value || '').trim()
if (!input) return ''
const fileBase = input.replace(/^.*[\\/]/, '').replace(/\.(?:t\.)?dat$/i, '')
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
}
}
if (!buf) {
try {
const base64 = Buffer.from(text, 'base64')
if (base64.length > 0) buf = base64
} catch {
buf = null
}
}
if (!buf || buf.length === 0) return ''
const printable: number[] = []
for (const byte of buf) {
if (byte >= 0x20 && byte <= 0x7e) printable.push(byte)
else printable.push(0x20)
}
return Buffer.from(printable).toString('utf-8')
}
const extractHexMd5 = (text: string): string => {
const input = String(text || '')
if (!input) return ''
const match = /([a-fA-F0-9]{32})/.exec(input)
return String(match?.[1] || '').toLowerCase()
}
const extractImageDatName = (row: Record<string, any>, content: string): string => {
const direct = pickString(row, [
'image_path',
'imagePath',
'image_dat_name',
'imageDatName',
'img_path',
'imgPath',
'img_name',
'imgName'
])
const normalizedDirect = normalizeDatBase(direct)
if (normalizedDirect) return normalizedDirect
const xmlCandidate = extractXmlValue(content, 'imgname') || extractXmlValue(content, 'cdnmidimgurl')
const normalizedXml = normalizeDatBase(xmlCandidate)
if (normalizedXml) return normalizedXml
const packedRaw = pickString(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 packedText = decodePackedToPrintable(packedRaw)
if (packedText) {
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(packedText)
if (datLike?.[1]) return String(datLike[1]).toLowerCase()
const md5Like = /([0-9a-fA-F]{16,64})/.exec(packedText)
if (md5Like?.[1]) return String(md5Like[1]).toLowerCase()
}
return ''
}
const extractPackedPayload = (row: Record<string, any>): string => {
const packedRaw = pickString(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
return decodePackedToPrintable(packedRaw)
}
const extractVideoMd5 = (xml: string): string => {
const byTag =
extractXmlValue(xml, 'rawmd5') ||
extractXmlValue(xml, 'videomd5') ||
extractXmlValue(xml, 'newmd5') ||
extractXmlValue(xml, 'md5')
if (byTag) return byTag
const byAttr = /(?:rawmd5|videomd5|newmd5|md5)\s*=\s*['"]?([a-fA-F0-9]{16,64})['"]?/i.exec(xml)
return byAttr?.[1] || ''
}
const requestedSessionId = String(options?.sessionId || '').trim()
const mediaType = String(options?.mediaType || 'all').trim() as 'image' | 'video' | 'all'
const beginTimestamp = Math.max(0, toInt(options?.beginTimestamp))
const endTimestamp = Math.max(0, toInt(options?.endTimestamp))
const offset = Math.max(0, toInt(options?.offset))
const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240))
const sessionsRes = await this.getSessions()
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) {
return { success: false, error: sessionsRes.error || '读取会话失败' }
}
const sessions = (sessionsRes.sessions || [])
.map((row: any) => ({
sessionId: String(
row.username ||
row.user_name ||
row.userName ||
row.usrName ||
row.UsrName ||
row.talker ||
''
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.last_timestamp ||
row.lastTimestamp ||
0
)
}))
.filter((row) => Boolean(row.sessionId))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const sessionRows = requestedSessionId
? sessions.filter((row) => row.sessionId === requestedSessionId)
: sessions
if (sessionRows.length === 0) {
return { success: true, items: [], hasMore: false, nextOffset: offset }
}
const sessionNameMap = new Map(sessionRows.map((row) => [row.sessionId, row.displayName || row.sessionId]))
const outPtr = [null as any]
const outHasMore = [0]
const mediaTypeCode = mediaType === 'image' ? 1 : mediaType === 'video' ? 2 : 0
const result = this.wcdbScanMediaStream(
this.handle,
JSON.stringify(sessionRows.map((row) => row.sessionId)),
mediaTypeCode,
beginTimestamp,
endTimestamp,
limit,
offset,
outPtr,
outHasMore
)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `扫描媒体流失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析媒体流失败' }
const rows = JSON.parse(jsonStr)
const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : []
let items = list.map((row) => {
const sessionId = pickString(row, ['session_id', 'sessionId']) || requestedSessionId
const localType = toInt(row.local_type ?? row.localType)
const rawMessageContent = pickString(row, [
'message_content',
'messageContent',
'message_content_text',
'messageText',
'StrContent',
'str_content',
'msg_content',
'msgContent',
'strContent',
'content',
'rawContent',
'WCDB_CT_message_content'
])
const rawCompressContent = pickString(row, [
'compress_content',
'compressContent',
'msg_compress_content',
'msgCompressContent',
'WCDB_CT_compress_content'
])
const useRawMessageContent = Boolean(
rawMessageContent &&
(rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg'))
)
const content = useRawMessageContent
? rawMessageContent
: decodeMessageContent(rawMessageContent, rawCompressContent)
const packedPayload = extractPackedPayload(row)
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
const imageMd5 = localType === 3
? (imageMd5ByColumn || extractImageMd5(content) || extractHexMd5(packedPayload) || undefined)
: undefined
const imageDatName = localType === 3 ? (extractImageDatName(row, content) || undefined) : undefined
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
const videoMd5 = localType === 43
? (videoMd5ByColumn || extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined)
: undefined
return {
sessionId,
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
mediaType: localType === 43 ? 'video' as const : 'image' as const,
localId: toInt(row.local_id ?? row.localId),
serverId: pickString(row, ['server_id', 'serverId']) || undefined,
createTime: toInt(row.create_time ?? row.createTime),
localType,
senderUsername: pickString(row, ['sender_username', 'senderUsername']) || undefined,
isSend: row.is_send === null || row.is_send === undefined ? null : toInt(row.is_send),
imageMd5,
imageDatName,
videoMd5,
content: content || undefined
}
})
const unresolvedSessionIds = Array.from(
new Set(
items
.map((item) => item.sessionId)
.filter((sessionId) => {
const name = String(sessionNameMap.get(sessionId) || '').trim()
return !name || name === sessionId
})
)
)
if (unresolvedSessionIds.length > 0) {
const displayNameRes = await this.getDisplayNames(unresolvedSessionIds)
if (displayNameRes.success && displayNameRes.map) {
unresolvedSessionIds.forEach((sessionId) => {
const display = String(displayNameRes.map?.[sessionId] || '').trim()
if (display) sessionNameMap.set(sessionId, display)
})
items = items.map((item) => ({
...item,
sessionDisplayName: sessionNameMap.get(item.sessionId) || item.sessionId
}))
}
}
return {
success: true,
items,
hasMore: Number(outHasMore[0]) > 0,
nextOffset: offset + items.length
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }

View File

@@ -268,6 +268,37 @@ export class WcdbService {
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
}
async getMediaStream(options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}): Promise<{
success: boolean
items?: Array<{
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}>
hasMore?: boolean
nextOffset?: number
error?: string
}> {
return this.callWorker('getMediaStream', { options })
}
/**
* 获取联系人昵称
*/

View File

@@ -80,6 +80,9 @@ if (parentPort) {
case 'getMessagesByType':
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
break
case 'getMediaStream':
result = await core.getMediaStream(payload.options)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break