mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-06 23:15:58 +00:00
新增资源管理并修复了朋友圈的资源缓存路径
This commit is contained in:
357
electron/main.ts
357
electron/main.ts
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 未连接' }
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人昵称
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user