1 parent
99dcb6e43b
commit
301cb5abce
48 files changed
+5444
-1015
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
},
|
||||
|
||||
|
||||
@@ -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 ``
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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.
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: '标准格式,支持其他软件导入' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
Vendored
+167
-7
@@ -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
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user