This commit is contained in:
cc committed 2026-07-01 15:34:37 +08:00
1 parent 99dcb6e43b
commit 301cb5abce
48 files changed
+5444 -1015

No files matched your search

+1 -1
View File
@@ -59,7 +59,7 @@ ArchLinux 用户可以选择 `yay -S weflow` 快速安装
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
| **消息导出** | 将微信聊天记录导出为多种格式:JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
| **消息导出** | 将微信聊天记录导出为多种格式:JSON、HTML、Markdown、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API,便于对接外部系统、自动化脚本与二次开发 |
+60 -16
View File
@@ -247,6 +247,24 @@ const mapWeliveEventToProgress = (event: WeliveExportEvent): any | null => {
}
}
const collectWeliveErrorText = (result: any): string => {
const parts = [
result?.error,
result?.stderr,
...Object.values(result?.failedSessionErrors || {})
]
return parts
.map((value) => String(value || '').trim())
.filter(Boolean)
.join('\n')
}
const isWeliveNativeCrashResult = (result: any): boolean => {
if (!result || result.success) return false
const text = collectWeliveErrorText(result)
return /3221225477|0x?c0000005|-1073741819/i.test(text)
}
async function runWeliveEngine() {
const path = require('path') as typeof import('path')
const fs = require('fs') as typeof import('fs')
@@ -255,7 +273,7 @@ async function runWeliveEngine() {
const { wcdbService } = await import('./services/wcdbService')
const {
buildSessionExportBaseName,
normalizeFileNamingMode,
normalizeExportConflictStrategy,
reserveUniqueOutputPath
} = await import('./services/export/utils/fileNaming')
const sessionIds = config.mode === 'single'
@@ -284,8 +302,7 @@ async function runWeliveEngine() {
? { ...normalizedOptions, exportVoiceAsText: false }
: normalizedOptions
await exportService.context.ensureConnected().catch(() => null)
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
const exportMediaEnabled = exportService.context.isMediaExportEnabled(effectiveOptions)
const writeLayout = exportService.context.resolveExportWriteLayout(effectiveOptions)
const exportBaseDir = writeLayout === 'A'
? path.join(outputDir, 'texts')
@@ -300,6 +317,7 @@ async function runWeliveEngine() {
if (format === 'chatlab-jsonl') return '.jsonl'
if (format === 'excel') return '.xlsx'
if (format === 'txt') return '.txt'
if (format === 'markdown') return '.md'
if (format === 'weclone') return '.csv'
if (format === 'html') return '.html'
return '.json'
@@ -317,13 +335,20 @@ async function runWeliveEngine() {
const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName
const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${getFormatExtension(String(effectiveOptions.format || 'json'))}`)
return normalizeFileNamingMode(effectiveOptions.fileNamingMode) === 'date-range'
return normalizeExportConflictStrategy(effectiveOptions.exportConflictStrategy) === 'rename'
? reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
: preferredOutputPath
}
const finalOutputPaths: Record<string, string> = {}
const mediaDirs: Record<string, string | undefined> = {}
const mediaTypes = [
effectiveOptions.exportImages ? 'image' : '',
effectiveOptions.exportVoices ? 'voice' : '',
effectiveOptions.exportVideos ? 'video' : '',
effectiveOptions.exportEmojis ? 'emoji' : '',
effectiveOptions.exportFiles ? 'file' : ''
].filter(Boolean) as Array<'image' | 'voice' | 'video' | 'emoji' | 'file'>
for (const sessionId of sessionIds) {
const finalOutputPath = await resolveFinalOutputPath(sessionId)
finalOutputPaths[sessionId] = finalOutputPath
@@ -357,6 +382,7 @@ async function runWeliveEngine() {
outputDir,
exportsDir: rawExportsDir,
mediaDir: mediaDirs[sessionId],
mediaTypes,
emojiCacheDir: resolveEmojiCacheDir(),
format: 'raw-jsonl',
parseContent: true,
@@ -433,6 +459,9 @@ async function runWeliveEngine() {
if (format === 'txt') {
return await exportService.orchestrator.exportSessionToTxt(sessionId, outputPath, options, queueProgress, taskControl)
}
if (format === 'markdown') {
return await exportService.orchestrator.exportSessionToMarkdown(sessionId, outputPath, options, queueProgress, taskControl)
}
if (format === 'weclone') {
return await exportService.orchestrator.exportSessionToWeCloneCsv(sessionId, outputPath, options, queueProgress, taskControl)
}
@@ -455,18 +484,7 @@ async function runWeliveEngine() {
}
}
async function run() {
if (shouldUseWeliveEngine()) {
const result = await runWeliveEngine()
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:result',
data: result
})
return
}
async function runLegacyEngine() {
const [{ wcdbService }, { exportService }] = await Promise.all([
import('./services/wcdbService'),
import('./services/export')
@@ -541,6 +559,32 @@ async function run() {
})
}
async function run() {
if (shouldUseWeliveEngine()) {
const result = await runWeliveEngine()
if (!isWeliveNativeCrashResult(result)) {
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:result',
data: result
})
return
}
queueProgress({
current: 0,
total: Array.isArray(config.sessionIds) ? config.sessionIds.length : 1,
phase: 'preparing',
phaseLabel: 'WeLive 导出引擎异常退出,正在切换兼容导出引擎'
})
flushProgress()
flushCreatedPaths()
}
await runLegacyEngine()
}
run().catch((error) => {
flushProgress()
flushCreatedPaths()
+315 -13
View File
@@ -71,6 +71,212 @@ let configService: ConfigService | null = null
const activeExportWorkers = new Map<string, Worker>()
const activeExportTasks = new Set<string>()
type ResourceDiagnosticsEntry = {
ts: number
payload: Record<string, unknown>
}
const resourceDiagnosticsEntries: ResourceDiagnosticsEntry[] = []
const maxResourceDiagnosticsEntries = 240
let resourceDiagnosticsPreloadTotalsBaseline: Record<string, number> | null = null
const sanitizeResourceDiagnosticsPayload = (payload: unknown): Record<string, unknown> => {
if (!payload || typeof payload !== 'object') return {}
try {
const serialized = JSON.stringify(payload)
if (serialized.length > 24_000) {
return {
truncated: true,
size: serialized.length
}
}
return JSON.parse(serialized)
} catch {
return { unserializable: true }
}
}
const getRecordValue = (value: unknown, key: string): unknown => {
if (!value || typeof value !== 'object') return undefined
return (value as Record<string, unknown>)[key]
}
const getNumericRecordValue = (value: unknown, path: string[]): number => {
let current: unknown = value
for (const key of path) {
current = getRecordValue(current, key)
}
const numeric = Number(current)
return Number.isFinite(numeric) ? numeric : 0
}
const summarizeResourceDiagnostics = (
entries: ResourceDiagnosticsEntry[],
preloadTotalsBaseline: Record<string, number> | null = resourceDiagnosticsPreloadTotalsBaseline
): Record<string, unknown> => {
const counterDeltas: Record<string, number> = {}
const preloadTotalDeltas: Record<string, number> = {}
let longFrames = 0
let maxRecentFrameMs = 0
let maxQueued = 0
let maxQueuedCache = 0
let maxQueuedDecrypt = 0
let maxQueuedHigh = 0
let maxQueuedNormal = 0
let maxQueuedLow = 0
let maxActiveCache = 0
let maxActiveDecrypt = 0
let maxPending = 0
let maxHighWaterQueued = 0
let maxHighWaterQueuedDecrypt = 0
let maxHighWaterQueuedLow = 0
let maxHighWaterActiveDecrypt = 0
let mediaStreamMaxLoadMs = 0
for (const entry of entries) {
const payload = entry.payload
longFrames += getNumericRecordValue(payload, ['frameDelta', 'longFrames'])
maxRecentFrameMs = Math.max(maxRecentFrameMs, getNumericRecordValue(payload, ['frameDelta', 'recentMaxFrameMs']))
maxQueued = Math.max(maxQueued, getNumericRecordValue(payload, ['preloadStats', 'queued']))
maxQueuedCache = Math.max(maxQueuedCache, getNumericRecordValue(payload, ['preloadStats', 'queuedCache']))
maxQueuedDecrypt = Math.max(maxQueuedDecrypt, getNumericRecordValue(payload, ['preloadStats', 'queuedDecrypt']))
maxQueuedHigh = Math.max(maxQueuedHigh, getNumericRecordValue(payload, ['preloadStats', 'queuedHigh']))
maxQueuedNormal = Math.max(maxQueuedNormal, getNumericRecordValue(payload, ['preloadStats', 'queuedNormal']))
maxQueuedLow = Math.max(maxQueuedLow, getNumericRecordValue(payload, ['preloadStats', 'queuedLow']))
maxActiveCache = Math.max(maxActiveCache, getNumericRecordValue(payload, ['preloadStats', 'activeCache']))
maxActiveDecrypt = Math.max(maxActiveDecrypt, getNumericRecordValue(payload, ['preloadStats', 'activeDecrypt']))
maxPending = Math.max(maxPending, getNumericRecordValue(payload, ['preloadStats', 'pending']))
maxHighWaterQueued = Math.max(maxHighWaterQueued, getNumericRecordValue(payload, ['preloadStats', 'highWater', 'queued']))
maxHighWaterQueuedDecrypt = Math.max(maxHighWaterQueuedDecrypt, getNumericRecordValue(payload, ['preloadStats', 'highWater', 'queuedDecrypt']))
maxHighWaterQueuedLow = Math.max(maxHighWaterQueuedLow, getNumericRecordValue(payload, ['preloadStats', 'highWater', 'queuedLow']))
maxHighWaterActiveDecrypt = Math.max(maxHighWaterActiveDecrypt, getNumericRecordValue(payload, ['preloadStats', 'highWater', 'activeDecrypt']))
mediaStreamMaxLoadMs = Math.max(
mediaStreamMaxLoadMs,
getNumericRecordValue(payload, ['counters', 'mediaStreamMaxLoadMs']),
getNumericRecordValue(payload, ['delta', 'mediaStreamMaxLoadMs'])
)
const delta = getRecordValue(payload, 'delta')
if (delta && typeof delta === 'object') {
for (const [key, value] of Object.entries(delta as Record<string, unknown>)) {
const numeric = Number(value)
if (!Number.isFinite(numeric)) continue
counterDeltas[key] = (counterDeltas[key] || 0) + numeric
}
}
}
const lastTotals = getRecordValue(getRecordValue(entries[entries.length - 1]?.payload, 'preloadStats'), 'totals')
const firstTotals = preloadTotalsBaseline || getRecordValue(getRecordValue(entries[0]?.payload, 'preloadStats'), 'totals')
if (firstTotals && typeof firstTotals === 'object' && lastTotals && typeof lastTotals === 'object') {
const keys = new Set([
...Object.keys(firstTotals as Record<string, unknown>),
...Object.keys(lastTotals as Record<string, unknown>)
])
for (const key of keys) {
const first = Number((firstTotals as Record<string, unknown>)[key])
const last = Number((lastTotals as Record<string, unknown>)[key])
if (!Number.isFinite(first) || !Number.isFinite(last)) continue
preloadTotalDeltas[key] = last - first
}
}
const mediaStreamLoadSamples = counterDeltas.mediaStreamLoadSamples || 0
const mediaStreamLoadMsTotal = counterDeltas.mediaStreamLoadMsTotal || 0
const mediaStreamPageCacheHits = counterDeltas.mediaStreamPageCacheHits || 0
const mediaStreamInflightMerges = counterDeltas.mediaStreamInflightMerges || 0
const mediaStreamAvoidedNativeLoads = mediaStreamPageCacheHits + mediaStreamInflightMerges
const mediaStreamNativeLoads = counterDeltas.mediaStreamNativeLoads || 0
const mediaStreamRowsLoaded = counterDeltas.mediaStreamRowsLoaded || 0
const mediaStreamDuplicateRows = counterDeltas.mediaStreamDuplicateRows || 0
const mediaStreamNoProgressStops = counterDeltas.mediaStreamNoProgressStops || 0
const preloadAccepted = preloadTotalDeltas.accepted || 0
const preloadMergedQueued = preloadTotalDeltas.mergedQueued || 0
const preloadSkippedActive = preloadTotalDeltas.skippedActive || 0
const preloadSkippedPending = preloadTotalDeltas.skippedPending || 0
const preloadDeduped = preloadMergedQueued + preloadSkippedActive + preloadSkippedPending
const preloadHandled = preloadAccepted + preloadDeduped
const preloadCanceledActive = preloadTotalDeltas.canceledActive || 0
const preloadDroppedQueued = preloadTotalDeltas.droppedQueued || 0
const preloadDeferredLowPriority = preloadTotalDeltas.deferredLowPriority || 0
const preloadLowPriorityIdleDeferrals = preloadTotalDeltas.lowPriorityIdleDeferrals || 0
const preloadActiveCacheSnapshots = preloadTotalDeltas.activeCacheSnapshots || 0
const preloadActiveCacheSnapshotSkipped = preloadTotalDeltas.activeCacheSnapshotSkipped || 0
const preloadActiveCacheSnapshotCanceled = preloadTotalDeltas.activeCacheSnapshotCanceled || 0
const preloadLowPriorityRejected = preloadTotalDeltas.lowPriorityRejected || 0
const imagePreloadRejectedCapacity = counterDeltas.imagePreloadRejectedCapacity || 0
const imagePredecryptRequests = counterDeltas.imagePredecryptRequests || 0
const imagePredecryptBackpressureSkips = counterDeltas.imagePredecryptBackpressureSkips || 0
const imagePredecryptRejectedCapacity = counterDeltas.imagePredecryptRejectedCapacity || 0
const imagePredecryptDeferred = counterDeltas.imagePredecryptDeferred || 0
const imagePredecryptPreviewUpgrades = counterDeltas.imagePredecryptPreviewUpgrades || 0
const predecryptHiddenSkips = counterDeltas.predecryptHiddenSkips || 0
const rangeHiddenSkips = counterDeltas.rangeHiddenSkips || 0
const rangeDuplicateSkips = counterDeltas.rangeDuplicateSkips || 0
const rangeVisibilityReschedules = counterDeltas.rangeVisibilityReschedules || 0
const transientStatePruneRuns = counterDeltas.transientStatePruneRuns || 0
return {
samples: entries.length,
longFrames,
maxRecentFrameMs,
maxQueued,
maxQueuedCache,
maxQueuedDecrypt,
maxQueuedHigh,
maxQueuedNormal,
maxQueuedLow,
maxPending,
maxActiveCache,
maxActiveDecrypt,
maxHighWaterQueued,
maxHighWaterQueuedDecrypt,
maxHighWaterQueuedLow,
maxHighWaterActiveDecrypt,
mediaStreamLoadSamples,
mediaStreamAvgLoadMs: mediaStreamLoadSamples > 0 ? mediaStreamLoadMsTotal / mediaStreamLoadSamples : 0,
mediaStreamMaxLoadMs,
mediaStreamNativeLoads,
mediaStreamPageCacheHits,
mediaStreamInflightMerges,
mediaStreamAvoidedNativeLoads,
mediaStreamAvoidedNativeRate: mediaStreamLoadSamples > 0 ? mediaStreamAvoidedNativeLoads / mediaStreamLoadSamples : 0,
mediaStreamPageCacheHitRate: mediaStreamLoadSamples > 0 ? mediaStreamPageCacheHits / mediaStreamLoadSamples : 0,
mediaStreamRowsLoaded,
mediaStreamDuplicateRows,
mediaStreamDuplicateRate: mediaStreamRowsLoaded > 0 ? mediaStreamDuplicateRows / mediaStreamRowsLoaded : 0,
mediaStreamNoProgressStops,
preloadAccepted,
preloadDeduped,
preloadHandled,
preloadDedupRate: preloadHandled > 0 ? preloadDeduped / preloadHandled : 0,
preloadCanceledActive,
preloadDroppedQueued,
preloadDeferredLowPriority,
preloadLowPriorityIdleDeferrals,
preloadActiveCacheSnapshots,
preloadActiveCacheSnapshotSkipped,
preloadActiveCacheSnapshotCanceled,
preloadLowPriorityRejected,
imagePreloadRejectedCapacity,
imagePredecryptRequests,
imagePredecryptBackpressureSkips,
imagePredecryptRejectedCapacity,
imagePredecryptDeferred,
imagePredecryptPreviewUpgrades,
imagePredecryptRejectRate: imagePredecryptRequests > 0 ? imagePredecryptRejectedCapacity / imagePredecryptRequests : 0,
imagePredecryptBackpressureRate: imagePredecryptRequests > 0 ? imagePredecryptBackpressureSkips / imagePredecryptRequests : 0,
predecryptHiddenSkips,
rangeHiddenSkips,
rangeDuplicateSkips,
rangeVisibilityReschedules,
transientStatePruneRuns,
preloadTotalsBaselineCaptured: Boolean(preloadTotalsBaseline),
counterDeltas,
preloadTotalDeltas
}
}
const normalizeExportTaskId = (taskId: unknown): string => String(taskId || '').trim()
const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
@@ -2062,6 +2268,47 @@ function registerIpcHandlers() {
return { success: true }
})
ipcMain.handle('diagnostics:recordResourceStats', async (_, payload?: unknown) => {
resourceDiagnosticsEntries.push({
ts: Date.now(),
payload: sanitizeResourceDiagnosticsPayload(payload)
})
while (resourceDiagnosticsEntries.length > maxResourceDiagnosticsEntries) {
resourceDiagnosticsEntries.shift()
}
return { success: true, count: resourceDiagnosticsEntries.length }
})
ipcMain.handle('diagnostics:getResourceStats', async (_, options?: { limit?: number }) => {
const limit = Math.max(1, Math.min(500, Number(options?.limit || maxResourceDiagnosticsEntries)))
const entries = resourceDiagnosticsEntries.slice(-limit)
return {
entries,
summary: {
count: resourceDiagnosticsEntries.length,
firstTs: resourceDiagnosticsEntries[0]?.ts || 0,
lastTs: resourceDiagnosticsEntries[resourceDiagnosticsEntries.length - 1]?.ts || 0,
...summarizeResourceDiagnostics(entries, resourceDiagnosticsPreloadTotalsBaseline)
}
}
})
ipcMain.handle('diagnostics:clearResourceStats', async () => {
resourceDiagnosticsEntries.length = 0
try {
const stats = imagePreloadService.getStats()
const totals = stats?.totals || {}
resourceDiagnosticsPreloadTotalsBaseline = Object.fromEntries(
Object.entries(totals)
.map(([key, value]) => [key, Number(value)])
.filter(([, value]) => Number.isFinite(value))
)
} catch {
resourceDiagnosticsPreloadTotalsBaseline = null
}
return { success: true }
})
ipcMain.handle('diagnostics:exportExportCardLogs', async (_, payload?: {
filePath?: string
frontendLogs?: unknown[]
@@ -2370,6 +2617,23 @@ function registerIpcHandlers() {
}
})
ipcMain.handle('video:getVideoInfoBatch', async (_, videoMd5List?: string[], options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => {
try {
const rows = await videoService.getVideoInfoBatch(Array.isArray(videoMd5List) ? videoMd5List : [], options)
return {
success: true,
rows: rows.map((row) => ({
index: row.index,
md5: row.md5,
success: true,
...row.info
}))
}
} catch (e) {
return { success: false, rows: [], error: String(e) }
}
})
ipcMain.handle('video:parseVideoMd5', async (_, content: string) => {
try {
const md5 = videoService.parseVideoMd5(content)
@@ -3043,6 +3307,8 @@ function registerIpcHandlers() {
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
allowCachePromotion?: boolean
allowFilesystemScan?: boolean
suppressEvents?: boolean
}) => {
return imageDecryptService.resolveCachedImage(payload)
@@ -3060,15 +3326,37 @@ function registerIpcHandlers() {
hardlinkOnly?: boolean
suppressEvents?: boolean
}>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; allowCachePromotion?: boolean; allowFilesystemScan?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => {
const list = Array.isArray(payloads) ? payloads : []
if (list.length === 0) return { success: true, rows: [] }
if (options?.hardlinkOnly === true && options?.allowFilesystemScan === false) {
const hardlinkMd5s = new Set<string>()
for (const payload of list) {
const imageMd5 = String(payload?.imageMd5 || '').trim().toLowerCase()
if (/^[a-f0-9]{32}$/i.test(imageMd5)) {
hardlinkMd5s.add(imageMd5)
continue
}
const imageDatName = String(payload?.imageDatName || '').trim().toLowerCase()
const datBase = imageDatName.endsWith('.dat') ? imageDatName.slice(0, -4) : imageDatName
if (/^[a-f0-9]{32}$/i.test(datBase)) {
hardlinkMd5s.add(datBase)
}
}
if (hardlinkMd5s.size > 0) {
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(hardlinkMd5s), {
chunkSize: 64,
yieldMs: 0,
filesystemFallback: false
})
}
}
const maxConcurrentRaw = Number(process.env.WEFLOW_IMAGE_RESOLVE_BATCH_CONCURRENCY || 10)
const maxConcurrentRaw = Number(process.env.WEFLOW_IMAGE_RESOLVE_BATCH_CONCURRENCY || 3)
const maxConcurrent = Number.isFinite(maxConcurrentRaw)
? Math.max(1, Math.min(Math.floor(maxConcurrentRaw), 48))
: 10
? Math.max(1, Math.min(Math.floor(maxConcurrentRaw), 12))
: 3
const workerCount = Math.min(maxConcurrent, list.length)
const rows: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> = new Array(list.length)
@@ -3083,8 +3371,9 @@ function registerIpcHandlers() {
const preferFilePath = payload.preferFilePath ?? options?.preferFilePath === true
const hardlinkOnly = payload.hardlinkOnly ?? options?.hardlinkOnly === true
const allowCacheIndex = options?.allowCacheIndex !== false
const allowCachePromotion = options?.allowCachePromotion !== false
const allowFilesystemScan = options?.allowFilesystemScan !== false
const disableUpdateCheck = options?.disableUpdateCheck === true
const suppressEvents = payload.suppressEvents ?? options?.suppressEvents === true
return [
sessionId,
imageMd5,
@@ -3093,8 +3382,9 @@ function registerIpcHandlers() {
preferFilePath ? 'pf1' : 'pf0',
hardlinkOnly ? 'hl1' : 'hl0',
allowCacheIndex ? 'ci1' : 'ci0',
disableUpdateCheck ? 'du1' : 'du0',
suppressEvents ? 'se1' : 'se0'
allowCachePromotion ? 'cp1' : 'cp0',
allowFilesystemScan ? 'fs1' : 'fs0',
disableUpdateCheck ? 'du1' : 'du0'
].join('|')
}
@@ -3104,6 +3394,8 @@ function registerIpcHandlers() {
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
disableUpdateCheck: options?.disableUpdateCheck === true,
allowCacheIndex: options?.allowCacheIndex !== false,
allowCachePromotion: options?.allowCachePromotion !== false,
allowFilesystemScan: options?.allowFilesystemScan !== false,
suppressEvents: payload.suppressEvents ?? options?.suppressEvents === true
})
@@ -3137,15 +3429,25 @@ function registerIpcHandlers() {
async (
_,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean; allowFilesystemScan?: boolean; emitResolved?: boolean; scope?: string; priority?: 'high' | 'normal' | 'low' }
) => {
imagePreloadService.enqueue(payloads || [], options)
return true
})
return imagePreloadService.enqueue(payloads || [], options)
})
ipcMain.handle(
'image:cancelPreloadScope',
async (_, scope?: string) => {
imagePreloadService.cancelScope(String(scope || ''))
return true
}
)
ipcMain.handle(
'image:getPreloadStats',
async () => imagePreloadService.getStats()
)
ipcMain.handle(
'image:preloadHardlinkMd5s',
async (_, md5List?: string[]) => {
await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : [])
async (_, md5List?: string[], options?: { chunkSize?: number; yieldMs?: number; filesystemFallback?: boolean }) => {
await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : [], options)
return true
}
)
+23 -8
View File
@@ -96,6 +96,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
clearExportCardLogs: () =>
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
recordResourceStats: (payload: any) =>
ipcRenderer.invoke('diagnostics:recordResourceStats', payload),
getResourceStats: (options?: { limit?: number }) =>
ipcRenderer.invoke('diagnostics:getResourceStats', options),
clearResourceStats: () =>
ipcRenderer.invoke('diagnostics:clearResourceStats'),
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
},
@@ -327,6 +333,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
allowFilesystemScan?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:decrypt', payload),
@@ -339,26 +346,32 @@ contextBridge.exposeInMainWorld('electronAPI', {
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
allowCachePromotion?: boolean
allowFilesystemScan?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:resolveCache', payload),
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; allowCachePromotion?: boolean; allowFilesystemScan?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean; allowFilesystemScan?: boolean; emitResolved?: boolean; scope?: string; priority?: 'high' | 'normal' | 'low' }
) => ipcRenderer.invoke('image:preload', payloads, options),
preloadHardlinkMd5s: (md5List: string[]) =>
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
cancelPreloadScope: (scope: string) =>
ipcRenderer.invoke('image:cancelPreloadScope', scope),
getPreloadStats: () =>
ipcRenderer.invoke('image:getPreloadStats'),
preloadHardlinkMd5s: (md5List: string[], options?: { chunkSize?: number; yieldMs?: number; filesystemFallback?: boolean }) =>
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List, options),
onUpdateAvailable: (callback: (payload: { cacheKey: string; sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }) => callback(payload)
ipcRenderer.on('image:updateAvailable', listener)
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
},
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
onCacheResolved: (callback: (payload: { cacheKey: string; sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
ipcRenderer.on('image:cacheResolved', listener)
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
},
@@ -391,6 +404,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 视频
video: {
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
getVideoInfoBatch: (videoMd5List: string[], options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) =>
ipcRenderer.invoke('video:getVideoInfoBatch', videoMd5List, options),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
+209 -143
View File
@@ -20,7 +20,7 @@ import { EXPORT_HTML_STYLES } from '../../exportHtmlStyles'
import { LRUCache } from '../../../utils/LRUCache.js'
import { normalizeTimestampSeconds, formatTimestamp, formatIsoTimestamp, parseCompactDateTimeDigitsToSeconds, parseDateTimeTextToSeconds, normalizeExportDateRange, normalizeRowTimestampSeconds, getTimestampSecondsFromRow } from '../../export/utils/timestamp';
import { escapeHtml, escapeAttribute, renderMultilineText, decodeHtmlEntities } from '../../export/utils/htmlEscape';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, normalizeExportConflictStrategy, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { extractXmlValue, extractXmlAttribute, extractAppMessageType, normalizeAppMessageContent } from '../../export/parsers/xmlExtractor';
import { decodeMessageContent, decodeMaybeCompressed, decodeBinaryContent, looksLikeHex, looksLikeBase64 } from '../../export/parsers/contentDecoder';
import { parseVoipMessage } from '../../export/parsers/voipParser';
@@ -177,17 +177,24 @@ export class ExportContext {
const normalizedDateRange = normalizeExportDateRange(options.dateRange);
const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb);
const normalizedWriteLayout = this.resolveExportWriteLayout(options);
const exportMedia = options.exportMedia === true;
const mediaContentType = this.getMediaContentType(options);
const exportMedia = options.exportMedia === true || this.isMediaOptionSelected(options) || mediaContentType !== null;
const resolveMediaOption = (key: keyof Pick<ExportOptions, 'exportImages' | 'exportVoices' | 'exportVideos' | 'exportEmojis' | 'exportFiles'>, type: MediaContentType): boolean | undefined => {
if (mediaContentType) return mediaContentType === type
return exportMedia ? options[key] !== false : options[key]
}
return {
...options,
dateRange: normalizedDateRange,
maxFileSizeMb: normalizedMaxFileSizeMb,
exportConflictStrategy: this.resolveExportConflictStrategy(options),
exportWriteLayout: normalizedWriteLayout,
exportImages: exportMedia ? options.exportImages !== false : options.exportImages,
exportVoices: exportMedia ? options.exportVoices !== false : options.exportVoices,
exportVideos: exportMedia ? options.exportVideos !== false : options.exportVideos,
exportEmojis: exportMedia ? options.exportEmojis !== false : options.exportEmojis,
exportFiles: exportMedia ? options.exportFiles !== false : options.exportFiles
exportMedia,
exportImages: resolveMediaOption('exportImages', 'image'),
exportVoices: resolveMediaOption('exportVoices', 'voice'),
exportVideos: resolveMediaOption('exportVideos', 'video'),
exportEmojis: resolveMediaOption('exportEmojis', 'emoji'),
exportFiles: resolveMediaOption('exportFiles', 'file')
}
}
@@ -200,6 +207,14 @@ export class ExportContext {
: 'B'
}
public resolveExportConflictStrategy(options?: Pick<ExportOptions, 'exportConflictStrategy'> | null): 'incremental' | 'overwrite' | 'rename' {
return normalizeExportConflictStrategy(options?.exportConflictStrategy)
}
public shouldReuseExistingExportFile(options?: Pick<ExportOptions, 'exportConflictStrategy'> | null): boolean {
return this.resolveExportConflictStrategy(options) === 'incremental'
}
public resolveExportPathStyle(options?: Pick<ExportOptions, 'exportPathStyle'> | null): 'posix' | 'windows' {
const style = options?.exportPathStyle
if (style === 'posix') return 'posix'
@@ -521,8 +536,15 @@ export class ExportContext {
return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP'
}
private async copyMediaWithCacheAndDedup(kind: 'image' | 'video' | 'emoji', sourcePath: string, destPath: string, control?: ExportTaskControl): Promise<{ success: boolean; code?: string }> {
private async copyMediaWithCacheAndDedup(kind: 'image' | 'video' | 'emoji', sourcePath: string, destPath: string, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<{ success: boolean; code?: string }> {
const existedBeforeCopy = await pathExists(destPath);
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({
doneFiles: 1,
dedupReuseFiles: 1
})
return { success: true }
}
const resolved = await this.resolvePreferredMediaSource(kind, sourcePath);
if (resolved.cacheHit) {
this.noteMediaTelemetry({ cacheHitFiles: 1 })
@@ -649,9 +671,12 @@ export class ExportContext {
}
}
private isMediaExportEnabled(options: ExportOptions): boolean {
return options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
private isMediaOptionSelected(options: Pick<ExportOptions, 'exportImages' | 'exportVoices' | 'exportVideos' | 'exportEmojis' | 'exportFiles'>): boolean {
return Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
}
public isMediaExportEnabled(options: ExportOptions): boolean {
return this.getMediaContentType(options) !== null || this.isMediaOptionSelected(options)
}
public isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
@@ -2706,6 +2731,46 @@ export class ExportContext {
/**
* 导出媒体文件到指定目录
*/
private resolveWeliveMediaPath(msg: any): string {
const nestedMedia = msg?.media && typeof msg.media === 'object' ? msg.media : null
const candidates = [
msg?.mediaPath,
msg?.media_path,
msg?.videoPath,
msg?.video_path,
msg?.imagePath,
msg?.image_path,
msg?.voicePath,
msg?.voice_path,
msg?.filePath,
msg?.file_path,
msg?.localPath,
msg?.local_path,
msg?.sourcePath,
msg?.source_path,
msg?.fullPath,
msg?.full_path,
msg?.absolutePath,
msg?.absolute_path,
nestedMedia?.path,
nestedMedia?.mediaPath,
nestedMedia?.media_path,
nestedMedia?.videoPath,
nestedMedia?.video_path,
nestedMedia?.localPath,
nestedMedia?.local_path,
nestedMedia?.filePath,
nestedMedia?.file_path
]
for (const candidate of candidates) {
const value = String(candidate || '').trim()
if (value) return value
}
return ''
}
public async exportMediaForMessage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, options: {
exportImages?: boolean
exportVoices?: boolean
@@ -2714,13 +2779,14 @@ export class ExportContext {
exportFiles?: boolean
maxFileSizeMb?: number
exportVoiceAsText?: boolean
exportConflictStrategy?: ExportOptions['exportConflictStrategy']
includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean
dirCache?: Set<string>
control?: ExportTaskControl
}): Promise<MediaExportItem | null> {
const localType = msg.localType;
const weliveMediaPath = String(msg?.mediaPath || msg?.media_path || '').trim()
const weliveMediaPath = this.resolveWeliveMediaPath(msg)
if (weliveMediaPath) {
const adopted = await this.adoptWeliveExportedMedia(msg, weliveMediaPath, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
@@ -2729,6 +2795,7 @@ export class ExportContext {
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportConflictStrategy: options.exportConflictStrategy,
dirCache: options.dirCache,
control: options.control
})
@@ -2742,7 +2809,8 @@ export class ExportContext {
mediaRootDir,
mediaRelativePrefix,
options.dirCache,
options.control
options.control,
options
)
if (result) {
}
@@ -2751,7 +2819,7 @@ export class ExportContext {
if (localType === 34) {
if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control, options)
}
if (options.exportVoiceAsText) {
return null
@@ -2759,7 +2827,7 @@ export class ExportContext {
}
if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control, options)
if (result) {
}
return result
@@ -2773,7 +2841,8 @@ export class ExportContext {
mediaRelativePrefix,
options.dirCache,
options.includeVideoPoster === true,
options.control
options.control,
options
)
}
@@ -2784,7 +2853,8 @@ export class ExportContext {
mediaRelativePrefix,
options.maxFileSizeMb,
options.dirCache,
options.control
options.control,
options
)
}
@@ -2798,6 +2868,7 @@ export class ExportContext {
exportEmojis?: boolean
exportFiles?: boolean
maxFileSizeMb?: number
exportConflictStrategy?: ExportOptions['exportConflictStrategy']
dirCache?: Set<string>
control?: ExportTaskControl
}): Promise<MediaExportItem | null> {
@@ -2846,14 +2917,18 @@ export class ExportContext {
const destPath = path.join(targetDir, destFileName)
if (path.resolve(sourcePath) !== path.resolve(destPath)) {
if (kind === 'image' || kind === 'emoji' || kind === 'video') {
const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control)
const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control, options)
if (!copied.success) return null
} else {
const existedBeforeCopy = await pathExists(destPath)
const copied = await copyFileOptimized(sourcePath, destPath)
if (!copied.success) return null
if (!existedBeforeCopy) options.control?.recordCreatedFile?.(destPath)
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
} else {
const copied = await copyFileOptimized(sourcePath, destPath)
if (!copied.success) return null
if (!existedBeforeCopy) options.control?.recordCreatedFile?.(destPath)
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
}
}
}
@@ -2869,7 +2944,7 @@ export class ExportContext {
/**
* 导出图片文件
*/
private async exportImage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportImage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
await ensureExportDir(imagesDir, control, dirCache)
@@ -2992,6 +3067,14 @@ export class ExportContext {
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
if (await pathExists(destPath) && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image'
}
}
const buffer = Buffer.from(base64Data, 'base64')
await this.recordCreatedFileBeforeWrite(destPath, control)
await fs.promises.writeFile(destPath, buffer)
@@ -3013,7 +3096,7 @@ export class ExportContext {
const ext = path.extname(sourcePath) || '.jpg'
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control)
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control, options)
if (!copied.success) {
if (copied.code === 'ENOENT') {
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
@@ -3033,10 +3116,19 @@ export class ExportContext {
}
}
public async preloadMediaLookupCaches(_sessionId: string, messages: any[], options: { exportImages?: boolean; exportVideos?: boolean }, control?: ExportTaskControl): Promise<void> {
public async preloadMediaLookupCaches(sessionId: string, messages: any[], options: { exportImages?: boolean; exportVideos?: boolean }, control?: ExportTaskControl): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
const md5Pattern = /^[a-f0-9]{32}$/i;
const imageMd5Set = new Set<string>();
const videoTokenSet = new Set<string>();
if (options.exportVideos) {
const videoMessages = messages.filter((msg) => Number(msg?.localType || 0) === 43)
if (videoMessages.length > 0) {
await this.backfillMediaFieldsFromMessageDetail(sessionId, videoMessages, new Set([43]), control)
}
}
let scanIndex = 0;
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
@@ -3054,12 +3146,27 @@ export class ExportContext {
}
}
if (options.exportVideos && msg?.localType === 43) {
const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase()
if (videoMd5) {
videoTokenSet.add(videoMd5)
continue
}
const parsedVideoMd5 = String(videoService.parseVideoMd5(String(msg?.content || '')) || '').trim().toLowerCase()
if (parsedVideoMd5) {
videoTokenSet.add(parsedVideoMd5)
}
}
}
const preloadTasks: Array<Promise<void>> = [];
if (imageMd5Set.size > 0) {
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
}
if (videoTokenSet.size > 0) {
preloadTasks.push(videoService.getVideoInfoBatch(Array.from(videoTokenSet), { includePoster: false }).then(() => undefined))
}
if (preloadTasks.length === 0) return
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
@@ -3112,7 +3219,7 @@ export class ExportContext {
/**
* 导出语音文件
*/
private async exportVoice(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportVoice(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
await ensureExportDir(voicesDir, control, dirCache)
@@ -3125,8 +3232,8 @@ export class ExportContext {
const fileName = `voice_${safeSession}_${stableKey || msgId}.wav`
const destPath = path.join(voicesDir, fileName)
// 如果已存在则跳过
if (await pathExists(destPath)) {
if (await pathExists(destPath) && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice'
@@ -3181,7 +3288,7 @@ export class ExportContext {
/**
* 导出表情文件
*/
private async exportEmoji(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportEmoji(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
await ensureExportDir(emojisDir, control, dirCache)
@@ -3211,7 +3318,7 @@ export class ExportContext {
const key = emojiMd5 || crypto.createHash('md5').update(emojiUrl || localPath || String(msg.localId || '')).digest('hex')
const fileName = `${key}${ext}`
const destPath = path.join(emojisDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control)
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control, options)
if (!copied.success) return null
return {
@@ -3227,9 +3334,34 @@ export class ExportContext {
/**
* 导出视频文件
*/
private async exportVideo(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, includePoster = false, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportVideo(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, includePoster = false, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
const collectLookupTokens = () => {
const contents = [
msg?.content,
msg?.messageContent,
msg?.message_content,
msg?.rawContent,
msg?.raw_content,
msg?.parsedContent,
msg?.parsed_content
].map((item) => String(item || '')).filter(Boolean)
const tokens = [
msg?.videoMd5,
msg?.video_md5,
msg?.rawMd5,
msg?.raw_md5,
this.extractVideoFileNameFromRow(msg, contents[0] || ''),
this.normalizeVideoFileToken(this.resolveWeliveMediaPath(msg))
]
for (const content of contents) {
tokens.push(videoService.parseVideoMd5(content))
tokens.push(this.extractVideoMd5(content))
}
return Array.from(new Set(tokens
.map((token) => this.normalizeVideoFileToken(token))
.filter((token): token is string => Boolean(token))))
}
const resolveVideoInfo = async (token: string) => {
if (!token) return null
const videoInfo = await videoService.getVideoInfo(token, { includePoster })
@@ -3237,13 +3369,19 @@ export class ExportContext {
return videoInfo
}
let videoInfo = await resolveVideoInfo(videoMd5)
let videoInfo = null
for (const token of collectLookupTokens()) {
videoInfo = await resolveVideoInfo(token)
if (videoInfo) break
}
if (!videoInfo) {
const localId = Number(msg?.localId || 0)
if (Number.isFinite(localId) && localId > 0) {
await this.backfillMediaFieldsFromMessageDetail(sessionId, [msg], new Set([43]), undefined, { force: true })
videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
videoInfo = await resolveVideoInfo(videoMd5)
for (const token of collectLookupTokens()) {
videoInfo = await resolveVideoInfo(token)
if (videoInfo) break
}
}
}
if (!videoInfo) return null
@@ -3256,7 +3394,7 @@ export class ExportContext {
const fileName = path.basename(sourcePath)
const destPath = path.join(videosDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control)
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control, options)
if (!copied.success) return null
return {
@@ -3398,12 +3536,19 @@ export class ExportContext {
private extractVideoMd5(content: string): string | undefined {
if (!content) return undefined
const attrMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content);
if (attrMatch) {
return attrMatch[1].toLowerCase()
const attrPatterns = [
/<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\snewmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\soriginsourcemd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/(?:^|\s)(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/i
]
for (const pattern of attrPatterns) {
const match = pattern.exec(content)
if (match?.[1]) return match[1].toLowerCase()
}
const tagMatch = /<md5>([^<]+)<\/md5>/i.exec(content);
const tagMatch = /<(?:md5|rawmd5|newmd5|originsourcemd5)>([a-fA-F0-9]+)<\/(?:md5|rawmd5|newmd5|originsourcemd5)>/i.exec(content);
return tagMatch?.[1]?.toLowerCase()
}
@@ -3752,7 +3897,7 @@ export class ExportContext {
return candidates
}
private async exportFileAttachment(msg: any, mediaRootDir: string, mediaRelativePrefix: string, maxFileSizeMb?: number, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportFileAttachment(msg: any, mediaRootDir: string, mediaRelativePrefix: string, maxFileSizeMb?: number, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const fileNameRaw = String(msg?.fileName || '').trim()
if (!fileNameRaw) return null
@@ -3804,6 +3949,14 @@ export class ExportContext {
const destFileName = `${messageId}_${safeBaseName}`
const destPath = path.join(fileDir, destFileName)
const existedBeforeCopy = await pathExists(destPath)
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
kind: 'file'
}
}
const copied = await copyFileOptimized(selected.sourcePath, destPath)
if (!copied.success) {
this.recordFileAttachmentMiss(msg, '附件复制失败', {
@@ -3882,8 +4035,7 @@ export class ExportContext {
mediaRootDir: string
mediaRelativePrefix: string
} {
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles);
const exportMediaEnabled = this.isMediaExportEnabled(options);
const outputDir = path.dirname(outputPath);
const writeLayout = this.resolveExportWriteLayout(options);
if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
@@ -3904,7 +4056,7 @@ export class ExportContext {
public async resolveWeliveRawMediaItem(msg: any, mediaRootDir: string, mediaRelativePrefix: string, options: ExportOptions, control?: ExportTaskControl): Promise<MediaExportItem | null> {
if (!this.isWeliveRawExportMode()) return null
const sourcePathRaw = String(msg?.mediaPath || msg?.media_path || '').trim()
const sourcePathRaw = this.resolveWeliveMediaPath(msg)
const localType = Number(msg?.localType || msg?.local_type || 0)
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
@@ -3922,26 +4074,9 @@ export class ExportContext {
}
if (!kind) return null
if (!sourcePathRaw) {
const emojiUrl = kind === 'emoji'
? String(msg?.emojiCdnUrl || msg?.emoji_cdn_url || '').trim()
: ''
if (emojiUrl) {
return await this.exportEmoji({
...msg,
emojiCdnUrl: emojiUrl,
emojiMd5: this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
}, String(msg?.sessionId || msg?.session_id || ''), mediaRootDir, mediaRelativePrefix, undefined, control)
}
return null
}
if (/^https?:\/\//i.test(sourcePathRaw)) {
if (kind === 'emoji') {
return await this.exportEmoji({
...msg,
emojiCdnUrl: sourcePathRaw,
emojiMd5: this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
}, String(msg?.sessionId || msg?.session_id || ''), mediaRootDir, mediaRelativePrefix, undefined, control)
}
return { relativePath: sourcePathRaw, kind }
}
@@ -3954,88 +4089,17 @@ export class ExportContext {
}
public async preloadWeliveRawEmojiMedia(
messages: any[],
mediaCache: Map<string, MediaExportItem | null>,
mediaRootDir: string,
mediaRelativePrefix: string,
options: ExportOptions,
control?: ExportTaskControl,
onProgress?: (progress: ExportProgress) => void,
sessionName = '',
progressCurrent = 25
_messages: any[],
_mediaCache: Map<string, MediaExportItem | null>,
_mediaRootDir: string,
_mediaRelativePrefix: string,
_options: ExportOptions,
_control?: ExportTaskControl,
_onProgress?: (progress: ExportProgress) => void,
_sessionName = '',
_progressCurrent = 25
): Promise<void> {
if (!this.isWeliveRawExportMode() || options.exportEmojis !== true || !Array.isArray(messages) || messages.length === 0) return
const groups = new Map<string, { msg: any; mediaKeys: string[] }>()
for (const msg of messages) {
const localType = Number(msg?.localType || msg?.local_type || 0)
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
if (localType !== 47 && mediaType !== 'emoji') continue
const mediaKey = this.getMediaCacheKey(msg)
if (mediaCache.has(mediaKey)) continue
const sourcePathRaw = String(msg?.mediaPath || msg?.media_path || '').trim()
const emojiUrl = /^https?:\/\//i.test(sourcePathRaw)
? sourcePathRaw
: this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || msg?.emoji_cdn_url || this.extractEmojiUrl(String(msg?.content || '')))
if (!emojiUrl) continue
const emojiMd5 = this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
const groupKey = emojiMd5 || emojiUrl
const existing = groups.get(groupKey)
if (existing) {
existing.mediaKeys.push(mediaKey)
} else {
groups.set(groupKey, {
msg: {
...msg,
mediaPath: /^https?:\/\//i.test(sourcePathRaw) ? sourcePathRaw : msg?.mediaPath,
media_path: /^https?:\/\//i.test(sourcePathRaw) ? sourcePathRaw : msg?.media_path,
emojiCdnUrl: emojiUrl,
emojiMd5: emojiMd5 || msg?.emojiMd5
},
mediaKeys: [mediaKey]
})
}
}
const workItems = Array.from(groups.values())
if (workItems.length === 0) return
const concurrencySeed = typeof options.exportConcurrency === 'number'
? options.exportConcurrency * 4
: undefined
const concurrency = this.getClampedConcurrency(concurrencySeed, 12, 24)
let completed = 0
let lastReportAt = 0
const report = (force = false) => {
if (!onProgress) return
const now = Date.now()
if (!force && now - lastReportAt < 350) return
lastReportAt = now
onProgress({
current: progressCurrent,
total: 100,
currentSession: sessionName,
phase: 'exporting-media',
phaseProgress: completed,
phaseTotal: workItems.length,
phaseLabel: `下载表情包 ${completed.toLocaleString()}/${workItems.length.toLocaleString()}`,
...this.getMediaTelemetrySnapshot()
})
}
report(true)
await parallelLimit(workItems, concurrency, async (item) => {
this.throwIfStopRequested(control)
const mediaItem = await this.resolveWeliveRawMediaItem(item.msg, mediaRootDir, mediaRelativePrefix, options, control)
for (const mediaKey of item.mediaKeys) {
mediaCache.set(mediaKey, mediaItem)
}
completed++
report(completed === workItems.length || completed % 10 === 0)
})
// WeLive raw export owns media resolution. Weflow must not scan/copy original media here.
}
public collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] {
@@ -4262,6 +4326,8 @@ export class ExportContext {
senderSet.add(actualSender)
rows.push({
sessionId,
session_id: sessionId,
localId: this.getIntFromRow(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
serverId: this.getIntFromRow(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', 'WCDB_CT_server_id'], 0),
serverIdRaw: this.normalizeUnsignedIntToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', 'WCDB_CT_server_id'])) || undefined,
@@ -4280,7 +4346,7 @@ export class ExportContext {
fileSize,
fileExt,
fileMd5,
mediaPath: String(row.media_path || row.mediaPath || '').trim() || undefined,
mediaPath: this.resolveWeliveMediaPath(row) || undefined,
mediaType: String(row.media_type || row.mediaType || '').trim() || undefined,
mediaError: String(row.media_error || row.mediaError || '').trim() || undefined,
locationLat,
@@ -18,7 +18,7 @@ import { EXPORT_HTML_STYLES } from '../../exportHtmlStyles'
import { LRUCache } from '../../../utils/LRUCache.js'
import { normalizeTimestampSeconds, formatTimestamp, formatIsoTimestamp, parseCompactDateTimeDigitsToSeconds, parseDateTimeTextToSeconds, normalizeExportDateRange, normalizeRowTimestampSeconds, getTimestampSecondsFromRow } from '../../export/utils/timestamp';
import { escapeHtml, escapeAttribute, renderMultilineText, decodeHtmlEntities } from '../../export/utils/htmlEscape';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeExportConflictStrategy, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { extractXmlValue, extractXmlAttribute, extractAppMessageType, normalizeAppMessageContent } from '../../export/parsers/xmlExtractor';
import { decodeMessageContent, decodeMaybeCompressed, decodeBinaryContent, looksLikeHex, looksLikeBase64 } from '../../export/parsers/contentDecoder';
import { parseVoipMessage } from '../../export/parsers/voipParser';
@@ -37,6 +37,7 @@ import { ChatLabFormatter } from '../formatters/ChatLabFormatter';
import { ExcelFormatter } from '../formatters/ExcelFormatter';
import { HtmlFormatter } from '../formatters/HtmlFormatter';
import { JsonFormatter } from '../formatters/JsonFormatter';
import { MarkdownFormatter } from '../formatters/MarkdownFormatter';
import { TxtFormatter } from '../formatters/TxtFormatter';
import { WeCloneFormatter } from '../formatters/WeCloneFormatter';
@@ -92,6 +93,14 @@ export class ExportOrchestrator {
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 导出单个会话为 Markdown 格式
*/
async exportSessionToMarkdown(sessionId: string, outputPath: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{ success: boolean; error?: string }> {
const formatter = new MarkdownFormatter(this.context);
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 批量导出多个会话
*/
@@ -133,9 +142,9 @@ export class ExportOrchestrator {
const effectiveOptions: ExportOptions = this.context.isMediaContentBatchExport(normalizedOptions)
? { ...normalizedOptions, exportVoiceAsText: false }
: normalizedOptions
const conflictStrategy = normalizeExportConflictStrategy(effectiveOptions.exportConflictStrategy)
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
const exportMediaEnabled = this.context.isMediaExportEnabled(effectiveOptions)
attachMediaTelemetry = exportMediaEnabled
if (exportMediaEnabled) {
this.context.triggerMediaFileCacheCleanup()
@@ -193,7 +202,7 @@ export class ExportOrchestrator {
this.context.isUnboundedDateRange(effectiveOptions.dateRange) &&
!String(effectiveOptions.senderUsername || '').trim()
const canFastSkipEmptySessions = false
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints && conflictStrategy === 'incremental'
const precheckSessionIds = canFastSkipEmptySessions
? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
: []
@@ -349,7 +358,6 @@ export class ExportOrchestrator {
phaseLabel: '准备导出'
})
const fileNamingMode = normalizeFileNamingMode(effectiveOptions.fileNamingMode)
const safeName = buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.context.getSessionFilePrefix(sessionId) : ''
@@ -366,6 +374,7 @@ export class ExportOrchestrator {
if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl'
else if (effectiveOptions.format === 'excel') ext = '.xlsx'
else if (effectiveOptions.format === 'txt') ext = '.txt'
else if (effectiveOptions.format === 'markdown') ext = '.md'
else if (effectiveOptions.format === 'weclone') ext = '.csv'
else if (effectiveOptions.format === 'html') ext = '.html'
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
@@ -402,7 +411,7 @@ export class ExportOrchestrator {
}
}
const outputPath = fileNamingMode === 'date-range'
const outputPath = conflictStrategy === 'rename'
? await reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
: preferredOutputPath
@@ -415,6 +424,8 @@ export class ExportOrchestrator {
result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'txt') {
result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'markdown') {
result = await this.exportSessionToMarkdown(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'weclone') {
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'html') {
@@ -161,6 +161,7 @@ export class ChatLabFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
@@ -276,6 +276,7 @@ export class ExcelFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
@@ -141,6 +141,7 @@ export class HtmlFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true,
exportVideos: options.exportVideos,
@@ -137,6 +137,7 @@ export class JsonFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
@@ -0,0 +1,463 @@
import * as fs from 'fs';
import * as path from 'path';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickname';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from '../../wcdbService';
const escapeMarkdownText = (value: unknown): string => {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
}
const escapeMarkdownLinkText = (value: unknown): string => {
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
}
const toMarkdownUrl = (value: string): string => {
return encodeURI(value)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}
const buildMarkdownBlockquote = (value: string): string => {
return value
.split(/\r?\n/)
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
.join('\n')
}
export class MarkdownFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) return contactCache.get(username)!
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const totalMessages = collected.rows.length
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
await this.exportService.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
const sortedMessages = collected.rows
const { mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVoiceWithTranscript: true,
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
onProgress?.({
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
const writeChunk = async (chunk: string): Promise<void> => {
await new Promise<void>((resolve, _reject) => {
this.exportService.throwIfStopRequested(control)
if (!stream.write(chunk)) stream.once('drain', resolve)
else resolve()
})
}
const exportMeta = this.exportService.getExportMeta(sessionId, sessionInfo, isGroup)
await writeChunk(`# ${escapeMarkdownText(sessionInfo.displayName || sessionId)}\n\n`)
await writeChunk(`- 会话ID: \`${String(sessionId).replace(/`/g, '\\`')}\`\n`)
await writeChunk(`- 会话类型: ${isGroup ? '群聊' : '私聊'}\n`)
await writeChunk(`- 消息数量: ${totalMessages}\n`)
await writeChunk(`- 导出时间: ${escapeMarkdownText(formatTimestamp(exportMeta.chatlab.exportedAt))}\n`)
await writeChunk(`- 导出工具: WeFlow\n\n---\n\n`)
const WRITE_BATCH = 80
let writeBuffer: string[] = []
const flushWriteBuffer = async (): Promise<void> => {
if (writeBuffer.length === 0) return
await writeChunk(writeBuffer.join(''))
writeBuffer = []
}
const senderProfileCache = new Map<string, ExportDisplayProfile>()
const getSenderRole = async (msg: any): Promise<string> => {
if (isGroup) {
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
let senderProfile = senderProfileCache.get(senderProfileKey)
if (!senderProfile) {
senderProfile = await resolveExportDisplayProfile(
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderProfileKey, senderProfile)
}
return senderProfile.displayName
}
if (msg.isSend) return '我'
const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) {
return contactDetail.contact.remark || contactDetail.contact.nickName || sessionId
}
return sessionInfo.displayName || sessionId
}
const getRawMediaRelativePath = (msg: any): string => {
const rawPath = String(msg?.mediaPath || msg?.media_path || '').trim()
if (!rawPath || /^(?:https?:|data:|blob:)/i.test(rawPath)) return rawPath
const normalized = rawPath.replace(/\\/g, '/')
if (!path.isAbsolute(rawPath)) return normalized.replace(/^\.\//, '')
const relativeFromMediaRoot = path.relative(mediaRootDir, rawPath)
if (relativeFromMediaRoot && !relativeFromMediaRoot.startsWith('..') && !path.isAbsolute(relativeFromMediaRoot)) {
return relativeFromMediaRoot.split(path.sep).join('/')
}
return ''
}
const getFallbackMediaKind = (msg: any): MediaExportItem['kind'] | null => {
const localType = Number(msg?.localType || msg?.local_type || 0)
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
if (mediaType === 'image' || localType === 3) return 'image'
if (mediaType === 'emoji' || localType === 47) return 'emoji'
if (mediaType === 'voice' || localType === 34) return 'voice'
if (mediaType === 'video' || localType === 43) return 'video'
if (mediaType === 'file') return 'file'
return null
}
const buildMediaMarkdown = (mediaItem: MediaExportItem | null | undefined, typeName: string, fallbackRelativePath = '', fallbackKind: MediaExportItem['kind'] | null = null): string => {
const relativePath = mediaItem?.relativePath || fallbackRelativePath
if (!relativePath) return ''
const mediaUrl = toMarkdownUrl(this.exportService.formatExportMediaPath(relativePath, { exportPathStyle: 'posix' }, 'url'))
const fileName = path.basename(relativePath.replace(/\\/g, '/')) || typeName
const kind = mediaItem?.kind || fallbackKind
if (kind === 'image' || kind === 'emoji') {
return `![${escapeMarkdownLinkText(typeName)}](${mediaUrl})`
}
if (kind === 'voice') {
return `[${escapeMarkdownLinkText('语音文件')}](${mediaUrl})`
}
if (kind === 'video') {
return `[${escapeMarkdownLinkText('视频文件')}](${mediaUrl})`
}
return `[${escapeMarkdownLinkText(fileName)}](${mediaUrl})`
}
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) this.exportService.throwIfStopRequested(control)
const msg = sortedMessages[i]
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const senderRole = await getSenderRole(msg)
const typeName = this.exportService.getMessageTypeName(msg.localType, msg.content)
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const mediaMarkdown = buildMediaMarkdown(mediaItem, typeName, getRawMediaRelativePath(msg), getFallbackMediaKind(msg))
let contentValue = shouldUseTranscript
? this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
: this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
if (mediaMarkdown && !shouldUseTranscript && (msg.localType === 3 || msg.localType === 34 || msg.localType === 43 || msg.localType === 47)) {
contentValue = ''
}
if (isTransferExportContent(contentValue) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
const c = await getContactCached(username)
if (c.success && c.contact) {
return c.contact.remark || c.contact.nickName || c.contact.alias || username
}
return username
}
)
if (transferDesc) {
contentValue = appendTransferDesc(contentValue, transferDesc)
}
}
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
const linkCard = quotedReplyDisplay ? null : this.exportService.extractHtmlLinkCard(msg.content, msg.localType)
const parts: string[] = []
let contentIsMarkdown = false
if (quotedReplyDisplay) {
const quotedLabel = quotedReplyDisplay.quotedSender
? `${quotedReplyDisplay.quotedSender}: ${quotedReplyDisplay.quotedPreview}`
: quotedReplyDisplay.quotedPreview
parts.push(buildMarkdownBlockquote(quotedLabel))
contentValue = quotedReplyDisplay.replyText || contentValue
} else if (linkCard?.url) {
contentValue = `[${escapeMarkdownLinkText(linkCard.title || linkCard.url)}](${toMarkdownUrl(linkCard.url)})`
contentIsMarkdown = true
}
if (mediaMarkdown) parts.push(mediaMarkdown)
if (contentValue) parts.push(contentIsMarkdown ? contentValue : escapeMarkdownText(contentValue))
const body = parts.length > 0 ? parts.join('\n\n') : escapeMarkdownText(`[${typeName}]`)
writeBuffer.push(`## ${escapeMarkdownText(formatTimestamp(msg.createTime))} ${escapeMarkdownText(senderRole)}\n\n${body}\n\n`)
if (writeBuffer.length >= WRITE_BATCH) {
await flushWriteBuffer()
}
if ((i + 1) % 200 === 0) {
onProgress?.({
current: 60 + Math.floor((i + 1) / sortedMessages.length * 30),
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
await flushWriteBuffer()
this.exportService.throwIfStopRequested(control)
await new Promise<void>((resolve, reject) => {
stream.on('error', reject)
stream.end(() => resolve())
})
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -144,6 +144,7 @@ export class TxtFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
@@ -142,6 +142,7 @@ export class WeCloneFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
+2 -1
View File
@@ -63,12 +63,13 @@ export interface ChatLabExport {
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'markdown' | 'txt' | 'excel' | 'weclone' | 'sql'
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
fileNamingMode?: 'classic' | 'date-range'
exportConflictStrategy?: 'incremental' | 'overwrite' | 'rename'
exportMedia?: boolean
exportAvatars?: boolean
exportImages?: boolean
@@ -21,6 +21,13 @@ export function normalizeFileNamingMode(value: unknown): 'classic' | 'date-range
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
}
export function normalizeExportConflictStrategy(value: unknown): 'incremental' | 'overwrite' | 'rename' {
const normalized = String(value || '').trim().toLowerCase()
if (normalized === 'overwrite') return 'overwrite'
if (normalized === 'rename') return 'rename'
return 'incremental'
}
export function formatDateTokenBySeconds(seconds?: number): string | null {
const normalizedSeconds = normalizeTimestampSeconds(seconds)
if (normalizedSeconds <= 0) return null
+588 -101
View File
@@ -1,8 +1,8 @@
import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
import { writeFile, rm, readdir } from 'fs/promises'
import { fileURLToPath, pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'
import { writeFile, rm, readdir, appendFile, readFile } from 'fs/promises'
import { homedir, tmpdir } from 'os'
import crypto from 'crypto'
import { ConfigService } from './config'
@@ -68,6 +68,8 @@ type CachedImagePayload = {
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
allowCachePromotion?: boolean
allowFilesystemScan?: boolean
suppressEvents?: boolean
}
@@ -75,18 +77,39 @@ type DecryptImagePayload = CachedImagePayload & {
force?: boolean
}
type PendingImageTask<T extends DecryptResult> = {
promise: Promise<T>
emitsEvents: boolean
}
export class ImageDecryptService {
private configService = new ConfigService()
private resolvedCache = new Map<string, string>()
private pending = new Map<string, Promise<DecryptResult>>()
private pending = new Map<string, PendingImageTask<DecryptResult>>()
private resolvePending = new Map<string, PendingImageTask<DecryptResult & { hasUpdate?: boolean }>>()
private updateFlags = new Map<string, boolean>()
private nativeLogged = false
private runtimeConfig: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
private datNameScanMissAt = new Map<string, number>()
private readonly datNameScanMissTtlMs = 1200
private readonly maxDatNameScanMissEntries = 2400
private readonly accountDirCache = new Map<string, string>()
private cacheRootPath: string | null = null
private readonly ensuredDirs = new Set<string>()
private readonly maxResolvedCacheEntries = 12000
private pendingLogLines: string[] = []
private logFlushTimer: ReturnType<typeof setTimeout> | null = null
private corruptedCacheCheckPending = new Set<string>()
private corruptedCacheCheckQueue: string[] = []
private activeCorruptedCacheChecks = 0
private corruptedCacheCheckLastAt = new Map<string, number>()
private corruptedCachePathUntil = new Map<string, number>()
private readonly maxPendingLogLines = 1000
private readonly logFlushDelayMs = 200
private readonly corruptedCacheCheckCooldownMs = 5 * 60 * 1000
private readonly corruptedCacheQuarantineMs = 5 * 60 * 1000
private readonly maxConcurrentCorruptedCacheChecks = 1
private readonly maxCorruptedCacheChecks = 6000
private shouldEmitImageEvents(payload?: { suppressEvents?: boolean }): boolean {
if (payload?.suppressEvents === true) return false
@@ -145,14 +168,36 @@ export class ImageDecryptService {
}
private writeLog(line: string): void {
if (!line) return
this.pendingLogLines.push(line)
while (this.pendingLogLines.length > this.maxPendingLogLines) {
this.pendingLogLines.shift()
}
if (this.logFlushTimer !== null) return
this.logFlushTimer = setTimeout(() => {
this.logFlushTimer = null
void this.flushPendingLogs()
}, this.logFlushDelayMs)
}
private async flushPendingLogs(): Promise<void> {
if (this.pendingLogLines.length === 0) return
const lines = this.pendingLogLines.splice(0, this.pendingLogLines.length).join('')
try {
const logDir = join(this.getUserDataPath(), 'logs')
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true })
}
appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' })
await appendFile(join(logDir, 'wcdb.log'), lines, { encoding: 'utf8' })
} catch (err) {
console.error('写入日志失败:', err)
} finally {
if (this.pendingLogLines.length > 0 && this.logFlushTimer === null) {
this.logFlushTimer = setTimeout(() => {
this.logFlushTimer = null
void this.flushPendingLogs()
}, this.logFlushDelayMs)
}
}
}
@@ -162,28 +207,51 @@ export class ImageDecryptService {
if (!cacheKey) {
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
}
const pendingKey = this.getResolvePendingKey(payload, cacheKey)
const pending = this.resolvePending.get(pendingKey)
if (pending) return this.resolvePendingResult(pending, payload, cacheKey)
const task = this.resolveCachedImageInternal({ ...payload, preferFilePath: true }, cacheKeys, cacheKey)
const pendingTask: PendingImageTask<DecryptResult & { hasUpdate?: boolean }> = {
promise: task,
emitsEvents: this.shouldEmitImageEvents(payload)
}
this.resolvePending.set(pendingKey, pendingTask)
try {
return await this.resolvePendingResult(pendingTask, payload, cacheKey)
} finally {
this.resolvePending.delete(pendingKey)
}
}
private async resolveCachedImageInternal(
payload: CachedImagePayload,
cacheKeys: string[],
cacheKey: string
): Promise<DecryptResult & { hasUpdate?: boolean }> {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
const cached = this.getResolvedMediaCache(payload, key)
if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) {
const upgraded = !this.isHdPath(cached)
const upgraded = !this.isHdPath(cached) && payload.allowCachePromotion !== false
? await this.tryPromoteThumbnailCache(payload, key, cached)
: null
const finalPath = upgraded || cached
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isNonHd = !this.isHdPath(finalPath)
const hasUpdate = isNonHd ? (this.updateFlags.get(key) ?? false) : false
const hasUpdate = isNonHd ? this.getUpdateFlag(payload, key) : false
if (isNonHd) {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, key, finalPath)
}
} else {
this.updateFlags.delete(key)
this.clearUpdateFlag(payload, key)
}
this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
if (cached && !this.isUsableImageCacheFile(cached)) {
this.resolvedCache.delete(key)
this.deleteResolvedMediaCache(payload, key)
}
}
@@ -199,26 +267,27 @@ export class ImageDecryptService {
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
allowDatNameScanFallback: payload.allowCacheIndex !== false,
allowFilesystemScan: payload.allowFilesystemScan !== false
}
)
if (datPath) {
const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false)
if (existing) {
const upgraded = !this.isHdPath(existing)
const upgraded = !this.isHdPath(existing) && payload.allowCachePromotion !== false
? await this.tryPromoteThumbnailCache(payload, cacheKey, existing)
: null
const finalPath = upgraded || existing
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath)
this.cacheResolvedPaths(payload, cacheKey, finalPath)
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isNonHd = !this.isHdPath(finalPath)
const hasUpdate = isNonHd ? (this.updateFlags.get(cacheKey) ?? false) : false
const hasUpdate = isNonHd ? this.getUpdateFlag(payload, cacheKey) : false
if (isNonHd) {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, cacheKey, finalPath)
}
} else {
this.updateFlags.delete(cacheKey)
this.clearUpdateFlag(payload, cacheKey)
}
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
@@ -239,24 +308,24 @@ export class ImageDecryptService {
if (payload.force) {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
const cached = this.getResolvedMediaCache(payload, key)
if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached) && this.isHdPath(cached)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.cacheResolvedPaths(payload, cacheKey, cached)
this.clearUpdateFlags(payload, cacheKey)
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.isUsableImageCacheFile(cached)) {
this.resolvedCache.delete(key)
this.deleteResolvedMediaCache(payload, key)
}
}
}
if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey)
const cached = this.getResolvedMediaCache(payload, cacheKey)
if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) {
const upgraded = !this.isHdPath(cached)
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
@@ -268,26 +337,34 @@ export class ImageDecryptService {
return { success: true, localPath }
}
if (cached && !this.isUsableImageCacheFile(cached)) {
this.resolvedCache.delete(cacheKey)
this.deleteResolvedMediaCache(payload, cacheKey)
}
}
const pending = this.pending.get(cacheKey)
const pendingKey = this.getDecryptPendingKey(payload, cacheKey)
const pending = this.pending.get(pendingKey)
if (pending) {
this.emitDecryptProgress(payload, cacheKey, 'queued', 8, 'running')
return pending
return this.resolvePendingResult(pending, payload, cacheKey)
}
const task = this.decryptImageInternal(payload, cacheKey)
this.pending.set(cacheKey, task)
const task = this.decryptImageInternal({ ...payload, preferFilePath: true }, cacheKey)
const pendingTask: PendingImageTask<DecryptResult> = {
promise: task,
emitsEvents: this.shouldEmitImageEvents(payload)
}
this.pending.set(pendingKey, pendingTask)
try {
return await task
return await this.resolvePendingResult(pendingTask, payload, cacheKey)
} finally {
this.pending.delete(cacheKey)
this.pending.delete(pendingKey)
}
}
async preloadImageHardlinkMd5s(md5List: string[]): Promise<void> {
async preloadImageHardlinkMd5s(
md5List: string[],
options?: { chunkSize?: number; yieldMs?: number; filesystemFallback?: boolean }
): Promise<void> {
const normalizedList = Array.from(
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
)
@@ -301,19 +378,76 @@ export class ImageDecryptService {
if (!accountDir) return
try {
for (const md5 of normalizedList) {
if (!this.looksLikeMd5(md5)) continue
const selectedPath = this.selectBestDatPathByBase(accountDir, md5, undefined, undefined, true)
if (!selectedPath) continue
this.cacheDatPath(accountDir, md5, selectedPath)
const fileName = basename(selectedPath).toLowerCase()
if (fileName) this.cacheDatPath(accountDir, fileName, selectedPath)
const chunkSize = Math.max(1, Math.min(256, Math.floor(Number(options?.chunkSize || normalizedList.length))))
const yieldMs = Math.max(0, Math.min(100, Math.floor(Number(options?.yieldMs || 0))))
const allowFilesystemFallback = options?.filesystemFallback !== false
for (let offset = 0; offset < normalizedList.length; offset += chunkSize) {
const chunk = normalizedList.slice(offset, offset + chunkSize).filter((md5) => this.looksLikeMd5(md5))
if (chunk.length === 0) continue
const resolvedByBatch = new Set<string>()
try {
const result = await wcdbService.resolveImageHardlinkBatch(chunk.map((md5) => ({ md5, accountDir })))
const rows = Array.isArray(result?.rows) ? result.rows : []
for (const row of rows) {
const md5 = String(row?.md5 || '').trim().toLowerCase()
if (!md5 || !row?.success || !row.data) continue
const fileName = String(row.data.file_name || '').trim()
const fullPath = String(row.data.full_path || '').trim()
if (!fileName || !fullPath) continue
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
if (!selectedPath || !existsSync(selectedPath)) continue
this.cacheDatPath(accountDir, md5, selectedPath)
const selectedFileName = basename(selectedPath).toLowerCase()
if (selectedFileName) this.cacheDatPath(accountDir, selectedFileName, selectedPath)
resolvedByBatch.add(md5)
}
} catch {
// fall through to optional filesystem fallback
}
if (!allowFilesystemFallback) {
if (yieldMs > 0 && offset + chunkSize < normalizedList.length) {
await new Promise((resolve) => setTimeout(resolve, yieldMs))
}
continue
}
for (const md5 of chunk) {
if (resolvedByBatch.has(md5)) continue
const selectedPath = this.selectBestDatPathByBase(accountDir, md5, undefined, undefined, true)
if (!selectedPath) continue
this.cacheDatPath(accountDir, md5, selectedPath)
const fileName = basename(selectedPath).toLowerCase()
if (fileName) this.cacheDatPath(accountDir, fileName, selectedPath)
}
if (yieldMs > 0 && offset + chunkSize < normalizedList.length) {
await new Promise((resolve) => setTimeout(resolve, yieldMs))
}
}
} catch {
// ignore preload failures
}
}
emitCacheResolvedPath(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; suppressEvents?: boolean },
cacheKey: string,
localPath: string
): void {
const normalizedCacheKey = String(cacheKey || '').trim()
const resolvedPath = String(localPath || '').trim()
if (!normalizedCacheKey || !resolvedPath) return
if (/^(data:image\/|blob:|https?:\/\/)/i.test(resolvedPath)) {
this.emitCacheResolved(payload, normalizedCacheKey, resolvedPath)
return
}
const filePath = /^file:\/\//i.test(resolvedPath)
? (this.fileUrlToPathSafely(resolvedPath) || resolvedPath)
: resolvedPath
this.emitCacheResolved(payload, normalizedCacheKey, this.resolveEmitPath(filePath, true))
}
private async decryptImageInternal(
payload: DecryptImagePayload,
cacheKey: string
@@ -353,7 +487,8 @@ export class ImageDecryptService {
allowThumbnail: false,
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
allowDatNameScanFallback: payload.allowCacheIndex !== false,
allowFilesystemScan: payload.allowFilesystemScan !== false
}
)
if (!datPath) {
@@ -367,7 +502,8 @@ export class ImageDecryptService {
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
allowDatNameScanFallback: payload.allowCacheIndex !== false,
allowFilesystemScan: payload.allowFilesystemScan !== false
}
)
fallbackToThumbnail = Boolean(datPath)
@@ -389,7 +525,8 @@ export class ImageDecryptService {
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
allowDatNameScanFallback: payload.allowCacheIndex !== false,
allowFilesystemScan: payload.allowFilesystemScan !== false
}
)
}
@@ -407,7 +544,7 @@ export class ImageDecryptService {
this.emitDecryptProgress(payload, cacheKey, 'locating', 34, 'running')
if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
this.cacheResolvedPaths(payload, cacheKey, datPath)
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(datPath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
@@ -421,7 +558,7 @@ export class ImageDecryptService {
this.logInfo('找到已解密文件(按DAT快速命中)', { existing: existingFast, isHd: this.isHdPath(existingFast) })
const isHd = this.isHdPath(existingFast)
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingFast)
this.cacheResolvedPaths(payload, cacheKey, existingFast)
const localPath = this.resolveLocalPathForPayload(existingFast, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existingFast)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingFast, payload.preferFilePath))
@@ -456,7 +593,7 @@ export class ImageDecryptService {
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
const nativeResult = await this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
if (!nativeResult) {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'Rust原生解密不可用')
return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置', failureKind: 'not_found' }
@@ -491,9 +628,9 @@ export class ImageDecryptService {
const isThumb = this.isThumbnailPath(datPath)
const isHdCache = this.isHdPath(outputPath)
this.removeDuplicateCacheCandidates(datPath, payload.sessionId, outputPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
this.cacheResolvedPaths(payload, cacheKey, outputPath)
if (isHdCache) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.clearUpdateFlags(payload, cacheKey)
} else {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, cacheKey, outputPath)
@@ -584,12 +721,13 @@ export class ImageDecryptService {
imageDatName?: string,
sessionId?: string,
createTime?: number,
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean; allowDatNameScanFallback?: boolean }
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean; allowDatNameScanFallback?: boolean; allowFilesystemScan?: boolean }
): Promise<string | null> {
const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false
const hardlinkOnly = options?.hardlinkOnly ?? false
const allowDatNameScanFallback = options?.allowDatNameScanFallback ?? true
const allowFilesystemScan = options?.allowFilesystemScan !== false
this.logInfo('[ImageDecrypt] resolveDatPath', {
imageMd5,
imageDatName,
@@ -597,7 +735,8 @@ export class ImageDecryptService {
allowThumbnail,
skipResolvedCache,
hardlinkOnly,
allowDatNameScanFallback
allowDatNameScanFallback,
allowFilesystemScan
})
const lookupBases = this.collectLookupBasesForScan(imageMd5, imageDatName, allowDatNameScanFallback)
@@ -606,6 +745,23 @@ export class ImageDecryptService {
return null
}
const missKey = this.getResolveDatPathMissKey(
accountDir,
lookupBases,
imageMd5,
imageDatName,
sessionId,
createTime,
allowThumbnail,
allowDatNameScanFallback,
skipResolvedCache,
allowFilesystemScan
)
const lastMiss = this.getDatNameScanMissAt(missKey)
if (lastMiss && (Date.now() - lastMiss) < this.datNameScanMissTtlMs) {
return null
}
if (!skipResolvedCache) {
const cacheCandidates = Array.from(new Set([
...lookupBases,
@@ -614,13 +770,34 @@ export class ImageDecryptService {
].filter(Boolean)))
for (const cacheKey of cacheCandidates) {
const scopedKey = `${accountDir}|${cacheKey}`
const cached = this.resolvedCache.get(scopedKey)
const cached = this.getResolvedCache(scopedKey)
if (!cached || !existsSync(cached)) continue
if (!allowThumbnail && !this.isHdDatPath(cached)) continue
return cached
}
}
if (hardlinkOnly) {
for (const baseMd5 of lookupBases) {
if (!this.looksLikeMd5(baseMd5)) continue
const hardlinkPath = await this.resolveHardlinkPath(accountDir, baseMd5, sessionId)
if (!hardlinkPath) continue
if (!allowThumbnail && !this.isHdDatPath(hardlinkPath)) continue
this.cacheDatPath(accountDir, baseMd5, hardlinkPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
const normalizedFileName = basename(hardlinkPath).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, hardlinkPath)
this.datNameScanMissAt.delete(missKey)
return hardlinkPath
}
}
if (!allowFilesystemScan) {
this.setDatNameScanMissAt(missKey, Date.now())
return null
}
for (const baseMd5 of lookupBases) {
const selectedPath = this.selectBestDatPathByBase(accountDir, baseMd5, sessionId, createTime, allowThumbnail)
if (!selectedPath) continue
@@ -635,9 +812,11 @@ export class ImageDecryptService {
selectedPath,
allowThumbnail
})
this.datNameScanMissAt.delete(missKey)
return selectedPath
}
this.setDatNameScanMissAt(missKey, Date.now())
this.logInfo('[ImageDecrypt] resolveDatPath miss (dat scan)', {
imageMd5,
imageDatName,
@@ -696,7 +875,7 @@ export class ImageDecryptService {
}
private async tryPromoteThumbnailCache(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean },
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; suppressEvents?: boolean },
cacheKey: string,
cachedPath: string
): Promise<string | null> {
@@ -719,8 +898,8 @@ export class ImageDecryptService {
const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true)
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && this.isHdPath(existingHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.cacheResolvedPaths(payload, cacheKey, existingHd)
this.clearUpdateFlags(payload, cacheKey)
this.removeThumbnailCacheFile(cachedPath, existingHd)
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
cacheKey,
@@ -739,19 +918,20 @@ export class ImageDecryptService {
preferFilePath: true,
force: true,
hardlinkOnly: true,
disableUpdateCheck: true
disableUpdateCheck: true,
suppressEvents: payload.suppressEvents === true
})
if (!upgraded.success) return null
const cachedResult = this.resolvedCache.get(cacheKey)
const cachedResult = this.getResolvedMediaCache(payload, cacheKey)
const upgradedPath = (cachedResult && existsSync(cachedResult))
? cachedResult
: String(upgraded.localPath || '').trim()
if (!upgradedPath || !existsSync(upgradedPath)) return null
if (!this.isImageFile(upgradedPath) || !this.isHdPath(upgradedPath)) return null
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.cacheResolvedPaths(payload, cacheKey, upgradedPath)
this.clearUpdateFlags(payload, cacheKey)
this.removeThumbnailCacheFile(cachedPath, upgradedPath)
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
cacheKey,
@@ -784,13 +964,13 @@ export class ImageDecryptService {
cachedPath: string
): void {
if (!this.shouldCheckImageUpdate(payload)) return
if (this.updateFlags.get(cacheKey)) return
if (this.getUpdateFlag(payload, cacheKey)) return
void this.checkHasUpdate(payload, cacheKey, cachedPath).then(async (hasUpdate) => {
if (!hasUpdate) return
this.updateFlags.set(cacheKey, true)
this.setUpdateFlag(payload, cacheKey, true)
const upgradedPath = await this.tryAutoRefreshBetterCache(payload, cacheKey, cachedPath)
if (upgradedPath) {
this.updateFlags.delete(cacheKey)
this.clearUpdateFlag(payload, cacheKey)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(upgradedPath, payload.preferFilePath))
return
}
@@ -828,7 +1008,7 @@ export class ImageDecryptService {
const refreshedPath = String(refreshed.localPath || '').trim()
if (!refreshedPath || !existsSync(refreshedPath)) return null
if (!this.isImageFile(refreshedPath)) return null
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, refreshedPath)
this.cacheResolvedPaths(payload, cacheKey, refreshedPath)
this.removeThumbnailCacheFile(cachedPath, refreshedPath)
return refreshedPath
}
@@ -975,7 +1155,7 @@ export class ImageDecryptService {
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
const missKey = `${accountDir}|scan|${String(sessionId || '').trim()}|${monthKey}|${baseMd5}|${allowThumbnail ? 'all' : 'hd'}`
const lastMiss = this.datNameScanMissAt.get(missKey) || 0
const lastMiss = this.getDatNameScanMissAt(missKey)
if (lastMiss && (Date.now() - lastMiss) < this.datNameScanMissTtlMs) {
return null
}
@@ -1001,7 +1181,7 @@ export class ImageDecryptService {
}
// 新策略:只扫描会话月目录,不做 account-wide 根目录回退。
this.datNameScanMissAt.set(missKey, Date.now())
this.setDatNameScanMissAt(missKey, Date.now())
this.logInfo('[ImageDecrypt] datName fallback precise scan miss', {
accountDir,
sessionId,
@@ -1025,6 +1205,71 @@ export class ImageDecryptService {
return `${y}-${m}`
}
private getResolveDatPathMissKey(
accountDir: string,
lookupBases: string[],
imageMd5?: string,
imageDatName?: string,
sessionId?: string,
createTime?: number,
allowThumbnail = true,
allowDatNameScanFallback = true,
skipResolvedCache = false,
allowFilesystemScan = true
): string {
const bases = Array.from(new Set(lookupBases.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)))
.sort()
.join(',')
const md5 = String(imageMd5 || '').trim().toLowerCase()
const datName = String(imageDatName || '').trim().toLowerCase()
const safeSessionId = String(sessionId || '').trim().toLowerCase()
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
return [
accountDir,
'resolve-dat-miss',
safeSessionId,
monthKey,
allowThumbnail ? 'all' : 'hd',
allowDatNameScanFallback ? 'fallback1' : 'fallback0',
skipResolvedCache ? 'skip-cache1' : 'skip-cache0',
allowFilesystemScan ? 'fs-scan1' : 'fs-scan0',
md5,
datName,
bases
].join('|')
}
private getDatNameScanMissAt(key: string): number {
const missedAt = this.datNameScanMissAt.get(key) || 0
if (!missedAt) return 0
if ((Date.now() - missedAt) >= this.datNameScanMissTtlMs) {
this.datNameScanMissAt.delete(key)
return 0
}
this.datNameScanMissAt.delete(key)
this.datNameScanMissAt.set(key, missedAt)
return missedAt
}
private setDatNameScanMissAt(key: string, missedAt: number): void {
if (!key || !missedAt) return
if (this.datNameScanMissAt.has(key)) {
this.datNameScanMissAt.delete(key)
}
this.datNameScanMissAt.set(key, missedAt)
const expiresBefore = Date.now() - this.datNameScanMissTtlMs
for (const [missKey, value] of this.datNameScanMissAt.entries()) {
if (value >= expiresBefore) continue
this.datNameScanMissAt.delete(missKey)
}
while (this.datNameScanMissAt.size > this.maxDatNameScanMissEntries) {
const oldestKey = this.datNameScanMissAt.keys().next().value
if (!oldestKey) break
this.datNameScanMissAt.delete(oldestKey)
}
}
private collectDatCandidatesFromSessionMonth(
accountDir: string,
baseMd5: string,
@@ -1393,14 +1638,74 @@ export class ImageDecryptService {
return null
}
private cacheResolvedPaths(cacheKey: string, imageMd5: string | undefined, imageDatName: string | undefined, outputPath: string): void {
this.resolvedCache.set(cacheKey, outputPath)
if (imageMd5 && imageMd5 !== cacheKey) {
this.resolvedCache.set(imageMd5, outputPath)
private cacheResolvedPaths(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
cacheKey: string,
outputPath: string
): void {
const normalizedCacheKey = String(cacheKey || '').trim().toLowerCase()
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
if (imageMd5) {
this.setResolvedCache(imageMd5, outputPath)
}
if (imageDatName && imageDatName !== cacheKey && imageDatName !== imageMd5) {
this.resolvedCache.set(imageDatName, outputPath)
if (normalizedCacheKey) {
this.setResolvedCache(this.getMediaResolvedCacheKey(payload, normalizedCacheKey), outputPath)
}
if (imageDatName) {
this.setResolvedCache(this.getMediaResolvedCacheKey(payload, imageDatName), outputPath)
}
}
private getResolvedMediaCache(payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }, key: string): string | undefined {
return this.getResolvedCache(this.getMediaResolvedCacheKey(payload, key))
}
private deleteResolvedMediaCache(payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }, key: string): void {
this.resolvedCache.delete(this.getMediaResolvedCacheKey(payload, key))
}
private deleteResolvedCachesByPath(filePath: string): void {
if (!filePath) return
for (const [key, value] of Array.from(this.resolvedCache.entries())) {
if (value !== filePath) continue
this.resolvedCache.delete(key)
this.updateFlags.delete(key)
}
}
private getResolvedCache(key: string): string | undefined {
const cached = this.resolvedCache.get(key)
if (cached === undefined) return undefined
this.resolvedCache.delete(key)
this.resolvedCache.set(key, cached)
return cached
}
private setResolvedCache(key: string, value: string): void {
if (!key || !value) return
if (this.resolvedCache.has(key)) {
this.resolvedCache.delete(key)
}
this.resolvedCache.set(key, value)
while (this.resolvedCache.size > this.maxResolvedCacheEntries) {
const oldestKey = this.resolvedCache.keys().next().value
if (!oldestKey) break
this.resolvedCache.delete(oldestKey)
this.updateFlags.delete(oldestKey)
}
}
private getMediaResolvedCacheKey(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
key: string
): string {
const normalizedKey = String(key || '').trim().toLowerCase()
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
if (imageMd5 && normalizedKey === imageMd5) return imageMd5
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
const createTime = Number(payload.createTime || 0) || 0
return `dat:${sessionId}|${createTime}|${normalizedKey}`
}
private getCacheKeys(payload: { imageMd5?: string; imageDatName?: string }): string[] {
@@ -1420,19 +1725,125 @@ export class ImageDecryptService {
return keys
}
private cacheDatPath(accountDir: string, datName: string, datPath: string): void {
const key = `${accountDir}|${datName}`
this.resolvedCache.set(key, datPath)
const normalized = this.normalizeDatBase(datName)
if (normalized && normalized !== datName.toLowerCase()) {
this.resolvedCache.set(`${accountDir}|${normalized}`, datPath)
private getResolvePendingKey(payload: CachedImagePayload, cacheKey: string): string {
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
const createTime = Number(payload.createTime || 0) || 0
return [
'resolve',
cacheKey.toLowerCase(),
sessionId,
String(createTime),
payload.hardlinkOnly === true ? 'hl1' : 'hl0',
payload.disableUpdateCheck === true ? 'du1' : 'du0',
payload.allowCacheIndex === false ? 'ci0' : 'ci1',
payload.allowCachePromotion === false ? 'cp0' : 'cp1'
].join('|')
}
private getDecryptPendingKey(payload: DecryptImagePayload, cacheKey: string): string {
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
const createTime = Number(payload.createTime || 0) || 0
return [
'decrypt',
cacheKey.toLowerCase(),
sessionId,
String(createTime),
payload.force === true ? 'f1' : 'f0',
payload.hardlinkOnly === true ? 'hl1' : 'hl0',
payload.disableUpdateCheck === true ? 'du1' : 'du0',
payload.allowCacheIndex === false ? 'ci0' : 'ci1',
payload.allowCachePromotion === false ? 'cp0' : 'cp1'
].join('|')
}
private async resolvePendingResult<T extends DecryptResult>(
pending: PendingImageTask<T>,
payload: CachedImagePayload,
cacheKey: string
): Promise<T> {
const result = await pending.promise
if (!pending.emitsEvents && this.shouldEmitImageEvents(payload)) {
pending.emitsEvents = true
this.emitCacheResolvedFromResult(payload, cacheKey, result)
}
return this.formatPendingResultForPayload(result, payload)
}
private formatPendingResultForPayload<T extends DecryptResult>(result: T, payload: CachedImagePayload): T {
const localPath = String(result.localPath || '').trim()
if (!result.success || !localPath) return result
let formattedPath = localPath
if (payload.preferFilePath === true) {
if (/^file:\/\//i.test(localPath)) {
formattedPath = this.fileUrlToPathSafely(localPath) || localPath
}
} else if (!/^(data:image\/|blob:|https?:\/\/)/i.test(localPath)) {
const filePath = /^file:\/\//i.test(localPath)
? (this.fileUrlToPathSafely(localPath) || localPath)
: localPath
formattedPath = this.resolveLocalPathForPayload(filePath, false)
}
if (formattedPath === localPath) return result
return { ...result, localPath: formattedPath }
}
private fileUrlToPathSafely(value: string): string | null {
try {
const url = new URL(value)
url.search = ''
url.hash = ''
return fileURLToPath(url)
} catch {
return null
}
}
private clearUpdateFlags(cacheKey: string, imageMd5?: string, imageDatName?: string): void {
this.updateFlags.delete(cacheKey)
if (imageMd5) this.updateFlags.delete(imageMd5)
if (imageDatName) this.updateFlags.delete(imageDatName)
private emitCacheResolvedFromResult(payload: CachedImagePayload, cacheKey: string, result: DecryptResult): void {
if (!result.success || !result.localPath) return
const localPath = String(result.localPath || '').trim()
if (!localPath) return
const emitPath = payload.preferFilePath === true && !/^(data:image\/|blob:|https?:\/\/|file:\/\/)/i.test(localPath)
? this.filePathToUrl(localPath)
: localPath
this.emitCacheResolved(payload, cacheKey, emitPath)
}
private cacheDatPath(accountDir: string, datName: string, datPath: string): void {
const key = `${accountDir}|${datName}`
this.setResolvedCache(key, datPath)
const normalized = this.normalizeDatBase(datName)
if (normalized && normalized !== datName.toLowerCase()) {
this.setResolvedCache(`${accountDir}|${normalized}`, datPath)
}
}
private getUpdateFlag(payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }, cacheKey: string): boolean {
return this.updateFlags.get(this.getMediaResolvedCacheKey(payload, cacheKey)) ?? false
}
private setUpdateFlag(payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }, cacheKey: string, value: boolean): void {
const key = this.getMediaResolvedCacheKey(payload, cacheKey)
if (value) this.updateFlags.set(key, true)
else this.updateFlags.delete(key)
}
private clearUpdateFlag(payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
this.updateFlags.delete(this.getMediaResolvedCacheKey(payload, cacheKey))
}
private clearUpdateFlags(payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
const keys = new Set<string>()
const normalizedCacheKey = String(cacheKey || '').trim().toLowerCase()
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
if (normalizedCacheKey) keys.add(this.getMediaResolvedCacheKey(payload, normalizedCacheKey))
if (imageMd5) keys.add(this.getMediaResolvedCacheKey(payload, imageMd5))
if (imageDatName) keys.add(this.getMediaResolvedCacheKey(payload, imageDatName))
for (const key of keys) {
this.updateFlags.delete(key)
}
}
private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> {
@@ -1452,9 +1863,15 @@ export class ImageDecryptService {
}
}
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string): void {
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; suppressEvents?: boolean }, cacheKey: string): void {
if (!this.shouldEmitImageEvents(payload)) return
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName }
const message = {
cacheKey,
sessionId: payload.sessionId,
createTime: payload.createTime,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName
}
for (const win of this.getActiveWindowsSafely()) {
if (!win.isDestroyed()) {
win.webContents.send('image:updateAvailable', message)
@@ -1462,9 +1879,16 @@ export class ImageDecryptService {
}
}
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string, localPath: string): void {
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; suppressEvents?: boolean }, cacheKey: string, localPath: string): void {
if (!this.shouldEmitImageEvents(payload)) return
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath }
const message = {
cacheKey,
sessionId: payload.sessionId,
createTime: payload.createTime,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
localPath
}
for (const win of this.getActiveWindowsSafely()) {
if (!win.isDestroyed()) {
win.webContents.send('image:cacheResolved', message)
@@ -1520,11 +1944,11 @@ export class ImageDecryptService {
this.ensuredDirs.add(dirPath)
}
private tryDecryptDatWithNative(
private async tryDecryptDatWithNative(
datPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
): Promise<{ data: Buffer; ext: string; isWxgf: boolean } | null> {
const result = decryptDatViaNative(datPath, xorKey, aesKey)
if (!this.nativeLogged) {
this.nativeLogged = true
@@ -1541,20 +1965,20 @@ export class ImageDecryptService {
}
}
if (result) return result
const fallback = this.tryDecryptDatWithJs(datPath, xorKey, aesKey)
const fallback = await this.tryDecryptDatWithJs(datPath, xorKey, aesKey)
if (fallback) {
this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext })
}
return fallback
}
private tryDecryptDatWithJs(
private async tryDecryptDatWithJs(
datPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
): Promise<{ data: Buffer; ext: string; isWxgf: boolean } | null> {
try {
const encrypted = readFileSync(datPath)
const encrypted = await readFile(datPath)
const directExt = this.detectImageExtension(encrypted)
if (directExt) return { data: encrypted, ext: directExt, isWxgf: false }
@@ -1727,22 +2151,79 @@ export class ImageDecryptService {
private isUsableImageCacheFile(filePath: string): boolean {
if (!this.isImageFile(filePath)) return false
if (!existsSync(filePath)) return false
if (this.isLikelyCorruptedDecodedImage(filePath)) {
this.logInfo('[ImageDecrypt] 跳过疑似损坏缓存文件', { filePath })
void rm(filePath, { force: true }).catch(() => { })
return false
}
const quarantinedUntil = this.corruptedCachePathUntil.get(filePath) || 0
if (quarantinedUntil > Date.now()) return false
this.scheduleCorruptedCacheValidation(filePath)
return true
}
private isLikelyCorruptedDecodedImage(filePath: string): boolean {
private scheduleCorruptedCacheValidation(filePath: string): void {
const ext = extname(filePath).toLowerCase()
if (ext !== '.jpg' && ext !== '.jpeg') return
const now = Date.now()
const lastCheckedAt = this.corruptedCacheCheckLastAt.get(filePath) || 0
if (lastCheckedAt && now - lastCheckedAt < this.corruptedCacheCheckCooldownMs) return
if (this.corruptedCacheCheckPending.has(filePath)) return
this.corruptedCacheCheckLastAt.set(filePath, now)
this.pruneCorruptedCacheCheckMaps(now)
this.corruptedCacheCheckPending.add(filePath)
this.corruptedCacheCheckQueue.push(filePath)
this.processCorruptedCacheValidationQueue()
}
private processCorruptedCacheValidationQueue(): void {
while (
this.activeCorruptedCacheChecks < this.maxConcurrentCorruptedCacheChecks &&
this.corruptedCacheCheckQueue.length > 0
) {
const filePath = this.corruptedCacheCheckQueue.shift()
if (!filePath) continue
this.activeCorruptedCacheChecks += 1
void this.validateCorruptedCacheFile(filePath).finally(() => {
this.activeCorruptedCacheChecks = Math.max(0, this.activeCorruptedCacheChecks - 1)
this.corruptedCacheCheckPending.delete(filePath)
this.processCorruptedCacheValidationQueue()
})
}
}
private async validateCorruptedCacheFile(filePath: string): Promise<void> {
try {
const ext = extname(filePath).toLowerCase()
if (ext !== '.jpg' && ext !== '.jpeg') return false
const data = readFileSync(filePath)
return this.isLikelyCorruptedJpegBuffer(data)
const data = await readFile(filePath)
if (!this.isLikelyCorruptedJpegBuffer(data)) return
this.logInfo('[ImageDecrypt] 后台移除疑似损坏缓存文件', { filePath })
this.corruptedCachePathUntil.set(filePath, Date.now() + this.corruptedCacheQuarantineMs)
this.deleteResolvedCachesByPath(filePath)
void rm(filePath, { force: true }).catch(() => { })
} catch {
return false
// Cache validation is best-effort and must not affect browsing.
}
}
private pruneCorruptedCacheCheckMaps(now = Date.now()): void {
const checkedExpiresBefore = now - this.corruptedCacheCheckCooldownMs
for (const [filePath, checkedAt] of this.corruptedCacheCheckLastAt.entries()) {
if (checkedAt >= checkedExpiresBefore) continue
this.corruptedCacheCheckLastAt.delete(filePath)
}
for (const [filePath, until] of this.corruptedCachePathUntil.entries()) {
if (until >= now) continue
this.corruptedCachePathUntil.delete(filePath)
}
while (this.corruptedCacheCheckLastAt.size > this.maxCorruptedCacheChecks) {
const oldest = this.corruptedCacheCheckLastAt.keys().next().value
if (!oldest) break
this.corruptedCacheCheckLastAt.delete(oldest)
}
while (this.corruptedCachePathUntil.size > this.maxCorruptedCacheChecks) {
const oldest = this.corruptedCachePathUntil.keys().next().value
if (!oldest) break
this.corruptedCachePathUntil.delete(oldest)
}
while (this.corruptedCacheCheckQueue.length > this.maxCorruptedCacheChecks) {
const dropped = this.corruptedCacheCheckQueue.shift()
if (dropped) this.corruptedCacheCheckPending.delete(dropped)
}
}
@@ -1995,7 +2476,7 @@ export class ImageDecryptService {
for (const attempt of attempts) {
// 清理上一轮的输出
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
await rm(tmpOutput, { force: true }).catch(() => {})
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label, attempt.outputArgs)
if (!result) continue
@@ -2008,8 +2489,8 @@ export class ImageDecryptService {
this.logError('ffmpeg 转换异常', e)
return null
} finally {
try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {}
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
await rm(tmpInput, { force: true }).catch(() => {})
await rm(tmpOutput, { force: true }).catch(() => {})
}
}
@@ -2046,11 +2527,11 @@ export class ImageDecryptService {
resolve(null)
}, 15000)
proc.on('close', (code: number) => {
proc.on('close', async (code: number) => {
clearTimeout(timer)
if (code === 0 && existsSync(tmpOutput)) {
try {
const jpgBuf = readFileSync(tmpOutput)
const jpgBuf = await readFile(tmpOutput)
if (jpgBuf.length > 0) {
this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length })
resolve(jpgBuf)
@@ -2180,9 +2661,15 @@ export class ImageDecryptService {
async clearCache(): Promise<{ success: boolean; error?: string }> {
this.resolvedCache.clear()
this.pending.clear()
this.resolvePending.clear()
this.updateFlags.clear()
this.accountDirCache.clear()
this.ensuredDirs.clear()
this.corruptedCacheCheckPending.clear()
this.corruptedCacheCheckQueue = []
this.activeCorruptedCacheChecks = 0
this.corruptedCacheCheckLastAt.clear()
this.corruptedCachePathUntil.clear()
this.cacheRootPath = null
const configured = this.configService.get('cachePath')
+782 -22
View File
@@ -10,58 +10,761 @@ type PreloadImagePayload = {
type PreloadOptions = {
allowDecrypt?: boolean
allowCacheIndex?: boolean
allowFilesystemScan?: boolean
emitResolved?: boolean
scope?: string
priority?: 'high' | 'normal' | 'low'
}
type PreloadEnqueueResult = {
success: true
requested: number
accepted: number
mergedQueued: number
skippedActive: number
skippedPending: number
ignoredCanceled: number
rejectedCapacity: number
deferred: number
handledIdentities: string[]
acceptedIdentities: string[]
mergedQueuedIdentities: string[]
skippedActiveIdentities: string[]
skippedPendingIdentities: string[]
rejectedIdentities: string[]
deferredIdentities: string[]
}
type PreloadTask = PreloadImagePayload & {
key: string
identity: string
allowDecrypt: boolean
allowCacheIndex: boolean
allowFilesystemScan: boolean
scopes: Set<string>
emitResolvedScopes: Set<string>
priority: 'high' | 'normal' | 'low'
}
type PreloadHighWaterStats = {
queued: number
pending: number
queuedCache: number
queuedDecrypt: number
queuedHigh: number
queuedNormal: number
queuedLow: number
activeCache: number
activeDecrypt: number
activeIdentities: number
}
const isLikelyThumbnailCachePath = (localPath?: string): boolean => {
const normalized = String(localPath || '').trim().toLowerCase()
if (!normalized) return false
return (
normalized.includes('_thumb') ||
normalized.includes('.thumb') ||
normalized.includes('_t.') ||
normalized.includes('.t.')
)
}
export class ImagePreloadService {
private queue: PreloadTask[] = []
private pending = new Set<string>()
private queuedCacheByIdentity = new Map<string, PreloadTask>()
private queuedDecryptByIdentity = new Map<string, PreloadTask>()
private activeByIdentity = new Map<string, PreloadTask>()
private canceledScopes = new Map<string, number>()
private totals = {
accepted: 0,
mergedQueued: 0,
skippedActive: 0,
ignoredCanceled: 0,
droppedQueued: 0,
canceledQueued: 0,
canceledActive: 0,
promotedActive: 0,
rejectedCapacity: 0,
deferredLowPriority: 0,
lowPriorityIdleDeferrals: 0,
skippedPending: 0,
forcedThumbnailRefreshes: 0,
forcedThumbnailRefreshFailures: 0,
lowPriorityRejected: 0,
activeCacheSnapshots: 0,
activeCacheSnapshotSkipped: 0,
activeCacheSnapshotCanceled: 0,
started: 0,
completed: 0
}
private activeCache = 0
private activeDecrypt = 0
private readonly maxCacheConcurrent = 8
private queuedPriorityCounts: Record<PreloadTask['priority'], number> = {
high: 0,
normal: 0,
low: 0
}
private activeCacheSnapshotPending = new Set<string>()
private activeCacheSnapshotLastAt = new Map<string, number>()
private lastForegroundActivityAt = Date.now()
private lowPriorityWakeTimer: ReturnType<typeof setTimeout> | null = null
private highWater: PreloadHighWaterStats = {
queued: 0,
pending: 0,
queuedCache: 0,
queuedDecrypt: 0,
queuedHigh: 0,
queuedNormal: 0,
queuedLow: 0,
activeCache: 0,
activeDecrypt: 0,
activeIdentities: 0
}
private readonly maxCacheConcurrent = 4
private readonly maxDecryptConcurrent = 2
private readonly maxQueueSize = 320
private readonly maxLowPriorityQueueSize = 160
private readonly lowPrioritySoftQueueSize = 240
private readonly canceledScopeTtlMs = 2 * 60 * 1000
private readonly activeCacheSnapshotCooldownMs = 1800
private readonly lowPriorityIdleDelayMs = 1500
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
if (!Array.isArray(payloads) || payloads.length === 0) return
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): PreloadEnqueueResult {
const result = this.createEnqueueResult(Array.isArray(payloads) ? payloads.length : 0)
if (!Array.isArray(payloads) || payloads.length === 0) return result
this.pruneCanceledScopes()
const allowDecrypt = options?.allowDecrypt !== false
const allowCacheIndex = options?.allowCacheIndex !== 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, allowDecrypt, allowCacheIndex })
const allowFilesystemScan = options?.allowFilesystemScan !== false
const emitResolved = options?.emitResolved === true
const scope = String(options?.scope || 'global').trim() || 'global'
const priority = options?.priority || (allowDecrypt ? 'low' : 'normal')
this.noteForegroundActivity(allowDecrypt, priority)
if (this.isScopeCanceled(scope)) {
this.totals.ignoredCanceled += payloads.length
result.ignoredCanceled += payloads.length
return result
}
for (const payload of payloads) {
const identity = this.getTaskIdentity(payload)
if (!identity) continue
const activeTask = this.activeByIdentity.get(identity)
if (activeTask && (!allowDecrypt || activeTask.allowDecrypt)) {
activeTask.scopes.add(scope)
activeTask.allowCacheIndex = activeTask.allowCacheIndex || allowCacheIndex
activeTask.allowFilesystemScan = activeTask.allowFilesystemScan || allowFilesystemScan
if (this.priorityScore(priority) < this.priorityScore(activeTask.priority)) {
activeTask.priority = priority
this.totals.promotedActive += 1
}
if (emitResolved) {
activeTask.emitResolvedScopes.add(scope)
}
if (!allowDecrypt && activeTask.allowDecrypt && emitResolved) {
this.emitActiveCacheSnapshot(payload, identity, allowCacheIndex, scope)
}
this.totals.skippedActive += 1
result.skippedActive += 1
result.handledIdentities.push(identity)
result.skippedActiveIdentities.push(identity)
continue
}
const mode = allowDecrypt ? 'decrypt' : 'cache'
const key = `${mode}|${identity}`
const queuedMap = this.getQueuedMap(allowDecrypt)
const queuedTask = queuedMap.get(identity)
if (queuedTask) {
this.totals.mergedQueued += 1
result.mergedQueued += 1
result.handledIdentities.push(identity)
result.mergedQueuedIdentities.push(identity)
this.mergeQueuedTaskPayload(queuedTask, payload)
queuedTask.allowCacheIndex = queuedTask.allowCacheIndex || allowCacheIndex
queuedTask.allowFilesystemScan = queuedTask.allowFilesystemScan || allowFilesystemScan
queuedTask.scopes.add(scope)
if (emitResolved) queuedTask.emitResolvedScopes.add(scope)
if (this.priorityScore(priority) < this.priorityScore(queuedTask.priority)) {
this.updateQueuedPriority(queuedTask, priority)
}
continue
}
if (this.shouldDeferLowPriorityDecrypt(allowDecrypt, priority)) {
this.deferLowPriority(result, identity)
continue
}
const queuedPeerTask = this.getQueuedMap(!allowDecrypt).get(identity)
const shouldKeepIncomingCacheSeparate = !allowDecrypt && queuedPeerTask?.allowDecrypt === true
if (queuedPeerTask && !shouldKeepIncomingCacheSeparate) {
if (this.shouldDeferPeerUpgrade(queuedPeerTask, allowDecrypt, priority)) {
this.deferLowPriority(result, identity)
continue
}
this.totals.mergedQueued += 1
result.mergedQueued += 1
result.handledIdentities.push(identity)
result.mergedQueuedIdentities.push(identity)
this.mergeQueuedTaskPayload(queuedPeerTask, payload)
queuedPeerTask.allowCacheIndex = queuedPeerTask.allowCacheIndex || allowCacheIndex
queuedPeerTask.allowFilesystemScan = queuedPeerTask.allowFilesystemScan || allowFilesystemScan
queuedPeerTask.scopes.add(scope)
if (emitResolved) queuedPeerTask.emitResolvedScopes.add(scope)
if (allowDecrypt && !queuedPeerTask.allowDecrypt) {
this.updateQueuedMode(queuedPeerTask, true)
}
if (this.priorityScore(priority) < this.priorityScore(queuedPeerTask.priority)) {
this.updateQueuedPriority(queuedPeerTask, priority)
}
continue
}
if (this.pending.has(key)) {
this.totals.skippedPending += 1
result.skippedPending += 1
result.handledIdentities.push(identity)
result.skippedPendingIdentities.push(identity)
continue
}
if (!this.ensureQueueCapacity(priority)) {
this.totals.rejectedCapacity += 1
if (this.isLowPrioritySoftLimitExceeded(priority)) {
this.totals.lowPriorityRejected += 1
}
result.rejectedCapacity += 1
result.rejectedIdentities.push(identity)
continue
}
this.pending.add(key)
const task = {
...payload,
key,
identity,
allowDecrypt,
allowCacheIndex,
allowFilesystemScan,
scopes: new Set([scope]),
emitResolvedScopes: emitResolved ? new Set([scope]) : new Set<string>(),
priority
}
this.addQueuedTask(task)
this.totals.accepted += 1
result.accepted += 1
result.handledIdentities.push(identity)
result.acceptedIdentities.push(identity)
this.recordHighWater()
}
this.sortQueue()
this.processQueue()
return result
}
cancelScope(scope: string): void {
const normalizedScope = String(scope || '').trim()
if (!normalizedScope) return
this.canceledScopes.set(normalizedScope, Date.now())
this.dropActiveCacheSnapshotsForScope(normalizedScope)
const kept: PreloadTask[] = []
for (const task of this.queue) {
task.scopes.delete(normalizedScope)
task.emitResolvedScopes.delete(normalizedScope)
if (this.isCanceled(task)) {
this.pending.delete(task.key)
this.removeQueuedTask(task)
this.totals.canceledQueued += 1
} else {
kept.push(task)
}
}
this.queue = kept
for (const task of this.activeByIdentity.values()) {
const hadScope = task.scopes.delete(normalizedScope)
task.emitResolvedScopes.delete(normalizedScope)
if (!hadScope) continue
if (this.isCanceled(task)) {
this.totals.canceledActive += 1
}
}
this.pruneCanceledScopes()
this.clearLowPriorityWakeIfUnneeded()
}
getStats(): {
queued: number
pending: number
activeCache: number
activeDecrypt: number
queuedIdentities: number
queuedCache: number
queuedDecrypt: number
queuedHigh: number
queuedNormal: number
queuedLow: number
activeIdentities: number
queuedScopes: number
activeScopes: number
canceledScopes: number
highWater: PreloadHighWaterStats
totals: {
accepted: number
mergedQueued: number
skippedActive: number
ignoredCanceled: number
droppedQueued: number
canceledQueued: number
canceledActive: number
promotedActive: number
rejectedCapacity: number
deferredLowPriority: number
lowPriorityIdleDeferrals: number
skippedPending: number
forcedThumbnailRefreshes: number
forcedThumbnailRefreshFailures: number
lowPriorityRejected: number
activeCacheSnapshots: number
activeCacheSnapshotSkipped: number
activeCacheSnapshotCanceled: number
started: number
completed: number
}
} {
this.recordHighWater()
const queuedIdentities = new Set([
...this.queuedCacheByIdentity.keys(),
...this.queuedDecryptByIdentity.keys()
]).size
const priorityCounts = this.countQueuedPriorities()
const highWater = { ...this.highWater }
this.resetHighWater()
return {
queued: this.queue.length,
pending: this.pending.size,
activeCache: this.activeCache,
activeDecrypt: this.activeDecrypt,
queuedIdentities,
queuedCache: this.queuedCacheByIdentity.size,
queuedDecrypt: this.queuedDecryptByIdentity.size,
queuedHigh: priorityCounts.high,
queuedNormal: priorityCounts.normal,
queuedLow: priorityCounts.low,
activeIdentities: this.activeByIdentity.size,
queuedScopes: this.countTaskScopes(this.queue),
activeScopes: this.countTaskScopes(this.activeByIdentity.values()),
canceledScopes: this.canceledScopes.size,
highWater,
totals: { ...this.totals }
}
}
private priorityScore(priority: PreloadTask['priority']): number {
if (priority === 'high') return 0
if (priority === 'normal') return 1
return 2
}
private noteForegroundActivity(allowDecrypt: boolean, priority: PreloadTask['priority']): void {
if (allowDecrypt && priority === 'low') return
this.lastForegroundActivityAt = Date.now()
}
private createEnqueueResult(requested: number): PreloadEnqueueResult {
return {
success: true,
requested,
accepted: 0,
mergedQueued: 0,
skippedActive: 0,
skippedPending: 0,
ignoredCanceled: 0,
rejectedCapacity: 0,
deferred: 0,
handledIdentities: [],
acceptedIdentities: [],
mergedQueuedIdentities: [],
skippedActiveIdentities: [],
skippedPendingIdentities: [],
rejectedIdentities: [],
deferredIdentities: []
}
}
private getTaskIdentity(payload: PreloadImagePayload): string {
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
if (imageMd5) return `md5:${imageMd5}`
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
if (imageDatName) {
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
const createTime = Number(payload.createTime || 0) || 0
return `dat:${sessionId}|${createTime}|${imageDatName}`
}
return ''
}
private getQueuedMap(allowDecrypt: boolean): Map<string, PreloadTask> {
return allowDecrypt ? this.queuedDecryptByIdentity : this.queuedCacheByIdentity
}
private deferLowPriority(result: PreloadEnqueueResult, identity: string): void {
this.totals.deferredLowPriority += 1
result.deferred += 1
result.deferredIdentities.push(identity)
}
private shouldDeferLowPriorityDecrypt(allowDecrypt: boolean, priority: PreloadTask['priority']): boolean {
if (!allowDecrypt || priority !== 'low') return false
return (
this.queuedPriorityCounts.high > 0 ||
this.queuedPriorityCounts.normal > 0 ||
this.activeCache >= this.maxCacheConcurrent ||
(this.activeCache > 0 && this.queuedCacheByIdentity.size > 0)
)
}
private shouldDeferPeerUpgrade(
peerTask: PreloadTask,
allowDecrypt: boolean,
priority: PreloadTask['priority']
): boolean {
return allowDecrypt && !peerTask.allowDecrypt && priority === 'low' && (
peerTask.priority !== 'low' ||
peerTask.emitResolvedScopes.size > 0
)
}
private emitActiveCacheSnapshot(
payload: PreloadImagePayload,
identity: string,
allowCacheIndex: boolean,
scope: string
): void {
if (this.isScopeCanceled(scope)) return
const now = Date.now()
this.pruneActiveCacheSnapshotCooldowns(now)
const snapshotKey = `${scope}|${identity}`
if (this.activeCacheSnapshotPending.has(snapshotKey)) {
this.totals.activeCacheSnapshotSkipped += 1
return
}
const lastSnapshotAt = this.activeCacheSnapshotLastAt.get(snapshotKey) || 0
if (lastSnapshotAt && now - lastSnapshotAt < this.activeCacheSnapshotCooldownMs) {
this.totals.activeCacheSnapshotSkipped += 1
return
}
this.activeCacheSnapshotPending.add(snapshotKey)
this.activeCacheSnapshotLastAt.set(snapshotKey, now)
this.totals.activeCacheSnapshots += 1
const cacheKey = this.getSnapshotCacheKey(payload)
void imageDecryptService.resolveCachedImage({
sessionId: payload.sessionId,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
createTime: payload.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex,
allowCachePromotion: false,
suppressEvents: true
}).then((result) => {
if (this.isScopeCanceled(scope)) {
this.totals.activeCacheSnapshotCanceled += 1
return
}
if (!result?.success || !result.localPath || !cacheKey) return
imageDecryptService.emitCacheResolvedPath({
sessionId: payload.sessionId,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
createTime: payload.createTime
}, cacheKey, result.localPath)
}).catch(() => {
// Visible cache snapshots are best-effort; the active decrypt will still finish normally.
}).finally(() => {
this.activeCacheSnapshotPending.delete(snapshotKey)
})
}
private getSnapshotCacheKey(payload: PreloadImagePayload): string {
return String(payload.imageMd5 || payload.imageDatName || '').trim().toLowerCase()
}
private pruneActiveCacheSnapshotCooldowns(now = Date.now()): void {
const expiresBefore = now - this.activeCacheSnapshotCooldownMs
for (const [key, timestamp] of this.activeCacheSnapshotLastAt.entries()) {
if (timestamp >= expiresBefore) continue
this.activeCacheSnapshotLastAt.delete(key)
}
}
private dropActiveCacheSnapshotsForScope(scope: string): void {
const prefix = `${scope}|`
for (const key of Array.from(this.activeCacheSnapshotPending)) {
if (key.startsWith(prefix)) this.activeCacheSnapshotPending.delete(key)
}
for (const key of Array.from(this.activeCacheSnapshotLastAt.keys())) {
if (key.startsWith(prefix)) this.activeCacheSnapshotLastAt.delete(key)
}
}
private mergeQueuedTaskPayload(task: PreloadTask, payload: PreloadImagePayload): void {
const incomingSessionId = String(payload.sessionId || '').trim()
if (!task.sessionId && incomingSessionId) {
task.sessionId = incomingSessionId
}
const incomingCreateTime = Number(payload.createTime || 0) || 0
const currentCreateTime = Number(task.createTime || 0) || 0
if (!currentCreateTime && incomingCreateTime) {
task.createTime = incomingCreateTime
}
const incomingImageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
if (!task.imageMd5 && incomingImageMd5) {
task.imageMd5 = incomingImageMd5
}
const incomingDatName = String(payload.imageDatName || '').trim().toLowerCase()
if (!task.imageDatName && incomingDatName) {
task.imageDatName = incomingDatName
}
}
private addQueuedTask(task: PreloadTask): void {
this.queue.push(task)
this.getQueuedMap(task.allowDecrypt).set(task.identity, task)
this.queuedPriorityCounts[task.priority] += 1
}
private updateQueuedPriority(task: PreloadTask, priority: PreloadTask['priority']): void {
if (task.priority === priority) return
this.queuedPriorityCounts[task.priority] = Math.max(0, this.queuedPriorityCounts[task.priority] - 1)
task.priority = priority
this.queuedPriorityCounts[priority] += 1
}
private updateQueuedMode(task: PreloadTask, allowDecrypt: boolean): void {
if (task.allowDecrypt === allowDecrypt) return
this.getQueuedMap(task.allowDecrypt).delete(task.identity)
this.pending.delete(task.key)
task.allowDecrypt = allowDecrypt
task.key = `${allowDecrypt ? 'decrypt' : 'cache'}|${task.identity}`
this.getQueuedMap(task.allowDecrypt).set(task.identity, task)
this.pending.add(task.key)
}
private removeQueuedTask(task: PreloadTask): void {
this.getQueuedMap(task.allowDecrypt).delete(task.identity)
this.queuedPriorityCounts[task.priority] = Math.max(0, this.queuedPriorityCounts[task.priority] - 1)
}
private ensureQueueCapacity(priority: PreloadTask['priority']): boolean {
if (this.isLowPrioritySoftLimitExceeded(priority)) {
return false
}
if (this.queue.length < this.maxQueueSize) return true
const incomingScore = this.priorityScore(priority)
let dropIndex = -1
let dropScore = incomingScore
for (let index = this.queue.length - 1; index >= 0; index -= 1) {
const score = this.priorityScore(this.queue[index].priority)
if (score <= dropScore) continue
dropScore = score
dropIndex = index
}
if (dropIndex < 0) return false
const [dropped] = this.queue.splice(dropIndex, 1)
if (dropped) {
this.pending.delete(dropped.key)
this.removeQueuedTask(dropped)
this.totals.droppedQueued += 1
}
return true
}
private isLowPrioritySoftLimitExceeded(priority: PreloadTask['priority']): boolean {
return priority === 'low' && (
this.queue.length >= this.lowPrioritySoftQueueSize ||
this.queuedPriorityCounts.low >= this.maxLowPriorityQueueSize
)
}
private isScopeCanceled(scope: string): boolean {
return this.canceledScopes.has(scope)
}
private isCanceled(task: PreloadTask): boolean {
if (task.scopes.size === 0) return true
for (const scope of task.scopes) {
if (!this.isScopeCanceled(scope)) return false
}
return true
}
private hasRunnableEmitScope(task: PreloadTask): boolean {
for (const scope of Array.from(task.emitResolvedScopes)) {
if (task.scopes.has(scope) && !this.isScopeCanceled(scope)) return true
task.emitResolvedScopes.delete(scope)
}
return false
}
private emitTaskCacheResolved(task: PreloadTask, result?: { success?: boolean; localPath?: string }): void {
if (!result?.success || !result.localPath) return
if (!this.hasRunnableEmitScope(task)) return
const cacheKey = this.getSnapshotCacheKey(task)
if (!cacheKey) return
imageDecryptService.emitCacheResolvedPath({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName,
createTime: task.createTime
}, cacheKey, result.localPath)
}
private pruneCanceledScopes(): void {
if (this.canceledScopes.size === 0) return
const referencedScopes = new Set<string>()
for (const task of this.queue) {
for (const scope of task.scopes) referencedScopes.add(scope)
}
for (const task of this.activeByIdentity.values()) {
for (const scope of task.scopes) referencedScopes.add(scope)
}
const expiresBefore = Date.now() - this.canceledScopeTtlMs
for (const [scope, canceledAt] of this.canceledScopes.entries()) {
if (referencedScopes.has(scope)) continue
if (canceledAt >= expiresBefore) continue
this.canceledScopes.delete(scope)
}
}
private countTaskScopes(tasks: Iterable<PreloadTask>): number {
const scopes = new Set<string>()
for (const task of tasks) {
for (const scope of task.scopes) {
scopes.add(scope)
}
}
return scopes.size
}
private countQueuedPriorities(): Record<PreloadTask['priority'], number> {
return { ...this.queuedPriorityCounts }
}
private recordHighWater(): void {
const priorityCounts = this.countQueuedPriorities()
this.highWater.queued = Math.max(this.highWater.queued, this.queue.length)
this.highWater.pending = Math.max(this.highWater.pending, this.pending.size)
this.highWater.queuedCache = Math.max(this.highWater.queuedCache, this.queuedCacheByIdentity.size)
this.highWater.queuedDecrypt = Math.max(this.highWater.queuedDecrypt, this.queuedDecryptByIdentity.size)
this.highWater.queuedHigh = Math.max(this.highWater.queuedHigh, priorityCounts.high)
this.highWater.queuedNormal = Math.max(this.highWater.queuedNormal, priorityCounts.normal)
this.highWater.queuedLow = Math.max(this.highWater.queuedLow, priorityCounts.low)
this.highWater.activeCache = Math.max(this.highWater.activeCache, this.activeCache)
this.highWater.activeDecrypt = Math.max(this.highWater.activeDecrypt, this.activeDecrypt)
this.highWater.activeIdentities = Math.max(this.highWater.activeIdentities, this.activeByIdentity.size)
}
private resetHighWater(): void {
const priorityCounts = this.countQueuedPriorities()
this.highWater = {
queued: this.queue.length,
pending: this.pending.size,
queuedCache: this.queuedCacheByIdentity.size,
queuedDecrypt: this.queuedDecryptByIdentity.size,
queuedHigh: priorityCounts.high,
queuedNormal: priorityCounts.normal,
queuedLow: priorityCounts.low,
activeCache: this.activeCache,
activeDecrypt: this.activeDecrypt,
activeIdentities: this.activeByIdentity.size
}
}
private sortQueue(): void {
this.queue.sort((a, b) => this.priorityScore(a.priority) - this.priorityScore(b.priority))
}
private hasRunnableCapacity(): boolean {
return this.activeCache < this.maxCacheConcurrent || this.activeDecrypt < this.maxDecryptConcurrent
}
private canRunTask(task: PreloadTask): boolean {
if (this.activeByIdentity.has(task.identity)) return false
if (task.allowDecrypt && task.priority === 'low' && !this.canRunLowPriorityDecrypt()) return false
return task.allowDecrypt
? this.activeDecrypt < this.maxDecryptConcurrent
: this.activeCache < this.maxCacheConcurrent
}
private canRunLowPriorityDecrypt(): boolean {
if (this.activeDecrypt > 0) return false
if (this.activeCache > 0) return false
if (this.queuedCacheByIdentity.size > 0) return false
if (this.queuedPriorityCounts.high > 0 || this.queuedPriorityCounts.normal > 0) return false
return Date.now() - this.lastForegroundActivityAt >= this.lowPriorityIdleDelayMs
}
private hasQueuedLowPriorityDecrypt(): boolean {
return this.queue.some((task) => task.allowDecrypt && task.priority === 'low')
}
private scheduleLowPriorityWakeIfNeeded(): void {
if (this.lowPriorityWakeTimer !== null) return
if (!this.hasQueuedLowPriorityDecrypt()) return
if (this.activeDecrypt > 0 || this.activeCache > 0) return
if (this.queuedCacheByIdentity.size > 0) return
if (this.queuedPriorityCounts.high > 0 || this.queuedPriorityCounts.normal > 0) return
const elapsed = Date.now() - this.lastForegroundActivityAt
const delayMs = Math.max(20, this.lowPriorityIdleDelayMs - elapsed)
this.totals.lowPriorityIdleDeferrals += 1
this.lowPriorityWakeTimer = setTimeout(() => {
this.lowPriorityWakeTimer = null
this.processQueue()
}, delayMs)
}
private clearLowPriorityWakeIfUnneeded(): void {
if (this.lowPriorityWakeTimer === null) return
if (this.hasQueuedLowPriorityDecrypt()) return
clearTimeout(this.lowPriorityWakeTimer)
this.lowPriorityWakeTimer = null
}
private takeNextRunnableTask(): PreloadTask | null {
if (!this.hasRunnableCapacity()) return null
const taskIndex = this.queue.findIndex((task) => this.canRunTask(task))
if (taskIndex < 0) return null
const task = this.queue.splice(taskIndex, 1)[0]
if (!task) return null
this.removeQueuedTask(task)
return task
}
private processQueue(): void {
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
const task = this.takeNextRunnableTask()
if (!task) {
this.scheduleLowPriorityWakeIfNeeded()
return
}
this.activeByIdentity.set(task.identity, task)
this.totals.started += 1
if (task.allowDecrypt) this.activeDecrypt += 1
else this.activeCache += 1
this.recordHighWater()
void this.handleTask(task).finally(() => {
if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
else this.activeCache = Math.max(0, this.activeCache - 1)
this.activeByIdentity.delete(task.identity)
this.pending.delete(task.key)
this.totals.completed += 1
this.pruneCanceledScopes()
this.processQueue()
})
}
@@ -70,6 +773,7 @@ export class ImagePreloadService {
private async handleTask(task: PreloadTask): Promise<void> {
const cacheKey = task.imageMd5 || task.imageDatName
if (!cacheKey) return
if (this.isCanceled(task)) return
try {
const cached = await imageDecryptService.resolveCachedImage({
sessionId: task.sessionId,
@@ -78,12 +782,52 @@ export class ImagePreloadService {
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: !task.allowDecrypt,
disableUpdateCheck: true,
allowCacheIndex: task.allowCacheIndex,
allowCachePromotion: false,
allowFilesystemScan: task.allowFilesystemScan,
suppressEvents: true
})
if (cached.success) return
if (cached.success) {
this.emitTaskCacheResolved(task, cached)
if (!task.allowDecrypt || this.isCanceled(task)) return
const shouldRefreshCachedPreview = cached.hasUpdate === true || isLikelyThumbnailCachePath(cached.localPath)
if (!shouldRefreshCachedPreview) return
this.totals.forcedThumbnailRefreshes += 1
const refreshed = await imageDecryptService.decryptImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
force: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowFilesystemScan: task.allowFilesystemScan,
suppressEvents: true
})
if (!refreshed.success) {
this.totals.forcedThumbnailRefreshFailures += 1
}
if (this.isCanceled(task)) return
const resolved = await imageDecryptService.resolveCachedImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: task.allowCacheIndex,
allowCachePromotion: false,
allowFilesystemScan: task.allowFilesystemScan,
suppressEvents: true
})
this.emitTaskCacheResolved(task, resolved)
return
}
if (!task.allowDecrypt) return
if (this.isCanceled(task)) return
await imageDecryptService.decryptImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
@@ -92,8 +836,24 @@ export class ImagePreloadService {
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowFilesystemScan: task.allowFilesystemScan,
suppressEvents: true
})
if (this.isCanceled(task)) return
const resolved = await imageDecryptService.resolveCachedImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: task.allowCacheIndex,
allowCachePromotion: false,
allowFilesystemScan: task.allowFilesystemScan,
suppressEvents: true
})
this.emitTaskCacheResolved(task, resolved)
} catch {
// ignore preload failures
}
+115 -42
View File
@@ -1,5 +1,6 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { constants, existsSync, mkdirSync } from 'fs'
import { access, appendFile, readFile, readdir } from 'fs/promises'
import { pathToFileURL } from 'url'
import { app } from 'electron'
import { ConfigService } from './config'
@@ -25,30 +26,68 @@ interface VideoIndexEntry {
type PosterFormat = 'dataUrl' | 'fileUrl'
const pathExists = async (filePath: string): Promise<boolean> => {
try {
await access(filePath, constants.F_OK)
return true
} catch {
return false
}
}
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 pendingVideoDirIndex = new Map<string, Promise<Map<string, VideoIndexEntry>>>()
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
private readonly videoIndexCacheTtlMs = 90 * 1000
private readonly maxCacheEntries = 2000
private readonly maxIndexEntries = 6
private logFlushTimer: ReturnType<typeof setTimeout> | null = null
private pendingLogLines: string[] = []
private readonly logFlushDelayMs = 200
private readonly maxPendingLogLines = 600
constructor() {
this.configService = new ConfigService()
}
private log(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
this.pendingLogLines.push(`[${timestamp}] [VideoService] ${message}${metaStr}\n`)
while (this.pendingLogLines.length > this.maxPendingLogLines) {
this.pendingLogLines.shift()
}
if (this.logFlushTimer) return
this.logFlushTimer = setTimeout(() => {
this.logFlushTimer = null
void this.flushPendingLogs()
}, this.logFlushDelayMs)
}
private async flushPendingLogs(): Promise<void> {
if (this.pendingLogLines.length === 0) return
const lines = this.pendingLogLines.splice(0, this.pendingLogLines.length).join('')
try {
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
} catch { }
await appendFile(join(logDir, 'wcdb.log'), lines, 'utf8')
} catch {
// Logging is diagnostic only; video browsing must never wait on it.
} finally {
if (this.pendingLogLines.length > 0 && this.logFlushTimer === null) {
this.logFlushTimer = setTimeout(() => {
this.logFlushTimer = null
void this.flushPendingLogs()
}, this.logFlushDelayMs)
}
}
}
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
@@ -272,21 +311,33 @@ class VideoService {
void md5List
}
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
private async fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): Promise<string | undefined> {
try {
if (!filePath || !existsSync(filePath)) return undefined
if (!filePath || !(await pathExists(filePath))) return undefined
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
const buffer = readFileSync(filePath)
const buffer = await readFile(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
return undefined
}
}
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
private async getOrBuildVideoIndex(videoBaseDir: string): Promise<Map<string, VideoIndexEntry>> {
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
if (cached) return cached
const pending = this.pendingVideoDirIndex.get(videoBaseDir)
if (pending) return pending
const task = this.buildVideoIndex(videoBaseDir)
this.pendingVideoDirIndex.set(videoBaseDir, task)
try {
return await task
} finally {
this.pendingVideoDirIndex.delete(videoBaseDir)
}
}
private async buildVideoIndex(videoBaseDir: string): Promise<Map<string, VideoIndexEntry>> {
const index = new Map<string, VideoIndexEntry>()
const ensureEntry = (key: string): VideoIndexEntry => {
let entry = index.get(key)
@@ -298,22 +349,18 @@ class VideoService {
}
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
const yearMonthDirs = (await readdir(videoBaseDir, { withFileTypes: true }))
.filter((dir) => dir.isDirectory())
.map((dir) => dir.name)
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
let files: string[] = []
try {
files = readdirSync(dirPath)
files = (await readdir(dirPath, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
} catch {
continue
}
@@ -369,12 +416,12 @@ class VideoService {
return index
}
private getVideoInfoFromIndex(
private async getVideoInfoFromIndex(
index: Map<string, VideoIndexEntry>,
md5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
): Promise<VideoInfo | null> {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!normalizedMd5) return null
@@ -389,7 +436,7 @@ class VideoService {
for (const key of candidates) {
const entry = index.get(key)
if (!entry?.videoPath) continue
if (!existsSync(entry.videoPath)) continue
if (!(await pathExists(entry.videoPath))) continue
if (!includePoster) {
return {
videoUrl: entry.videoPath,
@@ -398,8 +445,8 @@ class VideoService {
}
return {
videoUrl: entry.videoPath,
coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
coverUrl: await this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
thumbUrl: await this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
exists: true
}
}
@@ -424,28 +471,22 @@ class VideoService {
return String(fallback?.[1] || '').toLowerCase()
}
private fallbackScanVideo(
private async fallbackScanVideo(
videoBaseDir: string,
realVideoMd5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
): Promise<VideoInfo | null> {
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
const yearMonthDirs = (await readdir(videoBaseDir, { withFileTypes: true }))
.filter((dir) => dir.isDirectory())
.map((dir) => dir.name)
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
if (!existsSync(videoPath)) continue
if (!(await pathExists(videoPath))) continue
if (!includePoster) {
return {
videoUrl: videoPath,
@@ -457,8 +498,8 @@ class VideoService {
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
return {
videoUrl: videoPath,
coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
coverUrl: await this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
thumbUrl: await this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
exists: true
}
}
@@ -506,21 +547,21 @@ class VideoService {
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
if (!existsSync(videoBaseDir)) {
if (!(await pathExists(videoBaseDir))) {
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return miss
}
const index = this.getOrBuildVideoIndex(videoBaseDir)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
const index = await this.getOrBuildVideoIndex(videoBaseDir)
const indexed = await this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
if (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, posterFormat)
const fallback = await this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat)
if (fallback) {
const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
@@ -541,6 +582,38 @@ class VideoService {
}
}
async getVideoInfoBatch(
videoMd5List: string[],
options?: { includePoster?: boolean; posterFormat?: PosterFormat }
): Promise<Array<{ index: number; md5: string; info: VideoInfo }>> {
const rows: Array<{ index: number; md5: string; info: VideoInfo }> = new Array(videoMd5List.length)
const seen = new Map<string, Promise<VideoInfo>>()
for (let index = 0; index < videoMd5List.length; index += 1) {
const md5 = String(videoMd5List[index] || '').trim().toLowerCase()
if (!md5) {
rows[index] = { index, md5, info: { exists: false } }
continue
}
let task = seen.get(md5)
if (!task) {
task = this.getVideoInfo(md5, options)
seen.set(md5, task)
}
rows[index] = { index, md5, info: { exists: false } }
}
await Promise.all(rows.map(async (row, index) => {
if (!row.md5) return
try {
rows[index] = { ...row, info: await seen.get(row.md5)! }
} catch {
rows[index] = { ...row, info: { exists: false } }
}
}))
return rows
}
/**
* 根据消息内容解析视频MD5
*/
+402 -48
View File
@@ -1,5 +1,6 @@
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { appendFile } from 'fs/promises'
import { tmpdir } from 'os'
import * as fzstd from 'fzstd'
import { expandHomePath } from '../utils/pathUtils'
@@ -24,6 +25,23 @@ function cleanAccountDirName(dirName: string): string {
return trimmed
}
type MediaStreamPageCacheEntry = {
key: string
startOffset: number
endOffset: number
items: any[]
hasMore: boolean
updatedAt: number
}
type MediaStreamInflightEntry = {
startOffset: number
endOffset: number
promise: Promise<void>
resolve: () => void
finished: boolean
}
export class WcdbCore {
private resourcesPath: string | null = null
private userDataPath: string | null = null
@@ -136,6 +154,9 @@ export class WcdbCore {
private monitorPipePath: string = ''
private displayNameCache: Map<string, { displayName: string; updatedAt: number }> = new Map()
private readonly displayNameCacheTtlMs = 10 * 60 * 1000
private readonly displayNameCacheMaxEntries = 20000
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private imageHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
@@ -145,10 +166,18 @@ export class WcdbCore {
private mediaStreamSessionCache: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> | null = null
private mediaStreamSessionCacheAt = 0
private readonly mediaStreamSessionCacheTtlMs = 12 * 1000
private mediaStreamPageCache: Map<string, MediaStreamPageCacheEntry[]> = new Map()
private mediaStreamInflight = new Map<string, MediaStreamInflightEntry[]>()
private readonly mediaStreamPageCacheTtlMs = 30 * 1000
private readonly mediaStreamPageCacheMaxEntries = 16
private logTimer: NodeJS.Timeout | null = null
private logFlushTimer: ReturnType<typeof setTimeout> | null = null
private pendingLogLines: string[] = []
private lastLogTail: string | null = null
private lastResolvedLogPath: string | null = null
private lastCursorForceReopenAt = 0
private readonly maxPendingLogLines = 1200
private readonly logFlushDelayMs = 200
private readonly cursorForceReopenCooldownMs = 15000
setPaths(resourcesPath: string, userDataPath: string): void {
@@ -371,28 +400,52 @@ export class WcdbCore {
private writeLog(message: string, force = false): void {
if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}`
const line = `[${new Date().toISOString()}] ${message}\n`
this.pendingLogLines.push(line)
while (this.pendingLogLines.length > this.maxPendingLogLines) {
this.pendingLogLines.shift()
}
if (this.logFlushTimer) return
this.logFlushTimer = setTimeout(() => {
this.logFlushTimer = null
void this.flushPendingLogs()
}, this.logFlushDelayMs)
}
private getLogFileCandidates(): string[] {
const candidates: string[] = []
if (this.userDataPath) candidates.push(join(this.userDataPath, 'logs', 'wcdb.log'))
if (process.env.WCDB_LOG_DIR) candidates.push(join(process.env.WCDB_LOG_DIR, 'logs', 'wcdb.log'))
candidates.push(join(process.cwd(), 'logs', 'wcdb.log'))
candidates.push(join(tmpdir(), 'weflow-wcdb.log'))
return Array.from(new Set(candidates))
}
const uniq = Array.from(new Set(candidates))
for (const filePath of uniq) {
private async flushPendingLogs(): Promise<void> {
if (this.pendingLogLines.length === 0) return
const lines = this.pendingLogLines.splice(0, this.pendingLogLines.length).join('')
const candidates = this.getLogFileCandidates()
for (const filePath of candidates) {
try {
const dir = dirname(filePath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
appendFileSync(filePath, line + '\n', { encoding: 'utf8' })
await appendFile(filePath, lines, { encoding: 'utf8' })
this.lastResolvedLogPath = filePath
return
} catch (e) {
console.error(`[wcdbCore] writeLog failed path=${filePath}:`, e)
console.error(`[wcdbCore] flushPendingLogs failed path=${filePath}:`, e)
}
}
console.error('[wcdbCore] writeLog failed for all candidates:', uniq.join(' | '))
console.error('[wcdbCore] flushPendingLogs failed for all candidates:', candidates.join(' | '))
if (this.pendingLogLines.length > 0 && this.logFlushTimer === null) {
this.logFlushTimer = setTimeout(() => {
this.logFlushTimer = null
void this.flushPendingLogs()
}, this.logFlushDelayMs)
}
}
private formatSqlForLog(sql: string, maxLen = 240): string {
@@ -1545,6 +1598,172 @@ export class WcdbCore {
this.mediaStreamSessionCacheAt = 0
}
private clearDisplayNameCache(): void {
this.displayNameCache.clear()
}
private writeDisplayNameCache(username: string, displayName: string, now = Date.now()): void {
const normalizedUsername = String(username || '').trim()
const normalizedDisplayName = String(displayName || '').trim()
if (!normalizedUsername || !normalizedDisplayName) return
this.displayNameCache.set(normalizedUsername, {
displayName: normalizedDisplayName,
updatedAt: now
})
if (this.displayNameCache.size <= this.displayNameCacheMaxEntries) return
const expiresBefore = now - this.displayNameCacheTtlMs
for (const [key, entry] of this.displayNameCache) {
if (entry.updatedAt < expiresBefore) this.displayNameCache.delete(key)
}
while (this.displayNameCache.size > this.displayNameCacheMaxEntries) {
const oldestKey = this.displayNameCache.keys().next().value as string | undefined
if (!oldestKey) break
this.displayNameCache.delete(oldestKey)
}
}
private clearMediaStreamPageCache(): void {
this.mediaStreamPageCache.clear()
for (const entries of this.mediaStreamInflight.values()) {
for (const entry of entries) {
entry.finished = true
entry.resolve()
}
}
this.mediaStreamInflight.clear()
}
private pruneMediaStreamPageCache(now = Date.now()): void {
for (const [key, entries] of this.mediaStreamPageCache.entries()) {
const liveEntries = entries.filter((entry) => now - entry.updatedAt <= this.mediaStreamPageCacheTtlMs)
if (liveEntries.length > 0) {
this.mediaStreamPageCache.set(key, liveEntries)
} else {
this.mediaStreamPageCache.delete(key)
}
}
while (this.countMediaStreamPageCacheEntries() > this.mediaStreamPageCacheMaxEntries) {
let oldestKey = ''
let oldestIndex = -1
let oldestUpdatedAt = Number.POSITIVE_INFINITY
for (const [key, entries] of this.mediaStreamPageCache.entries()) {
for (let index = 0; index < entries.length; index += 1) {
const updatedAt = entries[index].updatedAt
if (updatedAt >= oldestUpdatedAt) continue
oldestUpdatedAt = updatedAt
oldestKey = key
oldestIndex = index
}
}
if (!oldestKey || oldestIndex < 0) break
const entries = this.mediaStreamPageCache.get(oldestKey) || []
entries.splice(oldestIndex, 1)
if (entries.length > 0) {
this.mediaStreamPageCache.set(oldestKey, entries)
} else {
this.mediaStreamPageCache.delete(oldestKey)
}
}
}
private countMediaStreamPageCacheEntries(): number {
let count = 0
for (const entries of this.mediaStreamPageCache.values()) {
count += entries.length
}
return count
}
private getMediaStreamPageCacheKey(options: {
sessionId?: string
mediaType?: string
beginTimestamp?: number
endTimestamp?: number
}): string {
return JSON.stringify([
this.currentPath || '',
this.currentWxid || '',
String(options.sessionId || '').trim(),
String(options.mediaType || 'all').trim(),
Number(options.beginTimestamp || 0) || 0,
Number(options.endTimestamp || 0) || 0
])
}
private readMediaStreamPageCache(key: string, offset: number, limit: number, startedAt = Date.now()): {
success: true
items: any[]
hasMore: boolean
nextOffset: number
streamSource: 'pageCache'
pageCacheHit: true
nativeRows: number
elapsedMs: number
} | null {
const now = Date.now()
this.pruneMediaStreamPageCache(now)
const entries = this.mediaStreamPageCache.get(key) || []
const entry = entries.find((candidate) => {
if (offset < candidate.startOffset) return false
if (offset > candidate.endOffset) return false
if (offset === candidate.endOffset && candidate.hasMore) return false
const start = offset - candidate.startOffset
const available = Math.max(0, candidate.items.length - start)
return available >= limit || !candidate.hasMore
})
if (!entry) return null
const start = offset - entry.startOffset
const available = Math.max(0, entry.items.length - start)
if (available <= 0) {
if (entry.hasMore) return null
return {
success: true,
items: [],
hasMore: false,
nextOffset: offset,
streamSource: 'pageCache',
pageCacheHit: true,
nativeRows: entry.items.length,
elapsedMs: Date.now() - startedAt
}
}
if (available < limit && entry.hasMore) return null
const items = entry.items.slice(start, start + limit)
entry.updatedAt = now
const nextOffset = offset + items.length
return {
success: true,
items,
hasMore: entry.hasMore || nextOffset < entry.endOffset,
nextOffset,
streamSource: 'pageCache',
pageCacheHit: true,
nativeRows: entry.items.length,
elapsedMs: Date.now() - startedAt
}
}
private writeMediaStreamPageCache(key: string, offset: number, items: any[], hasMore: boolean): void {
const now = Date.now()
this.pruneMediaStreamPageCache(now)
const nextEntry = {
key,
startOffset: offset,
endOffset: offset + items.length,
items,
hasMore,
updatedAt: now
}
const entries = (this.mediaStreamPageCache.get(key) || [])
.filter((entry) => entry.startOffset !== nextEntry.startOffset || entry.endOffset !== nextEntry.endOffset)
entries.push(nextEntry)
this.mediaStreamPageCache.set(key, entries)
this.pruneMediaStreamPageCache(now)
}
isReady(): boolean {
return this.ensureReady()
}
@@ -1621,6 +1840,8 @@ export class WcdbCore {
this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath
this.initialized = true
this.clearDisplayNameCache()
this.clearMediaStreamPageCache()
lastDllInitError = null
if (this.wcdbSetMyWxid && wxid) {
try {
@@ -1666,7 +1887,9 @@ export class WcdbCore {
this.currentDbStoragePath = null
this.initialized = false
this.clearHardlinkCaches()
this.clearDisplayNameCache()
this.clearMediaStreamSessionCache()
this.clearMediaStreamPageCache()
this.stopLogPolling()
}
}
@@ -1740,6 +1963,7 @@ export class WcdbCore {
return { success: false, error: message || `一键已读失败: ${result}` }
}
this.clearMediaStreamSessionCache()
this.clearMediaStreamPageCache()
this.writeLog('markAllSessionsRead ok')
return { success: true }
} catch (e) {
@@ -2098,10 +2322,17 @@ export class WcdbCore {
}>
hasMore?: boolean
nextOffset?: number
streamSource?: 'native' | 'pageCache' | 'inflight'
pageCacheHit?: boolean
inflightMerged?: boolean
nativeLimit?: number
nativeRows?: number
elapsedMs?: number
error?: string
}> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持资源扫描,请先更新 wcdb 数据服务' }
let finishMediaStreamInflight: (() => void) | null = null
try {
const toInt = (value: unknown): number => {
const n = Number(value || 0)
@@ -2295,7 +2526,12 @@ export class WcdbCore {
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
return String(fallback || '').toLowerCase()
}
const extractImageDatName = (row: Record<string, any>, content: string): string => {
const extractImageDatName = (
row: Record<string, any>,
content: string,
packedPayload?: string,
allowPackedFallback = true
): string => {
const direct = pickString(row, [
'image_path',
'imagePath',
@@ -2313,21 +2549,11 @@ export class WcdbCore {
const normalizedXml = normalizeDatBase(xmlCandidate)
if (normalizedXml) return normalizedXml
const packedRaw = pickRaw(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
const packedText = decodePackedToPrintable(packedRaw)
if (!allowPackedFallback) return ''
const packedText = packedPayload === undefined
? extractPackedPayload(row)
: packedPayload
if (packedText) {
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(packedText)
if (datLike?.[1]) return String(datLike[1]).toLowerCase()
@@ -2371,6 +2597,62 @@ export class WcdbCore {
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 nativeLimit = Math.min(1200, Math.max(limit, Math.min(Math.max(limit * 4, 320), 720)))
const pageCacheKey = this.getMediaStreamPageCacheKey({
sessionId: requestedSessionId,
mediaType,
beginTimestamp,
endTimestamp
})
const startedAt = Date.now()
const cachedPage = this.readMediaStreamPageCache(pageCacheKey, offset, limit, startedAt)
if (cachedPage) return cachedPage
const requestedEndOffset = offset + limit
const existingInflight = (this.mediaStreamInflight.get(pageCacheKey) || []).find((entry) => (
offset >= entry.startOffset &&
requestedEndOffset <= entry.endOffset
))
if (existingInflight) {
await existingInflight.promise.catch(() => { })
const inflightPage = this.readMediaStreamPageCache(pageCacheKey, offset, limit, startedAt)
if (inflightPage) {
return {
...inflightPage,
streamSource: 'inflight',
pageCacheHit: false,
inflightMerged: true,
elapsedMs: Date.now() - startedAt
}
}
}
let completeInflight: (() => void) | null = null
const inflightPromise = new Promise<void>((resolve) => {
completeInflight = resolve
})
const inflightEntry: MediaStreamInflightEntry = {
startOffset: offset,
endOffset: offset + nativeLimit,
promise: inflightPromise,
resolve: () => completeInflight?.(),
finished: false
}
const inflightEntries = this.mediaStreamInflight.get(pageCacheKey) || []
inflightEntries.push(inflightEntry)
this.mediaStreamInflight.set(pageCacheKey, inflightEntries)
const finishInflight = () => {
if (inflightEntry.finished) return
inflightEntry.finished = true
const entries = this.mediaStreamInflight.get(pageCacheKey) || []
const nextEntries = entries.filter((entry) => entry !== inflightEntry)
if (nextEntries.length > 0) {
this.mediaStreamInflight.set(pageCacheKey, nextEntries)
} else {
this.mediaStreamInflight.delete(pageCacheKey)
}
inflightEntry.resolve()
}
finishMediaStreamInflight = finishInflight
const getSessionRows = async (): Promise<{
success: boolean
@@ -2425,12 +2707,15 @@ export class WcdbCore {
} else {
const sessionsRowsRes = await getSessionRows()
if (!sessionsRowsRes.success || !Array.isArray(sessionsRowsRes.rows)) {
finishInflight()
return { success: false, error: sessionsRowsRes.error || '读取会话失败' }
}
sessionRows = sessionsRowsRes.rows
}
if (sessionRows.length === 0) {
this.writeMediaStreamPageCache(pageCacheKey, offset, [], false)
finishInflight()
return { success: true, items: [], hasMore: false, nextOffset: offset }
}
const sessionNameMap = new Map(sessionRows.map((row) => [row.sessionId, row.displayName || row.sessionId]))
@@ -2444,16 +2729,20 @@ export class WcdbCore {
mediaTypeCode,
beginTimestamp,
endTimestamp,
limit,
nativeLimit,
offset,
outPtr,
outHasMore
)
if (result !== 0 || !outPtr[0]) {
finishInflight()
return { success: false, error: `扫描资源失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析资源失败' }
if (!jsonStr) {
finishInflight()
return { success: false, error: '解析资源失败' }
}
const rows = JSON.parse(jsonStr)
const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : []
@@ -2490,7 +2779,6 @@ export class WcdbCore {
if (!rawMessageContent && !rawCompressContent) return ''
return decodeMessageContent(rawMessageContent, rawCompressContent)
}
const packedPayload = extractPackedPayload(row)
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
const packedRaw = pickRaw(row, [
@@ -2507,6 +2795,11 @@ export class WcdbCore {
'Reserved0',
'WCDB_CT_Reserved0'
])
let packedPayload: string | undefined
const getPackedPayload = (): string => {
if (packedPayload === undefined) packedPayload = extractPackedPayload(row)
return packedPayload
}
let content = ''
let imageMd5: string | undefined
@@ -2514,24 +2807,25 @@ export class WcdbCore {
let videoMd5: string | undefined
if (localType === 3) {
imageMd5 = imageMd5ByColumn || extractHexMd5(packedPayload) || undefined
imageDatName = extractImageDatName(row, '') || undefined
imageMd5 = imageMd5ByColumn || undefined
imageDatName = extractImageDatName(row, '', undefined, false) || undefined
if (!imageMd5) imageMd5 = extractHexMd5(getPackedPayload()) || undefined
if (!imageMd5 || !imageDatName) {
content = decodeContentIfNeeded()
if (!imageMd5) imageMd5 = extractImageMd5(content) || extractHexMd5(packedPayload) || undefined
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
if (!imageMd5) imageMd5 = extractImageMd5(content) || extractHexMd5(getPackedPayload()) || undefined
if (!imageDatName) imageDatName = extractImageDatName(row, content, getPackedPayload()) || undefined
}
} else if (localType === 43) {
videoMd5 =
extractVideoFileNameFromPackedRaw(packedRaw) ||
normalizeVideoFileToken(videoMd5ByColumn) ||
extractHexMd5(packedPayload) ||
extractHexMd5(getPackedPayload()) ||
undefined
if (!videoMd5) {
content = decodeContentIfNeeded()
videoMd5 =
normalizeVideoFileToken(extractVideoMd5(content)) ||
extractHexMd5(packedPayload) ||
extractHexMd5(getPackedPayload()) ||
undefined
} else if (useRawMessageContent) {
// 占位态标题只依赖简单 XML,已带 md5 时不做额外解压
@@ -2547,7 +2841,7 @@ export class WcdbCore {
serverId: pickString(row, ['server_id', 'serverId']) || undefined,
createTime: toInt(row.create_time ?? row.createTime),
localType,
senderUsername: pickString(row, ['sender_username', 'senderUsername']) || undefined,
senderUsername: pickString(row, ['sender_username', 'senderUsername', 'real_sender_id', 'realSenderId']) || undefined,
isSend: row.is_send === null || row.is_send === undefined ? null : toInt(row.is_send),
imageMd5,
imageDatName,
@@ -2580,13 +2874,27 @@ export class WcdbCore {
}
}
const nativeHasMore = Number(outHasMore[0]) > 0
const canAdvance = items.length > 0
this.writeMediaStreamPageCache(pageCacheKey, offset, items, canAdvance && nativeHasMore)
const pageItems = items.slice(0, limit)
const nextOffset = offset + pageItems.length
const hasMore = nextOffset > offset && (nativeHasMore || pageItems.length < items.length)
finishInflight()
return {
success: true,
items,
hasMore: Number(outHasMore[0]) > 0,
nextOffset: offset + items.length
items: pageItems,
hasMore,
nextOffset,
streamSource: 'native',
pageCacheHit: false,
nativeLimit,
nativeRows: items.length,
elapsedMs: Date.now() - startedAt
}
} catch (e) {
finishMediaStreamInflight?.()
return { success: false, error: String(e) }
}
}
@@ -2597,14 +2905,35 @@ export class WcdbCore {
}
if (usernames.length === 0) return { success: true, map: {} }
try {
const now = Date.now()
const resultMap: Record<string, string> = {}
const toFetch: string[] = []
const seen = new Set<string>()
for (const username of usernames) {
const normalizedUsername = String(username || '').trim()
if (!normalizedUsername || seen.has(normalizedUsername)) continue
seen.add(normalizedUsername)
const cached = this.displayNameCache.get(normalizedUsername)
if (cached && now - cached.updatedAt < this.displayNameCacheTtlMs) {
resultMap[normalizedUsername] = cached.displayName
continue
}
toFetch.push(normalizedUsername)
}
if (toFetch.length === 0) return { success: true, map: resultMap }
if (process.platform === 'darwin') {
const uniq = Array.from(new Set(usernames.map((x) => String(x || '').trim()).filter(Boolean)))
if (uniq.length === 0) return { success: true, map: {} }
const inList = uniq.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
const q = await this.execQuery('contact', null, sql)
if (!q.success) return { success: false, error: q.error || '获取昵称失败' }
const map: Record<string, string> = {}
if (!q.success) {
if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: q.error || '获取昵称失败' }
}
return { success: false, error: q.error || '获取昵称失败' }
}
for (const row of (q.rows || []) as Array<Record<string, any>>) {
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
if (!username) continue
@@ -2613,31 +2942,56 @@ export class WcdbCore {
'nick_name', 'nickName', 'nickname', 'NickName',
'alias', 'Alias'
]) || username
map[username] = display
resultMap[username] = display
this.writeDisplayNameCache(username, display, now)
}
// 保证每个请求用户名至少有回退值
for (const u of uniq) {
if (!map[u]) map[u] = u
for (const u of toFetch) {
if (!resultMap[u]) {
resultMap[u] = u
this.writeDisplayNameCache(u, u, now)
}
}
return { success: true, map }
return { success: true, map: resultMap }
}
// 让出控制权,避免阻塞事件循环
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any]
const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr)
const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(toFetch), outPtr)
//数据服务调用后再次让出控制权
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) {
if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: `获取昵称失败: ${result}` }
}
return { success: false, error: `获取昵称失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析昵称失败' }
if (!jsonStr) {
if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: '解析昵称失败' }
}
return { success: false, error: '解析昵称失败' }
}
const map = JSON.parse(jsonStr)
return { success: true, map }
if (map && typeof map === 'object') {
for (const username of toFetch) {
const display = String((map as Record<string, unknown>)[username] || '').trim()
if (!display) continue
resultMap[username] = display
this.writeDisplayNameCache(username, display, now)
}
}
for (const username of toFetch) {
if (resultMap[username]) continue
resultMap[username] = username
this.writeDisplayNameCache(username, username, now)
}
return { success: true, map: resultMap }
} catch (e) {
return { success: false, error: String(e) }
}
+6
View File
@@ -308,6 +308,12 @@ export class WcdbService {
}>
hasMore?: boolean
nextOffset?: number
streamSource?: 'native' | 'pageCache' | 'inflight'
pageCacheHit?: boolean
inflightMerged?: boolean
nativeLimit?: number
nativeRows?: number
elapsedMs?: number
error?: string
}> {
return this.callWorker('getMediaStream', { options })
+65 -3
View File
@@ -43,6 +43,7 @@ export interface WeliveExportRequest {
outputDir: string
exportsDir?: string
mediaDir?: string
mediaTypes?: Array<'image' | 'voice' | 'video' | 'emoji' | 'file'>
emojiCacheDir?: string
formattedDir?: string
format?: string
@@ -76,6 +77,7 @@ export interface WeliveExportResult {
rawResult?: WeliveExportEvent
stderr?: string
error?: string
diagnostics?: Record<string, unknown>
}
const platformDir = () => {
@@ -90,6 +92,36 @@ const archDir = () => {
const executableName = () => process.platform === 'win32' ? 'welive.exe' : 'welive'
const formatExitCode = (code: number | null, signal: NodeJS.Signals | null | undefined) => {
if (code === null) return signal ? `signal:${signal}` : 'unknown'
const unsigned = code >>> 0
const signed = unsigned > 0x7fffffff ? unsigned - 0x100000000 : unsigned
const hex = `0x${unsigned.toString(16).toUpperCase().padStart(8, '0')}`
return signed === code
? `${code} (${hex})`
: `${code} (${hex}, signed ${signed})`
}
const summarizeEvent = (event?: WeliveExportEvent) => {
if (!event) return undefined
const summary: Record<string, unknown> = { type: event.type }
for (const key of ['phase', 'label', 'session_id', 'current', 'total', 'exported_messages', 'estimated_total_messages']) {
if ((event as any)[key] !== undefined) summary[key] = (event as any)[key]
}
return summary
}
const formatDiagnostics = (diagnostics: Record<string, unknown>) => {
const parts = [
diagnostics.exit ? `exit=${diagnostics.exit}` : '',
diagnostics.exe ? `exe=${diagnostics.exe}` : '',
diagnostics.platform ? `platform=${diagnostics.platform}` : '',
diagnostics.arch ? `arch=${diagnostics.arch}` : '',
diagnostics.lastEvent ? `lastEvent=${JSON.stringify(diagnostics.lastEvent)}` : ''
].filter(Boolean)
return parts.length > 0 ? `[welive-diagnostics] ${parts.join(' ')}` : ''
}
export function resolveWeliveExecutable(resourcesPath: string, appPath?: string): string | null {
const platform = platformDir()
const arch = archDir()
@@ -137,6 +169,8 @@ export async function runWeliveExport(options: RunWeliveExportOptions): Promise<
let stderr = ''
let settled = false
let rawResult: WeliveExportEvent | undefined
let lastEvent: WeliveExportEvent | undefined
const eventTrail: Array<Record<string, unknown>> = []
const failedSessionErrors: Record<string, string> = {}
const sessionOutputPaths: Record<string, string> = {}
const rawSessionOutputPaths: Record<string, string> = {}
@@ -156,6 +190,12 @@ export async function runWeliveExport(options: RunWeliveExportOptions): Promise<
options.signal?.addEventListener('abort', abort)
const handleEvent = (event: WeliveExportEvent) => {
lastEvent = event
const eventSummary = summarizeEvent(event)
if (eventSummary) {
eventTrail.push(eventSummary)
if (eventTrail.length > 8) eventTrail.shift()
}
options.onEvent?.(event)
if (event.type === 'session_error') {
const sessionId = String(event.session_id || '').trim()
@@ -224,14 +264,35 @@ export async function runWeliveExport(options: RunWeliveExportOptions): Promise<
sessionOutputPaths,
rawSessionOutputPaths,
rawResult,
error: error.message
error: error.message,
diagnostics: {
exe,
platform: process.platform,
arch: process.arch,
pid: child.pid,
lastEvent: summarizeEvent(lastEvent),
eventTrail
}
})
})
child.on('exit', (code) => {
child.on('exit', (code, signal) => {
drainStdout()
const failedSessionIds = Object.keys(failedSessionErrors)
const success = code === 0 && rawResult?.type === 'result' && rawResult.success !== false
const diagnostics = {
exit: formatExitCode(code, signal),
code,
signal,
exe,
platform: process.platform,
arch: process.arch,
pid: child.pid,
lastEvent: summarizeEvent(lastEvent),
eventTrail
}
const diagnosticText = success ? '' : formatDiagnostics(diagnostics)
const errorText = stderr.trim() || `WeLive 导出引擎退出码: ${formatExitCode(code, signal)}`
finish({
success,
successCount: Number((rawResult as any)?.success_count ?? Object.keys(sessionOutputPaths).length),
@@ -241,7 +302,8 @@ export async function runWeliveExport(options: RunWeliveExportOptions): Promise<
sessionOutputPaths,
rawSessionOutputPaths,
rawResult,
error: success ? undefined : stderr.trim() || `WeLive 导出引擎退出码: ${code}`
diagnostics,
error: success ? undefined : [errorText, diagnosticText].filter(Boolean).join('\n')
})
})
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -34,6 +34,7 @@ const exportFormatOptions = [
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'markdown', label: 'Markdown', desc: '支持文本、图片与链接,适合 AI 场景' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
+69 -1
View File
@@ -6,7 +6,7 @@
*/
import { memo, useEffect, useState, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import ExportTopBar from './components/ExportTopBar'
import SessionTable from './components/SessionTable'
import ExportDialog from './components/ExportDialog'
@@ -28,18 +28,26 @@ import { AutomationTaskForm } from './components/Automation/AutomationTaskForm'
import type { SessionRow } from './types'
import { getSelectionScopeFromRows, resolveScopeDisplayNames } from './utils/session'
import {
emitSingleExportDialogStatus,
onOpenSingleExport,
type OpenSingleExportPayload
} from '../../services/exportBridge'
import './ExportPage.scss'
function ExportPage() {
const location = useLocation()
const navigate = useNavigate()
const isExportRoute = location.pathname === '/export'
const [selectedSessionIds, setSelectedSessionIds] = useState<Set<string>>(new Set())
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)
const [isAutomationModalOpen, setIsAutomationModalOpen] = useState(false)
const [draftAutomationPayload, setDraftAutomationPayload] = useState<any>(null)
const [pendingSingleExportRequest, setPendingSingleExportRequest] = useState<OpenSingleExportPayload | null>(null)
const {
isLoaded: isConfigLoaded,
options,
updateOptions,
exportPath,
@@ -63,6 +71,7 @@ function ExportPage() {
const { metricsMap, fetchMetrics } = useSessionMetrics()
const {
sessions,
filteredSessions,
isLoading: isSessionsLoading,
activeTab,
@@ -115,6 +124,13 @@ function ExportPage() {
// ── 4. Dialog & Exports ──
const { dialogState, openDialog, closeDialog } = useExportDialog()
const resolveSingleExportName = useCallback((payload: OpenSingleExportPayload) => {
const sessionId = String(payload.sessionId || '').trim()
const payloadName = typeof payload.sessionName === 'string' ? payload.sessionName.trim() : ''
const sessionRow = sessions.find(s => s.username === sessionId)
return payloadName || sessionRow?.displayName || sessionRow?.remark || sessionRow?.nickname || sessionId
}, [sessions])
const handleExportDefaultsChanged = useCallback((patch: ExportDefaultsSettingsPatch) => {
updateOptions({
...(patch.format ? { defaultFormat: patch.format as any } : {}),
@@ -171,6 +187,58 @@ function ExportPage() {
})
}, [displayedSessions, options.displayNamePreference, openDialog])
const handleOpenSingleExportRequest = useCallback((payload: OpenSingleExportPayload) => {
const requestId = typeof payload?.requestId === 'string' ? payload.requestId.trim() : ''
const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId.trim() : ''
if (!requestId || !sessionId) {
if (requestId) {
emitSingleExportDialogStatus({
requestId,
status: 'failed',
message: '无法打开导出配置:缺少会话信息'
})
}
return
}
emitSingleExportDialogStatus({ requestId, status: 'initializing' })
if (!isConfigLoaded) {
setPendingSingleExportRequest({
...payload,
requestId,
sessionId
})
return
}
const sessionName = resolveSingleExportName(payload)
setSelectedSessionIds(new Set([sessionId]))
setActiveTab(sessionId.includes('@chatroom') ? 'group' : 'private')
navigate('/export')
openDialog({
intent: 'manual',
scope: 'single',
sessionIds: [sessionId],
sessionNames: [sessionName],
title: `导出: ${sessionName}`
})
emitSingleExportDialogStatus({ requestId, status: 'opened' })
}, [isConfigLoaded, navigate, openDialog, resolveSingleExportName, setActiveTab])
useEffect(() => {
return onOpenSingleExport(handleOpenSingleExportRequest)
}, [handleOpenSingleExportRequest])
useEffect(() => {
if (!isConfigLoaded || !pendingSingleExportRequest) return
setPendingSingleExportRequest(null)
handleOpenSingleExportRequest(pendingSingleExportRequest)
}, [handleOpenSingleExportRequest, isConfigLoaded, pendingSingleExportRequest])
const handleConfirmExport = useCallback((finalOptions: any) => {
startTask({
sessionIds: dialogState.sessionIds,
@@ -326,7 +326,7 @@
}
}
.file-size-limit, .concurrency-control, .path-style-control {
.file-size-limit, .concurrency-control, .path-style-control, .conflict-strategy-control {
margin-top: 16px;
display: flex;
align-items: center;
@@ -354,7 +354,7 @@
}
}
.path-style-control {
.path-style-control, .conflict-strategy-control {
flex: 1 1 100%;
margin-top: 4px;
@@ -362,7 +362,7 @@
flex: 0 0 auto;
}
.path-style-segmented {
.path-style-segmented, .conflict-strategy-segmented {
display: inline-flex;
min-height: 34px;
padding: 3px;
@@ -21,7 +21,7 @@ import {
type ExportDefaultDateRangeConfig
} from '../../../../utils/exportDateRange'
import type { ExportDialogState, ExportOptions, TextExportFormat } from '../../types'
import { formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { conflictStrategyOptions, formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { formatPathBrief } from '../../utils/format'
import './ExportDialog.scss'
@@ -282,6 +282,23 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
</div>
</div>
<div className="conflict-strategy-control">
<span>同名文件:</span>
<div className="conflict-strategy-segmented" title="控制导出目标已有同名文件时的处理方式">
{conflictStrategyOptions.map(item => (
<button
key={item.value}
type="button"
className={options.exportConflictStrategy === item.value ? 'active' : ''}
title={item.desc}
onClick={() => onOptionsChange({ exportConflictStrategy: item.value })}
>
{item.label}
</button>
))}
</div>
</div>
<div className="concurrency-control">
<span>导出并发数:</span>
<select
+9
View File
@@ -7,6 +7,7 @@ import type {
ContentType,
ConversationTab,
DisplayNamePreference,
ExportConflictStrategy,
TextExportFormat,
TaskProgress,
ContactsSortConfig
@@ -45,6 +46,7 @@ export const formatOptions: Array<{ value: TextExportFormat; label: string; desc
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'markdown', label: 'Markdown', desc: '支持文本、图片与链接,适合 AI 场景' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
@@ -59,6 +61,12 @@ export const displayNameOptions: Array<{ value: DisplayNamePreference; label: st
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
]
export const conflictStrategyOptions: Array<{ value: ExportConflictStrategy; label: string; desc: string }> = [
{ value: 'incremental', label: '增量跳过', desc: '复用已有同名媒体;聊天文件有变化时更新原文件' },
{ value: 'overwrite', label: '全量覆盖', desc: '同名文件直接替换,适合强制刷新导出结果' },
{ value: 'rename', label: '保留副本', desc: '同名导出追加序号,保留旧版本' }
]
// ─── Write layout options ────────────────────────────────────
export const writeLayoutOptions: Array<{ value: ExportWriteLayout; label: string; desc: string }> = [
@@ -163,6 +171,7 @@ export const createDefaultExportOptions = (): import('./types').ExportOptions =>
maxFileSizeMb: 200,
exportVoiceAsText: false,
exportPathStyle: 'auto',
exportConflictStrategy: 'incremental',
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
+19 -5
View File
@@ -11,6 +11,7 @@ import { createEmptyProgress } from '../constants'
import { useExportTaskStore } from '../../../stores/exportTaskStore'
import { buildProgressPayloadSignature } from '../utils/progress'
import { resolvePerfStageByPhase, applyProgressToTaskPerformance } from '../utils/performance'
import { emitExportSessionStatus, onExportSessionStatusRequest } from '../../../services/exportBridge'
import type { ExportProgress } from '../types'
export interface ExportTasksResult {
@@ -32,19 +33,32 @@ export function useExportTasks(): ExportTasksResult {
const { setSessionStatus } = useExportTaskStore()
// Track the ongoing tasks to update the global zustand store badge
useEffect(() => {
const publishSessionStatus = useCallback(() => {
const activeTasks = tasksRef.current.filter(t => t.status === 'running' || t.status === 'cancel_requested')
const inProgressSessionIds = new Set<string>()
activeTasks.forEach(task => {
task.payload.sessionIds.forEach(id => inProgressSessionIds.add(id))
})
setSessionStatus({
const payload = {
activeTaskCount: activeTasks.length,
inProgressSessionIds: Array.from(inProgressSessionIds)
})
}, [tasks, setSessionStatus])
}
setSessionStatus(payload)
emitExportSessionStatus(payload)
}, [setSessionStatus])
// Track the ongoing tasks to update the global zustand store badge
useEffect(() => {
publishSessionStatus()
}, [tasks, publishSessionStatus])
useEffect(() => {
const unsubscribe = onExportSessionStatusRequest(publishSessionStatus)
publishSessionStatus()
return unsubscribe
}, [publishSessionStatus])
const updateTask = useCallback((taskId: string, updater: (prev: ExportTask) => ExportTask) => {
setTasks(prev => prev.map(t => t.id === taskId ? updater(t) : t))
+4
View File
@@ -30,12 +30,15 @@ export type SessionLayout = 'shared' | 'per-session'
export type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
export type ExportConflictStrategy = 'incremental' | 'overwrite' | 'rename'
export type TextExportFormat =
| 'chatlab'
| 'chatlab-jsonl'
| 'json'
| 'arkme-json'
| 'html'
| 'markdown'
| 'txt'
| 'excel'
| 'weclone'
@@ -67,6 +70,7 @@ export interface ExportOptions {
maxFileSizeMb: number
exportVoiceAsText: boolean
exportPathStyle: 'auto' | 'posix' | 'windows'
exportConflictStrategy: ExportConflictStrategy
excelCompactColumns: boolean
txtColumns: string[]
displayNamePreference: DisplayNamePreference
+3 -2
View File
@@ -38,7 +38,7 @@ interface GroupMessageRank {
}
type AnalysisFunction = 'members' | 'memberMessages' | 'memberAnalytics' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'markdown' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
format: MemberExportFormat
@@ -234,6 +234,7 @@ function GroupAnalyticsPage() {
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'markdown', label: 'Markdown', desc: '支持文本、图片与链接,适合 AI 场景' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }
]), [])
const displayNameOptions = useMemo<Array<{
@@ -886,7 +887,7 @@ function GroupAnalyticsPage() {
const handleMemberExportFormatChange = (format: MemberExportFormat) => {
setMemberExportOptions(prev => {
const next = { ...prev, format }
if (format === 'html') {
if (format === 'html' || format === 'markdown') {
return {
...next,
exportMedia: true,
+36 -2
View File
@@ -209,6 +209,7 @@
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.stream-grid {
@@ -389,6 +390,19 @@
color: var(--text-tertiary);
padding: 12px;
text-align: center;
position: relative;
overflow: hidden;
background: color-mix(in srgb, var(--bg-tertiary) 72%, transparent);
svg,
span {
position: relative;
z-index: 1;
}
svg {
animation: decrypt-pulse 1.4s ease-in-out infinite;
}
span {
font-size: 12px;
@@ -415,8 +429,9 @@
content: '';
position: absolute;
inset: -40%;
background: none;
animation: none;
background:
linear-gradient(110deg, transparent 38%, rgba(255, 255, 255, 0.38) 50%, transparent 62%);
animation: decrypt-sheen 1.1s ease-in-out infinite;
pointer-events: none;
}
}
@@ -488,6 +503,25 @@
color: var(--text-tertiary);
}
.grid-refreshing {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
min-height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
color: var(--text-secondary);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
pointer-events: none;
}
.spin {
animation: resources-spin 1s linear infinite;
}
+1882 -580
View File
@@ -1,51 +1,46 @@
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { forwardRef, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type HTMLAttributes, type MutableRefObject } from 'react'
import { Calendar, Image as ImageIcon, Info, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
import { VirtuosoGrid } from 'react-virtuoso'
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
import {
BATCH_IMAGE_DECRYPT_CONCURRENCY,
BATCH_IMAGE_DECRYPT_YIELD_MS,
BATCH_IMAGE_HARDLINK_PRELOAD_CHUNK_SIZE,
BATCH_IMAGE_HARDLINK_PRELOAD_YIELD_MS,
IMAGE_PREDECRYPT_LOOKAHEAD,
IMAGE_PREDECRYPT_IDLE_DELAY_MS,
IMAGE_PREDECRYPT_TIMER_MS,
INITIAL_IMAGE_PREDECRYPT_END,
INITIAL_IMAGE_PRELOAD_END,
INITIAL_IMAGE_RESOLVE_END,
MAX_IMAGE_CACHE_PRELOAD_PER_TICK,
MAX_IMAGE_CACHE_RESOLVE_PER_TICK,
MAX_IMAGE_PREDECRYPT_PER_TICK,
MAX_MEDIA_PATCHES_PER_FLUSH,
MAX_VIDEO_POSTER_RESOLVE_PER_TICK,
PAGE_SIZE,
TASK_PROGRESS_UPDATE_MAX_STEPS,
TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS,
extractVideoMd5,
extractVideoTitle,
formatInfoValue,
formatTimeLabel,
getItemKey,
getRangeTimestampEnd,
getRangeTimestampStart,
getSafeImageDatName,
hasImageLocator,
normalizeMediaToken,
toRenderableMediaSrc,
type ContactOption,
type DialogState,
type ImagePreloadPayload,
type MediaStreamItem,
type MediaTab
} from './ResourcesPage.utils'
import './ResourcesPage.scss'
type MediaTab = 'image' | 'video'
interface MediaStreamItem {
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
}
interface ContactOption {
id: string
name: string
}
type DialogState = {
mode: 'alert' | 'confirm' | 'info'
title: string
message?: string
infoRows?: Array<{ label: string; value: string }>
confirmText?: string
cancelText?: string
onConfirm?: (() => void) | null
}
const PAGE_SIZE = 96
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 3
const INITIAL_IMAGE_PRELOAD_END = 48
const INITIAL_IMAGE_RESOLVE_END = 12
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const BATCH_IMAGE_DECRYPT_CONCURRENCY = 8
const waitForBatchDecryptYield = () => new Promise<void>((resolve) => window.setTimeout(resolve, BATCH_IMAGE_DECRYPT_YIELD_MS))
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
const { className = '', ...rest } = props
@@ -53,120 +48,178 @@ const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(func
return <div ref={ref} className={mergedClassName} {...rest} />
})
function GridItem(props: HTMLAttributes<HTMLDivElement>) {
const GridItem = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridItem(props, ref) {
const { className = '', ...rest } = props
const mergedClassName = ['stream-grid-item', className].filter(Boolean).join(' ')
return <div className={mergedClassName} {...rest} />
return <div ref={ref} className={mergedClassName} {...rest} />
})
const GRID_COMPONENTS = {
List: GridList,
Item: GridItem
}
function getRangeTimestampStart(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T00:00:00`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
type MediaItemMeta = {
itemKey: string
imageMd5: string
imageDatName: string
imageIdentity: string
videoMd5: string
videoTitle: string
hasImageLocator: boolean
}
function getRangeTimestampEnd(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T23:59:59`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
type ResourceRuntimeCounters = {
cacheResolvedEvents: number
cacheResolvedPatchedItems: number
cacheResolvedUnmatchedEvents: number
imageResolveStarted: number
imageResolveCompleted: number
imageResolveSuperseded: number
imageResolveIdentityMissSkipped: number
imageResolveMisses: number
imageResolveContainedSkips: number
imageResolvePendingMerges: number
imageResolvePendingReplacements: number
imagePreloadRequests: number
imagePreloadRejectedCapacity: number
imagePreloadSubmittingSkips: number
imagePredecryptRequests: number
imagePredecryptBackpressureSkips: number
imagePredecryptRejectedCapacity: number
imagePredecryptDeferred: number
imagePredecryptSubmittingSkips: number
imagePredecryptPreviewUpgrades: number
loadMoreRequests: number
loadMoreSkipped: number
loadResets: number
mediaStreamLoadSamples: number
mediaStreamLoadMsTotal: number
mediaStreamMaxLoadMs: number
mediaStreamPageCacheHits: number
mediaStreamInflightMerges: number
mediaStreamNativeLoads: number
mediaStreamRowsLoaded: number
mediaStreamDuplicateRows: number
mediaStreamNoProgressStops: number
previewPatchesQueued: number
rangeFlushes: number
rangeSchedules: number
rangeDuplicateSkips: number
rangeHiddenSkips: number
rangeVisibilityReschedules: number
rangeContainedSkips: number
rangePendingMerges: number
rangePendingReplacements: number
predecryptFlushes: number
predecryptContainedSkips: number
predecryptHiddenSkips: number
predecryptPendingUpdates: number
predecryptPendingReplacements: number
predecryptSchedules: number
transientStatePruneRuns: number
videoPosterBatches: number
videoPosterMd5CacheHits: number
videoPosterMd5MissSkips: number
videoPosterContainedSkips: number
videoPosterPendingMerges: number
videoPosterPendingReplacements: number
}
function normalizeMediaToken(value?: string): string {
return String(value || '').trim().toLowerCase()
type ResourceFrameMetrics = {
samples: number
longFrames: number
maxFrameMs: number
lastFrameMs: number
}
function getSafeImageDatName(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): string {
const datName = normalizeMediaToken(item.imageDatName)
if (!datName) return ''
return datName
}
const createResourceRuntimeCounters = (): ResourceRuntimeCounters => ({
cacheResolvedEvents: 0,
cacheResolvedPatchedItems: 0,
cacheResolvedUnmatchedEvents: 0,
imageResolveStarted: 0,
imageResolveCompleted: 0,
imageResolveSuperseded: 0,
imageResolveIdentityMissSkipped: 0,
imageResolveMisses: 0,
imageResolveContainedSkips: 0,
imageResolvePendingMerges: 0,
imageResolvePendingReplacements: 0,
imagePreloadRequests: 0,
imagePreloadRejectedCapacity: 0,
imagePreloadSubmittingSkips: 0,
imagePredecryptRequests: 0,
imagePredecryptBackpressureSkips: 0,
imagePredecryptRejectedCapacity: 0,
imagePredecryptDeferred: 0,
imagePredecryptSubmittingSkips: 0,
imagePredecryptPreviewUpgrades: 0,
loadMoreRequests: 0,
loadMoreSkipped: 0,
loadResets: 0,
mediaStreamLoadSamples: 0,
mediaStreamLoadMsTotal: 0,
mediaStreamMaxLoadMs: 0,
mediaStreamPageCacheHits: 0,
mediaStreamInflightMerges: 0,
mediaStreamNativeLoads: 0,
mediaStreamRowsLoaded: 0,
mediaStreamDuplicateRows: 0,
mediaStreamNoProgressStops: 0,
previewPatchesQueued: 0,
rangeFlushes: 0,
rangeSchedules: 0,
rangeDuplicateSkips: 0,
rangeHiddenSkips: 0,
rangeVisibilityReschedules: 0,
rangeContainedSkips: 0,
rangePendingMerges: 0,
rangePendingReplacements: 0,
predecryptFlushes: 0,
predecryptContainedSkips: 0,
predecryptHiddenSkips: 0,
predecryptPendingUpdates: 0,
predecryptPendingReplacements: 0,
predecryptSchedules: 0,
transientStatePruneRuns: 0,
videoPosterBatches: 0,
videoPosterMd5CacheHits: 0,
videoPosterMd5MissSkips: 0,
videoPosterContainedSkips: 0,
videoPosterPendingMerges: 0,
videoPosterPendingReplacements: 0
})
function hasImageLocator(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): boolean {
return Boolean(normalizeMediaToken(item.imageMd5) || getSafeImageDatName(item))
}
const createResourceFrameMetrics = (): ResourceFrameMetrics => ({
samples: 0,
longFrames: 0,
maxFrameMs: 0,
lastFrameMs: 0
})
function getItemKey(item: MediaStreamItem): string {
const sessionId = String(item.sessionId || '').trim().toLowerCase()
const localId = Number(item.localId || 0)
if (sessionId && Number.isFinite(localId) && localId > 0) {
return `${sessionId}|${localId}`
type ResourcePreloadStatsSnapshot = {
queuedDecrypt?: number
queuedLow?: number
activeDecrypt?: number
highWater?: {
queuedDecrypt?: number
queuedLow?: number
activeDecrypt?: number
}
const serverId = String(item.serverId || '').trim().toLowerCase()
const createTime = Number(item.createTime || 0)
const localType = Number(item.localType || 0)
const mediaId = String(
item.mediaType === 'video'
? (item.videoMd5 || '')
: (item.imageMd5 || getSafeImageDatName(item) || '')
).trim().toLowerCase()
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
}
function formatTimeLabel(timestampSec: number): string {
if (!timestampSec) return '--:--'
return new Date(timestampSec * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
type ImagePreloadCandidate = {
payload: ImagePreloadPayload
itemKey: string
imageIdentity: string
previewUpgrade: boolean
}
function formatInfoValue(value: unknown): string {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function extractVideoTitle(content?: string): string {
const xml = String(content || '')
if (!xml) return '视频'
const match = /<title>([\s\S]*?)<\/title>/i.exec(xml)
const text = String(match?.[1] || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
return text || '视频'
}
function toRenderableMediaSrc(rawPath?: string): string {
const src = String(rawPath || '').trim()
if (!src) return ''
if (/^(data:image\/|blob:|https?:\/\/)/i.test(src)) {
return src
}
if (/^file:\/\//i.test(src)) {
return src.replace(/#/g, '%23')
}
if (src.startsWith('/')) {
return encodeURI(`file://${src}`).replace(/#/g, '%23')
}
if (/^[a-zA-Z]:[\\/]/.test(src)) {
return encodeURI(`file:///${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
return encodeURI(`file://${src.startsWith('/') ? '' : '/'}${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
const MediaCard = memo(function MediaCard({
item,
sessionName,
previewPath,
videoPosterPath,
imageIsLong,
hasPreviewUpdate,
selected,
decrypting,
onToggleSelect,
onDelete,
onShowInfo,
onImagePreviewAction,
onUpdateImageQuality,
onOpenVideo,
onImageLoaded
}: {
type MediaCardProps = {
item: MediaStreamItem
itemKey: string
sessionName: string
videoTitle: string
previewPath: string
videoPosterPath: string
imageIsLong: boolean
@@ -180,10 +233,291 @@ const MediaCard = memo(function MediaCard({
onUpdateImageQuality: (item: MediaStreamItem) => void
onOpenVideo: (item: MediaStreamItem) => void
onImageLoaded: (item: MediaStreamItem, width: number, height: number) => void
}) {
}
type MediaCardDynamicState = Pick<
MediaCardProps,
'previewPath' | 'videoPosterPath' | 'imageIsLong' | 'hasPreviewUpdate' | 'selected' | 'decrypting'
>
type MediaCardStore = {
getSnapshot: (itemKey: string) => MediaCardDynamicState
subscribe: (itemKey: string, listener: () => void) => () => void
update: (itemKey: string, patch: Partial<MediaCardDynamicState>) => void
remove: (itemKey: string) => void
pruneTransient: (keepKeys: Set<string>) => void
clear: () => void
}
type MediaCardContainerProps = Omit<MediaCardProps, keyof MediaCardDynamicState> & {
stateStore: MediaCardStore
}
const EMPTY_MEDIA_CARD_STATE: MediaCardDynamicState = {
previewPath: '',
videoPosterPath: '',
imageIsLong: false,
hasPreviewUpdate: false,
selected: false,
decrypting: false
}
function areMediaCardStatesEqual(prev: MediaCardDynamicState, next: MediaCardDynamicState): boolean {
return (
prev.previewPath === next.previewPath &&
prev.videoPosterPath === next.videoPosterPath &&
prev.imageIsLong === next.imageIsLong &&
prev.hasPreviewUpdate === next.hasPreviewUpdate &&
prev.selected === next.selected &&
prev.decrypting === next.decrypting
)
}
function createMediaCardStore(): MediaCardStore {
const snapshots = new Map<string, MediaCardDynamicState>()
const listeners = new Map<string, Set<() => void>>()
const notify = (itemKey: string) => {
const itemListeners = listeners.get(itemKey)
if (!itemListeners || itemListeners.size === 0) return
for (const listener of Array.from(itemListeners)) listener()
}
return {
getSnapshot: (itemKey: string) => snapshots.get(itemKey) || EMPTY_MEDIA_CARD_STATE,
subscribe: (itemKey: string, listener: () => void) => {
const itemListeners = listeners.get(itemKey) || new Set<() => void>()
itemListeners.add(listener)
listeners.set(itemKey, itemListeners)
return () => {
itemListeners.delete(listener)
if (itemListeners.size === 0) listeners.delete(itemKey)
}
},
update: (itemKey: string, patch: Partial<MediaCardDynamicState>) => {
if (!itemKey) return
const previous = snapshots.get(itemKey) || EMPTY_MEDIA_CARD_STATE
const next = { ...previous, ...patch }
if (areMediaCardStatesEqual(previous, next)) return
snapshots.set(itemKey, next)
notify(itemKey)
},
remove: (itemKey: string) => {
if (!snapshots.delete(itemKey)) return
notify(itemKey)
},
pruneTransient: (keepKeys: Set<string>) => {
for (const [itemKey, previous] of snapshots.entries()) {
if (keepKeys.has(itemKey)) continue
const next: MediaCardDynamicState = {
...EMPTY_MEDIA_CARD_STATE,
selected: previous.selected,
decrypting: previous.decrypting
}
if (areMediaCardStatesEqual(previous, next)) continue
if (next.selected || next.decrypting) {
snapshots.set(itemKey, next)
} else {
snapshots.delete(itemKey)
}
notify(itemKey)
}
},
clear: () => {
if (snapshots.size === 0) return
const keys = Array.from(snapshots.keys())
snapshots.clear()
keys.forEach(notify)
}
}
}
function buildMediaItemMeta(item: MediaStreamItem): MediaItemMeta {
const imageMd5 = item.mediaType === 'image' ? normalizeMediaToken(item.imageMd5) : ''
const imageDatName = item.mediaType === 'image' ? getSafeImageDatName(item) : ''
const videoMd5 = item.mediaType === 'video'
? (normalizeMediaToken(item.videoMd5) || extractVideoMd5(item.content))
: ''
const videoTitle = item.mediaType === 'video' ? extractVideoTitle(item.content) : ''
const sessionId = String(item.sessionId || '').trim().toLowerCase()
const createTime = Number(item.createTime || 0) || 0
return {
itemKey: getItemKey(item),
imageMd5,
imageDatName,
imageIdentity: imageMd5 ? `md5:${imageMd5}` : (imageDatName ? `dat:${sessionId}|${createTime}|${imageDatName}` : ''),
videoMd5,
videoTitle,
hasImageLocator: Boolean(imageMd5 || imageDatName)
}
}
function mergeRecordPatch<T extends string | number | boolean>(prev: Record<string, T>, patch: Record<string, T>): Record<string, T> {
let next: Record<string, T> | null = null
for (const key in patch) {
const value = patch[key]
if (prev[key] === value) continue
if (!next) next = { ...prev }
next[key] = value
}
return next || prev
}
function removeRecordKeys<T extends string | number | boolean>(prev: Record<string, T>, keys: Set<string>): Record<string, T> {
let next: Record<string, T> | null = null
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(prev, key)) continue
if (!next) next = { ...prev }
delete next[key]
}
return next || prev
}
function pruneRecordToKeys<T extends string | number | boolean>(prev: Record<string, T>, keepKeys: Set<string>): Record<string, T> {
let next: Record<string, T> | null = null
for (const key in prev) {
if (keepKeys.has(key)) continue
if (!next) next = { ...prev }
delete next[key]
}
return next || prev
}
function removeItemsByKeys(
prev: MediaStreamItem[],
keys: Set<string>,
getMeta: (item: MediaStreamItem) => MediaItemMeta
): MediaStreamItem[] {
if (keys.size === 0 || prev.length === 0) return prev
let removed = false
const next = prev.filter((item) => {
const keep = !keys.has(getMeta(item).itemKey)
if (!keep) removed = true
return keep
})
return removed ? next : prev
}
function clearArray<T>(prev: T[]): T[] {
return prev.length === 0 ? prev : []
}
function clearSet<T>(prev: Set<T>): Set<T> {
return prev.size === 0 ? prev : new Set()
}
function clearRecord<T extends string | number | boolean>(prev: Record<string, T>): Record<string, T> {
return hasRecordEntries(prev) ? {} : prev
}
function addSetValue<T>(prev: Set<T>, value: T): Set<T> {
if (prev.has(value)) return prev
const next = new Set(prev)
next.add(value)
return next
}
function removeSetValue<T>(prev: Set<T>, value: T): Set<T> {
if (!prev.has(value)) return prev
const next = new Set(prev)
next.delete(value)
return next
}
function hasRecordEntries<T>(record: Record<string, T>): boolean {
for (const key in record) {
if (Object.prototype.hasOwnProperty.call(record, key)) return true
}
return false
}
function mergeRange(
current: { start: number; end: number } | null,
next: { start: number; end: number }
): { start: number; end: number } {
if (!current) return next
return {
start: Math.min(current.start, next.start),
end: Math.max(current.end, next.end)
}
}
function mergeNearbyRange(
current: { start: number; end: number } | null,
next: { start: number; end: number },
maxGap: number
): { range: { start: number; end: number }; merged: boolean } {
if (!current) return { range: next, merged: false }
if (next.start > current.end + maxGap || current.start > next.end + maxGap) {
return { range: next, merged: false }
}
return { range: mergeRange(current, next), merged: true }
}
function areMediaItemsEquivalent(prev: MediaStreamItem, next: MediaStreamItem): boolean {
if (prev === next) return true
return (
prev.mediaType === next.mediaType &&
prev.createTime === next.createTime &&
prev.senderUsername === next.senderUsername &&
prev.sessionId === next.sessionId &&
prev.sessionDisplayName === next.sessionDisplayName &&
prev.localId === next.localId &&
prev.serverId === next.serverId &&
prev.localType === next.localType &&
prev.isSend === next.isSend &&
prev.imageMd5 === next.imageMd5 &&
prev.imageDatName === next.imageDatName &&
prev.videoMd5 === next.videoMd5 &&
prev.content === next.content
)
}
function areMediaCardPropsEqual(prev: MediaCardProps, next: MediaCardProps): boolean {
return (
prev.itemKey === next.itemKey &&
areMediaItemsEquivalent(prev.item, next.item) &&
prev.sessionName === next.sessionName &&
prev.videoTitle === next.videoTitle &&
prev.previewPath === next.previewPath &&
prev.videoPosterPath === next.videoPosterPath &&
prev.imageIsLong === next.imageIsLong &&
prev.hasPreviewUpdate === next.hasPreviewUpdate &&
prev.selected === next.selected &&
prev.decrypting === next.decrypting &&
prev.onToggleSelect === next.onToggleSelect &&
prev.onDelete === next.onDelete &&
prev.onShowInfo === next.onShowInfo &&
prev.onImagePreviewAction === next.onImagePreviewAction &&
prev.onUpdateImageQuality === next.onUpdateImageQuality &&
prev.onOpenVideo === next.onOpenVideo &&
prev.onImageLoaded === next.onImageLoaded
)
}
const MediaCard = memo(function MediaCard({
item,
sessionName,
videoTitle,
previewPath,
videoPosterPath,
imageIsLong,
hasPreviewUpdate,
selected,
decrypting,
onToggleSelect,
onDelete,
onShowInfo,
onImagePreviewAction,
onUpdateImageQuality,
onOpenVideo,
onImageLoaded
}: MediaCardProps) {
const isImage = item.mediaType === 'image'
const isDecryptingVisual = decrypting
const showDecryptOverlay = isImage && isDecryptingVisual
const previewSrc = useMemo(() => previewPath ? toRenderableMediaSrc(previewPath) : '', [previewPath])
const videoPosterSrc = useMemo(() => videoPosterPath ? toRenderableMediaSrc(videoPosterPath) : '', [videoPosterPath])
const timeLabel = useMemo(() => formatTimeLabel(item.createTime), [item.createTime])
return (
<article className={`media-card ${selected ? 'selected' : ''} ${isDecryptingVisual ? 'decrypting' : ''}`}>
@@ -223,7 +557,7 @@ const MediaCard = memo(function MediaCard({
{isImage ? (
previewPath
? <img
src={toRenderableMediaSrc(previewPath)}
src={previewSrc}
alt="图片资源"
className={imageIsLong ? 'long-image' : ''}
loading="lazy"
@@ -237,10 +571,10 @@ const MediaCard = memo(function MediaCard({
: <div className="placeholder"><ImageIcon size={30} /></div>
) : (
videoPosterPath
? <img src={toRenderableMediaSrc(videoPosterPath)} alt="视频封面" loading="lazy" decoding="async" />
? <img src={videoPosterSrc} alt="视频封面" loading="lazy" decoding="async" />
: <div className="placeholder">
<PlayCircle size={34} />
<span>{extractVideoTitle(item.content)}</span>
<span>{videoTitle}</span>
</div>
)}
{showDecryptOverlay && (
@@ -253,7 +587,7 @@ const MediaCard = memo(function MediaCard({
<div className="card-meta" onClick={() => onToggleSelect(item)}>
<div className="title-row">
<span className="session" title={sessionName}>{sessionName}</span>
<span className="time">{formatTimeLabel(item.createTime)}</span>
<span className="time">{timeLabel}</span>
</div>
<div className="sub-row">
<span>{item.mediaType === 'image' ? '图片' : '视频'}</span>
@@ -262,6 +596,30 @@ const MediaCard = memo(function MediaCard({
</div>
</article>
)
}, areMediaCardPropsEqual)
const MediaCardContainer = memo(function MediaCardContainer({
stateStore,
itemKey,
...props
}: MediaCardContainerProps) {
const state = useSyncExternalStore(
useCallback((listener) => stateStore.subscribe(itemKey, listener), [itemKey, stateStore]),
useCallback(() => stateStore.getSnapshot(itemKey), [itemKey, stateStore]),
() => EMPTY_MEDIA_CARD_STATE
)
return (
<MediaCard
{...props}
itemKey={itemKey}
previewPath={state.previewPath}
videoPosterPath={state.videoPosterPath}
imageIsLong={state.imageIsLong}
hasPreviewUpdate={state.hasPreviewUpdate}
selected={state.selected}
decrypting={state.decrypting}
/>
)
})
function ResourcesPage() {
@@ -279,26 +637,37 @@ function ResourcesPage() {
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState('')
const [batchBusy, setBatchBusy] = useState(false)
const [decryptingKeys, setDecryptingKeys] = useState<Set<string>>(new Set())
const [actionMessage, setActionMessage] = useState('')
const [sessionNameMap, setSessionNameMap] = useState<Record<string, string>>({})
const [previewPathMap, setPreviewPathMap] = useState<Record<string, string>>({})
const [previewUpdateMap, setPreviewUpdateMap] = useState<Record<string, boolean>>({})
const [videoPosterMap, setVideoPosterMap] = useState<Record<string, string>>({})
const [imageAspectMap, setImageAspectMap] = useState<Record<string, number>>({})
const [dialog, setDialog] = useState<DialogState | null>(null)
const previewPathMapRef = useRef<Record<string, string>>({})
const previewUpdateMapRef = useRef<Record<string, boolean>>({})
const videoPosterMapRef = useRef<Record<string, string>>({})
const imageAspectMapRef = useRef<Record<string, number>>({})
const selectedKeysRef = useRef<Set<string>>(new Set())
const decryptingKeysRef = useRef<Set<string>>(new Set())
const mediaCardStateStoreRef = useRef<MediaCardStore>(createMediaCardStore())
const imageResolveBatchIdRef = useRef(0)
const resolvingImageCacheBatchRef = useRef(false)
const pendingImageResolveRangeRef = useRef<{ start: number; end: number } | null>(null)
const imagePreloadUntilRef = useRef<Record<string, number>>({})
const imagePredecryptUntilRef = useRef<Record<string, number>>({})
const imagePreloadIdentityUntilRef = useRef<Record<string, number>>({})
const imagePredecryptIdentityUntilRef = useRef<Record<string, number>>({})
const imagePreloadSubmittingIdentityRef = useRef<Set<string>>(new Set())
const imagePredecryptSubmittingIdentityRef = useRef<Set<string>>(new Set())
const preloadSubmissionEpochRef = useRef(0)
const imageCacheMissUntilRef = useRef<Record<string, number>>({})
const imageCacheIdentityMissUntilRef = useRef<Record<string, number>>({})
const videoPosterBatchIdRef = useRef(0)
const resolvingVideoPosterBatchRef = useRef(false)
const pendingVideoPosterRangeRef = useRef<{ start: number; end: number } | null>(null)
const resolvingVideoPosterKeysRef = useRef<Set<string>>(new Set())
const attemptedVideoPosterKeysRef = useRef<Set<string>>(new Set())
const resolvedVideoMd5Ref = useRef<Record<string, string>>({})
const resolvingVideoPosterMd5Ref = useRef<Set<string>>(new Set())
const attemptedVideoPosterMd5Ref = useRef<Set<string>>(new Set())
const videoPosterByMd5Ref = useRef<Record<string, string>>({})
const previewPatchRef = useRef<Record<string, string>>({})
const updatePatchRef = useRef<Record<string, boolean>>({})
const previewPatchTimerRef = useRef<number | null>(null)
@@ -307,25 +676,323 @@ function ResourcesPage() {
const aspectPatchRef = useRef<Record<string, number>>({})
const aspectPatchTimerRef = useRef<number | null>(null)
const pendingRangeRef = useRef<{ start: number; end: number } | null>(null)
const lastVisibleRangeRef = useRef<{ start: number; end: number } | null>(null)
const lastRangeActivityAtRef = useRef(0)
const rangeTimerRef = useRef<number | null>(null)
const transientStatePruneTimerRef = useRef<number | null>(null)
const pendingTransientPruneRangeRef = useRef<{ start: number; end: number } | null>(null)
const pendingPredecryptRangeRef = useRef<{ start: number; end: number } | null>(null)
const predecryptTimerRef = useRef<number | null>(null)
const streamRequestIdRef = useRef(0)
const loadingRef = useRef(false)
const loadingMoreRef = useRef(false)
const nextOffsetRef = useRef(0)
const hasMoreRef = useRef(false)
const preloadScopeRef = useRef('resources-initial')
const initialRangeScheduleKeyRef = useRef('')
const itemMetaMapRef = useRef<WeakMap<MediaStreamItem, MediaItemMeta>>(new WeakMap())
const itemByKeyRef = useRef<Record<string, MediaStreamItem>>({})
const itemMetaByKeyRef = useRef<Record<string, MediaItemMeta>>({})
const loadedItemKeysRef = useRef<Set<string>>(new Set())
const validItemKeysRef = useRef<Set<string>>(new Set())
const imageItemKeysRef = useRef<Set<string>>(new Set())
const videoItemKeysRef = useRef<Set<string>>(new Set())
const imageIdentityKeysRef = useRef<Set<string>>(new Set())
const imageIdentityItemKeyMapRef = useRef<Map<string, Set<string>>>(new Map())
const runtimeCountersRef = useRef<ResourceRuntimeCounters>(createResourceRuntimeCounters())
const lastDiagnosticsSampleRef = useRef<ResourceRuntimeCounters>(createResourceRuntimeCounters())
const frameMetricsRef = useRef<ResourceFrameMetrics>(createResourceFrameMetrics())
const lastDiagnosticsFrameMetricsRef = useRef<ResourceFrameMetrics>(createResourceFrameMetrics())
const recentMaxFrameMsRef = useRef(0)
const diagnosticsSamplingRef = useRef(false)
const diagnosticsEpochRef = useRef(0)
const lastPreloadStatsRef = useRef<ResourcePreloadStatsSnapshot | null>(null)
const tabRef = useRef<MediaTab>(tab)
const itemsRef = useRef<MediaStreamItem[]>([])
const itemsLengthRef = useRef(0)
const selectedContactRef = useRef(selectedContact)
const bumpRuntimeCounter = useCallback((key: keyof ResourceRuntimeCounters, amount = 1) => {
runtimeCountersRef.current[key] += amount
}, [])
const resetResourceDiagnosticsWindow = useCallback(() => {
diagnosticsEpochRef.current += 1
lastDiagnosticsSampleRef.current = { ...runtimeCountersRef.current }
lastDiagnosticsFrameMetricsRef.current = { ...frameMetricsRef.current }
recentMaxFrameMsRef.current = 0
void window.electronAPI.diagnostics.clearResourceStats().catch(() => {})
}, [])
useEffect(() => {
previewPathMapRef.current = previewPathMap
}, [previewPathMap])
tabRef.current = tab
}, [tab])
useLayoutEffect(() => {
itemsRef.current = items
itemsLengthRef.current = items.length
}, [items])
useEffect(() => {
previewUpdateMapRef.current = previewUpdateMap
}, [previewUpdateMap])
selectedContactRef.current = selectedContact
}, [selectedContact])
useEffect(() => {
videoPosterMapRef.current = videoPosterMap
}, [videoPosterMap])
loadingRef.current = loading
}, [loading])
useEffect(() => {
imageAspectMapRef.current = imageAspectMap
}, [imageAspectMap])
loadingMoreRef.current = loadingMore
}, [loadingMore])
useEffect(() => () => {
useEffect(() => {
nextOffsetRef.current = nextOffset
}, [nextOffset])
useEffect(() => {
hasMoreRef.current = hasMore
}, [hasMore])
useEffect(() => {
selectedKeysRef.current = selectedKeys
}, [selectedKeys])
useEffect(() => {
let animationFrameId = 0
let lastFrameAt = performance.now()
const scheduleNextFrame = () => {
if (document.visibilityState === 'hidden') return
animationFrameId = window.requestAnimationFrame(tick)
}
const tick = (now: number) => {
const deltaMs = Math.max(0, now - lastFrameAt)
lastFrameAt = now
const roundedDelta = Math.round(deltaMs * 10) / 10
const metrics = frameMetricsRef.current
metrics.samples += 1
metrics.lastFrameMs = roundedDelta
if (roundedDelta > metrics.maxFrameMs) metrics.maxFrameMs = roundedDelta
if (roundedDelta > recentMaxFrameMsRef.current) recentMaxFrameMsRef.current = roundedDelta
if (roundedDelta >= 50) metrics.longFrames += 1
scheduleNextFrame()
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
window.cancelAnimationFrame(animationFrameId)
animationFrameId = 0
return
}
lastFrameAt = performance.now()
if (!animationFrameId) scheduleNextFrame()
}
document.addEventListener('visibilitychange', handleVisibilityChange)
scheduleNextFrame()
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.cancelAnimationFrame(animationFrameId)
}
}, [])
useEffect(() => {
const sample = async () => {
if (document.visibilityState === 'hidden') return
if (diagnosticsSamplingRef.current) return
diagnosticsSamplingRef.current = true
const sampleEpoch = diagnosticsEpochRef.current
try {
const counters = { ...runtimeCountersRef.current }
const previous = lastDiagnosticsSampleRef.current
const delta = Object.fromEntries(
(Object.keys(counters) as Array<keyof ResourceRuntimeCounters>).map((key) => [
key,
counters[key] - previous[key]
])
)
const frameMetrics = { ...frameMetricsRef.current }
const previousFrameMetrics = lastDiagnosticsFrameMetricsRef.current
const frameDelta = {
samples: frameMetrics.samples - previousFrameMetrics.samples,
longFrames: frameMetrics.longFrames - previousFrameMetrics.longFrames,
recentMaxFrameMs: recentMaxFrameMsRef.current,
lastFrameMs: frameMetrics.lastFrameMs
}
const preloadStats = await window.electronAPI.image.getPreloadStats()
if (sampleEpoch !== diagnosticsEpochRef.current) return
lastDiagnosticsSampleRef.current = counters
lastDiagnosticsFrameMetricsRef.current = frameMetrics
recentMaxFrameMsRef.current = 0
lastPreloadStatsRef.current = preloadStats
await window.electronAPI.diagnostics.recordResourceStats({
page: 'resources',
tab: tabRef.current,
selectedContact: selectedContactRef.current,
scope: preloadScopeRef.current,
items: itemsLengthRef.current,
nextOffset: nextOffsetRef.current,
hasMore: hasMoreRef.current,
loading: loadingRef.current,
loadingMore: loadingMoreRef.current,
counters,
delta,
frameMetrics,
frameDelta,
preloadStats
})
} catch {
// diagnostics must never affect browsing
} finally {
diagnosticsSamplingRef.current = false
}
}
const timer = window.setInterval(() => {
void sample()
}, 3000)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void sample()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
void sample()
return () => {
window.clearInterval(timer)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
const getMeta = useCallback((item: MediaStreamItem): MediaItemMeta => {
const cached = itemMetaMapRef.current.get(item)
if (cached) return cached
const meta = buildMediaItemMeta(item)
itemMetaMapRef.current.set(item, meta)
return meta
}, [])
const getScopedDatIdentity = useCallback((sessionId: unknown, createTime: unknown, imageDatName: unknown): string => {
const datName = normalizeMediaToken(String(imageDatName || ''))
if (!datName) return ''
const safeSessionId = String(sessionId || '').trim().toLowerCase()
const safeCreateTime = Number(createTime || 0) || 0
return `dat:${safeSessionId}|${safeCreateTime}|${datName}`
}, [])
const removeImageIdentityMapping = useCallback((identity: string, itemKey: string) => {
if (!identity || !itemKey) return
const current = imageIdentityItemKeyMapRef.current.get(identity)
if (!current) return
current.delete(itemKey)
if (current.size === 0) {
imageIdentityItemKeyMapRef.current.delete(identity)
imageIdentityKeysRef.current.delete(identity)
}
}, [])
const addResourceIndexItem = useCallback((item: MediaStreamItem, meta: MediaItemMeta = getMeta(item)) => {
const { itemKey, imageIdentity } = meta
if (!itemKey) return
itemByKeyRef.current[itemKey] = item
itemMetaByKeyRef.current[itemKey] = meta
validItemKeysRef.current.add(itemKey)
if (item.mediaType === 'video') {
videoItemKeysRef.current.add(itemKey)
return
}
imageItemKeysRef.current.add(itemKey)
if (imageIdentity) {
imageIdentityKeysRef.current.add(imageIdentity)
const identitySet = imageIdentityItemKeyMapRef.current.get(imageIdentity) || new Set<string>()
identitySet.add(itemKey)
imageIdentityItemKeyMapRef.current.set(imageIdentity, identitySet)
}
}, [getMeta])
const pruneImageIdentityCooldowns = useCallback(() => {
const validIdentities = imageIdentityKeysRef.current
const nextImagePreloadIdentityUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imagePreloadIdentityUntilRef.current)) {
if (!validIdentities.has(key)) continue
nextImagePreloadIdentityUntil[key] = value
}
imagePreloadIdentityUntilRef.current = nextImagePreloadIdentityUntil
const nextImagePredecryptIdentityUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imagePredecryptIdentityUntilRef.current)) {
if (!validIdentities.has(key)) continue
nextImagePredecryptIdentityUntil[key] = value
}
imagePredecryptIdentityUntilRef.current = nextImagePredecryptIdentityUntil
const nextImageCacheIdentityMissUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imageCacheIdentityMissUntilRef.current)) {
if (!validIdentities.has(key)) continue
nextImageCacheIdentityMissUntil[key] = value
}
imageCacheIdentityMissUntilRef.current = nextImageCacheIdentityMissUntil
}, [])
const removeResourceIndexItem = useCallback((itemKey: string, meta?: MediaItemMeta) => {
if (!itemKey) return
loadedItemKeysRef.current.delete(itemKey)
validItemKeysRef.current.delete(itemKey)
videoItemKeysRef.current.delete(itemKey)
imageItemKeysRef.current.delete(itemKey)
delete itemByKeyRef.current[itemKey]
delete itemMetaByKeyRef.current[itemKey]
if (meta) {
removeImageIdentityMapping(meta.imageIdentity, itemKey)
} else {
for (const identity of Array.from(imageIdentityItemKeyMapRef.current.keys())) {
removeImageIdentityMapping(identity, itemKey)
}
}
delete imageCacheMissUntilRef.current[itemKey]
if (meta?.imageIdentity && !imageIdentityKeysRef.current.has(meta.imageIdentity)) {
delete imageCacheIdentityMissUntilRef.current[meta.imageIdentity]
}
delete imagePreloadUntilRef.current[itemKey]
delete imagePredecryptUntilRef.current[itemKey]
attemptedVideoPosterKeysRef.current.delete(itemKey)
resolvingVideoPosterKeysRef.current.delete(itemKey)
pruneImageIdentityCooldowns()
}, [pruneImageIdentityCooldowns, removeImageIdentityMapping])
const resetResourceIndexes = useCallback((nextItems: MediaStreamItem[]) => {
imageIdentityItemKeyMapRef.current = new Map()
itemByKeyRef.current = {}
itemMetaByKeyRef.current = {}
loadedItemKeysRef.current = new Set()
validItemKeysRef.current = new Set()
imageItemKeysRef.current = new Set()
videoItemKeysRef.current = new Set()
imageIdentityKeysRef.current = new Set()
for (const item of nextItems) {
const meta = getMeta(item)
loadedItemKeysRef.current.add(meta.itemKey)
addResourceIndexItem(item, meta)
}
}, [addResourceIndexItem, getMeta])
const updateMediaCardState = useCallback((itemKey: string, patch: Partial<MediaCardDynamicState>) => {
mediaCardStateStoreRef.current.update(itemKey, patch)
}, [])
const removeMediaCardState = useCallback((itemKey: string) => {
mediaCardStateStoreRef.current.remove(itemKey)
}, [])
const clearMediaCardStates = useCallback(() => {
mediaCardStateStoreRef.current.clear()
}, [])
const cancelPendingResourceTimers = useCallback(() => {
previewPatchRef.current = {}
updatePatchRef.current = {}
posterPatchRef.current = {}
aspectPatchRef.current = {}
previewPathMapRef.current = {}
previewUpdateMapRef.current = {}
videoPosterMapRef.current = {}
imageAspectMapRef.current = {}
if (previewPatchTimerRef.current !== null) {
window.clearTimeout(previewPatchTimerRef.current)
previewPatchTimerRef.current = null
@@ -342,8 +1009,59 @@ function ResourcesPage() {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
if (transientStatePruneTimerRef.current !== null) {
window.clearTimeout(transientStatePruneTimerRef.current)
transientStatePruneTimerRef.current = null
}
if (predecryptTimerRef.current !== null) {
window.clearTimeout(predecryptTimerRef.current)
predecryptTimerRef.current = null
}
pendingRangeRef.current = null
pendingTransientPruneRangeRef.current = null
pendingPredecryptRangeRef.current = null
}, [])
const resetAsyncResourceWork = useCallback((options?: { clearIndexes?: boolean; cancelScope?: boolean }) => {
cancelPendingResourceTimers()
initialRangeScheduleKeyRef.current = ''
imageResolveBatchIdRef.current += 1
resolvingImageCacheBatchRef.current = false
pendingImageResolveRangeRef.current = null
preloadSubmissionEpochRef.current += 1
imagePreloadUntilRef.current = {}
imagePredecryptUntilRef.current = {}
imagePreloadIdentityUntilRef.current = {}
imagePredecryptIdentityUntilRef.current = {}
imagePreloadSubmittingIdentityRef.current.clear()
imagePredecryptSubmittingIdentityRef.current.clear()
selectedKeysRef.current = new Set()
decryptingKeysRef.current = new Set()
lastPreloadStatsRef.current = null
imageCacheMissUntilRef.current = {}
imageCacheIdentityMissUntilRef.current = {}
videoPosterBatchIdRef.current += 1
resolvingVideoPosterBatchRef.current = false
pendingVideoPosterRangeRef.current = null
resolvingVideoPosterKeysRef.current.clear()
attemptedVideoPosterKeysRef.current.clear()
resolvingVideoPosterMd5Ref.current.clear()
attemptedVideoPosterMd5Ref.current.clear()
videoPosterByMd5Ref.current = {}
if (options?.clearIndexes) {
itemsRef.current = []
itemsLengthRef.current = 0
lastVisibleRangeRef.current = null
resetResourceIndexes([])
clearMediaCardStates()
}
if (options?.cancelScope) void window.electronAPI.image.cancelPreloadScope(preloadScopeRef.current)
}, [cancelPendingResourceTimers, clearMediaCardStates, resetResourceIndexes])
useEffect(() => () => {
resetAsyncResourceWork({ cancelScope: true })
}, [resetAsyncResourceWork])
const showAlert = useCallback((message: string, title: string = '提示') => {
setDialog({
mode: 'alert',
@@ -375,66 +1093,253 @@ function ResourcesPage() {
return lower.includes('_thumb') || lower.includes('_t.') || lower.includes('.t.')
}, [])
const isCurrentItemKey = useCallback((itemKey: string): boolean => {
return Boolean(itemKey && validItemKeysRef.current.has(itemKey))
}, [])
const flushPreviewPatch = useCallback(() => {
const pathPatch = previewPatchRef.current
const updatePatch = updatePatchRef.current
previewPatchRef.current = {}
updatePatchRef.current = {}
const pathPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
let drained = 0
let hasRemaining = false
let hasPathPatch = false
let hasUpdatePatch = false
for (const key in previewPatchRef.current) {
if (drained >= MAX_MEDIA_PATCHES_PER_FLUSH) {
hasRemaining = true
break
}
const value = previewPatchRef.current[key]
const hasUpdate = updatePatchRef.current[key]
delete previewPatchRef.current[key]
delete updatePatchRef.current[key]
drained += 1
if (!isCurrentItemKey(key)) continue
pathPatch[key] = value
updatePatch[key] = hasUpdate
updateMediaCardState(key, {
previewPath: value,
hasPreviewUpdate: Boolean(hasUpdate)
})
hasPathPatch = true
hasUpdatePatch = true
}
previewPatchTimerRef.current = null
const hasPathPatch = Object.keys(pathPatch).length > 0
const hasUpdatePatch = Object.keys(updatePatch).length > 0
if (hasPathPatch) {
setPreviewPathMap((prev) => ({ ...prev, ...pathPatch }))
previewPathMapRef.current = mergeRecordPatch(previewPathMapRef.current, pathPatch)
}
if (hasUpdatePatch) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
previewUpdateMapRef.current = mergeRecordPatch(previewUpdateMapRef.current, updatePatch)
}
}, [])
if (hasRemaining) {
previewPatchTimerRef.current = window.setTimeout(flushPreviewPatch, 16)
}
}, [isCurrentItemKey, updateMediaCardState])
const queuePreviewPatch = useCallback((itemKey: string, localPath: string, hasUpdate: boolean) => {
if (!itemKey || !localPath) return
if (!isCurrentItemKey(itemKey)) return
if (previewPatchRef.current[itemKey] === localPath && updatePatchRef.current[itemKey] === hasUpdate) return
if (previewPathMapRef.current[itemKey] === localPath && previewUpdateMapRef.current[itemKey] === hasUpdate) return
const meta = itemMetaByKeyRef.current[itemKey]
delete imageCacheMissUntilRef.current[itemKey]
if (meta?.imageIdentity) delete imageCacheIdentityMissUntilRef.current[meta.imageIdentity]
bumpRuntimeCounter('previewPatchesQueued')
previewPatchRef.current[itemKey] = localPath
updatePatchRef.current[itemKey] = hasUpdate
if (previewPatchTimerRef.current !== null) return
previewPatchTimerRef.current = window.setTimeout(flushPreviewPatch, 16)
}, [flushPreviewPatch])
}, [bumpRuntimeCounter, flushPreviewPatch, isCurrentItemKey])
const flushPosterPatch = useCallback(() => {
const patch = posterPatchRef.current
posterPatchRef.current = {}
const patch: Record<string, string> = {}
let drained = 0
let hasRemaining = false
let hasPatch = false
for (const key in posterPatchRef.current) {
if (drained >= MAX_MEDIA_PATCHES_PER_FLUSH) {
hasRemaining = true
break
}
const value = posterPatchRef.current[key]
delete posterPatchRef.current[key]
drained += 1
if (!isCurrentItemKey(key)) continue
patch[key] = value
updateMediaCardState(key, { videoPosterPath: value })
hasPatch = true
}
posterPatchTimerRef.current = null
if (Object.keys(patch).length === 0) return
setVideoPosterMap((prev) => ({ ...prev, ...patch }))
}, [])
if (hasPatch) {
videoPosterMapRef.current = mergeRecordPatch(videoPosterMapRef.current, patch)
}
if (hasRemaining) {
posterPatchTimerRef.current = window.setTimeout(flushPosterPatch, 16)
}
}, [isCurrentItemKey, updateMediaCardState])
const queuePosterPatch = useCallback((itemKey: string, posterPath: string) => {
if (!itemKey || !posterPath) return
if (!isCurrentItemKey(itemKey)) return
if (posterPatchRef.current[itemKey] === posterPath) return
if (videoPosterMapRef.current[itemKey] === posterPath) return
posterPatchRef.current[itemKey] = posterPath
if (posterPatchTimerRef.current !== null) return
posterPatchTimerRef.current = window.setTimeout(flushPosterPatch, 16)
}, [flushPosterPatch])
}, [flushPosterPatch, isCurrentItemKey])
const flushAspectPatch = useCallback(() => {
const patch = aspectPatchRef.current
aspectPatchRef.current = {}
const patch: Record<string, number> = {}
let drained = 0
let hasRemaining = false
let hasPatch = false
for (const key in aspectPatchRef.current) {
if (drained >= MAX_MEDIA_PATCHES_PER_FLUSH) {
hasRemaining = true
break
}
const value = aspectPatchRef.current[key]
delete aspectPatchRef.current[key]
drained += 1
if (!isCurrentItemKey(key)) continue
patch[key] = value
updateMediaCardState(key, { imageIsLong: value >= 2.8 })
hasPatch = true
}
aspectPatchTimerRef.current = null
if (Object.keys(patch).length === 0) return
setImageAspectMap((prev) => ({ ...prev, ...patch }))
}, [])
if (hasPatch) {
imageAspectMapRef.current = mergeRecordPatch(imageAspectMapRef.current, patch)
}
if (hasRemaining) {
aspectPatchTimerRef.current = window.setTimeout(flushAspectPatch, 24)
}
}, [isCurrentItemKey, updateMediaCardState])
const queueAspectPatch = useCallback((itemKey: string, ratio: number) => {
if (!isCurrentItemKey(itemKey)) return
const old = imageAspectMapRef.current[itemKey]
if (typeof old === 'number' && Math.abs(old - ratio) < 0.01) return
const pending = aspectPatchRef.current[itemKey]
if (typeof pending === 'number' && Math.abs(pending - ratio) < 0.01) return
aspectPatchRef.current[itemKey] = ratio
if (aspectPatchTimerRef.current !== null) return
aspectPatchTimerRef.current = window.setTimeout(flushAspectPatch, 24)
}, [flushAspectPatch])
}, [flushAspectPatch, isCurrentItemKey])
const pruneTransientMediaState = useCallback((range: { start: number; end: number }) => {
const currentItems = itemsRef.current
if (currentItems.length === 0) return
const retainStart = Math.max(0, range.start - 160)
const retainEnd = Math.min(currentItems.length - 1, range.end + 320)
if (retainEnd < retainStart) return
const keepKeys = new Set<string>()
for (let i = retainStart; i <= retainEnd; i += 1) {
const item = currentItems[i]
if (!item) continue
keepKeys.add(getMeta(item).itemKey)
}
bumpRuntimeCounter('transientStatePruneRuns')
previewPathMapRef.current = pruneRecordToKeys(previewPathMapRef.current, keepKeys)
previewUpdateMapRef.current = pruneRecordToKeys(previewUpdateMapRef.current, keepKeys)
videoPosterMapRef.current = pruneRecordToKeys(videoPosterMapRef.current, keepKeys)
imageAspectMapRef.current = pruneRecordToKeys(imageAspectMapRef.current, keepKeys)
previewPatchRef.current = pruneRecordToKeys(previewPatchRef.current, keepKeys)
updatePatchRef.current = pruneRecordToKeys(updatePatchRef.current, keepKeys)
posterPatchRef.current = pruneRecordToKeys(posterPatchRef.current, keepKeys)
aspectPatchRef.current = pruneRecordToKeys(aspectPatchRef.current, keepKeys)
mediaCardStateStoreRef.current.pruneTransient(keepKeys)
}, [bumpRuntimeCounter, getMeta])
const flushTransientStatePrune = useCallback(() => {
transientStatePruneTimerRef.current = null
const pending = pendingTransientPruneRangeRef.current
pendingTransientPruneRangeRef.current = null
if (!pending) return
pruneTransientMediaState(pending)
}, [pruneTransientMediaState])
const scheduleTransientStatePrune = useCallback((range: { start: number; end: number }) => {
pendingTransientPruneRangeRef.current = range
if (transientStatePruneTimerRef.current !== null) return
transientStatePruneTimerRef.current = window.setTimeout(flushTransientStatePrune, 650)
}, [flushTransientStatePrune])
useEffect(() => {
const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => {
bumpRuntimeCounter('cacheResolvedEvents')
const localPath = String(payload.localPath || '').trim()
if (!localPath) return
const cacheKey = normalizeMediaToken(payload.cacheKey)
let patchedCount = 0
const md5Candidates = [
normalizeMediaToken(payload.imageMd5),
!payload.imageDatName && /^[a-f0-9]{32}$/i.test(cacheKey) ? cacheKey : ''
].filter(Boolean)
for (const token of md5Candidates) {
const itemKeys = imageIdentityItemKeyMapRef.current.get(`md5:${token}`)
if (!itemKeys || itemKeys.size === 0) continue
for (const itemKey of itemKeys) {
queuePreviewPatch(itemKey, localPath, isLikelyThumbnailPreview(localPath))
patchedCount += 1
}
bumpRuntimeCounter('cacheResolvedPatchedItems', patchedCount)
return
}
const datCandidates = [
normalizeMediaToken(payload.imageDatName),
cacheKey && !/^[a-f0-9]{32}$/i.test(cacheKey) ? cacheKey : ''
].filter(Boolean)
for (const token of datCandidates) {
const eventIdentity = getScopedDatIdentity(payload.sessionId, payload.createTime, token)
if (!eventIdentity) continue
const itemKeys = imageIdentityItemKeyMapRef.current.get(eventIdentity)
if (!itemKeys || itemKeys.size === 0) continue
for (const itemKey of itemKeys) {
queuePreviewPatch(itemKey, localPath, isLikelyThumbnailPreview(localPath))
patchedCount += 1
}
bumpRuntimeCounter('cacheResolvedPatchedItems', patchedCount)
return
}
bumpRuntimeCounter('cacheResolvedUnmatchedEvents')
})
return unsubscribe
}, [bumpRuntimeCounter, getScopedDatIdentity, isLikelyThumbnailPreview, queuePreviewPatch])
const loadStream = useCallback(async (reset: boolean) => {
if (reset) setLoading(true)
else setLoadingMore(true)
if (!reset) {
if (loadingRef.current || loadingMoreRef.current || !hasMoreRef.current) {
bumpRuntimeCounter('loadMoreSkipped')
return
}
bumpRuntimeCounter('loadMoreRequests')
loadingMoreRef.current = true
} else {
bumpRuntimeCounter('loadResets')
loadingRef.current = true
loadingMoreRef.current = false
}
const requestId = streamRequestIdRef.current + 1
streamRequestIdRef.current = requestId
if (reset) {
const previousScope = preloadScopeRef.current
preloadScopeRef.current = `resources-${requestId}-${Date.now()}`
resetAsyncResourceWork({ clearIndexes: true })
resetResourceDiagnosticsWindow()
void window.electronAPI.image.cancelPreloadScope(previousScope)
}
if (reset) {
setLoading(true)
setLoadingMore(false)
setItems(clearArray)
setSelectedKeys(clearSet)
nextOffsetRef.current = 0
hasMoreRef.current = false
setNextOffset(0)
setHasMore(false)
} else {
setLoadingMore(true)
}
if (reset) {
setError('')
setActionMessage('')
@@ -443,12 +1348,14 @@ function ResourcesPage() {
try {
if (reset) {
const connectResult = await window.electronAPI.chat.connect()
if (requestId !== streamRequestIdRef.current) return
if (!connectResult.success) {
setError(connectResult.error || '连接数据库失败')
return
}
}
const requestOffset = reset ? 0 : nextOffset
const requestOffset = reset ? 0 : nextOffsetRef.current
const streamLoadStartedAt = performance.now()
const streamResult = await window.electronAPI.chat.getMediaStream({
sessionId: selectedContact === 'all' ? undefined : selectedContact,
mediaType: tab,
@@ -457,106 +1364,85 @@ function ResourcesPage() {
offset: requestOffset,
limit: PAGE_SIZE
})
if (requestId !== streamRequestIdRef.current) return
const streamLoadMs = Math.max(0, Math.round((performance.now() - streamLoadStartedAt) * 10) / 10)
bumpRuntimeCounter('mediaStreamLoadSamples')
bumpRuntimeCounter('mediaStreamLoadMsTotal', streamLoadMs)
if (streamLoadMs > runtimeCountersRef.current.mediaStreamMaxLoadMs) {
runtimeCountersRef.current.mediaStreamMaxLoadMs = streamLoadMs
}
if (streamResult.pageCacheHit || streamResult.streamSource === 'pageCache') {
bumpRuntimeCounter('mediaStreamPageCacheHits')
} else if (streamResult.inflightMerged || streamResult.streamSource === 'inflight') {
bumpRuntimeCounter('mediaStreamInflightMerges')
} else if (streamResult.success) {
bumpRuntimeCounter('mediaStreamNativeLoads')
}
if (!streamResult.success) {
setError(streamResult.error || '加载失败')
if (reset) {
previewPatchRef.current = {}
updatePatchRef.current = {}
posterPatchRef.current = {}
aspectPatchRef.current = {}
if (previewPatchTimerRef.current !== null) {
window.clearTimeout(previewPatchTimerRef.current)
previewPatchTimerRef.current = null
}
if (posterPatchTimerRef.current !== null) {
window.clearTimeout(posterPatchTimerRef.current)
posterPatchTimerRef.current = null
}
if (aspectPatchTimerRef.current !== null) {
window.clearTimeout(aspectPatchTimerRef.current)
aspectPatchTimerRef.current = null
}
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
pendingRangeRef.current = null
resolvingImageCacheBatchRef.current = false
pendingImageResolveRangeRef.current = null
imagePreloadUntilRef.current = {}
imageCacheMissUntilRef.current = {}
resolvingVideoPosterKeysRef.current.clear()
attemptedVideoPosterKeysRef.current.clear()
resolvedVideoMd5Ref.current = {}
setItems([])
setNextOffset(0)
setHasMore(false)
setSelectedKeys(new Set())
setPreviewPathMap({})
setPreviewUpdateMap({})
setVideoPosterMap({})
setImageAspectMap({})
}
return
}
const incoming = (streamResult.items || []) as MediaStreamItem[]
bumpRuntimeCounter('mediaStreamRowsLoaded', incoming.length)
if (reset) {
previewPatchRef.current = {}
updatePatchRef.current = {}
posterPatchRef.current = {}
aspectPatchRef.current = {}
if (previewPatchTimerRef.current !== null) {
window.clearTimeout(previewPatchTimerRef.current)
previewPatchTimerRef.current = null
const nextLoadedKeys = new Set<string>()
const uniqueIncoming: MediaStreamItem[] = []
for (const item of incoming) {
const meta = getMeta(item)
if (nextLoadedKeys.has(meta.itemKey)) continue
nextLoadedKeys.add(meta.itemKey)
uniqueIncoming.push(item)
}
if (posterPatchTimerRef.current !== null) {
window.clearTimeout(posterPatchTimerRef.current)
posterPatchTimerRef.current = null
}
if (aspectPatchTimerRef.current !== null) {
window.clearTimeout(aspectPatchTimerRef.current)
aspectPatchTimerRef.current = null
}
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
pendingRangeRef.current = null
resolvingImageCacheBatchRef.current = false
pendingImageResolveRangeRef.current = null
imagePreloadUntilRef.current = {}
imageCacheMissUntilRef.current = {}
resolvingVideoPosterKeysRef.current.clear()
attemptedVideoPosterKeysRef.current.clear()
resolvedVideoMd5Ref.current = {}
setItems(incoming)
setSelectedKeys(new Set())
setPreviewPathMap({})
setPreviewUpdateMap({})
setVideoPosterMap({})
setImageAspectMap({})
bumpRuntimeCounter('mediaStreamDuplicateRows', incoming.length - uniqueIncoming.length)
resetResourceIndexes(uniqueIncoming)
setItems(uniqueIncoming)
setSelectedKeys(clearSet)
} else {
setItems((prev) => {
const map = new Map(prev.map((row) => [getItemKey(row), row]))
incoming.forEach((row) => map.set(getItemKey(row), row))
return Array.from(map.values())
})
const uniqueIncoming: MediaStreamItem[] = []
const loadedKeys = loadedItemKeysRef.current
for (const item of incoming) {
const meta = getMeta(item)
if (loadedKeys.has(meta.itemKey)) continue
loadedKeys.add(meta.itemKey)
addResourceIndexItem(item, meta)
uniqueIncoming.push(item)
}
bumpRuntimeCounter('mediaStreamDuplicateRows', incoming.length - uniqueIncoming.length)
if (uniqueIncoming.length > 0) {
setItems((prev) => prev.concat(uniqueIncoming))
}
}
setNextOffset(Number(streamResult.nextOffset || requestOffset + incoming.length))
setHasMore(Boolean(streamResult.hasMore))
const reportedNextOffset = Number(streamResult.nextOffset)
const nextOffsetValue = Number.isFinite(reportedNextOffset)
? Math.max(requestOffset, reportedNextOffset)
: requestOffset + incoming.length
let hasMoreValue = Boolean(streamResult.hasMore)
if (hasMoreValue && nextOffsetValue <= requestOffset) {
bumpRuntimeCounter('mediaStreamNoProgressStops')
hasMoreValue = false
}
nextOffsetRef.current = nextOffsetValue
hasMoreRef.current = hasMoreValue
setNextOffset(nextOffsetValue)
setHasMore(hasMoreValue)
} catch (e) {
if (requestId !== streamRequestIdRef.current) return
setError(String(e))
} finally {
setLoading(false)
setLoadingMore(false)
if (requestId === streamRequestIdRef.current) {
if (reset) loadingRef.current = false
else loadingMoreRef.current = false
setLoading(false)
setLoadingMore(false)
}
}
}, [dateEnd, dateStart, nextOffset, selectedContact, tab])
}, [addResourceIndexItem, bumpRuntimeCounter, dateEnd, dateStart, getMeta, resetAsyncResourceWork, resetResourceDiagnosticsWindow, resetResourceIndexes, selectedContact, tab])
useEffect(() => {
void loadStream(true)
}, [tab, selectedContact, dateStart, dateEnd])
}, [loadStream])
useEffect(() => {
let cancelled = false
@@ -587,167 +1473,133 @@ function ResourcesPage() {
}
}, [])
const displayItems = useMemo(() => (
items.map((item) => ({
...item,
sessionDisplayName: item.sessionDisplayName || sessionNameMap[item.sessionId] || item.sessionId
}))
), [items, sessionNameMap])
useEffect(() => {
const imageKeySet = new Set(
displayItems
.filter((item) => item.mediaType === 'image')
.map((item) => getItemKey(item))
)
const videoKeySet = new Set(
displayItems
.filter((item) => item.mediaType === 'video')
.map((item) => getItemKey(item))
)
setPreviewPathMap((prev) => {
let changed = false
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(prev)) {
if (!imageKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
setPreviewUpdateMap((prev) => {
let changed = false
const next: Record<string, boolean> = {}
for (const [key, value] of Object.entries(prev)) {
if (!imageKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
setImageAspectMap((prev) => {
let changed = false
const next: Record<string, number> = {}
for (const [key, value] of Object.entries(prev)) {
if (!imageKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
setVideoPosterMap((prev) => {
let changed = false
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(prev)) {
if (!videoKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
const validKeys = new Set<string>(displayItems.map((item) => getItemKey(item)))
const nextResolvedVideoMd5: Record<string, string> = {}
for (const [key, value] of Object.entries(resolvedVideoMd5Ref.current)) {
if (!validKeys.has(key)) continue
nextResolvedVideoMd5[key] = value
}
resolvedVideoMd5Ref.current = nextResolvedVideoMd5
const nextAttempted = new Set<string>()
attemptedVideoPosterKeysRef.current.forEach((key) => {
if (validKeys.has(key)) nextAttempted.add(key)
})
attemptedVideoPosterKeysRef.current = nextAttempted
const nextImageMissUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imageCacheMissUntilRef.current)) {
if (!validKeys.has(key)) continue
nextImageMissUntil[key] = value
}
imageCacheMissUntilRef.current = nextImageMissUntil
const nextImagePreloadUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imagePreloadUntilRef.current)) {
if (!validKeys.has(key)) continue
nextImagePreloadUntil[key] = value
}
imagePreloadUntilRef.current = nextImagePreloadUntil
}, [displayItems])
const resolveImageCacheRange = useCallback((start: number, end: number) => {
const currentItems = itemsRef.current
const from = Math.max(0, start)
const to = Math.min(displayItems.length - 1, end)
const to = Math.min(currentItems.length - 1, end)
if (to < from) return
if (resolvingImageCacheBatchRef.current) {
const previous = pendingImageResolveRangeRef.current
if (previous && from >= previous.start && to <= previous.end) {
bumpRuntimeCounter('imageResolveContainedSkips')
return
}
const merged = mergeNearbyRange(pendingImageResolveRangeRef.current, { start: from, end: to }, 24)
pendingImageResolveRangeRef.current = merged.range
bumpRuntimeCounter(merged.merged ? 'imageResolvePendingMerges' : 'imageResolvePendingReplacements')
return
}
const now = Date.now()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
const itemKeys: string[] = []
const payloadItemKeys: string[][] = []
const payloadIdentities: string[] = []
const dedup = new Map<string, number>()
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
const item = currentItems[i]
if (!item || item.mediaType !== 'image') continue
const itemKey = getItemKey(item)
const meta = getMeta(item)
const { itemKey, imageMd5, imageDatName, imageIdentity } = meta
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
if (!hasImageLocator(item)) continue
if (!meta.hasImageLocator) continue
if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue
if ((imageCacheIdentityMissUntilRef.current[imageIdentity] || 0) > now) {
imageCacheMissUntilRef.current[itemKey] = imageCacheIdentityMissUntilRef.current[imageIdentity]
bumpRuntimeCounter('imageResolveIdentityMissSkipped')
continue
}
const dedupKey = imageIdentity
const existingIndex = dedup.get(dedupKey)
if (existingIndex !== undefined) {
payloadItemKeys[existingIndex].push(itemKey)
continue
}
payloads.push({
sessionId: item.sessionId,
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
imageMd5: imageMd5 || undefined,
imageDatName: imageDatName || undefined,
createTime: Number(item.createTime || 0) || undefined
})
itemKeys.push(itemKey)
dedup.set(dedupKey, payloads.length - 1)
payloadItemKeys.push([itemKey])
payloadIdentities.push(imageIdentity)
if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break
}
if (payloads.length === 0) return
if (resolvingImageCacheBatchRef.current) {
pendingImageResolveRangeRef.current = { start: from, end: to }
return
}
resolvingImageCacheBatchRef.current = true
bumpRuntimeCounter('imageResolveStarted')
const batchId = imageResolveBatchIdRef.current + 1
imageResolveBatchIdRef.current = batchId
void (async () => {
try {
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
disableUpdateCheck: true,
allowCacheIndex: true,
allowCachePromotion: false,
allowFilesystemScan: false,
preferFilePath: true,
hardlinkOnly: true,
suppressEvents: true
})
if (batchId !== imageResolveBatchIdRef.current) {
bumpRuntimeCounter('imageResolveSuperseded')
return
}
const rows = Array.isArray(result?.rows) ? result.rows : []
const pathPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
const missUntil = Date.now() + 4500
let hasPathPatch = false
let hasUpdatePatch = false
for (let i = 0; i < itemKeys.length; i += 1) {
const itemKey = itemKeys[i]
for (let i = 0; i < payloadItemKeys.length; i += 1) {
const row = rows[i]
if (row?.success && row.localPath) {
delete imageCacheMissUntilRef.current[itemKey]
pathPatch[itemKey] = row.localPath
updatePatch[itemKey] = Boolean(row.hasUpdate)
} else {
imageCacheMissUntilRef.current[itemKey] = missUntil
const imageIdentity = payloadIdentities[i] || ''
for (const itemKey of payloadItemKeys[i] || []) {
if (!isCurrentItemKey(itemKey)) continue
if (row?.success && row.localPath) {
delete imageCacheMissUntilRef.current[itemKey]
if (imageIdentity) delete imageCacheIdentityMissUntilRef.current[imageIdentity]
pathPatch[itemKey] = row.localPath
updatePatch[itemKey] = Boolean(row.hasUpdate)
updateMediaCardState(itemKey, {
previewPath: row.localPath,
hasPreviewUpdate: Boolean(row.hasUpdate)
})
hasPathPatch = true
hasUpdatePatch = true
} else {
imageCacheMissUntilRef.current[itemKey] = missUntil
if (imageIdentity) imageCacheIdentityMissUntilRef.current[imageIdentity] = missUntil
bumpRuntimeCounter('imageResolveMisses')
}
}
}
if (Object.keys(pathPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...pathPatch }))
if (hasPathPatch) {
previewPathMapRef.current = mergeRecordPatch(previewPathMapRef.current, pathPatch)
}
if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
if (hasUpdatePatch) {
previewUpdateMapRef.current = mergeRecordPatch(previewUpdateMapRef.current, updatePatch)
}
} catch {
if (batchId !== imageResolveBatchIdRef.current) {
bumpRuntimeCounter('imageResolveSuperseded')
return
}
const missUntil = Date.now() + 4500
itemKeys.forEach((itemKey) => {
imageCacheMissUntilRef.current[itemKey] = missUntil
payloadItemKeys.forEach((itemKeys, index) => {
const imageIdentity = payloadIdentities[index] || ''
if (imageIdentity) imageCacheIdentityMissUntilRef.current[imageIdentity] = missUntil
itemKeys.forEach((itemKey) => {
if (!isCurrentItemKey(itemKey)) return
imageCacheMissUntilRef.current[itemKey] = missUntil
bumpRuntimeCounter('imageResolveMisses')
})
})
} finally {
if (batchId !== imageResolveBatchIdRef.current) return
bumpRuntimeCounter('imageResolveCompleted')
resolvingImageCacheBatchRef.current = false
const pending = pendingImageResolveRangeRef.current
pendingImageResolveRangeRef.current = null
@@ -756,60 +1608,331 @@ function ResourcesPage() {
}
}
})()
}, [displayItems])
}, [bumpRuntimeCounter, getMeta, isCurrentItemKey, updateMediaCardState])
const preloadImageCacheRange = useCallback((start: number, end: number) => {
const collectImagePreloadPayloads = useCallback((
start: number,
end: number,
options: {
cooldownRef: MutableRefObject<Record<string, number>>
identityCooldownRef: MutableRefObject<Record<string, number>>
submittingIdentityRef: MutableRefObject<Set<string>>
submittingSkipCounter: keyof ResourceRuntimeCounters
cooldownMs: number
limit: number
includeExistingPreview?: boolean
}
): ImagePreloadCandidate[] => {
const currentItems = itemsRef.current
const from = Math.max(0, start)
const to = Math.min(displayItems.length - 1, end)
if (to < from) return
const to = Math.min(currentItems.length - 1, end)
if (to < from) return []
const now = Date.now()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
const candidates: ImagePreloadCandidate[] = []
const dedup = new Set<string>()
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
const item = currentItems[i]
if (!item || item.mediaType !== 'image') continue
const itemKey = getItemKey(item)
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
if (!hasImageLocator(item)) continue
if ((imagePreloadUntilRef.current[itemKey] || 0) > now) continue
const dedupKey = `${item.sessionId || ''}|${normalizeMediaToken(item.imageMd5)}|${getSafeImageDatName(item)}`
const meta = getMeta(item)
const { itemKey, imageMd5, imageDatName, imageIdentity } = meta
const previewPath = previewPatchRef.current[itemKey] || previewPathMapRef.current[itemKey] || ''
const isExistingPreviewCandidate = Boolean(previewPath && options.includeExistingPreview === true)
if (previewPath) {
const previewMayNeedUpgrade =
options.includeExistingPreview === true &&
(
previewUpdateMapRef.current[itemKey] === true ||
updatePatchRef.current[itemKey] === true ||
isLikelyThumbnailPreview(previewPath)
)
if (!previewMayNeedUpgrade) continue
}
if (!meta.hasImageLocator) continue
if ((options.cooldownRef.current[itemKey] || 0) > now) continue
const dedupKey = imageIdentity
if ((options.identityCooldownRef.current[dedupKey] || 0) > now) {
options.cooldownRef.current[itemKey] = options.identityCooldownRef.current[dedupKey]
continue
}
if (options.submittingIdentityRef.current.has(dedupKey)) {
bumpRuntimeCounter(options.submittingSkipCounter)
continue
}
if (dedup.has(dedupKey)) continue
dedup.add(dedupKey)
imagePreloadUntilRef.current[itemKey] = now + 12000
payloads.push({
sessionId: item.sessionId,
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined
candidates.push({
itemKey,
imageIdentity,
previewUpgrade: isExistingPreviewCandidate,
payload: {
sessionId: item.sessionId,
imageMd5: imageMd5 || undefined,
imageDatName: imageDatName || undefined,
createTime: Number(item.createTime || 0) || undefined
}
})
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
if (candidates.length >= options.limit) break
}
if (payloads.length === 0) return
void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: true
})
}, [displayItems])
return candidates
}, [bumpRuntimeCounter, getMeta, isLikelyThumbnailPreview])
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
const itemKey = getItemKey(item)
const cached = resolvedVideoMd5Ref.current[itemKey]
if (cached) return cached
const commitImagePreloadCooldowns = useCallback((
candidates: ImagePreloadCandidate[],
handledIdentities: string[],
cooldownRef: MutableRefObject<Record<string, number>>,
identityCooldownRef: MutableRefObject<Record<string, number>>,
cooldownMs: number
) => {
if (candidates.length === 0 || handledIdentities.length === 0) return
const handled = new Set(handledIdentities)
const until = Date.now() + cooldownMs
for (const candidate of candidates) {
if (!handled.has(candidate.imageIdentity)) continue
cooldownRef.current[candidate.itemKey] = until
identityCooldownRef.current[candidate.imageIdentity] = until
if (candidate.previewUpgrade) {
bumpRuntimeCounter('imagePredecryptPreviewUpgrades')
}
}
}, [bumpRuntimeCounter])
let md5 = String(item.videoMd5 || '').trim().toLowerCase()
if (md5) {
resolvedVideoMd5Ref.current[itemKey] = md5
return md5
const commitImagePreloadRetryCooldowns = useCallback((
candidates: ImagePreloadCandidate[],
rejectedIdentities: string[],
cooldownRef: MutableRefObject<Record<string, number>>,
identityCooldownRef: MutableRefObject<Record<string, number>>,
cooldownMs: number
) => {
if (candidates.length === 0 || rejectedIdentities.length === 0) return
const rejected = new Set(rejectedIdentities)
const until = Date.now() + cooldownMs
for (const candidate of candidates) {
if (!rejected.has(candidate.imageIdentity)) continue
cooldownRef.current[candidate.itemKey] = until
identityCooldownRef.current[candidate.imageIdentity] = until
}
const parsed = await window.electronAPI.video.parseVideoMd5(String(item.content || ''))
if (parsed.success && parsed.md5) md5 = String(parsed.md5).trim().toLowerCase()
if (md5) resolvedVideoMd5Ref.current[itemKey] = md5
return md5
}, [])
const markImagePreloadSubmitting = useCallback((
candidates: ImagePreloadCandidate[],
submittingIdentityRef: MutableRefObject<Set<string>>
) => {
candidates.forEach((candidate) => {
submittingIdentityRef.current.add(candidate.imageIdentity)
})
}, [])
const clearImagePreloadSubmitting = useCallback((
candidates: ImagePreloadCandidate[],
submittingIdentityRef: MutableRefObject<Set<string>>
) => {
candidates.forEach((candidate) => {
submittingIdentityRef.current.delete(candidate.imageIdentity)
})
}, [])
const preloadImageCacheRange = useCallback((start: number, end: number) => {
const candidates = collectImagePreloadPayloads(start, end, {
cooldownRef: imagePreloadUntilRef,
identityCooldownRef: imagePreloadIdentityUntilRef,
submittingIdentityRef: imagePreloadSubmittingIdentityRef,
submittingSkipCounter: 'imagePreloadSubmittingSkips',
cooldownMs: 12000,
limit: MAX_IMAGE_CACHE_PRELOAD_PER_TICK
})
const payloads = candidates.map((candidate) => candidate.payload)
if (payloads.length === 0) return
markImagePreloadSubmitting(candidates, imagePreloadSubmittingIdentityRef)
const submissionEpoch = preloadSubmissionEpochRef.current
bumpRuntimeCounter('imagePreloadRequests', payloads.length)
void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: true,
allowFilesystemScan: false,
emitResolved: true,
scope: preloadScopeRef.current,
priority: 'high'
}).then((result) => {
if (submissionEpoch !== preloadSubmissionEpochRef.current) return
const rejectedIdentities = Array.isArray(result?.rejectedIdentities) ? result.rejectedIdentities : []
if (rejectedIdentities.length > 0) {
bumpRuntimeCounter('imagePreloadRejectedCapacity', rejectedIdentities.length)
commitImagePreloadRetryCooldowns(
candidates,
rejectedIdentities,
imagePreloadUntilRef,
imagePreloadIdentityUntilRef,
1600
)
}
commitImagePreloadCooldowns(
candidates,
Array.isArray(result?.handledIdentities) ? result.handledIdentities : [],
imagePreloadUntilRef,
imagePreloadIdentityUntilRef,
12000
)
}).catch(() => { }).finally(() => {
if (submissionEpoch !== preloadSubmissionEpochRef.current) return
clearImagePreloadSubmitting(candidates, imagePreloadSubmittingIdentityRef)
})
}, [bumpRuntimeCounter, clearImagePreloadSubmitting, collectImagePreloadPayloads, commitImagePreloadCooldowns, commitImagePreloadRetryCooldowns, markImagePreloadSubmitting])
const shouldSkipPredecryptForBackpressure = useCallback((): boolean => {
const stats = lastPreloadStatsRef.current
if (!stats) return false
const queuedDecrypt = Number(stats.queuedDecrypt || 0)
const queuedLow = Number(stats.queuedLow || 0)
const activeDecrypt = Number(stats.activeDecrypt || 0)
const highWaterQueuedDecrypt = Number(stats.highWater?.queuedDecrypt || 0)
const highWaterQueuedLow = Number(stats.highWater?.queuedLow || 0)
return (
queuedDecrypt >= 48 ||
queuedLow >= 96 ||
highWaterQueuedDecrypt >= 96 ||
highWaterQueuedLow >= 160 ||
(activeDecrypt >= 2 && queuedDecrypt >= 24)
)
}, [])
const predecryptImageRange = useCallback((start: number, end: number) => {
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('predecryptHiddenSkips')
return
}
if (shouldSkipPredecryptForBackpressure()) {
bumpRuntimeCounter('imagePredecryptBackpressureSkips')
return
}
const candidates = collectImagePreloadPayloads(start, end, {
cooldownRef: imagePredecryptUntilRef,
identityCooldownRef: imagePredecryptIdentityUntilRef,
submittingIdentityRef: imagePredecryptSubmittingIdentityRef,
submittingSkipCounter: 'imagePredecryptSubmittingSkips',
cooldownMs: 30000,
limit: MAX_IMAGE_PREDECRYPT_PER_TICK,
includeExistingPreview: true
})
const payloads = candidates.map((candidate) => candidate.payload)
if (payloads.length === 0) return
markImagePreloadSubmitting(candidates, imagePredecryptSubmittingIdentityRef)
const submissionEpoch = preloadSubmissionEpochRef.current
bumpRuntimeCounter('imagePredecryptRequests', payloads.length)
void window.electronAPI.image.preload(payloads, {
allowDecrypt: true,
allowCacheIndex: true,
allowFilesystemScan: true,
emitResolved: true,
scope: preloadScopeRef.current,
priority: 'low'
}).then((result) => {
if (submissionEpoch !== preloadSubmissionEpochRef.current) return
const rejectedIdentities = Array.isArray(result?.rejectedIdentities) ? result.rejectedIdentities : []
const deferredIdentities = Array.isArray(result?.deferredIdentities) ? result.deferredIdentities : []
if (rejectedIdentities.length > 0) {
bumpRuntimeCounter('imagePredecryptRejectedCapacity', rejectedIdentities.length)
commitImagePreloadRetryCooldowns(
candidates,
rejectedIdentities,
imagePredecryptUntilRef,
imagePredecryptIdentityUntilRef,
3000
)
}
if (deferredIdentities.length > 0) {
bumpRuntimeCounter('imagePredecryptDeferred', deferredIdentities.length)
commitImagePreloadRetryCooldowns(
candidates,
deferredIdentities,
imagePredecryptUntilRef,
imagePredecryptIdentityUntilRef,
3600
)
}
commitImagePreloadCooldowns(
candidates,
Array.isArray(result?.acceptedIdentities) ? result.acceptedIdentities : [],
imagePredecryptUntilRef,
imagePredecryptIdentityUntilRef,
12000
)
commitImagePreloadCooldowns(
candidates,
[
...(Array.isArray(result?.mergedQueuedIdentities) ? result.mergedQueuedIdentities : []),
...(Array.isArray(result?.skippedActiveIdentities) ? result.skippedActiveIdentities : []),
...(Array.isArray(result?.skippedPendingIdentities) ? result.skippedPendingIdentities : [])
],
imagePredecryptUntilRef,
imagePredecryptIdentityUntilRef,
30000
)
}).catch(() => { }).finally(() => {
if (submissionEpoch !== preloadSubmissionEpochRef.current) return
clearImagePreloadSubmitting(candidates, imagePredecryptSubmittingIdentityRef)
})
}, [bumpRuntimeCounter, clearImagePreloadSubmitting, collectImagePreloadPayloads, commitImagePreloadCooldowns, commitImagePreloadRetryCooldowns, markImagePreloadSubmitting, shouldSkipPredecryptForBackpressure])
const flushPredecryptRange = useCallback(() => {
predecryptTimerRef.current = null
const pending = pendingPredecryptRangeRef.current
pendingPredecryptRangeRef.current = null
if (!pending) return
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('predecryptHiddenSkips')
return
}
const idleForMs = Date.now() - lastRangeActivityAtRef.current
if (lastRangeActivityAtRef.current > 0 && idleForMs < IMAGE_PREDECRYPT_IDLE_DELAY_MS) {
pendingPredecryptRangeRef.current = pending
predecryptTimerRef.current = window.setTimeout(
flushPredecryptRange,
Math.max(80, IMAGE_PREDECRYPT_IDLE_DELAY_MS - idleForMs)
)
return
}
bumpRuntimeCounter('predecryptFlushes')
predecryptImageRange(pending.start, pending.end)
}, [bumpRuntimeCounter, predecryptImageRange])
const schedulePredecryptRange = useCallback((start: number, end: number) => {
bumpRuntimeCounter('predecryptSchedules')
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('predecryptHiddenSkips')
return
}
const nextRange = {
start: Math.max(0, start),
end: Math.max(Math.max(0, start), end)
}
const previous = pendingPredecryptRangeRef.current
if (previous && nextRange.start >= previous.start && nextRange.end <= previous.end) {
bumpRuntimeCounter('predecryptContainedSkips')
if (predecryptTimerRef.current !== null) {
window.clearTimeout(predecryptTimerRef.current)
predecryptTimerRef.current = window.setTimeout(flushPredecryptRange, IMAGE_PREDECRYPT_TIMER_MS)
}
return
}
if (previous) {
bumpRuntimeCounter('predecryptPendingUpdates')
bumpRuntimeCounter('predecryptPendingReplacements')
}
pendingPredecryptRangeRef.current = nextRange
if (predecryptTimerRef.current !== null) {
window.clearTimeout(predecryptTimerRef.current)
}
predecryptTimerRef.current = window.setTimeout(flushPredecryptRange, IMAGE_PREDECRYPT_TIMER_MS)
}, [bumpRuntimeCounter, flushPredecryptRange])
const resolveItemVideoMd5 = useCallback((item: MediaStreamItem): string => {
return getMeta(item).videoMd5
}, [getMeta])
const showMediaInfo = useCallback(async (item: MediaStreamItem) => {
const itemKey = getItemKey(item)
const meta = getMeta(item)
const itemKey = meta.itemKey
const mediaLabel = item.mediaType === 'image' ? '图片' : '视频'
const baseRows: Array<{ label: string; value: string }> = [
{ label: '资源类型', value: mediaLabel },
@@ -893,114 +2016,227 @@ function ResourcesPage() {
onConfirm: null
})
}
}, [resolveItemVideoMd5])
const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'video') return
const itemKey = getItemKey(item)
if (videoPosterMapRef.current[itemKey]) return
if (attemptedVideoPosterKeysRef.current.has(itemKey)) return
if (resolvingVideoPosterKeysRef.current.has(itemKey)) return
resolvingVideoPosterKeysRef.current.add(itemKey)
try {
const md5 = await resolveItemVideoMd5(item)
if (!md5) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
const info = await window.electronAPI.video.getVideoInfo(md5, { includePoster: true, posterFormat: 'fileUrl' })
if (!info.success || !info.exists) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
const poster = String(info.coverUrl || '')
if (!poster) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
queuePosterPatch(itemKey, poster)
attemptedVideoPosterKeysRef.current.add(itemKey)
} catch {
attemptedVideoPosterKeysRef.current.add(itemKey)
} finally {
resolvingVideoPosterKeysRef.current.delete(itemKey)
}
}, [queuePosterPatch, resolveItemVideoMd5])
}, [getMeta, resolveItemVideoMd5])
const resolvePosterRange = useCallback((start: number, end: number) => {
const currentItems = itemsRef.current
const from = Math.max(0, start)
const to = Math.min(displayItems.length - 1, end)
const to = Math.min(currentItems.length - 1, end)
if (to < from) return
let resolvedCount = 0
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
if (!item || item.mediaType !== 'video') continue
void resolveVideoPoster(item)
resolvedCount += 1
if (resolvedCount >= MAX_VIDEO_POSTER_RESOLVE_PER_TICK) break
if (resolvingVideoPosterBatchRef.current) {
const previous = pendingVideoPosterRangeRef.current
if (previous && from >= previous.start && to <= previous.end) {
bumpRuntimeCounter('videoPosterContainedSkips')
return
}
const merged = mergeNearbyRange(pendingVideoPosterRangeRef.current, { start: from, end: to }, 18)
pendingVideoPosterRangeRef.current = merged.range
bumpRuntimeCounter(merged.merged ? 'videoPosterPendingMerges' : 'videoPosterPendingReplacements')
return
}
}, [displayItems, resolveVideoPoster])
const md5List: string[] = []
const md5ItemKeys: string[][] = []
const md5IndexMap = new Map<string, number>()
for (let i = from; i <= to; i += 1) {
const item = currentItems[i]
if (!item || item.mediaType !== 'video') continue
const itemKey = getMeta(item).itemKey
if (videoPosterMapRef.current[itemKey] || posterPatchRef.current[itemKey]) continue
if (attemptedVideoPosterKeysRef.current.has(itemKey)) continue
if (resolvingVideoPosterKeysRef.current.has(itemKey)) continue
const md5 = resolveItemVideoMd5(item)
if (!md5) {
attemptedVideoPosterKeysRef.current.add(itemKey)
continue
}
const cachedPoster = videoPosterByMd5Ref.current[md5]
if (cachedPoster) {
bumpRuntimeCounter('videoPosterMd5CacheHits')
queuePosterPatch(itemKey, cachedPoster)
attemptedVideoPosterKeysRef.current.add(itemKey)
continue
}
if (attemptedVideoPosterMd5Ref.current.has(md5)) {
bumpRuntimeCounter('videoPosterMd5MissSkips')
attemptedVideoPosterKeysRef.current.add(itemKey)
continue
}
const existingIndex = md5IndexMap.get(md5)
if (existingIndex !== undefined) {
resolvingVideoPosterKeysRef.current.add(itemKey)
md5ItemKeys[existingIndex].push(itemKey)
continue
}
if (resolvingVideoPosterMd5Ref.current.has(md5)) continue
resolvingVideoPosterKeysRef.current.add(itemKey)
md5IndexMap.set(md5, md5List.length)
resolvingVideoPosterMd5Ref.current.add(md5)
md5ItemKeys.push([itemKey])
md5List.push(md5)
if (md5List.length >= MAX_VIDEO_POSTER_RESOLVE_PER_TICK) break
}
if (md5List.length === 0) return
resolvingVideoPosterBatchRef.current = true
bumpRuntimeCounter('videoPosterBatches')
const batchId = videoPosterBatchIdRef.current + 1
videoPosterBatchIdRef.current = batchId
void (async () => {
try {
const result = await window.electronAPI.video.getVideoInfoBatch(md5List, {
includePoster: true,
posterFormat: 'fileUrl'
})
if (batchId !== videoPosterBatchIdRef.current) return
const rows = Array.isArray(result?.rows) ? result.rows : []
const rowsByIndex = new Map(rows.map((row) => [Number(row.index), row]))
for (let i = 0; i < md5ItemKeys.length; i += 1) {
const row = rowsByIndex.get(i)
const md5 = md5List[i] || ''
for (const itemKey of md5ItemKeys[i] || []) {
if (row?.success && row.exists && row.coverUrl) {
const coverUrl = String(row.coverUrl)
if (md5) videoPosterByMd5Ref.current[md5] = coverUrl
queuePosterPatch(itemKey, coverUrl)
}
attemptedVideoPosterKeysRef.current.add(itemKey)
}
if (md5) attemptedVideoPosterMd5Ref.current.add(md5)
}
} catch {
if (batchId !== videoPosterBatchIdRef.current) return
md5ItemKeys.forEach((itemKeys, index) => {
const md5 = md5List[index] || ''
if (md5) attemptedVideoPosterMd5Ref.current.add(md5)
itemKeys.forEach((itemKey) => {
attemptedVideoPosterKeysRef.current.add(itemKey)
})
})
} finally {
if (batchId !== videoPosterBatchIdRef.current) return
md5ItemKeys.forEach((itemKeys, index) => {
const md5 = md5List[index] || ''
if (md5) resolvingVideoPosterMd5Ref.current.delete(md5)
itemKeys.forEach((itemKey) => {
resolvingVideoPosterKeysRef.current.delete(itemKey)
})
})
resolvingVideoPosterBatchRef.current = false
const pending = pendingVideoPosterRangeRef.current
pendingVideoPosterRangeRef.current = null
if (pending) resolvePosterRange(pending.start, pending.end)
}
})()
}, [bumpRuntimeCounter, getMeta, queuePosterPatch, resolveItemVideoMd5])
const flushRangeResolve = useCallback(() => {
rangeTimerRef.current = null
const pending = pendingRangeRef.current
if (!pending) return
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('rangeHiddenSkips')
return
}
pendingRangeRef.current = null
bumpRuntimeCounter('rangeFlushes')
if (tab === 'image') {
preloadImageCacheRange(pending.start - 4, pending.end + 20)
resolveImageCacheRange(pending.start - 1, pending.end + 6)
schedulePredecryptRange(pending.start - 1, pending.end + IMAGE_PREDECRYPT_LOOKAHEAD)
return
}
resolvePosterRange(pending.start, pending.end)
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
}, [bumpRuntimeCounter, preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, schedulePredecryptRange, tab])
const scheduleRangeResolve = useCallback((start: number, end: number) => {
bumpRuntimeCounter('rangeSchedules')
const armRangeTimer = () => {
if (rangeTimerRef.current !== null) return
if (document.visibilityState === 'hidden') return
rangeTimerRef.current = window.setTimeout(flushRangeResolve, 120)
}
const previous = pendingRangeRef.current
if (previous && start >= previous.start && end <= previous.end) {
bumpRuntimeCounter('rangeContainedSkips')
armRangeTimer()
return
}
pendingRangeRef.current = { start, end }
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
if (previous) {
const merged = mergeNearbyRange(previous, { start, end }, 18)
pendingRangeRef.current = merged.range
bumpRuntimeCounter(merged.merged ? 'rangePendingMerges' : 'rangePendingReplacements')
} else {
pendingRangeRef.current = { start, end }
}
rangeTimerRef.current = window.setTimeout(flushRangeResolve, 120)
}, [flushRangeResolve])
armRangeTimer()
}, [bumpRuntimeCounter, flushRangeResolve])
useEffect(() => {
if (displayItems.length === 0) return
if (tab === 'image') {
preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END))
resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END))
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') return
if (itemsLengthRef.current <= 0) return
const range = lastVisibleRangeRef.current || {
start: 0,
end: Math.min(itemsLengthRef.current - 1, INITIAL_IMAGE_RESOLVE_END)
}
bumpRuntimeCounter('rangeVisibilityReschedules')
scheduleRangeResolve(range.start, range.end)
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [bumpRuntimeCounter, scheduleRangeResolve])
useEffect(() => {
if (items.length === 0) return
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('rangeHiddenSkips')
return
}
resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
}, [displayItems, preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
const scheduleKey = `${preloadScopeRef.current}|${tab}`
if (initialRangeScheduleKeyRef.current === scheduleKey) return
initialRangeScheduleKeyRef.current = scheduleKey
if (tab === 'image') {
preloadImageCacheRange(0, Math.min(items.length - 1, INITIAL_IMAGE_PRELOAD_END))
resolveImageCacheRange(0, Math.min(items.length - 1, INITIAL_IMAGE_RESOLVE_END))
schedulePredecryptRange(0, Math.min(items.length - 1, INITIAL_IMAGE_PREDECRYPT_END))
return
}
resolvePosterRange(0, Math.min(items.length - 1, 12))
}, [bumpRuntimeCounter, items, preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, schedulePredecryptRange, tab])
const selectedItems = useMemo(() => {
if (selectedKeys.size === 0) return []
return displayItems.filter((item) => selectedKeys.has(getItemKey(item)))
}, [displayItems, selectedKeys])
const selected: MediaStreamItem[] = []
for (const key of selectedKeys) {
const item = itemByKeyRef.current[key]
if (item) selected.push(item)
}
return selected
}, [selectedKeys])
const toggleSelect = useCallback((item: MediaStreamItem) => {
const key = getItemKey(item)
const key = getMeta(item).itemKey
setSelectedKeys((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
selectedKeysRef.current = next
updateMediaCardState(key, { selected: next.has(key) })
return next
})
}, [])
}, [getMeta, updateMediaCardState])
const onImageLoaded = useCallback((item: MediaStreamItem, width: number, height: number) => {
if (item.mediaType !== 'image') return
if (!width || !height) return
const ratio = height / width
if (!Number.isFinite(ratio) || ratio <= 0) return
const itemKey = getItemKey(item)
const itemKey = getMeta(item).itemKey
queueAspectPatch(itemKey, ratio)
}, [queueAspectPatch])
}, [getMeta, queueAspectPatch])
const deleteOne = useCallback((item: MediaStreamItem) => {
showConfirm('确认删除该原始记录?此操作不可恢复。', () => {
@@ -1011,29 +2247,24 @@ function ResourcesPage() {
return
}
const key = getItemKey(item)
setItems((prev) => prev.filter((row) => getItemKey(row) !== key))
const key = getMeta(item).itemKey
removeResourceIndexItem(key, getMeta(item))
removeMediaCardState(key)
const deletedKeys = new Set([key])
setItems((prev) => removeItemsByKeys(prev, deletedKeys, getMeta))
setSelectedKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
setPreviewPathMap((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
setPreviewUpdateMap((prev) => {
if (prev[key] === undefined) return prev
const next = { ...prev }
delete next[key]
const next = removeSetValue(prev, key)
if (next !== prev) selectedKeysRef.current = next
return next
})
previewPathMapRef.current = removeRecordKeys(previewPathMapRef.current, deletedKeys)
previewUpdateMapRef.current = removeRecordKeys(previewUpdateMapRef.current, deletedKeys)
videoPosterMapRef.current = removeRecordKeys(videoPosterMapRef.current, deletedKeys)
imageAspectMapRef.current = removeRecordKeys(imageAspectMapRef.current, deletedKeys)
setActionMessage('删除成功')
})()
}, '删除确认')
}, [showAlert, showConfirm])
}, [getMeta, removeMediaCardState, removeResourceIndexItem, showAlert, showConfirm])
const batchDelete = useCallback(() => {
if (selectedItems.length === 0 || batchBusy) return
@@ -1048,22 +2279,29 @@ function ResourcesPage() {
const result = await window.electronAPI.chat.deleteMessage(item.sessionId, item.localId, item.createTime)
if (result.success) {
success += 1
deletedKeys.add(getItemKey(item))
deletedKeys.add(getMeta(item).itemKey)
}
}
setItems((prev) => prev.filter((item) => !deletedKeys.has(getItemKey(item))))
setSelectedKeys(new Set())
setPreviewPathMap((prev) => {
const next = { ...prev }
deletedKeys.forEach((key) => { delete next[key] })
return next
})
setPreviewUpdateMap((prev) => {
const next = { ...prev }
deletedKeys.forEach((key) => { delete next[key] })
return next
if (deletedKeys.size > 0) {
setItems((prev) => removeItemsByKeys(prev, deletedKeys, getMeta))
selectedItems.forEach((item) => {
const meta = getMeta(item)
if (deletedKeys.has(meta.itemKey)) {
removeResourceIndexItem(meta.itemKey, meta)
removeMediaCardState(meta.itemKey)
}
})
}
setSelectedKeys(clearSet)
selectedKeysRef.current = new Set()
selectedItems.forEach((item) => {
updateMediaCardState(getMeta(item).itemKey, { selected: false })
})
previewPathMapRef.current = removeRecordKeys(previewPathMapRef.current, deletedKeys)
previewUpdateMapRef.current = removeRecordKeys(previewUpdateMapRef.current, deletedKeys)
videoPosterMapRef.current = removeRecordKeys(videoPosterMapRef.current, deletedKeys)
imageAspectMapRef.current = removeRecordKeys(imageAspectMapRef.current, deletedKeys)
setActionMessage(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`)
showAlert(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`, '批量删除完成')
} finally {
@@ -1071,7 +2309,7 @@ function ResourcesPage() {
}
})()
}, '批量删除确认')
}, [batchBusy, selectedItems, showAlert, showConfirm])
}, [batchBusy, getMeta, removeMediaCardState, removeResourceIndexItem, selectedItems, showAlert, showConfirm, updateMediaCardState])
const decryptImage = useCallback(async (
item: MediaStreamItem,
@@ -1079,17 +2317,17 @@ function ResourcesPage() {
): Promise<string | undefined> => {
if (item.mediaType !== 'image') return
const key = getItemKey(item)
const key = getMeta(item).itemKey
if (!hasImageLocator(item)) {
showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密')
return
}
setDecryptingKeys((prev) => {
const next = new Set(prev)
next.add(key)
return next
})
const nextDecryptingKeys = addSetValue(decryptingKeysRef.current, key)
if (nextDecryptingKeys !== decryptingKeysRef.current) {
decryptingKeysRef.current = nextDecryptingKeys
updateMediaCardState(key, { decrypting: true })
}
try {
const result = await window.electronAPI.image.decrypt({
@@ -1104,6 +2342,7 @@ function ResourcesPage() {
suppressEvents: true
})
if (!result?.success) {
if (!isCurrentItemKey(key)) return undefined
if (result?.failureKind === 'decrypt_failed') {
showAlert(`解密失败:${result?.error || '解密后不是有效图片'}`, '解密失败')
} else {
@@ -1114,9 +2353,10 @@ function ResourcesPage() {
if (result.localPath) {
const localPath = result.localPath as string
setPreviewPathMap((prev) => ({ ...prev, [key]: localPath }))
setPreviewUpdateMap((prev) => ({ ...prev, [key]: isLikelyThumbnailPreview(localPath) }))
setActionMessage('图片解密完成')
if (isCurrentItemKey(key)) {
queuePreviewPatch(key, localPath, isLikelyThumbnailPreview(localPath))
setActionMessage('图片解密完成')
}
return localPath
}
try {
@@ -1132,34 +2372,38 @@ function ResourcesPage() {
})
if (resolved?.success && resolved.localPath) {
const localPath = resolved.localPath
setPreviewPathMap((prev) => ({ ...prev, [key]: localPath }))
setPreviewUpdateMap((prev) => ({ ...prev, [key]: Boolean(resolved.hasUpdate) }))
setActionMessage('图片解密完成')
if (isCurrentItemKey(key)) {
queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate))
setActionMessage('图片解密完成')
}
return localPath
}
} catch {
// ignore
}
setActionMessage('图片解密完成')
if (isCurrentItemKey(key)) setActionMessage('图片解密完成')
return undefined
} catch (e) {
if (!isCurrentItemKey(key)) return undefined
showAlert(`本地无数据:${String(e)}`, '未找到本地数据')
return undefined
} finally {
setDecryptingKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
const nextDecryptingKeys = removeSetValue(decryptingKeysRef.current, key)
if (nextDecryptingKeys !== decryptingKeysRef.current) {
decryptingKeysRef.current = nextDecryptingKeys
updateMediaCardState(key, { decrypting: false })
}
}
}, [isLikelyThumbnailPreview, showAlert])
}, [getMeta, isCurrentItemKey, isLikelyThumbnailPreview, queuePreviewPatch, showAlert, updateMediaCardState])
const onImagePreviewAction = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'image') return
const key = getItemKey(item)
const key = getMeta(item).itemKey
let localPath = previewPathMapRef.current[key] || previewPatchRef.current[key] || ''
const hadRenderedPreviewAtClick = Boolean(localPath)
if (!isCurrentItemKey(key)) return
if (localPath) {
if (hadRenderedPreviewAtClick) {
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
@@ -1178,12 +2422,17 @@ function ResourcesPage() {
} catch {
// ignore
}
if (localPath) {
if (localPath && isCurrentItemKey(key)) {
await window.electronAPI.window.openImageViewerWindow(localPath)
return
}
}
if (!hadRenderedPreviewAtClick) {
await decryptImage(item)
return
}
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
@@ -1198,7 +2447,6 @@ function ResourcesPage() {
if (resolved?.success && resolved.localPath) {
localPath = resolved.localPath
queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate))
await window.electronAPI.window.openImageViewerWindow(localPath)
return
}
} catch {
@@ -1206,7 +2454,7 @@ function ResourcesPage() {
}
await decryptImage(item)
}, [decryptImage, queuePreviewPatch])
}, [decryptImage, getMeta, isCurrentItemKey, queuePreviewPatch])
const updateImageQuality = useCallback(async (item: MediaStreamItem) => {
await decryptImage(item)
@@ -1267,7 +2515,11 @@ function ResourcesPage() {
}
if (hardlinkMd5Set.size > 0) {
try {
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set))
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set), {
chunkSize: BATCH_IMAGE_HARDLINK_PRELOAD_CHUNK_SIZE,
yieldMs: BATCH_IMAGE_HARDLINK_PRELOAD_YIELD_MS,
filesystemFallback: false
})
} catch {
// ignore preload failures and continue decrypt
}
@@ -1303,9 +2555,14 @@ function ResourcesPage() {
} else {
success += 1
if (result.localPath) {
const key = getItemKey(item)
const key = getMeta(item).itemKey
if (!isCurrentItemKey(key)) continue
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
updateMediaCardState(key, {
previewPath: result.localPath,
hasPreviewUpdate: isLikelyThumbnailPreview(result.localPath)
})
}
}
} catch {
@@ -1313,17 +2570,38 @@ function ResourcesPage() {
} finally {
completed += 1
updateTaskProgress()
if (BATCH_IMAGE_DECRYPT_YIELD_MS > 0 && cursor < imageItems.length) {
await waitForBatchDecryptYield()
}
}
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()))
updateTaskProgress(true)
if (Object.keys(previewPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
if (hasRecordEntries(previewPatch)) {
const validPatch: Record<string, string> = {}
let hasValidPatch = false
for (const key in previewPatch) {
if (!isCurrentItemKey(key)) continue
validPatch[key] = previewPatch[key]
hasValidPatch = true
}
if (hasValidPatch) {
previewPathMapRef.current = mergeRecordPatch(previewPathMapRef.current, validPatch)
}
}
if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
if (hasRecordEntries(updatePatch)) {
const validPatch: Record<string, boolean> = {}
let hasValidPatch = false
for (const key in updatePatch) {
if (!isCurrentItemKey(key)) continue
validPatch[key] = updatePatch[key]
hasValidPatch = true
}
if (hasValidPatch) {
previewUpdateMapRef.current = mergeRecordPatch(previewUpdateMapRef.current, validPatch)
}
}
setActionMessage(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`)
showAlert(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`, '批量解密完成')
@@ -1339,7 +2617,7 @@ function ResourcesPage() {
} finally {
setBatchBusy(false)
}
}, [batchBusy, isLikelyThumbnailPreview, selectedItems, showAlert])
}, [batchBusy, getMeta, isCurrentItemKey, isLikelyThumbnailPreview, selectedItems, showAlert, updateMediaCardState])
const openVideo = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'video') return
@@ -1359,6 +2637,59 @@ function ResourcesPage() {
await window.electronAPI.window.openVideoPlayerWindow(info.videoUrl)
}, [resolveItemVideoMd5, showAlert])
const computeGridItemKey = useCallback((_: number, item: MediaStreamItem) => {
return getMeta(item).itemKey
}, [getMeta])
const handleGridRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
lastRangeActivityAtRef.current = Date.now()
const nextRange = { start: range.startIndex - 3, end: range.endIndex + 6 }
const previousRange = lastVisibleRangeRef.current
if (previousRange && previousRange.start === nextRange.start && previousRange.end === nextRange.end) {
bumpRuntimeCounter('rangeDuplicateSkips')
return
}
lastVisibleRangeRef.current = nextRange
scheduleRangeResolve(nextRange.start, nextRange.end)
scheduleTransientStatePrune(nextRange)
}, [bumpRuntimeCounter, scheduleRangeResolve, scheduleTransientStatePrune])
const handleGridEndReached = useCallback(() => {
if (!hasMoreRef.current || loadingRef.current || loadingMoreRef.current) return
void loadStream(false)
}, [loadStream])
const renderGridItem = useCallback((_: number, item: MediaStreamItem) => {
const meta = getMeta(item)
const itemKey = meta.itemKey
return (
<MediaCardContainer
item={item}
itemKey={itemKey}
stateStore={mediaCardStateStoreRef.current}
sessionName={item.sessionDisplayName || sessionNameMap[item.sessionId] || item.sessionId}
videoTitle={meta.videoTitle}
onToggleSelect={toggleSelect}
onDelete={deleteOne}
onShowInfo={showMediaInfo}
onImagePreviewAction={onImagePreviewAction}
onUpdateImageQuality={updateImageQuality}
onOpenVideo={openVideo}
onImageLoaded={onImageLoaded}
/>
)
}, [
deleteOne,
getMeta,
onImageLoaded,
onImagePreviewAction,
openVideo,
sessionNameMap,
showMediaInfo,
toggleSelect,
updateImageQuality
])
return (
<div className="resources-page stream-rebuild">
<header className="stream-toolbar">
@@ -1442,43 +2773,14 @@ function ResourcesPage() {
<VirtuosoGrid
className="stream-grid"
overscan={48}
components={{
List: GridList,
Item: GridItem
}}
data={displayItems}
computeItemKey={(_, item) => getItemKey(item)}
rangeChanged={(range) => {
scheduleRangeResolve(range.startIndex - 3, range.endIndex + 6)
}}
endReached={() => {
if (!hasMore || loading || loadingMore) return
void loadStream(false)
}}
itemContent={(_, item) => {
const itemKey = getItemKey(item)
const aspect = imageAspectMap[itemKey] || 0
return (
<MediaCard
item={item}
sessionName={item.sessionDisplayName || item.sessionId}
previewPath={previewPathMap[itemKey] || ''}
videoPosterPath={videoPosterMap[itemKey] || ''}
imageIsLong={aspect >= 2.8}
hasPreviewUpdate={Boolean(previewUpdateMap[itemKey])}
selected={selectedKeys.has(itemKey)}
decrypting={decryptingKeys.has(itemKey)}
onToggleSelect={toggleSelect}
onDelete={deleteOne}
onShowInfo={showMediaInfo}
onImagePreviewAction={onImagePreviewAction}
onUpdateImageQuality={updateImageQuality}
onOpenVideo={openVideo}
onImageLoaded={onImageLoaded}
/>
)
}}
components={GRID_COMPONENTS}
data={items}
computeItemKey={computeGridItemKey}
rangeChanged={handleGridRangeChanged}
endReached={handleGridEndReached}
itemContent={renderGridItem}
/>
{loading && <div className="grid-refreshing"><Loader2 size={16} className="spin" /> 正在刷新...</div>}
{loadingMore && <div className="grid-loading-more"><Loader2 size={16} className="spin" /> 加载更多中...</div>}
{!hasMore && <div className="grid-end">已加载到底</div>}
</div>
+164
View File
@@ -0,0 +1,164 @@
export type MediaTab = 'image' | 'video'
export interface MediaStreamItem {
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
}
export interface ContactOption {
id: string
name: string
}
export type ImagePreloadPayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
}
export type DialogState = {
mode: 'alert' | 'confirm' | 'info'
title: string
message?: string
infoRows?: Array<{ label: string; value: string }>
confirmText?: string
cancelText?: string
onConfirm?: (() => void) | null
}
export const PAGE_SIZE = 96
export const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
export const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 12
export const MAX_IMAGE_PREDECRYPT_PER_TICK = 4
export const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 8
export const MAX_MEDIA_PATCHES_PER_FLUSH = 32
export const INITIAL_IMAGE_PRELOAD_END = 48
export const INITIAL_IMAGE_RESOLVE_END = 12
export const INITIAL_IMAGE_PREDECRYPT_END = 28
export const IMAGE_PREDECRYPT_LOOKAHEAD = 36
export const IMAGE_PREDECRYPT_IDLE_DELAY_MS = 1200
export const IMAGE_PREDECRYPT_TIMER_MS = 900
export const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
export const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
export const BATCH_IMAGE_DECRYPT_CONCURRENCY = 2
export const BATCH_IMAGE_DECRYPT_YIELD_MS = 16
export const BATCH_IMAGE_HARDLINK_PRELOAD_CHUNK_SIZE = 8
export const BATCH_IMAGE_HARDLINK_PRELOAD_YIELD_MS = 16
export function getRangeTimestampStart(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T00:00:00`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
}
export function getRangeTimestampEnd(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T23:59:59`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
}
export function normalizeMediaToken(value?: string): string {
return String(value || '').trim().toLowerCase()
}
export function getSafeImageDatName(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): string {
const datName = normalizeMediaToken(item.imageDatName)
if (!datName) return ''
return datName
}
export function hasImageLocator(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): boolean {
return Boolean(normalizeMediaToken(item.imageMd5) || getSafeImageDatName(item))
}
export function getItemKey(item: MediaStreamItem): string {
const sessionId = String(item.sessionId || '').trim().toLowerCase()
const localId = Number(item.localId || 0)
if (sessionId && Number.isFinite(localId) && localId > 0) {
return `${sessionId}|${localId}`
}
const serverId = String(item.serverId || '').trim().toLowerCase()
const createTime = Number(item.createTime || 0)
const localType = Number(item.localType || 0)
const mediaId = String(
item.mediaType === 'video'
? (item.videoMd5 || '')
: (item.imageMd5 || getSafeImageDatName(item) || '')
).trim().toLowerCase()
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
}
export function formatTimeLabel(timestampSec: number): string {
if (!timestampSec) return '--:--'
return new Date(timestampSec * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
export function formatInfoValue(value: unknown): string {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
export function extractVideoTitle(content?: string): string {
const xml = String(content || '')
if (!xml) return '视频'
const match = /<title>([\s\S]*?)<\/title>/i.exec(xml)
const text = String(match?.[1] || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
return text || '视频'
}
export function extractVideoMd5(content?: string): string {
const xml = String(content || '')
if (!xml) return ''
const patterns = [
/<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/(?:^|[^a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<md5>([a-fA-F0-9]+)<\/md5>/i,
/\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i
]
for (const pattern of patterns) {
const match = pattern.exec(xml)
if (match?.[1]) return match[1].toLowerCase()
}
return ''
}
export function toRenderableMediaSrc(rawPath?: string): string {
const src = String(rawPath || '').trim()
if (!src) return ''
if (/^(data:image\/|blob:|https?:\/\/)/i.test(src)) {
return src
}
if (/^file:\/\//i.test(src)) {
return src.replace(/#/g, '%23')
}
if (src.startsWith('/')) {
return encodeURI(`file://${src}`).replace(/#/g, '%23')
}
if (/^[a-zA-Z]:[\\/]/.test(src)) {
return encodeURI(`file:///${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
return encodeURI(`file://${src.startsWith('/') ? '' : '/'}${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
+167 -7
View File
@@ -428,6 +428,73 @@ export interface ElectronAPI {
}
}>
clearExportCardLogs: () => Promise<{ success: boolean }>
recordResourceStats: (payload: unknown) => Promise<{ success: boolean; count?: number }>
getResourceStats: (options?: { limit?: number }) => Promise<{
entries: Array<{ ts: number; payload: Record<string, unknown> }>
summary: {
count: number
firstTs: number
lastTs: number
samples?: number
longFrames?: number
maxRecentFrameMs?: number
maxQueued?: number
maxQueuedCache?: number
maxQueuedDecrypt?: number
maxQueuedHigh?: number
maxQueuedNormal?: number
maxQueuedLow?: number
maxPending?: number
maxActiveCache?: number
maxActiveDecrypt?: number
maxHighWaterQueued?: number
maxHighWaterQueuedDecrypt?: number
maxHighWaterQueuedLow?: number
maxHighWaterActiveDecrypt?: number
mediaStreamLoadSamples?: number
mediaStreamAvgLoadMs?: number
mediaStreamMaxLoadMs?: number
mediaStreamNativeLoads?: number
mediaStreamPageCacheHits?: number
mediaStreamInflightMerges?: number
mediaStreamAvoidedNativeLoads?: number
mediaStreamAvoidedNativeRate?: number
mediaStreamPageCacheHitRate?: number
mediaStreamRowsLoaded?: number
mediaStreamDuplicateRows?: number
mediaStreamDuplicateRate?: number
mediaStreamNoProgressStops?: number
preloadAccepted?: number
preloadDeduped?: number
preloadHandled?: number
preloadDedupRate?: number
preloadCanceledActive?: number
preloadDroppedQueued?: number
preloadDeferredLowPriority?: number
preloadLowPriorityIdleDeferrals?: number
preloadActiveCacheSnapshots?: number
preloadActiveCacheSnapshotSkipped?: number
preloadActiveCacheSnapshotCanceled?: number
preloadLowPriorityRejected?: number
imagePreloadRejectedCapacity?: number
imagePredecryptRequests?: number
imagePredecryptBackpressureSkips?: number
imagePredecryptRejectedCapacity?: number
imagePredecryptDeferred?: number
imagePredecryptPreviewUpgrades?: number
imagePredecryptRejectRate?: number
imagePredecryptBackpressureRate?: number
predecryptHiddenSkips?: number
rangeHiddenSkips?: number
rangeDuplicateSkips?: number
rangeVisibilityReschedules?: number
transientStatePruneRuns?: number
preloadTotalsBaselineCaptured?: boolean
counterDeltas?: Record<string, number>
preloadTotalDeltas?: Record<string, number>
}
}>
clearResourceStats: () => Promise<{ success: boolean }>
exportExportCardLogs: (payload: {
filePath: string
frontendLogs?: unknown[]
@@ -726,6 +793,12 @@ export interface ElectronAPI {
}>
hasMore?: boolean
nextOffset?: number
streamSource?: 'native' | 'pageCache' | 'inflight'
pageCacheHit?: boolean
inflightMerged?: boolean
nativeLimit?: number
nativeRows?: number
elapsedMs?: number
error?: string
}>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
@@ -840,6 +913,8 @@ export interface ElectronAPI {
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
allowCachePromotion?: boolean
allowFilesystemScan?: boolean
suppressEvents?: boolean
}) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
resolveCache: (payload: {
@@ -851,11 +926,13 @@ export interface ElectronAPI {
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
allowCachePromotion?: boolean
allowFilesystemScan?: boolean
suppressEvents?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; allowCachePromotion?: boolean; allowFilesystemScan?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => Promise<{
success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
@@ -863,11 +940,79 @@ export interface ElectronAPI {
}>
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean>
preloadHardlinkMd5s: (md5List: string[]) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean; allowFilesystemScan?: boolean; emitResolved?: boolean; scope?: string; priority?: 'high' | 'normal' | 'low' }
) => Promise<{
success: true
requested: number
accepted: number
mergedQueued: number
skippedActive: number
skippedPending: number
ignoredCanceled: number
rejectedCapacity: number
deferred: number
handledIdentities: string[]
acceptedIdentities: string[]
mergedQueuedIdentities: string[]
skippedActiveIdentities: string[]
skippedPendingIdentities: string[]
rejectedIdentities: string[]
deferredIdentities: string[]
}>
cancelPreloadScope: (scope: string) => Promise<boolean>
getPreloadStats: () => Promise<{
queued: number
pending: number
activeCache: number
activeDecrypt: number
queuedIdentities: number
queuedCache: number
queuedDecrypt: number
queuedHigh: number
queuedNormal: number
queuedLow: number
activeIdentities: number
queuedScopes: number
activeScopes: number
canceledScopes: number
highWater: {
queued: number
pending: number
queuedCache: number
queuedDecrypt: number
queuedHigh: number
queuedNormal: number
queuedLow: number
activeCache: number
activeDecrypt: number
activeIdentities: number
}
totals: {
accepted: number
mergedQueued: number
skippedActive: number
ignoredCanceled: number
droppedQueued: number
canceledQueued: number
canceledActive: number
promotedActive: number
rejectedCapacity: number
deferredLowPriority: number
lowPriorityIdleDeferrals: number
skippedPending: number
forcedThumbnailRefreshes: number
forcedThumbnailRefreshFailures: number
lowPriorityRejected: number
activeCacheSnapshots: number
activeCacheSnapshotSkipped: number
activeCacheSnapshotCanceled: number
started: number
completed: number
}
}>
preloadHardlinkMd5s: (md5List: string[], options?: { chunkSize?: number; yieldMs?: number; filesystemFallback?: boolean }) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
onDecryptProgress: (callback: (payload: {
cacheKey: string
imageMd5?: string
@@ -887,6 +1032,20 @@ export interface ElectronAPI {
thumbUrl?: string
error?: string
}>
getVideoInfoBatch: (videoMd5List: string[], options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => Promise<{
success: boolean
rows?: Array<{
index: number
md5: string
success: boolean
exists: boolean
videoUrl?: string
coverUrl?: string
thumbUrl?: string
error?: string
}>
error?: string
}>
parseVideoMd5: (content: string) => Promise<{
success: boolean
md5?: string
@@ -1529,12 +1688,13 @@ export interface ElectronAPI {
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'markdown' | 'txt' | 'excel' | 'weclone' | 'sql'
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
exportMedia?: boolean
exportConflictStrategy?: 'incremental' | 'overwrite' | 'rename'
exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean
+8 -10
View File
@@ -1,7 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import electron from 'vite-plugin-electron'
import renderer from 'vite-plugin-electron-renderer'
import { resolve } from 'path'
const handleElectronOnStart = (options: { reload: () => void }) => {
@@ -132,7 +131,7 @@ export default defineConfig({
],
output: {
entryFileNames: 'annualReportWorker.js',
inlineDynamicImports: true
codeSplitting: false
}
}
}
@@ -151,7 +150,7 @@ export default defineConfig({
],
output: {
entryFileNames: 'dualReportWorker.js',
inlineDynamicImports: true
codeSplitting: false
}
}
}
@@ -166,7 +165,7 @@ export default defineConfig({
rollupOptions: {
output: {
entryFileNames: 'imageSearchWorker.js',
inlineDynamicImports: true
codeSplitting: false
}
}
}
@@ -186,7 +185,7 @@ export default defineConfig({
],
output: {
entryFileNames: 'wcdbWorker.js',
inlineDynamicImports: true
codeSplitting: false
}
}
}
@@ -204,7 +203,7 @@ export default defineConfig({
],
output: {
entryFileNames: 'transcribeWorker.js',
inlineDynamicImports: true
codeSplitting: false
}
}
}
@@ -226,7 +225,7 @@ export default defineConfig({
],
output: {
entryFileNames: 'exportWorker.js',
inlineDynamicImports: true
codeSplitting: false
}
}
}
@@ -241,7 +240,7 @@ export default defineConfig({
rollupOptions: {
output: {
entryFileNames: 'apiMessageWorker.js',
inlineDynamicImports: true
codeSplitting: false
}
}
}
@@ -256,8 +255,7 @@ export default defineConfig({
}
}
}
]),
renderer()
])
],
resolve: {
dedupe: ['react', 'react-dom'],