mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-22 07:26:46 +00:00
图片与视频索引优化 #786;修复 #786;修复导出页面打开目录缺失路径的问题;完善朋友圈卡片封面解析
This commit is contained in:
@@ -4336,9 +4336,9 @@ class ChatService {
|
|||||||
encrypVer = imageInfo.encrypVer
|
encrypVer = imageInfo.encrypVer
|
||||||
cdnThumbUrl = imageInfo.cdnThumbUrl
|
cdnThumbUrl = imageInfo.cdnThumbUrl
|
||||||
imageDatName = this.parseImageDatNameFromRow(row)
|
imageDatName = this.parseImageDatNameFromRow(row)
|
||||||
} else if (localType === 43 && content) {
|
} else if (localType === 43) {
|
||||||
// 视频消息
|
// 视频消息:优先从 packed_info_data 提取真实文件名(32位十六进制),再回退 XML
|
||||||
videoMd5 = this.parseVideoMd5(content)
|
videoMd5 = this.parseVideoFileNameFromRow(row, content)
|
||||||
} else if (localType === 34 && content) {
|
} else if (localType === 34 && content) {
|
||||||
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
|
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
|
||||||
} else if (localType === 42 && content) {
|
} else if (localType === 42 && content) {
|
||||||
@@ -4876,7 +4876,20 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
|
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
|
||||||
const packed = row.packed_info_data
|
const packed = this.getRowField(row, [
|
||||||
|
'packed_info_data',
|
||||||
|
'packedInfoData',
|
||||||
|
'packed_info_blob',
|
||||||
|
'packedInfoBlob',
|
||||||
|
'packed_info',
|
||||||
|
'packedInfo',
|
||||||
|
'BytesExtra',
|
||||||
|
'bytes_extra',
|
||||||
|
'WCDB_CT_packed_info',
|
||||||
|
'reserved0',
|
||||||
|
'Reserved0',
|
||||||
|
'WCDB_CT_Reserved0'
|
||||||
|
])
|
||||||
const buffer = this.decodePackedInfo(packed)
|
const buffer = this.decodePackedInfo(packed)
|
||||||
if (!buffer || buffer.length === 0) return undefined
|
if (!buffer || buffer.length === 0) return undefined
|
||||||
const printable: number[] = []
|
const printable: number[] = []
|
||||||
@@ -4894,6 +4907,81 @@ class ChatService {
|
|||||||
return hexMatch?.[1]?.toLowerCase()
|
return hexMatch?.[1]?.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseVideoFileNameFromRow(row: Record<string, any>, content?: string): string | undefined {
|
||||||
|
const packed = this.getRowField(row, [
|
||||||
|
'packed_info_data',
|
||||||
|
'packedInfoData',
|
||||||
|
'packed_info_blob',
|
||||||
|
'packedInfoBlob',
|
||||||
|
'packed_info',
|
||||||
|
'packedInfo',
|
||||||
|
'BytesExtra',
|
||||||
|
'bytes_extra',
|
||||||
|
'WCDB_CT_packed_info',
|
||||||
|
'reserved0',
|
||||||
|
'Reserved0',
|
||||||
|
'WCDB_CT_Reserved0'
|
||||||
|
])
|
||||||
|
const packedToken = this.extractVideoTokenFromPackedRaw(packed)
|
||||||
|
if (packedToken) return packedToken
|
||||||
|
|
||||||
|
const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [
|
||||||
|
'video_md5',
|
||||||
|
'videoMd5',
|
||||||
|
'raw_md5',
|
||||||
|
'rawMd5',
|
||||||
|
'video_file_name',
|
||||||
|
'videoFileName'
|
||||||
|
]))
|
||||||
|
if (byColumn) return byColumn
|
||||||
|
|
||||||
|
return this.normalizeVideoFileToken(this.parseVideoMd5(content || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeVideoFileToken(value: unknown): string | undefined {
|
||||||
|
let text = String(value || '').trim().toLowerCase()
|
||||||
|
if (!text) return undefined
|
||||||
|
text = text.replace(/^.*[\\/]/, '')
|
||||||
|
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||||
|
text = text.replace(/_thumb$/, '')
|
||||||
|
const directMatch = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||||
|
if (directMatch) {
|
||||||
|
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||||
|
return `${directMatch[1].toLowerCase()}${suffix}`
|
||||||
|
}
|
||||||
|
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||||
|
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||||
|
const generic = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||||
|
return generic?.[1]?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractVideoTokenFromPackedRaw(raw: unknown): string | undefined {
|
||||||
|
const buffer = this.decodePackedInfo(raw)
|
||||||
|
if (!buffer || buffer.length === 0) return undefined
|
||||||
|
const candidates: string[] = []
|
||||||
|
let current = ''
|
||||||
|
for (const byte of buffer) {
|
||||||
|
const isHex =
|
||||||
|
(byte >= 0x30 && byte <= 0x39) ||
|
||||||
|
(byte >= 0x41 && byte <= 0x46) ||
|
||||||
|
(byte >= 0x61 && byte <= 0x66)
|
||||||
|
if (isHex) {
|
||||||
|
current += String.fromCharCode(byte)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (current.length >= 16) candidates.push(current)
|
||||||
|
current = ''
|
||||||
|
}
|
||||||
|
if (current.length >= 16) candidates.push(current)
|
||||||
|
if (candidates.length === 0) return undefined
|
||||||
|
|
||||||
|
const exact32 = candidates.find((item) => item.length === 32)
|
||||||
|
if (exact32) return exact32.toLowerCase()
|
||||||
|
|
||||||
|
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
|
||||||
|
return fallback?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
private decodePackedInfo(raw: any): Buffer | null {
|
private decodePackedInfo(raw: any): Buffer | null {
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
if (Buffer.isBuffer(raw)) return raw
|
if (Buffer.isBuffer(raw)) return raw
|
||||||
@@ -4901,9 +4989,10 @@ class ChatService {
|
|||||||
if (Array.isArray(raw)) return Buffer.from(raw)
|
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
const trimmed = raw.trim()
|
const trimmed = raw.trim()
|
||||||
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
|
const compactHex = trimmed.replace(/\s+/g, '')
|
||||||
|
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
|
||||||
try {
|
try {
|
||||||
return Buffer.from(trimmed, 'hex')
|
return Buffer.from(compactHex, 'hex')
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -10490,6 +10579,8 @@ class ChatService {
|
|||||||
const imgInfo = this.parseImageInfo(rawContent)
|
const imgInfo = this.parseImageInfo(rawContent)
|
||||||
Object.assign(msg, imgInfo)
|
Object.assign(msg, imgInfo)
|
||||||
msg.imageDatName = this.parseImageDatNameFromRow(row)
|
msg.imageDatName = this.parseImageDatNameFromRow(row)
|
||||||
|
} else if (msg.localType === 43) { // Video
|
||||||
|
msg.videoMd5 = this.parseVideoFileNameFromRow(row, rawContent)
|
||||||
} else if (msg.localType === 47) { // Emoji
|
} else if (msg.localType === 47) { // Emoji
|
||||||
const emojiInfo = this.parseEmojiInfo(rawContent)
|
const emojiInfo = this.parseEmojiInfo(rawContent)
|
||||||
msg.emojiCdnUrl = emojiInfo.cdnUrl
|
msg.emojiCdnUrl = emojiInfo.cdnUrl
|
||||||
|
|||||||
@@ -3780,7 +3780,6 @@ class ExportService {
|
|||||||
|
|
||||||
const md5Pattern = /^[a-f0-9]{32}$/i
|
const md5Pattern = /^[a-f0-9]{32}$/i
|
||||||
const imageMd5Set = new Set<string>()
|
const imageMd5Set = new Set<string>()
|
||||||
const videoMd5Set = new Set<string>()
|
|
||||||
|
|
||||||
let scanIndex = 0
|
let scanIndex = 0
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
@@ -3800,19 +3799,12 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.exportVideos && msg?.localType === 43) {
|
|
||||||
const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase()
|
|
||||||
if (videoMd5) videoMd5Set.add(videoMd5)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const preloadTasks: Array<Promise<void>> = []
|
const preloadTasks: Array<Promise<void>> = []
|
||||||
if (imageMd5Set.size > 0) {
|
if (imageMd5Set.size > 0) {
|
||||||
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
|
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
|
||||||
}
|
}
|
||||||
if (videoMd5Set.size > 0) {
|
|
||||||
preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set)))
|
|
||||||
}
|
|
||||||
if (preloadTasks.length === 0) return
|
if (preloadTasks.length === 0) return
|
||||||
|
|
||||||
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
|
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
|
||||||
@@ -4102,6 +4094,95 @@ class ExportService {
|
|||||||
return tagMatch?.[1]?.toLowerCase()
|
return tagMatch?.[1]?.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private decodePackedInfoBuffer(raw: unknown): Buffer | null {
|
||||||
|
if (!raw) return null
|
||||||
|
if (Buffer.isBuffer(raw)) return raw
|
||||||
|
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||||
|
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const compactHex = trimmed.replace(/\s+/g, '')
|
||||||
|
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
|
||||||
|
try {
|
||||||
|
return Buffer.from(compactHex, 'hex')
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(trimmed, 'base64')
|
||||||
|
if (decoded.length > 0) return decoded
|
||||||
|
} catch { }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
|
||||||
|
return Buffer.from((raw as any).data)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeVideoFileToken(value: unknown): string | undefined {
|
||||||
|
let text = String(value || '').trim().toLowerCase()
|
||||||
|
if (!text) return undefined
|
||||||
|
text = text.replace(/^.*[\\/]/, '')
|
||||||
|
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||||
|
text = text.replace(/_thumb$/, '')
|
||||||
|
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||||
|
if (direct) {
|
||||||
|
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||||
|
return `${direct[1].toLowerCase()}${suffix}`
|
||||||
|
}
|
||||||
|
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||||
|
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||||
|
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||||
|
return fallback?.[1]?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractVideoFileNameFromPackedRaw(raw: unknown): string | undefined {
|
||||||
|
const buffer = this.decodePackedInfoBuffer(raw)
|
||||||
|
if (!buffer || buffer.length === 0) return undefined
|
||||||
|
const candidates: string[] = []
|
||||||
|
let current = ''
|
||||||
|
for (const byte of buffer) {
|
||||||
|
const isHex =
|
||||||
|
(byte >= 0x30 && byte <= 0x39) ||
|
||||||
|
(byte >= 0x41 && byte <= 0x46) ||
|
||||||
|
(byte >= 0x61 && byte <= 0x66)
|
||||||
|
if (isHex) {
|
||||||
|
current += String.fromCharCode(byte)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (current.length >= 16) candidates.push(current)
|
||||||
|
current = ''
|
||||||
|
}
|
||||||
|
if (current.length >= 16) candidates.push(current)
|
||||||
|
if (candidates.length === 0) return undefined
|
||||||
|
|
||||||
|
const exact32 = candidates.find((item) => item.length === 32)
|
||||||
|
if (exact32) return exact32.toLowerCase()
|
||||||
|
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
|
||||||
|
return fallback?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractVideoFileNameFromRow(row: Record<string, any>, content?: string): string | undefined {
|
||||||
|
const packedRaw = this.getRowField(row, [
|
||||||
|
'packed_info_data', 'packedInfoData',
|
||||||
|
'packed_info_blob', 'packedInfoBlob',
|
||||||
|
'packed_info', 'packedInfo',
|
||||||
|
'BytesExtra', 'bytes_extra',
|
||||||
|
'WCDB_CT_packed_info',
|
||||||
|
'reserved0', 'Reserved0', 'WCDB_CT_Reserved0'
|
||||||
|
])
|
||||||
|
const byPacked = this.extractVideoFileNameFromPackedRaw(packedRaw)
|
||||||
|
if (byPacked) return byPacked
|
||||||
|
|
||||||
|
const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [
|
||||||
|
'video_md5', 'videoMd5', 'raw_md5', 'rawMd5', 'video_file_name', 'videoFileName'
|
||||||
|
]))
|
||||||
|
if (byColumn) return byColumn
|
||||||
|
|
||||||
|
return this.normalizeVideoFileToken(this.extractVideoMd5(content || ''))
|
||||||
|
}
|
||||||
|
|
||||||
private resolveFileAttachmentRoots(): string[] {
|
private resolveFileAttachmentRoots(): string[] {
|
||||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||||
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
@@ -4567,7 +4648,7 @@ class ExportService {
|
|||||||
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
|
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
|
||||||
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
||||||
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
|
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
|
||||||
videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined
|
videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||||
|
|
||||||
if (localType === 3 && content) {
|
if (localType === 3 && content) {
|
||||||
// 图片消息
|
// 图片消息
|
||||||
@@ -4575,7 +4656,7 @@ class ExportService {
|
|||||||
imageDatName = imageDatName || this.extractImageDatName(content)
|
imageDatName = imageDatName || this.extractImageDatName(content)
|
||||||
} else if (localType === 43 && content) {
|
} else if (localType === 43 && content) {
|
||||||
// 视频消息
|
// 视频消息
|
||||||
videoMd5 = videoMd5 || this.extractVideoMd5(content)
|
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
|
||||||
} else if (collectMode === 'full' && content && (localType === 49 || content.includes('<appmsg') || content.includes('<appmsg'))) {
|
} else if (collectMode === 'full' && content && (localType === 49 || content.includes('<appmsg') || content.includes('<appmsg'))) {
|
||||||
// 检查是否是聊天记录消息(type=19),兼容大 localType 的 appmsg
|
// 检查是否是聊天记录消息(type=19),兼容大 localType 的 appmsg
|
||||||
const normalizedContent = this.normalizeAppMessageContent(content)
|
const normalizedContent = this.normalizeAppMessageContent(content)
|
||||||
@@ -4720,7 +4801,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.localType === 43) {
|
if (msg.localType === 43) {
|
||||||
const videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || this.extractVideoMd5(content)
|
const videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||||
if (videoMd5) msg.videoMd5 = videoMd5
|
if (videoMd5) msg.videoMd5 = videoMd5
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -8941,12 +9022,14 @@ class ExportService {
|
|||||||
pendingSessionIds?: string[]
|
pendingSessionIds?: string[]
|
||||||
successSessionIds?: string[]
|
successSessionIds?: string[]
|
||||||
failedSessionIds?: string[]
|
failedSessionIds?: string[]
|
||||||
|
sessionOutputPaths?: Record<string, string>
|
||||||
error?: string
|
error?: string
|
||||||
}> {
|
}> {
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
const successSessionIds: string[] = []
|
const successSessionIds: string[] = []
|
||||||
const failedSessionIds: string[] = []
|
const failedSessionIds: string[] = []
|
||||||
|
const sessionOutputPaths: Record<string, string> = {}
|
||||||
const progressEmitter = this.createProgressEmitter(onProgress)
|
const progressEmitter = this.createProgressEmitter(onProgress)
|
||||||
let attachMediaTelemetry = false
|
let attachMediaTelemetry = false
|
||||||
const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => {
|
const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => {
|
||||||
@@ -9144,7 +9227,8 @@ class ExportService {
|
|||||||
stopped: true,
|
stopped: true,
|
||||||
pendingSessionIds: [...queue],
|
pendingSessionIds: [...queue],
|
||||||
successSessionIds,
|
successSessionIds,
|
||||||
failedSessionIds
|
failedSessionIds,
|
||||||
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pauseRequested) {
|
if (pauseRequested) {
|
||||||
@@ -9155,7 +9239,8 @@ class ExportService {
|
|||||||
paused: true,
|
paused: true,
|
||||||
pendingSessionIds: [...queue],
|
pendingSessionIds: [...queue],
|
||||||
successSessionIds,
|
successSessionIds,
|
||||||
failedSessionIds
|
failedSessionIds,
|
||||||
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9266,6 +9351,7 @@ class ExportService {
|
|||||||
if (hasNoDataChange) {
|
if (hasNoDataChange) {
|
||||||
successCount++
|
successCount++
|
||||||
successSessionIds.push(sessionId)
|
successSessionIds.push(sessionId)
|
||||||
|
sessionOutputPaths[sessionId] = preferredOutputPath
|
||||||
activeSessionRatios.delete(sessionId)
|
activeSessionRatios.delete(sessionId)
|
||||||
completedCount++
|
completedCount++
|
||||||
emitProgress({
|
emitProgress({
|
||||||
@@ -9311,6 +9397,7 @@ class ExportService {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
successCount++
|
successCount++
|
||||||
successSessionIds.push(sessionId)
|
successSessionIds.push(sessionId)
|
||||||
|
sessionOutputPaths[sessionId] = outputPath
|
||||||
if (typeof messageCountHint === 'number' && messageCountHint >= 0) {
|
if (typeof messageCountHint === 'number' && messageCountHint >= 0) {
|
||||||
exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, {
|
exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, {
|
||||||
sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0
|
sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0
|
||||||
@@ -9401,7 +9488,8 @@ class ExportService {
|
|||||||
stopped: true,
|
stopped: true,
|
||||||
pendingSessionIds,
|
pendingSessionIds,
|
||||||
successSessionIds,
|
successSessionIds,
|
||||||
failedSessionIds
|
failedSessionIds,
|
||||||
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pauseRequested && pendingSessionIds.length > 0) {
|
if (pauseRequested && pendingSessionIds.length > 0) {
|
||||||
@@ -9412,7 +9500,8 @@ class ExportService {
|
|||||||
paused: true,
|
paused: true,
|
||||||
pendingSessionIds,
|
pendingSessionIds,
|
||||||
successSessionIds,
|
successSessionIds,
|
||||||
failedSessionIds
|
failedSessionIds,
|
||||||
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9425,7 +9514,7 @@ class ExportService {
|
|||||||
}, { force: true })
|
}, { force: true })
|
||||||
progressEmitter.flush()
|
progressEmitter.flush()
|
||||||
|
|
||||||
return { success: true, successCount, failCount, successSessionIds, failedSessionIds }
|
return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
progressEmitter.flush()
|
progressEmitter.flush()
|
||||||
return { success: false, successCount, failCount, error: String(e) }
|
return { success: false, successCount, failCount, error: String(e) }
|
||||||
|
|||||||
@@ -144,14 +144,14 @@ export class ImageDecryptService {
|
|||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const cached = this.resolvedCache.get(key)
|
const cached = this.resolvedCache.get(key)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
const upgraded = this.isThumbnailPath(cached)
|
const upgraded = !this.isHdPath(cached)
|
||||||
? await this.tryPromoteThumbnailCache(payload, key, cached)
|
? await this.tryPromoteThumbnailCache(payload, key, cached)
|
||||||
: null
|
: null
|
||||||
const finalPath = upgraded || cached
|
const finalPath = upgraded || cached
|
||||||
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
||||||
const isThumb = this.isThumbnailPath(finalPath)
|
const isNonHd = !this.isHdPath(finalPath)
|
||||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
const hasUpdate = isNonHd ? (this.updateFlags.get(key) ?? false) : false
|
||||||
if (isThumb) {
|
if (isNonHd) {
|
||||||
if (this.shouldCheckImageUpdate(payload)) {
|
if (this.shouldCheckImageUpdate(payload)) {
|
||||||
this.triggerUpdateCheck(payload, key, finalPath)
|
this.triggerUpdateCheck(payload, key, finalPath)
|
||||||
}
|
}
|
||||||
@@ -184,15 +184,15 @@ export class ImageDecryptService {
|
|||||||
if (datPath) {
|
if (datPath) {
|
||||||
const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false)
|
const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const upgraded = this.isThumbnailPath(existing)
|
const upgraded = !this.isHdPath(existing)
|
||||||
? await this.tryPromoteThumbnailCache(payload, cacheKey, existing)
|
? await this.tryPromoteThumbnailCache(payload, cacheKey, existing)
|
||||||
: null
|
: null
|
||||||
const finalPath = upgraded || existing
|
const finalPath = upgraded || existing
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath)
|
||||||
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
||||||
const isThumb = this.isThumbnailPath(finalPath)
|
const isNonHd = !this.isHdPath(finalPath)
|
||||||
const hasUpdate = isThumb ? (this.updateFlags.get(cacheKey) ?? false) : false
|
const hasUpdate = isNonHd ? (this.updateFlags.get(cacheKey) ?? false) : false
|
||||||
if (isThumb) {
|
if (isNonHd) {
|
||||||
if (this.shouldCheckImageUpdate(payload)) {
|
if (this.shouldCheckImageUpdate(payload)) {
|
||||||
this.triggerUpdateCheck(payload, cacheKey, finalPath)
|
this.triggerUpdateCheck(payload, cacheKey, finalPath)
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ export class ImageDecryptService {
|
|||||||
if (payload.force) {
|
if (payload.force) {
|
||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const cached = this.resolvedCache.get(key)
|
const cached = this.resolvedCache.get(key)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached) && this.isHdPath(cached)) {
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||||
@@ -237,7 +237,7 @@ export class ImageDecryptService {
|
|||||||
if (!payload.force) {
|
if (!payload.force) {
|
||||||
const cached = this.resolvedCache.get(cacheKey)
|
const cached = this.resolvedCache.get(cacheKey)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
const upgraded = this.isThumbnailPath(cached)
|
const upgraded = !this.isHdPath(cached)
|
||||||
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
|
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
|
||||||
: null
|
: null
|
||||||
const finalPath = upgraded || cached
|
const finalPath = upgraded || cached
|
||||||
@@ -280,22 +280,13 @@ export class ImageDecryptService {
|
|||||||
if (!accountDir) return
|
if (!accountDir) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ready = await this.ensureWcdbReady()
|
for (const md5 of normalizedList) {
|
||||||
if (!ready) return
|
if (!this.looksLikeMd5(md5)) continue
|
||||||
const requests = normalizedList.map((md5) => ({ md5, accountDir }))
|
const selectedPath = this.selectBestDatPathByBase(accountDir, md5, undefined, undefined, true)
|
||||||
const result = await wcdbService.resolveImageHardlinkBatch(requests)
|
if (!selectedPath) continue
|
||||||
if (!result.success || !Array.isArray(result.rows)) return
|
|
||||||
|
|
||||||
for (const row of result.rows) {
|
|
||||||
const md5 = String(row?.md5 || '').trim().toLowerCase()
|
|
||||||
if (!md5) continue
|
|
||||||
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
|
|
||||||
const fullPath = String(row?.data?.full_path || '').trim()
|
|
||||||
if (!fileName || !fullPath) continue
|
|
||||||
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
|
|
||||||
if (!selectedPath || !existsSync(selectedPath)) continue
|
|
||||||
this.cacheDatPath(accountDir, md5, selectedPath)
|
this.cacheDatPath(accountDir, md5, selectedPath)
|
||||||
this.cacheDatPath(accountDir, fileName, selectedPath)
|
const fileName = basename(selectedPath).toLowerCase()
|
||||||
|
if (fileName) this.cacheDatPath(accountDir, fileName, selectedPath)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore preload failures
|
// ignore preload failures
|
||||||
@@ -477,8 +468,10 @@ export class ImageDecryptService {
|
|||||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||||
|
|
||||||
const isThumb = this.isThumbnailPath(datPath)
|
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(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||||
if (!isThumb) {
|
if (isHdCache) {
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
} else {
|
} else {
|
||||||
if (this.shouldCheckImageUpdate(payload)) {
|
if (this.shouldCheckImageUpdate(payload)) {
|
||||||
@@ -625,95 +618,49 @@ export class ImageDecryptService {
|
|||||||
allowDatNameScanFallback
|
allowDatNameScanFallback
|
||||||
})
|
})
|
||||||
|
|
||||||
const lookupMd5s = this.collectHardlinkLookupMd5s(imageMd5, imageDatName)
|
const lookupBases = this.collectLookupBasesForScan(imageMd5, imageDatName, allowDatNameScanFallback)
|
||||||
const fallbackDatName = String(imageDatName || imageMd5 || '').trim().toLowerCase() || undefined
|
if (lookupBases.length === 0) {
|
||||||
if (lookupMd5s.length === 0) {
|
this.logInfo('[ImageDecrypt] resolveDatPath miss (no lookup base)', { imageMd5, imageDatName })
|
||||||
if (!allowDatNameScanFallback) {
|
|
||||||
this.logInfo('[ImageDecrypt] resolveDatPath skip datName scan (no hardlink md5)', {
|
|
||||||
imageMd5,
|
|
||||||
imageDatName,
|
|
||||||
sessionId,
|
|
||||||
createTime
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
|
|
||||||
if (packedDatFallback) {
|
|
||||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
|
|
||||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
|
|
||||||
const normalizedFileName = basename(packedDatFallback).toLowerCase()
|
|
||||||
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
|
|
||||||
this.logInfo('[ImageDecrypt] datName fallback hit (no hardlink md5)', {
|
|
||||||
imageMd5,
|
|
||||||
imageDatName,
|
|
||||||
selectedPath: packedDatFallback
|
|
||||||
})
|
|
||||||
return packedDatFallback
|
|
||||||
}
|
|
||||||
this.logInfo('[ImageDecrypt] resolveDatPath miss (no hardlink md5)', { imageMd5, imageDatName })
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skipResolvedCache) {
|
if (!skipResolvedCache) {
|
||||||
const cacheCandidates = Array.from(new Set([
|
const cacheCandidates = Array.from(new Set([
|
||||||
...lookupMd5s,
|
...lookupBases,
|
||||||
String(imageMd5 || '').trim().toLowerCase(),
|
String(imageMd5 || '').trim().toLowerCase(),
|
||||||
String(imageDatName || '').trim().toLowerCase()
|
String(imageDatName || '').trim().toLowerCase()
|
||||||
].filter(Boolean)))
|
].filter(Boolean)))
|
||||||
for (const cacheKey of cacheCandidates) {
|
for (const cacheKey of cacheCandidates) {
|
||||||
const scopedKey = `${accountDir}|${cacheKey}`
|
const scopedKey = `${accountDir}|${cacheKey}`
|
||||||
const cached = this.resolvedCache.get(scopedKey)
|
const cached = this.resolvedCache.get(scopedKey)
|
||||||
if (!cached) continue
|
if (!cached || !existsSync(cached)) continue
|
||||||
if (!existsSync(cached)) continue
|
if (!allowThumbnail && !this.isHdDatPath(cached)) continue
|
||||||
if (!allowThumbnail && this.isThumbnailPath(cached)) continue
|
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const lookupMd5 of lookupMd5s) {
|
for (const baseMd5 of lookupBases) {
|
||||||
this.logInfo('[ImageDecrypt] hardlink lookup', { lookupMd5, sessionId, hardlinkOnly })
|
const selectedPath = this.selectBestDatPathByBase(accountDir, baseMd5, sessionId, createTime, allowThumbnail)
|
||||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, lookupMd5, sessionId)
|
if (!selectedPath) continue
|
||||||
if (!hardlinkPath) continue
|
|
||||||
if (!allowThumbnail && this.isThumbnailPath(hardlinkPath)) continue
|
|
||||||
|
|
||||||
this.cacheDatPath(accountDir, lookupMd5, hardlinkPath)
|
this.cacheDatPath(accountDir, baseMd5, selectedPath)
|
||||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
|
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, selectedPath)
|
||||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, selectedPath)
|
||||||
const normalizedFileName = basename(hardlinkPath).toLowerCase()
|
const normalizedFileName = basename(selectedPath).toLowerCase()
|
||||||
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, hardlinkPath)
|
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, selectedPath)
|
||||||
return hardlinkPath
|
this.logInfo('[ImageDecrypt] dat scan selected', {
|
||||||
}
|
baseMd5,
|
||||||
|
selectedPath,
|
||||||
if (!allowDatNameScanFallback) {
|
allowThumbnail
|
||||||
this.logInfo('[ImageDecrypt] resolveDatPath skip datName fallback after hardlink miss', {
|
|
||||||
imageMd5,
|
|
||||||
imageDatName,
|
|
||||||
sessionId,
|
|
||||||
createTime,
|
|
||||||
lookupMd5s
|
|
||||||
})
|
})
|
||||||
return null
|
return selectedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
|
this.logInfo('[ImageDecrypt] resolveDatPath miss (dat scan)', {
|
||||||
if (packedDatFallback) {
|
|
||||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
|
|
||||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
|
|
||||||
const normalizedFileName = basename(packedDatFallback).toLowerCase()
|
|
||||||
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
|
|
||||||
this.logInfo('[ImageDecrypt] datName fallback hit (hardlink miss)', {
|
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
lookupMd5s,
|
lookupBases,
|
||||||
selectedPath: packedDatFallback
|
allowThumbnail
|
||||||
})
|
|
||||||
return packedDatFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink + datName fallback)', {
|
|
||||||
imageMd5,
|
|
||||||
imageDatName,
|
|
||||||
lookupMd5s
|
|
||||||
})
|
})
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -724,23 +671,46 @@ export class ImageDecryptService {
|
|||||||
cachedPath: string
|
cachedPath: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!cachedPath || !existsSync(cachedPath)) return false
|
if (!cachedPath || !existsSync(cachedPath)) return false
|
||||||
const isThumbnail = this.isThumbnailPath(cachedPath)
|
if (this.isHdPath(cachedPath)) return false
|
||||||
if (!isThumbnail) return false
|
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.configService.get('myWxid')
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = this.configService.get('dbPath')
|
||||||
if (!wxid || !dbPath) return false
|
if (!wxid || !dbPath) return false
|
||||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||||
if (!accountDir) return false
|
if (!accountDir) return false
|
||||||
|
|
||||||
const hdPath = await this.resolveDatPath(
|
const lookupBases = this.collectLookupBasesForScan(payload.imageMd5, payload.imageDatName, true)
|
||||||
accountDir,
|
if (lookupBases.length === 0) return false
|
||||||
payload.imageMd5,
|
|
||||||
payload.imageDatName,
|
let currentTier = this.getCachedPathTier(cachedPath)
|
||||||
payload.sessionId,
|
let bestDatPath: string | null = null
|
||||||
payload.createTime,
|
let bestDatTier = -1
|
||||||
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false }
|
for (const baseMd5 of lookupBases) {
|
||||||
)
|
const candidate = this.selectBestDatPathByBase(accountDir, baseMd5, payload.sessionId, payload.createTime, true)
|
||||||
return Boolean(hdPath)
|
if (!candidate) continue
|
||||||
|
const candidateTier = this.getDatTier(candidate, baseMd5)
|
||||||
|
if (candidateTier <= 0) continue
|
||||||
|
if (!bestDatPath) {
|
||||||
|
bestDatPath = candidate
|
||||||
|
bestDatTier = candidateTier
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (candidateTier > bestDatTier) {
|
||||||
|
bestDatPath = candidate
|
||||||
|
bestDatTier = candidateTier
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (candidateTier === bestDatTier) {
|
||||||
|
const candidateSize = this.fileSizeSafe(candidate)
|
||||||
|
const bestSize = this.fileSizeSafe(bestDatPath)
|
||||||
|
if (candidateSize > bestSize) {
|
||||||
|
bestDatPath = candidate
|
||||||
|
bestDatTier = candidateTier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bestDatPath || bestDatTier <= 0) return false
|
||||||
|
if (currentTier < 0) currentTier = 1
|
||||||
|
return bestDatTier > currentTier
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryPromoteThumbnailCache(
|
private async tryPromoteThumbnailCache(
|
||||||
@@ -750,7 +720,7 @@ export class ImageDecryptService {
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (!cachedPath || !existsSync(cachedPath)) return null
|
if (!cachedPath || !existsSync(cachedPath)) return null
|
||||||
if (!this.isImageFile(cachedPath)) return null
|
if (!this.isImageFile(cachedPath)) return null
|
||||||
if (!this.isThumbnailPath(cachedPath)) return null
|
if (this.isHdPath(cachedPath)) return null
|
||||||
|
|
||||||
const accountDir = this.resolveCurrentAccountDir()
|
const accountDir = this.resolveCurrentAccountDir()
|
||||||
if (!accountDir) return null
|
if (!accountDir) return null
|
||||||
@@ -766,7 +736,7 @@ export class ImageDecryptService {
|
|||||||
if (!hdDatPath) return null
|
if (!hdDatPath) return null
|
||||||
|
|
||||||
const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true)
|
const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true)
|
||||||
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && !this.isThumbnailPath(existingHd)) {
|
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && this.isHdPath(existingHd)) {
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
this.removeThumbnailCacheFile(cachedPath, existingHd)
|
this.removeThumbnailCacheFile(cachedPath, existingHd)
|
||||||
@@ -796,7 +766,7 @@ export class ImageDecryptService {
|
|||||||
? cachedResult
|
? cachedResult
|
||||||
: String(upgraded.localPath || '').trim()
|
: String(upgraded.localPath || '').trim()
|
||||||
if (!upgradedPath || !existsSync(upgradedPath)) return null
|
if (!upgradedPath || !existsSync(upgradedPath)) return null
|
||||||
if (!this.isImageFile(upgradedPath) || this.isThumbnailPath(upgradedPath)) return null
|
if (!this.isImageFile(upgradedPath) || !this.isHdPath(upgradedPath)) return null
|
||||||
|
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath)
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
@@ -814,24 +784,73 @@ export class ImageDecryptService {
|
|||||||
if (!oldPath) return
|
if (!oldPath) return
|
||||||
if (keepPath && oldPath === keepPath) return
|
if (keepPath && oldPath === keepPath) return
|
||||||
if (!existsSync(oldPath)) return
|
if (!existsSync(oldPath)) return
|
||||||
if (!this.isThumbnailPath(oldPath)) return
|
if (this.isHdPath(oldPath)) return
|
||||||
void rm(oldPath, { force: true }).catch(() => { })
|
void rm(oldPath, { force: true }).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
private triggerUpdateCheck(
|
private triggerUpdateCheck(
|
||||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; disableUpdateCheck?: boolean; suppressEvents?: boolean },
|
payload: {
|
||||||
|
sessionId?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
preferFilePath?: boolean
|
||||||
|
disableUpdateCheck?: boolean
|
||||||
|
suppressEvents?: boolean
|
||||||
|
},
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
cachedPath: string
|
cachedPath: string
|
||||||
): void {
|
): void {
|
||||||
if (!this.shouldCheckImageUpdate(payload)) return
|
if (!this.shouldCheckImageUpdate(payload)) return
|
||||||
if (this.updateFlags.get(cacheKey)) return
|
if (this.updateFlags.get(cacheKey)) return
|
||||||
void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => {
|
void this.checkHasUpdate(payload, cacheKey, cachedPath).then(async (hasUpdate) => {
|
||||||
if (!hasUpdate) return
|
if (!hasUpdate) return
|
||||||
this.updateFlags.set(cacheKey, true)
|
this.updateFlags.set(cacheKey, true)
|
||||||
|
const upgradedPath = await this.tryAutoRefreshBetterCache(payload, cacheKey, cachedPath)
|
||||||
|
if (upgradedPath) {
|
||||||
|
this.updateFlags.delete(cacheKey)
|
||||||
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(upgradedPath, payload.preferFilePath))
|
||||||
|
return
|
||||||
|
}
|
||||||
this.emitImageUpdate(payload, cacheKey)
|
this.emitImageUpdate(payload, cacheKey)
|
||||||
}).catch(() => { })
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async tryAutoRefreshBetterCache(
|
||||||
|
payload: {
|
||||||
|
sessionId?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
preferFilePath?: boolean
|
||||||
|
disableUpdateCheck?: boolean
|
||||||
|
suppressEvents?: boolean
|
||||||
|
},
|
||||||
|
cacheKey: string,
|
||||||
|
cachedPath: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!cachedPath || !existsSync(cachedPath)) return null
|
||||||
|
if (this.isHdPath(cachedPath)) return null
|
||||||
|
const refreshed = await this.decryptImage({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
imageMd5: payload.imageMd5,
|
||||||
|
imageDatName: payload.imageDatName,
|
||||||
|
createTime: payload.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
force: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
suppressEvents: true
|
||||||
|
})
|
||||||
|
if (!refreshed.success || !refreshed.localPath) return null
|
||||||
|
const refreshedPath = String(refreshed.localPath || '').trim()
|
||||||
|
if (!refreshedPath || !existsSync(refreshedPath)) return null
|
||||||
|
if (!this.isImageFile(refreshedPath)) return null
|
||||||
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, refreshedPath)
|
||||||
|
this.removeThumbnailCacheFile(cachedPath, refreshedPath)
|
||||||
|
return refreshedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] {
|
private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] {
|
||||||
@@ -854,6 +873,111 @@ export class ImageDecryptService {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private collectLookupBasesForScan(imageMd5?: string, imageDatName?: string, allowDatNameScanFallback = true): string[] {
|
||||||
|
const bases = this.collectHardlinkLookupMd5s(imageMd5, imageDatName)
|
||||||
|
if (!allowDatNameScanFallback) return bases
|
||||||
|
const fallbackRaw = String(imageDatName || imageMd5 || '').trim().toLowerCase()
|
||||||
|
if (!fallbackRaw) return bases
|
||||||
|
const fallbackNoExt = fallbackRaw.endsWith('.dat') ? fallbackRaw.slice(0, -4) : fallbackRaw
|
||||||
|
const fallbackBase = this.normalizeDatBase(fallbackNoExt)
|
||||||
|
if (this.looksLikeMd5(fallbackBase) && !bases.includes(fallbackBase)) {
|
||||||
|
bases.push(fallbackBase)
|
||||||
|
}
|
||||||
|
return bases
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectAllDatCandidatesForBase(
|
||||||
|
accountDir: string,
|
||||||
|
baseMd5: string,
|
||||||
|
sessionId?: string,
|
||||||
|
createTime?: number
|
||||||
|
): string[] {
|
||||||
|
const sessionMonth = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime)
|
||||||
|
return Array.from(new Set(sessionMonth.filter((item) => {
|
||||||
|
const path = String(item || '').trim()
|
||||||
|
return path && existsSync(path) && path.toLowerCase().endsWith('.dat')
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
private isImgScopedDatPath(filePath: string): boolean {
|
||||||
|
const lower = String(filePath || '').toLowerCase()
|
||||||
|
return /[\\/](img|image|msgimg)[\\/]/.test(lower)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fileSizeSafe(filePath: string): number {
|
||||||
|
try {
|
||||||
|
return statSync(filePath).size || 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fileMtimeSafe(filePath: string): number {
|
||||||
|
try {
|
||||||
|
return statSync(filePath).mtimeMs || 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickLargestDatPath(paths: string[]): string | null {
|
||||||
|
const list = Array.from(new Set(paths.filter(Boolean)))
|
||||||
|
if (list.length === 0) return null
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const sizeDiff = this.fileSizeSafe(b) - this.fileSizeSafe(a)
|
||||||
|
if (sizeDiff !== 0) return sizeDiff
|
||||||
|
const mtimeDiff = this.fileMtimeSafe(b) - this.fileMtimeSafe(a)
|
||||||
|
if (mtimeDiff !== 0) return mtimeDiff
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
return list[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectBestDatPathByBase(
|
||||||
|
accountDir: string,
|
||||||
|
baseMd5: string,
|
||||||
|
sessionId?: string,
|
||||||
|
createTime?: number,
|
||||||
|
allowThumbnail = true
|
||||||
|
): string | null {
|
||||||
|
const candidates = this.collectAllDatCandidatesForBase(accountDir, baseMd5, sessionId, createTime)
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
|
||||||
|
const imgCandidates = candidates.filter((item) => this.isImgScopedDatPath(item))
|
||||||
|
const imgHdCandidates = imgCandidates.filter((item) => this.isHdDatPath(item))
|
||||||
|
const hdInImg = this.pickLargestDatPath(imgHdCandidates)
|
||||||
|
if (hdInImg) return hdInImg
|
||||||
|
|
||||||
|
if (!allowThumbnail) {
|
||||||
|
// 高清优先仅认 img/image/msgimg 路径中的 H 变体;
|
||||||
|
// 若该范围没有,则交由 allowThumbnail=true 的回退分支按 base.dat/_t 继续挑选。
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无 H 时,优先尝试原始无后缀 DAT({md5}.dat)。
|
||||||
|
const baseDatInImg = this.pickLargestDatPath(
|
||||||
|
imgCandidates.filter((item) => this.isBaseDatPath(item, baseMd5))
|
||||||
|
)
|
||||||
|
if (baseDatInImg) return baseDatInImg
|
||||||
|
|
||||||
|
const baseDatAny = this.pickLargestDatPath(
|
||||||
|
candidates.filter((item) => this.isBaseDatPath(item, baseMd5))
|
||||||
|
)
|
||||||
|
if (baseDatAny) return baseDatAny
|
||||||
|
|
||||||
|
const thumbDatInImg = this.pickLargestDatPath(
|
||||||
|
imgCandidates.filter((item) => this.isTVariantDat(item))
|
||||||
|
)
|
||||||
|
if (thumbDatInImg) return thumbDatInImg
|
||||||
|
|
||||||
|
const thumbDatAny = this.pickLargestDatPath(
|
||||||
|
candidates.filter((item) => this.isTVariantDat(item))
|
||||||
|
)
|
||||||
|
if (thumbDatAny) return thumbDatAny
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private resolveDatPathFromParsedDatName(
|
private resolveDatPathFromParsedDatName(
|
||||||
accountDir: string,
|
accountDir: string,
|
||||||
imageDatName?: string,
|
imageDatName?: string,
|
||||||
@@ -878,7 +1002,7 @@ export class ImageDecryptService {
|
|||||||
if (sessionMonthCandidates.length > 0) {
|
if (sessionMonthCandidates.length > 0) {
|
||||||
const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5)
|
const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5)
|
||||||
for (const candidatePath of orderedSessionMonth) {
|
for (const candidatePath of orderedSessionMonth) {
|
||||||
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
|
if (!allowThumbnail && !this.isHdDatPath(candidatePath)) continue
|
||||||
this.datNameScanMissAt.delete(missKey)
|
this.datNameScanMissAt.delete(missKey)
|
||||||
this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', {
|
this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', {
|
||||||
accountDir,
|
accountDir,
|
||||||
@@ -894,8 +1018,7 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPreciseContext = Boolean(String(sessionId || '').trim() && monthKey)
|
// 新策略:只扫描会话月目录,不做 account-wide 根目录回退。
|
||||||
if (hasPreciseContext) {
|
|
||||||
this.datNameScanMissAt.set(missKey, Date.now())
|
this.datNameScanMissAt.set(missKey, Date.now())
|
||||||
this.logInfo('[ImageDecrypt] datName fallback precise scan miss', {
|
this.logInfo('[ImageDecrypt] datName fallback precise scan miss', {
|
||||||
accountDir,
|
accountDir,
|
||||||
@@ -909,42 +1032,6 @@ export class ImageDecryptService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = this.collectDatCandidatesFromAccountDir(accountDir, baseMd5)
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
this.datNameScanMissAt.set(missKey, Date.now())
|
|
||||||
this.logInfo('[ImageDecrypt] datName fallback scan miss', {
|
|
||||||
accountDir,
|
|
||||||
sessionId,
|
|
||||||
imageDatName: datNameRaw,
|
|
||||||
createTime,
|
|
||||||
monthKey,
|
|
||||||
baseMd5,
|
|
||||||
allowThumbnail
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const ordered = this.sortDatCandidatePaths(candidates, baseMd5)
|
|
||||||
for (const candidatePath of ordered) {
|
|
||||||
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
|
|
||||||
this.datNameScanMissAt.delete(missKey)
|
|
||||||
this.logInfo('[ImageDecrypt] datName fallback selected', {
|
|
||||||
accountDir,
|
|
||||||
sessionId,
|
|
||||||
imageDatName: datNameRaw,
|
|
||||||
createTime,
|
|
||||||
monthKey,
|
|
||||||
baseMd5,
|
|
||||||
allowThumbnail,
|
|
||||||
selectedPath: candidatePath
|
|
||||||
})
|
|
||||||
return candidatePath
|
|
||||||
}
|
|
||||||
|
|
||||||
this.datNameScanMissAt.set(missKey, Date.now())
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveYearMonthFromCreateTime(createTime?: number): string {
|
private resolveYearMonthFromCreateTime(createTime?: number): string {
|
||||||
const raw = Number(createTime)
|
const raw = Number(createTime)
|
||||||
if (!Number.isFinite(raw) || raw <= 0) return ''
|
if (!Number.isFinite(raw) || raw <= 0) return ''
|
||||||
@@ -966,27 +1053,14 @@ export class ImageDecryptService {
|
|||||||
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
|
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
|
||||||
if (!normalizedSessionId || !monthKey) return []
|
if (!normalizedSessionId || !monthKey) return []
|
||||||
|
|
||||||
const attachRoots = this.getAttachScanRoots(accountDir)
|
const sessionDir = this.resolveSessionDirForStorage(normalizedSessionId)
|
||||||
const cacheRoots = this.getMessageCacheScanRoots(accountDir)
|
if (!sessionDir) return []
|
||||||
const sessionDirs = this.getAttachSessionDirCandidates(normalizedSessionId)
|
|
||||||
const candidates = new Set<string>()
|
const candidates = new Set<string>()
|
||||||
const budget = { remaining: 600 }
|
const budget = { remaining: 240 }
|
||||||
const targetDirs: Array<{ dir: string; depth: number }> = []
|
const targetDirs: Array<{ dir: string; depth: number }> = [
|
||||||
|
// 1) accountDir/msg/attach/{sessionMd5}/{yyyy-MM}/Img
|
||||||
for (const root of attachRoots) {
|
{ dir: join(accountDir, 'msg', 'attach', sessionDir, monthKey, 'Img'), depth: 1 }
|
||||||
for (const sessionDir of sessionDirs) {
|
]
|
||||||
targetDirs.push({ dir: join(root, sessionDir, monthKey), depth: 2 })
|
|
||||||
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Img'), depth: 1 })
|
|
||||||
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Image'), depth: 1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const root of cacheRoots) {
|
|
||||||
for (const sessionDir of sessionDirs) {
|
|
||||||
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir, 'Bubble'), depth: 1 })
|
|
||||||
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir), depth: 2 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const target of targetDirs) {
|
for (const target of targetDirs) {
|
||||||
if (budget.remaining <= 0) break
|
if (budget.remaining <= 0) break
|
||||||
@@ -996,98 +1070,13 @@ export class ImageDecryptService {
|
|||||||
return Array.from(candidates)
|
return Array.from(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAttachScanRoots(accountDir: string): string[] {
|
private resolveSessionDirForStorage(sessionId: string): string {
|
||||||
const roots: string[] = []
|
const normalized = String(sessionId || '').trim().toLowerCase()
|
||||||
const push = (value: string) => {
|
if (!normalized) return ''
|
||||||
const normalized = String(value || '').trim()
|
if (this.looksLikeMd5(normalized)) return normalized
|
||||||
if (!normalized) return
|
const cleaned = this.cleanAccountDirName(normalized).toLowerCase()
|
||||||
if (!roots.includes(normalized)) roots.push(normalized)
|
if (this.looksLikeMd5(cleaned)) return cleaned
|
||||||
}
|
return crypto.createHash('md5').update(cleaned || normalized).digest('hex').toLowerCase()
|
||||||
|
|
||||||
push(join(accountDir, 'msg', 'attach'))
|
|
||||||
push(join(accountDir, 'attach'))
|
|
||||||
const parent = dirname(accountDir)
|
|
||||||
if (parent && parent !== accountDir) {
|
|
||||||
push(join(parent, 'msg', 'attach'))
|
|
||||||
push(join(parent, 'attach'))
|
|
||||||
}
|
|
||||||
return roots
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMessageCacheScanRoots(accountDir: string): string[] {
|
|
||||||
const roots: string[] = []
|
|
||||||
const push = (value: string) => {
|
|
||||||
const normalized = String(value || '').trim()
|
|
||||||
if (!normalized) return
|
|
||||||
if (!roots.includes(normalized)) roots.push(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
push(join(accountDir, 'cache'))
|
|
||||||
const parent = dirname(accountDir)
|
|
||||||
if (parent && parent !== accountDir) {
|
|
||||||
push(join(parent, 'cache'))
|
|
||||||
}
|
|
||||||
return roots
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAttachSessionDirCandidates(sessionId: string): string[] {
|
|
||||||
const normalized = String(sessionId || '').trim()
|
|
||||||
if (!normalized) return []
|
|
||||||
const lower = normalized.toLowerCase()
|
|
||||||
const cleaned = this.cleanAccountDirName(normalized)
|
|
||||||
const inputs = Array.from(new Set([normalized, lower, cleaned, cleaned.toLowerCase()].filter(Boolean)))
|
|
||||||
const results: string[] = []
|
|
||||||
const push = (value: string) => {
|
|
||||||
if (!value) return
|
|
||||||
if (!results.includes(value)) results.push(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of inputs) {
|
|
||||||
push(item)
|
|
||||||
const md5 = crypto.createHash('md5').update(item).digest('hex').toLowerCase()
|
|
||||||
push(md5)
|
|
||||||
push(md5.slice(0, 16))
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
private collectDatCandidatesFromAccountDir(accountDir: string, baseMd5: string): string[] {
|
|
||||||
const roots = this.getDatScanRoots(accountDir)
|
|
||||||
const candidates = new Set<string>()
|
|
||||||
const budget = { remaining: 1400 }
|
|
||||||
|
|
||||||
for (const item of roots) {
|
|
||||||
if (budget.remaining <= 0) break
|
|
||||||
this.scanDatCandidatesUnderRoot(item.root, baseMd5, item.maxDepth, candidates, budget)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidates.size === 0 && budget.remaining <= 0) {
|
|
||||||
this.logInfo('[ImageDecrypt] datName fallback budget exhausted', {
|
|
||||||
accountDir,
|
|
||||||
baseMd5,
|
|
||||||
roots: roots.map((item) => item.root)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(candidates)
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDatScanRoots(accountDir: string): Array<{ root: string; maxDepth: number }> {
|
|
||||||
const roots: Array<{ root: string; maxDepth: number }> = []
|
|
||||||
const push = (root: string, maxDepth: number) => {
|
|
||||||
const normalized = String(root || '').trim()
|
|
||||||
if (!normalized) return
|
|
||||||
if (roots.some((item) => item.root === normalized)) return
|
|
||||||
roots.push({ root: normalized, maxDepth })
|
|
||||||
}
|
|
||||||
|
|
||||||
push(join(accountDir, 'attach'), 4)
|
|
||||||
push(join(accountDir, 'msg', 'attach'), 4)
|
|
||||||
push(join(accountDir, 'FileStorage', 'Image'), 3)
|
|
||||||
push(join(accountDir, 'FileStorage', 'Image2'), 3)
|
|
||||||
push(join(accountDir, 'FileStorage', 'MsgImg'), 3)
|
|
||||||
|
|
||||||
return roots
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private scanDatCandidatesUnderRoot(
|
private scanDatCandidatesUnderRoot(
|
||||||
@@ -1296,17 +1285,60 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCacheVariantSuffixFromDat(datPath: string): string {
|
||||||
|
if (this.isHdDatPath(datPath)) return '_hd'
|
||||||
|
const name = basename(datPath)
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
const stem = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||||
|
const base = this.normalizeDatBase(stem)
|
||||||
|
const rawSuffix = stem.slice(base.length)
|
||||||
|
if (!rawSuffix) return ''
|
||||||
|
const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '')
|
||||||
|
if (!safe) return ''
|
||||||
|
if (safe.startsWith('_') || safe.startsWith('.')) return safe
|
||||||
|
return `_${safe}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCacheVariantSuffixFromCachedPath(cachePath: string): string {
|
||||||
|
const raw = String(cachePath || '').split('?')[0]
|
||||||
|
const name = basename(raw)
|
||||||
|
const ext = extname(name).toLowerCase()
|
||||||
|
const stem = (ext ? name.slice(0, -ext.length) : name).toLowerCase()
|
||||||
|
const base = this.normalizeDatBase(stem)
|
||||||
|
const rawSuffix = stem.slice(base.length)
|
||||||
|
if (!rawSuffix) return ''
|
||||||
|
const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '')
|
||||||
|
if (!safe) return ''
|
||||||
|
if (safe.startsWith('_') || safe.startsWith('.')) return safe
|
||||||
|
return `_${safe}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCacheSuffixSearchOrder(primarySuffix: string, preferHd: boolean): string[] {
|
||||||
|
const fallbackSuffixes = [
|
||||||
|
'_hd',
|
||||||
|
'_thumb',
|
||||||
|
'_t',
|
||||||
|
'.t',
|
||||||
|
'_b',
|
||||||
|
'.b',
|
||||||
|
'_w',
|
||||||
|
'.w',
|
||||||
|
'_c',
|
||||||
|
'.c',
|
||||||
|
''
|
||||||
|
]
|
||||||
|
const ordered = preferHd
|
||||||
|
? ['_hd', primarySuffix, ...fallbackSuffixes]
|
||||||
|
: [primarySuffix, '_hd', ...fallbackSuffixes]
|
||||||
|
return Array.from(new Set(ordered.map((item) => String(item || '').trim()).filter((item) => item.length >= 0)))
|
||||||
|
}
|
||||||
|
|
||||||
private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string {
|
private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string {
|
||||||
const name = basename(datPath)
|
const name = basename(datPath)
|
||||||
const lower = name.toLowerCase()
|
const lower = name.toLowerCase()
|
||||||
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
|
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||||
|
|
||||||
// 提取基础名称(去掉 _t, _h 等后缀)
|
|
||||||
const normalizedBase = this.normalizeDatBase(base)
|
const normalizedBase = this.normalizeDatBase(base)
|
||||||
|
const suffix = this.getCacheVariantSuffixFromDat(datPath)
|
||||||
// 判断是缩略图还是高清图
|
|
||||||
const isThumb = this.isThumbnailDat(lower)
|
|
||||||
const suffix = isThumb ? '_thumb' : '_hd'
|
|
||||||
|
|
||||||
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
|
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
|
||||||
const timeDir = this.resolveTimeDir(datPath)
|
const timeDir = this.resolveTimeDir(datPath)
|
||||||
@@ -1319,9 +1351,10 @@ export class ImageDecryptService {
|
|||||||
private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] {
|
private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] {
|
||||||
const name = basename(datPath)
|
const name = basename(datPath)
|
||||||
const lower = name.toLowerCase()
|
const lower = name.toLowerCase()
|
||||||
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
|
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||||
const normalizedBase = this.normalizeDatBase(base)
|
const normalizedBase = this.normalizeDatBase(base)
|
||||||
const suffixes = preferHd ? ['_hd', '_thumb'] : ['_thumb', '_hd']
|
const primarySuffix = this.getCacheVariantSuffixFromDat(datPath)
|
||||||
|
const suffixes = this.buildCacheSuffixSearchOrder(primarySuffix, preferHd)
|
||||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||||
|
|
||||||
const root = this.getCacheRoot()
|
const root = this.getCacheRoot()
|
||||||
@@ -1354,6 +1387,20 @@ export class ImageDecryptService {
|
|||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private removeDuplicateCacheCandidates(datPath: string, sessionId: string | undefined, keepPath: string): void {
|
||||||
|
const candidateSets = [
|
||||||
|
...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, false),
|
||||||
|
...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, true)
|
||||||
|
]
|
||||||
|
const candidates = Array.from(new Set(candidateSets))
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate || candidate === keepPath) continue
|
||||||
|
if (!existsSync(candidate)) continue
|
||||||
|
if (!this.isImageFile(candidate)) continue
|
||||||
|
void rm(candidate, { force: true }).catch(() => { })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null {
|
private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null {
|
||||||
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd)
|
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd)
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -1786,8 +1833,54 @@ export class ImageDecryptService {
|
|||||||
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
|
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isHdDatPath(datPath: string): boolean {
|
||||||
|
const name = basename(String(datPath || '')).toLowerCase()
|
||||||
|
if (!name.endsWith('.dat')) return false
|
||||||
|
const stem = name.slice(0, -4)
|
||||||
|
return (
|
||||||
|
stem.endsWith('_h') ||
|
||||||
|
stem.endsWith('.h') ||
|
||||||
|
stem.endsWith('_hd') ||
|
||||||
|
stem.endsWith('.hd')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTVariantDat(datPath: string): boolean {
|
||||||
|
const name = basename(String(datPath || '')).toLowerCase()
|
||||||
|
return this.isThumbnailDat(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isBaseDatPath(datPath: string, baseMd5: string): boolean {
|
||||||
|
const normalizedBase = String(baseMd5 || '').trim().toLowerCase()
|
||||||
|
if (!normalizedBase) return false
|
||||||
|
const name = basename(String(datPath || '')).toLowerCase()
|
||||||
|
return name === `${normalizedBase}.dat`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDatTier(datPath: string, baseMd5: string): number {
|
||||||
|
if (this.isHdDatPath(datPath)) return 3
|
||||||
|
if (this.isBaseDatPath(datPath, baseMd5)) return 2
|
||||||
|
if (this.isTVariantDat(datPath)) return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachedPathTier(cachePath: string): number {
|
||||||
|
if (this.isHdPath(cachePath)) return 3
|
||||||
|
const suffix = this.getCacheVariantSuffixFromCachedPath(cachePath)
|
||||||
|
if (!suffix) return 2
|
||||||
|
const normalized = suffix.toLowerCase()
|
||||||
|
if (normalized === '_t' || normalized === '.t' || normalized === '_thumb' || normalized === '.thumb') {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
private isHdPath(p: string): boolean {
|
private isHdPath(p: string): boolean {
|
||||||
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
|
const raw = String(p || '').split('?')[0]
|
||||||
|
const name = basename(raw).toLowerCase()
|
||||||
|
const ext = extname(name).toLowerCase()
|
||||||
|
const stem = ext ? name.slice(0, -ext.length) : name
|
||||||
|
return stem.endsWith('_hd')
|
||||||
}
|
}
|
||||||
|
|
||||||
private isThumbnailPath(p: string): boolean {
|
private isThumbnailPath(p: string): boolean {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { ConfigService } from './config'
|
|||||||
import { chatService, type ChatSession, type Message } from './chatService'
|
import { chatService, type ChatSession, type Message } from './chatService'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { httpService } from './httpService'
|
import { httpService } from './httpService'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { pathToFileURL } from 'url'
|
||||||
|
|
||||||
interface SessionBaseline {
|
interface SessionBaseline {
|
||||||
lastTimestamp: number
|
lastTimestamp: number
|
||||||
@@ -33,6 +37,8 @@ class MessagePushService {
|
|||||||
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
||||||
private readonly recentMessageKeys = new Map<string, number>()
|
private readonly recentMessageKeys = new Map<string, number>()
|
||||||
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
||||||
|
private readonly pushAvatarCacheDir: string
|
||||||
|
private readonly pushAvatarDataCache = new Map<string, string>()
|
||||||
private readonly debounceMs = 350
|
private readonly debounceMs = 350
|
||||||
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
||||||
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
||||||
@@ -45,6 +51,7 @@ class MessagePushService {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = ConfigService.getInstance()
|
this.configService = ConfigService.getInstance()
|
||||||
|
this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files')
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -310,12 +317,13 @@ class MessagePushService {
|
|||||||
const groupInfo = await chatService.getContactAvatar(sessionId)
|
const groupInfo = await chatService.getContactAvatar(sessionId)
|
||||||
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
||||||
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
||||||
|
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl)
|
||||||
return {
|
return {
|
||||||
event: 'message.new',
|
event: 'message.new',
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionType,
|
sessionType,
|
||||||
messageKey,
|
messageKey,
|
||||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
avatarUrl,
|
||||||
groupName,
|
groupName,
|
||||||
sourceName,
|
sourceName,
|
||||||
content
|
content
|
||||||
@@ -323,17 +331,63 @@ class MessagePushService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contactInfo = await chatService.getContactAvatar(sessionId)
|
const contactInfo = await chatService.getContactAvatar(sessionId)
|
||||||
|
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl)
|
||||||
return {
|
return {
|
||||||
event: 'message.new',
|
event: 'message.new',
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionType,
|
sessionType,
|
||||||
messageKey,
|
messageKey,
|
||||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
avatarUrl,
|
||||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async normalizePushAvatarUrl(avatarUrl?: string): Promise<string | undefined> {
|
||||||
|
const normalized = String(avatarUrl || '').trim()
|
||||||
|
if (!normalized) return undefined
|
||||||
|
if (!normalized.startsWith('data:image/')) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = this.pushAvatarDataCache.get(normalized)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized)
|
||||||
|
if (!match) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mimeType = match[1].toLowerCase()
|
||||||
|
const base64Data = match[2]
|
||||||
|
const imageBuffer = Buffer.from(base64Data, 'base64')
|
||||||
|
if (!imageBuffer.length) return undefined
|
||||||
|
|
||||||
|
const ext = this.getImageExtFromMime(mimeType)
|
||||||
|
const hash = createHash('sha1').update(normalized).digest('hex')
|
||||||
|
const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`)
|
||||||
|
|
||||||
|
await fs.mkdir(this.pushAvatarCacheDir, { recursive: true })
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(filePath, imageBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileUrl = pathToFileURL(filePath).toString()
|
||||||
|
this.pushAvatarDataCache.set(normalized, fileUrl)
|
||||||
|
return fileUrl
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImageExtFromMime(mimeType: string): string {
|
||||||
|
if (mimeType === 'image/png') return 'png'
|
||||||
|
if (mimeType === 'image/gif') return 'gif'
|
||||||
|
if (mimeType === 'image/webp') return 'webp'
|
||||||
|
return 'jpg'
|
||||||
|
}
|
||||||
|
|
||||||
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
|
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
|
||||||
if (sessionId.endsWith('@chatroom')) {
|
if (sessionId.endsWith('@chatroom')) {
|
||||||
return 'group'
|
return 'group'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { wcdbService } from './wcdbService'
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { ContactCacheService } from './contactCacheService'
|
import { ContactCacheService } from './contactCacheService'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { existsSync, mkdirSync } from 'fs'
|
import { existsSync, mkdirSync, unlinkSync } from 'fs'
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
@@ -174,8 +174,17 @@ const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
|
|||||||
// BMP
|
// BMP
|
||||||
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
||||||
|
|
||||||
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
|
// ISO BMFF 家族:优先识别 AVIF/HEIF,避免误判为 MP4
|
||||||
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
|
if (buf.length > 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
|
||||||
|
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
|
||||||
|
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return 'image/avif'
|
||||||
|
if (
|
||||||
|
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
|
||||||
|
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
|
||||||
|
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
|
||||||
|
) return 'image/heic'
|
||||||
|
return 'video/mp4'
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback logic for video
|
// Fallback logic for video
|
||||||
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
||||||
@@ -1231,7 +1240,19 @@ class SnsService {
|
|||||||
const cacheKey = `${url}|${key ?? ''}`
|
const cacheKey = `${url}|${key ?? ''}`
|
||||||
|
|
||||||
if (this.imageCache.has(cacheKey)) {
|
if (this.imageCache.has(cacheKey)) {
|
||||||
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
|
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
||||||
|
const base64Part = cachedDataUrl.split(',')[1] || ''
|
||||||
|
if (base64Part) {
|
||||||
|
try {
|
||||||
|
const cachedBuf = Buffer.from(base64Part, 'base64')
|
||||||
|
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
||||||
|
return { success: true, dataUrl: cachedDataUrl }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and fall through to refetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.imageCache.delete(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.fetchAndDecryptImage(url, key)
|
const result = await this.fetchAndDecryptImage(url, key)
|
||||||
@@ -1244,6 +1265,9 @@ class SnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.data && result.contentType) {
|
if (result.data && result.contentType) {
|
||||||
|
if (!detectImageMime(result.data, '').startsWith('image/')) {
|
||||||
|
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
|
||||||
|
}
|
||||||
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||||||
this.imageCache.set(cacheKey, dataUrl)
|
this.imageCache.set(cacheKey, dataUrl)
|
||||||
return { success: true, dataUrl }
|
return { success: true, dataUrl }
|
||||||
@@ -1853,8 +1877,13 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await readFile(cachePath)
|
const data = await readFile(cachePath)
|
||||||
|
if (!detectImageMime(data, '').startsWith('image/')) {
|
||||||
|
// 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。
|
||||||
|
try { unlinkSync(cachePath) } catch { }
|
||||||
|
} else {
|
||||||
const contentType = detectImageMime(data)
|
const contentType = detectImageMime(data)
|
||||||
return { success: true, data, contentType, cachePath }
|
return { success: true, data, contentType, cachePath }
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
||||||
}
|
}
|
||||||
@@ -2006,6 +2035,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
const xEnc = String(res.headers['x-enc'] || '').trim()
|
const xEnc = String(res.headers['x-enc'] || '').trim()
|
||||||
|
|
||||||
let decoded = raw
|
let decoded = raw
|
||||||
|
const rawMagicMime = detectImageMime(raw, '')
|
||||||
|
|
||||||
// 图片逻辑
|
// 图片逻辑
|
||||||
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||||||
@@ -2023,13 +2053,24 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
decrypted[i] = raw[i] ^ keystream[i]
|
decrypted[i] = raw[i] ^ keystream[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decryptedMagicMime = detectImageMime(decrypted, '')
|
||||||
|
if (decryptedMagicMime.startsWith('image/')) {
|
||||||
decoded = decrypted
|
decoded = decrypted
|
||||||
|
} else if (!rawMagicMime.startsWith('image/')) {
|
||||||
|
decoded = decrypted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[SnsService] TS Decrypt Error:', e)
|
console.error('[SnsService] TS Decrypt Error:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decodedMagicMime = detectImageMime(decoded, '')
|
||||||
|
if (!decodedMagicMime.startsWith('image/')) {
|
||||||
|
resolve({ success: false, error: '图片解密失败:无法识别图片格式' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 写入磁盘缓存
|
// 写入磁盘缓存
|
||||||
try {
|
try {
|
||||||
await writeFile(cachePath, decoded)
|
await writeFile(cachePath, decoded)
|
||||||
@@ -2063,6 +2104,15 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
|
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
|
||||||
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
||||||
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true
|
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true
|
||||||
|
if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
|
||||||
|
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
|
||||||
|
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return true
|
||||||
|
if (
|
||||||
|
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
|
||||||
|
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
|
||||||
|
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
|
||||||
|
) return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||||
import { spawn } from 'child_process'
|
|
||||||
import { pathToFileURL } from 'url'
|
import { pathToFileURL } from 'url'
|
||||||
import crypto from 'crypto'
|
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
@@ -27,48 +25,15 @@ interface VideoIndexEntry {
|
|||||||
|
|
||||||
type PosterFormat = 'dataUrl' | 'fileUrl'
|
type PosterFormat = 'dataUrl' | 'fileUrl'
|
||||||
|
|
||||||
function getStaticFfmpegPath(): string | null {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const ffmpegStatic = require('ffmpeg-static')
|
|
||||||
if (typeof ffmpegStatic === 'string') {
|
|
||||||
let fixedPath = ffmpegStatic
|
|
||||||
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
|
|
||||||
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
|
|
||||||
}
|
|
||||||
if (existsSync(fixedPath)) return fixedPath
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
|
||||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName)
|
|
||||||
if (existsSync(devPath)) return devPath
|
|
||||||
|
|
||||||
if (app.isPackaged) {
|
|
||||||
const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName)
|
|
||||||
if (existsSync(packedPath)) return packedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
class VideoService {
|
class VideoService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||||
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||||
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||||
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
||||||
private pendingPosterExtract = new Map<string, Promise<string | null>>()
|
|
||||||
private extractedPosterCache = new Map<string, TimedCacheEntry<string | null>>()
|
|
||||||
private posterExtractRunning = 0
|
|
||||||
private posterExtractQueue: Array<() => void> = []
|
|
||||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||||
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||||
private readonly videoIndexCacheTtlMs = 90 * 1000
|
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||||
private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000
|
|
||||||
private readonly maxPosterExtractConcurrency = 1
|
|
||||||
private readonly maxCacheEntries = 2000
|
private readonly maxCacheEntries = 2000
|
||||||
private readonly maxIndexEntries = 6
|
private readonly maxIndexEntries = 6
|
||||||
|
|
||||||
@@ -287,11 +252,9 @@ class VideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||||
const dbPath = this.getDbPath()
|
// 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。
|
||||||
const wxid = this.getMyWxid()
|
// 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
void md5List
|
||||||
if (!dbPath || !wxid) return
|
|
||||||
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
||||||
@@ -429,6 +392,23 @@ class VideoService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeVideoLookupKey(value: string): string {
|
||||||
|
let text = String(value || '').trim().toLowerCase()
|
||||||
|
if (!text) return ''
|
||||||
|
text = text.replace(/^.*[\\/]/, '')
|
||||||
|
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||||
|
text = text.replace(/_thumb$/, '')
|
||||||
|
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||||
|
if (direct) {
|
||||||
|
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||||
|
return `${direct[1].toLowerCase()}${suffix}`
|
||||||
|
}
|
||||||
|
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||||
|
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||||
|
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||||
|
return String(fallback?.[1] || '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
private fallbackScanVideo(
|
private fallbackScanVideo(
|
||||||
videoBaseDir: string,
|
videoBaseDir: string,
|
||||||
realVideoMd5: string,
|
realVideoMd5: string,
|
||||||
@@ -473,154 +453,10 @@ class VideoService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFfmpegPath(): string {
|
|
||||||
const staticPath = getStaticFfmpegPath()
|
|
||||||
if (staticPath) return staticPath
|
|
||||||
return 'ffmpeg'
|
|
||||||
}
|
|
||||||
|
|
||||||
private async withPosterExtractSlot<T>(run: () => Promise<T>): Promise<T> {
|
|
||||||
if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
this.posterExtractQueue.push(resolve)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
this.posterExtractRunning += 1
|
|
||||||
try {
|
|
||||||
return await run()
|
|
||||||
} finally {
|
|
||||||
this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1)
|
|
||||||
const next = this.posterExtractQueue.shift()
|
|
||||||
if (next) next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise<string | null> {
|
|
||||||
const normalizedPath = String(videoPath || '').trim()
|
|
||||||
if (!normalizedPath || !existsSync(normalizedPath)) return null
|
|
||||||
|
|
||||||
const cacheKey = `${normalizedPath}|format=${posterFormat}`
|
|
||||||
const cached = this.readTimedCache(this.extractedPosterCache, cacheKey)
|
|
||||||
if (cached !== undefined) return cached
|
|
||||||
|
|
||||||
const pending = this.pendingPosterExtract.get(cacheKey)
|
|
||||||
if (pending) return pending
|
|
||||||
|
|
||||||
const task = this.withPosterExtractSlot(() => new Promise<string | null>((resolve) => {
|
|
||||||
const tmpDir = join(app.getPath('temp'), 'weflow_video_frames')
|
|
||||||
try {
|
|
||||||
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
|
||||||
} catch {
|
|
||||||
resolve(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24)
|
|
||||||
const outputPath = join(tmpDir, `frame_${stableHash}.jpg`)
|
|
||||||
if (posterFormat === 'fileUrl' && existsSync(outputPath)) {
|
|
||||||
resolve(pathToFileURL(outputPath).toString())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const ffmpegPath = this.getFfmpegPath()
|
|
||||||
const args = [
|
|
||||||
'-hide_banner', '-loglevel', 'error', '-y',
|
|
||||||
'-ss', '0',
|
|
||||||
'-i', normalizedPath,
|
|
||||||
'-frames:v', '1',
|
|
||||||
'-q:v', '3',
|
|
||||||
outputPath
|
|
||||||
]
|
|
||||||
|
|
||||||
const errChunks: Buffer[] = []
|
|
||||||
let done = false
|
|
||||||
const finish = (value: string | null) => {
|
|
||||||
if (done) return
|
|
||||||
done = true
|
|
||||||
if (posterFormat === 'dataUrl') {
|
|
||||||
try {
|
|
||||||
if (existsSync(outputPath)) unlinkSync(outputPath)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolve(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = spawn(ffmpegPath, args, {
|
|
||||||
stdio: ['ignore', 'ignore', 'pipe'],
|
|
||||||
windowsHide: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
try { proc.kill('SIGKILL') } catch { /* ignore */ }
|
|
||||||
finish(null)
|
|
||||||
}, 12000)
|
|
||||||
|
|
||||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
|
||||||
|
|
||||||
proc.on('error', () => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
finish(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.on('close', (code: number) => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
if (code !== 0 || !existsSync(outputPath)) {
|
|
||||||
if (errChunks.length > 0) {
|
|
||||||
this.log('extractFirstFrameDataUrl failed', {
|
|
||||||
videoPath: normalizedPath,
|
|
||||||
error: Buffer.concat(errChunks).toString().slice(0, 240)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
finish(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const jpgBuf = readFileSync(outputPath)
|
|
||||||
if (!jpgBuf.length) {
|
|
||||||
finish(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (posterFormat === 'fileUrl') {
|
|
||||||
finish(pathToFileURL(outputPath).toString())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`)
|
|
||||||
} catch {
|
|
||||||
finish(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
this.pendingPosterExtract.set(cacheKey, task)
|
|
||||||
try {
|
|
||||||
const result = await task
|
|
||||||
this.writeTimedCache(
|
|
||||||
this.extractedPosterCache,
|
|
||||||
cacheKey,
|
|
||||||
result,
|
|
||||||
this.extractedPosterCacheTtlMs,
|
|
||||||
this.maxCacheEntries
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
} finally {
|
|
||||||
this.pendingPosterExtract.delete(cacheKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
|
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
|
||||||
|
void posterFormat
|
||||||
if (!includePoster) return info
|
if (!includePoster) return info
|
||||||
if (!info.exists || !info.videoUrl) return info
|
return info
|
||||||
if (info.coverUrl || info.thumbUrl) return info
|
|
||||||
|
|
||||||
const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat)
|
|
||||||
if (!extracted) return info
|
|
||||||
return {
|
|
||||||
...info,
|
|
||||||
coverUrl: extracted,
|
|
||||||
thumbUrl: extracted
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -652,7 +488,7 @@ class VideoService {
|
|||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
||||||
const task = (async (): Promise<VideoInfo> => {
|
const task = (async (): Promise<VideoInfo> => {
|
||||||
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
|
||||||
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||||
|
|
||||||
if (!existsSync(videoBaseDir)) {
|
if (!existsSync(videoBaseDir)) {
|
||||||
@@ -678,7 +514,7 @@ class VideoService {
|
|||||||
|
|
||||||
const miss = { exists: false }
|
const miss = { exists: false }
|
||||||
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
|
this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 })
|
||||||
return miss
|
return miss
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
@@ -2011,6 +2011,14 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
const pickRaw = (row: Record<string, any>, keys: string[]): unknown => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key]
|
||||||
|
if (value === null || value === undefined) continue
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
const extractXmlValue = (xml: string, tag: string): string => {
|
const extractXmlValue = (xml: string, tag: string): string => {
|
||||||
if (!xml) return ''
|
if (!xml) return ''
|
||||||
const regex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i')
|
const regex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i')
|
||||||
@@ -2096,25 +2104,37 @@ export class WcdbCore {
|
|||||||
const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase)
|
const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase)
|
||||||
return String(md5Like?.[1] || fileBase || '').trim().toLowerCase()
|
return String(md5Like?.[1] || fileBase || '').trim().toLowerCase()
|
||||||
}
|
}
|
||||||
const decodePackedToPrintable = (raw: string): string => {
|
const decodePackedInfoBuffer = (raw: unknown): Buffer | null => {
|
||||||
const text = String(raw || '').trim()
|
if (!raw) return null
|
||||||
if (!text) return ''
|
if (Buffer.isBuffer(raw)) return raw
|
||||||
let buf: Buffer | null = null
|
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||||
if (/^[a-fA-F0-9]+$/.test(text) && text.length % 2 === 0) {
|
if (Array.isArray(raw)) return Buffer.from(raw as any[])
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const text = raw.trim()
|
||||||
|
if (!text) return null
|
||||||
|
const compactHex = text.replace(/\s+/g, '')
|
||||||
|
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
|
||||||
try {
|
try {
|
||||||
buf = Buffer.from(text, 'hex')
|
return Buffer.from(compactHex, 'hex')
|
||||||
} catch {
|
} catch {
|
||||||
buf = null
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!buf) {
|
|
||||||
try {
|
try {
|
||||||
const base64 = Buffer.from(text, 'base64')
|
const base64 = Buffer.from(text, 'base64')
|
||||||
if (base64.length > 0) buf = base64
|
if (base64.length > 0) return base64
|
||||||
} catch {
|
} catch {
|
||||||
buf = null
|
// ignore
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
|
||||||
|
return Buffer.from((raw as any).data)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const decodePackedToPrintable = (raw: unknown): string => {
|
||||||
|
const buf = decodePackedInfoBuffer(raw)
|
||||||
if (!buf || buf.length === 0) return ''
|
if (!buf || buf.length === 0) return ''
|
||||||
const printable: number[] = []
|
const printable: number[] = []
|
||||||
for (const byte of buf) {
|
for (const byte of buf) {
|
||||||
@@ -2129,6 +2149,46 @@ export class WcdbCore {
|
|||||||
const match = /([a-fA-F0-9]{32})/.exec(input)
|
const match = /([a-fA-F0-9]{32})/.exec(input)
|
||||||
return String(match?.[1] || '').toLowerCase()
|
return String(match?.[1] || '').toLowerCase()
|
||||||
}
|
}
|
||||||
|
const normalizeVideoFileToken = (value: unknown): string => {
|
||||||
|
let text = String(value || '').trim().toLowerCase()
|
||||||
|
if (!text) return ''
|
||||||
|
text = text.replace(/^.*[\\/]/, '')
|
||||||
|
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||||
|
text = text.replace(/_thumb$/, '')
|
||||||
|
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||||
|
if (direct) {
|
||||||
|
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||||
|
return `${direct[1].toLowerCase()}${suffix}`
|
||||||
|
}
|
||||||
|
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||||
|
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||||
|
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||||
|
return String(fallback?.[1] || '').toLowerCase()
|
||||||
|
}
|
||||||
|
const extractVideoFileNameFromPackedRaw = (raw: unknown): string => {
|
||||||
|
const buf = decodePackedInfoBuffer(raw)
|
||||||
|
if (!buf || buf.length === 0) return ''
|
||||||
|
const candidates: string[] = []
|
||||||
|
let current = ''
|
||||||
|
for (const byte of buf) {
|
||||||
|
const isHex =
|
||||||
|
(byte >= 0x30 && byte <= 0x39) ||
|
||||||
|
(byte >= 0x41 && byte <= 0x46) ||
|
||||||
|
(byte >= 0x61 && byte <= 0x66)
|
||||||
|
if (isHex) {
|
||||||
|
current += String.fromCharCode(byte)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (current.length >= 16) candidates.push(current)
|
||||||
|
current = ''
|
||||||
|
}
|
||||||
|
if (current.length >= 16) candidates.push(current)
|
||||||
|
if (candidates.length === 0) return ''
|
||||||
|
const exact32 = candidates.find((item) => item.length === 32)
|
||||||
|
if (exact32) return exact32.toLowerCase()
|
||||||
|
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
|
||||||
|
return String(fallback || '').toLowerCase()
|
||||||
|
}
|
||||||
const extractImageDatName = (row: Record<string, any>, content: string): string => {
|
const extractImageDatName = (row: Record<string, any>, content: string): string => {
|
||||||
const direct = pickString(row, [
|
const direct = pickString(row, [
|
||||||
'image_path',
|
'image_path',
|
||||||
@@ -2147,7 +2207,7 @@ export class WcdbCore {
|
|||||||
const normalizedXml = normalizeDatBase(xmlCandidate)
|
const normalizedXml = normalizeDatBase(xmlCandidate)
|
||||||
if (normalizedXml) return normalizedXml
|
if (normalizedXml) return normalizedXml
|
||||||
|
|
||||||
const packedRaw = pickString(row, [
|
const packedRaw = pickRaw(row, [
|
||||||
'packed_info_data',
|
'packed_info_data',
|
||||||
'packedInfoData',
|
'packedInfoData',
|
||||||
'packed_info_blob',
|
'packed_info_blob',
|
||||||
@@ -2172,7 +2232,7 @@ export class WcdbCore {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
const extractPackedPayload = (row: Record<string, any>): string => {
|
const extractPackedPayload = (row: Record<string, any>): string => {
|
||||||
const packedRaw = pickString(row, [
|
const packedRaw = pickRaw(row, [
|
||||||
'packed_info_data',
|
'packed_info_data',
|
||||||
'packedInfoData',
|
'packedInfoData',
|
||||||
'packed_info_blob',
|
'packed_info_blob',
|
||||||
@@ -2327,6 +2387,20 @@ export class WcdbCore {
|
|||||||
const packedPayload = extractPackedPayload(row)
|
const packedPayload = extractPackedPayload(row)
|
||||||
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
|
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
|
||||||
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
|
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
|
||||||
|
const packedRaw = pickRaw(row, [
|
||||||
|
'packed_info_data',
|
||||||
|
'packedInfoData',
|
||||||
|
'packed_info_blob',
|
||||||
|
'packedInfoBlob',
|
||||||
|
'packed_info',
|
||||||
|
'packedInfo',
|
||||||
|
'BytesExtra',
|
||||||
|
'bytes_extra',
|
||||||
|
'WCDB_CT_packed_info',
|
||||||
|
'reserved0',
|
||||||
|
'Reserved0',
|
||||||
|
'WCDB_CT_Reserved0'
|
||||||
|
])
|
||||||
|
|
||||||
let content = ''
|
let content = ''
|
||||||
let imageMd5: string | undefined
|
let imageMd5: string | undefined
|
||||||
@@ -2342,10 +2416,17 @@ export class WcdbCore {
|
|||||||
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
|
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
|
||||||
}
|
}
|
||||||
} else if (localType === 43) {
|
} else if (localType === 43) {
|
||||||
videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined
|
videoMd5 =
|
||||||
|
extractVideoFileNameFromPackedRaw(packedRaw) ||
|
||||||
|
normalizeVideoFileToken(videoMd5ByColumn) ||
|
||||||
|
extractHexMd5(packedPayload) ||
|
||||||
|
undefined
|
||||||
if (!videoMd5) {
|
if (!videoMd5) {
|
||||||
content = decodeContentIfNeeded()
|
content = decodeContentIfNeeded()
|
||||||
videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined
|
videoMd5 =
|
||||||
|
normalizeVideoFileToken(extractVideoMd5(content)) ||
|
||||||
|
extractHexMd5(packedPayload) ||
|
||||||
|
undefined
|
||||||
} else if (useRawMessageContent) {
|
} else if (useRawMessageContent) {
|
||||||
// 占位态标题只依赖简单 XML,已带 md5 时不做额外解压
|
// 占位态标题只依赖简单 XML,已带 md5 时不做额外解压
|
||||||
content = rawMessageContent
|
content = rawMessageContent
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react'
|
import React, { useState, useMemo, useEffect, useRef } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
|
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
|
||||||
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
|
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
|
||||||
@@ -8,6 +8,7 @@ import { getEmojiPath } from 'wechat-emojis'
|
|||||||
|
|
||||||
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||||
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||||
|
const LINK_XML_DIRECT_URL_TAGS = ['contentUrl', ...LINK_XML_URL_TAGS]
|
||||||
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
|
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
|
||||||
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
|
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
|
||||||
|
|
||||||
@@ -29,6 +30,13 @@ const decodeHtmlEntities = (text: string): string => {
|
|||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeRawXmlForParsing = (xml: string): string => {
|
||||||
|
if (!xml) return ''
|
||||||
|
return decodeHtmlEntities(xml)
|
||||||
|
.replace(/\\+"/g, '"')
|
||||||
|
.replace(/\\+'/g, "'")
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeUrlCandidate = (raw: string): string | null => {
|
const normalizeUrlCandidate = (raw: string): string | null => {
|
||||||
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
|
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
@@ -43,12 +51,13 @@ const simplifyUrlForCompare = (value: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
|
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
|
||||||
if (!xml) return []
|
const normalizedXml = normalizeRawXmlForParsing(xml)
|
||||||
|
if (!normalizedXml) return []
|
||||||
const results: string[] = []
|
const results: string[] = []
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
|
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
|
||||||
let match: RegExpExecArray | null
|
let match: RegExpExecArray | null
|
||||||
while ((match = reg.exec(xml)) !== null) {
|
while ((match = reg.exec(normalizedXml)) !== null) {
|
||||||
if (match[1]) results.push(match[1])
|
if (match[1]) results.push(match[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,20 +74,87 @@ const isLikelyMediaAssetUrl = (url: string): boolean => {
|
|||||||
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
|
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
const normalizeSnsAssetUrl = (url: string, token?: string, encIdx?: string): string => {
|
||||||
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
|
const base = decodeHtmlEntities(url).trim()
|
||||||
if (post.type === 3) {
|
if (!base) return ''
|
||||||
const url = post.media[0]?.url || post.linkUrl
|
|
||||||
if (!url) return null
|
let fixed = base.replace(/^http:\/\//i, 'https://')
|
||||||
|
|
||||||
|
const normalizedToken = decodeHtmlEntities(String(token || '')).trim()
|
||||||
|
const normalizedEncIdx = decodeHtmlEntities(String(encIdx || '')).trim()
|
||||||
|
const effectiveIdx = normalizedEncIdx || (normalizedToken ? '1' : '')
|
||||||
|
const appendParams: string[] = []
|
||||||
|
if (normalizedToken && !/[?&]token=/i.test(fixed)) {
|
||||||
|
appendParams.push(`token=${normalizedToken}`)
|
||||||
|
}
|
||||||
|
if (effectiveIdx && !/[?&]idx=/i.test(fixed)) {
|
||||||
|
appendParams.push(`idx=${effectiveIdx}`)
|
||||||
|
}
|
||||||
|
if (appendParams.length > 0) {
|
||||||
|
const connector = fixed.includes('?') ? '&' : '?'
|
||||||
|
fixed = `${fixed}${connector}${appendParams.join('&')}`
|
||||||
|
}
|
||||||
|
return fixed
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractCardThumbMetaFromXml = (xml: string): { thumb?: string; thumbKey?: string } => {
|
||||||
|
const normalizedXml = normalizeRawXmlForParsing(xml)
|
||||||
|
if (!normalizedXml) return {}
|
||||||
|
const mediaMatch = normalizedXml.match(/<media>([\s\S]*?)<\/media>/i)
|
||||||
|
if (!mediaMatch?.[1]) return {}
|
||||||
|
|
||||||
|
const mediaXml = mediaMatch[1]
|
||||||
|
const thumbMatch = mediaXml.match(/<thumb([^>]*)>([^<]+)<\/thumb>/i)
|
||||||
|
if (!thumbMatch) return {}
|
||||||
|
|
||||||
|
const attrs = thumbMatch[1] || ''
|
||||||
|
const getAttr = (name: string): string | undefined => {
|
||||||
|
const reg = new RegExp(`${name}\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))`, 'i')
|
||||||
|
const m = attrs.match(reg)
|
||||||
|
return decodeHtmlEntities((m?.[1] || m?.[2] || m?.[3] || '').trim()) || undefined
|
||||||
|
}
|
||||||
|
const thumbRawUrl = thumbMatch[2] || ''
|
||||||
|
const thumbToken = getAttr('token')
|
||||||
|
const thumbKey = getAttr('key')
|
||||||
|
const thumbEncIdx = getAttr('enc_idx')
|
||||||
|
const thumb = normalizeSnsAssetUrl(thumbRawUrl, thumbToken, thumbEncIdx)
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumb: thumb || undefined,
|
||||||
|
thumbKey: thumbKey ? decodeHtmlEntities(thumbKey).trim() : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickCardTitle = (post: SnsPost): string => {
|
||||||
const titleCandidates = [
|
const titleCandidates = [
|
||||||
post.linkTitle || '',
|
post.linkTitle || '',
|
||||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||||
post.contentDesc || ''
|
post.contentDesc || ''
|
||||||
]
|
]
|
||||||
const title = titleCandidates
|
return titleCandidates
|
||||||
.map((v) => decodeHtmlEntities(v))
|
.map((value) => decodeHtmlEntities(value))
|
||||||
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
|
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) || '网页链接'
|
||||||
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
|
}
|
||||||
|
|
||||||
|
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||||
|
// type 3 / 5 是链接卡片类型,优先按卡片链接解析
|
||||||
|
if (post.type === 3 || post.type === 5) {
|
||||||
|
const thumbMeta = extractCardThumbMetaFromXml(post.rawXml || '')
|
||||||
|
const directUrlCandidates = [
|
||||||
|
post.linkUrl || '',
|
||||||
|
...getXmlTagValues(post.rawXml || '', LINK_XML_DIRECT_URL_TAGS),
|
||||||
|
...post.media.map((item) => item.url || '')
|
||||||
|
]
|
||||||
|
const url = directUrlCandidates
|
||||||
|
.map(normalizeUrlCandidate)
|
||||||
|
.find((value): value is string => Boolean(value))
|
||||||
|
if (!url) return null
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: pickCardTitle(post),
|
||||||
|
thumb: thumbMeta.thumb || post.media[0]?.thumb || post.media[0]?.url,
|
||||||
|
thumbKey: thumbMeta.thumbKey || post.media[0]?.key
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
@@ -117,19 +193,9 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
|||||||
|
|
||||||
if (!linkUrl) return null
|
if (!linkUrl) return null
|
||||||
|
|
||||||
const titleCandidates = [
|
|
||||||
post.linkTitle || '',
|
|
||||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
|
||||||
post.contentDesc || ''
|
|
||||||
]
|
|
||||||
|
|
||||||
const title = titleCandidates
|
|
||||||
.map((value) => decodeHtmlEntities(value))
|
|
||||||
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: linkUrl,
|
url: linkUrl,
|
||||||
title: title || '网页链接',
|
title: pickCardTitle(post),
|
||||||
thumb: post.media[0]?.thumb || post.media[0]?.url
|
thumb: post.media[0]?.thumb || post.media[0]?.url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,8 +224,11 @@ const buildLocationText = (location?: SnsLocation): string => {
|
|||||||
return primary || region
|
return primary || region
|
||||||
}
|
}
|
||||||
|
|
||||||
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
const SnsLinkCard = ({ card, thumbKey }: { card: SnsLinkCardData; thumbKey?: string }) => {
|
||||||
const [thumbFailed, setThumbFailed] = useState(false)
|
const [thumbFailed, setThumbFailed] = useState(false)
|
||||||
|
const [thumbSrc, setThumbSrc] = useState(card.thumb || '')
|
||||||
|
const [reloadNonce, setReloadNonce] = useState(0)
|
||||||
|
const retryCountRef = useRef(0)
|
||||||
const hostname = useMemo(() => {
|
const hostname = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return new URL(card.url).hostname.replace(/^www\./i, '')
|
return new URL(card.url).hostname.replace(/^www\./i, '')
|
||||||
@@ -168,6 +237,58 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
|||||||
}
|
}
|
||||||
}, [card.url])
|
}, [card.url])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
retryCountRef.current = 0
|
||||||
|
}, [card.thumb, thumbKey])
|
||||||
|
|
||||||
|
const scheduleRetry = () => {
|
||||||
|
if (retryCountRef.current >= 2) return
|
||||||
|
retryCountRef.current += 1
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setReloadNonce((v) => v + 1)
|
||||||
|
}, 900)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const rawThumb = card.thumb || ''
|
||||||
|
setThumbFailed(false)
|
||||||
|
setThumbSrc(rawThumb)
|
||||||
|
if (!rawThumb) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const loadThumb = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sns.proxyImage({
|
||||||
|
url: rawThumb,
|
||||||
|
key: thumbKey
|
||||||
|
})
|
||||||
|
if (cancelled) return
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn('[SnsLinkCard] thumb decrypt failed', {
|
||||||
|
url: rawThumb,
|
||||||
|
key: thumbKey,
|
||||||
|
error: result.error
|
||||||
|
})
|
||||||
|
scheduleRetry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.dataUrl) {
|
||||||
|
setThumbSrc(result.dataUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.videoPath) {
|
||||||
|
setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// noop: keep raw thumb fallback
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThumb()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [card.thumb, thumbKey, reloadNonce])
|
||||||
|
|
||||||
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
try {
|
try {
|
||||||
@@ -180,13 +301,31 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
|||||||
return (
|
return (
|
||||||
<button type="button" className="post-link-card" onClick={handleClick}>
|
<button type="button" className="post-link-card" onClick={handleClick}>
|
||||||
<div className="link-thumb">
|
<div className="link-thumb">
|
||||||
{card.thumb && !thumbFailed ? (
|
{thumbSrc && !thumbFailed ? (
|
||||||
<img
|
<img
|
||||||
src={card.thumb}
|
src={thumbSrc}
|
||||||
alt=""
|
alt=""
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={() => setThumbFailed(true)}
|
onError={() => {
|
||||||
|
const rawThumb = card.thumb || ''
|
||||||
|
if (thumbSrc !== rawThumb && rawThumb) {
|
||||||
|
console.warn('[SnsLinkCard] thumb render failed, fallback raw thumb', {
|
||||||
|
failedSrc: thumbSrc,
|
||||||
|
rawThumb,
|
||||||
|
key: thumbKey
|
||||||
|
})
|
||||||
|
setThumbSrc(rawThumb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn('[SnsLinkCard] thumb render failed, fallback exhausted', {
|
||||||
|
failedSrc: thumbSrc,
|
||||||
|
rawThumb,
|
||||||
|
key: thumbKey
|
||||||
|
})
|
||||||
|
setThumbFailed(true)
|
||||||
|
scheduleRetry()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="link-thumb-fallback">
|
<div className="link-thumb-fallback">
|
||||||
@@ -278,9 +417,11 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const linkCard = buildLinkCardData(post)
|
const linkCard = buildLinkCardData(post)
|
||||||
|
const linkCardThumbKey = linkCard?.thumbKey || post.media[0]?.key
|
||||||
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
|
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
|
||||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
const isLinkCardType = post.type === 3 || post.type === 5
|
||||||
|
const showLinkCard = Boolean(linkCard) && !hasVideoMedia && (isLinkCardType || post.media.length <= 1)
|
||||||
const showMediaGrid = post.media.length > 0 && !showLinkCard
|
const showMediaGrid = post.media.length > 0 && !showLinkCard
|
||||||
|
|
||||||
const formatTime = (ts: number) => {
|
const formatTime = (ts: number) => {
|
||||||
@@ -412,7 +553,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showLinkCard && linkCard && (
|
{showLinkCard && linkCard && (
|
||||||
<SnsLinkCard card={linkCard} />
|
<SnsLinkCard card={linkCard} thumbKey={linkCardThumbKey} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showMediaGrid && (
|
{showMediaGrid && (
|
||||||
|
|||||||
@@ -3043,6 +3043,30 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-name-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.table-name-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-name-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.detail-table-placeholder {
|
.detail-table-placeholder {
|
||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
|
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
|
||||||
@@ -3056,6 +3080,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
|
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||||
@@ -3071,15 +3096,17 @@
|
|||||||
.db-name {
|
.db-name {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
max-width: 62%;
|
line-height: 1.35;
|
||||||
overflow: hidden;
|
word-break: break-all;
|
||||||
text-overflow: ellipsis;
|
user-select: text;
|
||||||
white-space: nowrap;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-count {
|
.table-count {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7424,14 +7424,29 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<span>数据库分布</span>
|
<span>数据库分布</span>
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
|
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="table-name-summary">
|
||||||
|
<span className="table-name-label">表名</span>
|
||||||
|
<span className="table-name-value">
|
||||||
|
{(() => {
|
||||||
|
const tableNames = Array.from(new Set(
|
||||||
|
sessionDetail.messageTables
|
||||||
|
.map(item => String(item.tableName || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
))
|
||||||
|
return tableNames[0] || '—'
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="table-list">
|
<div className="table-list">
|
||||||
{sessionDetail.messageTables.map((t, i) => (
|
{sessionDetail.messageTables.map((t, i) => (
|
||||||
<div key={i} className="table-item">
|
<div key={`${t.dbName}-${t.tableName}-${i}`} className="table-item">
|
||||||
<span className="db-name">{t.dbName}</span>
|
<span className="db-name">{t.dbName || '—'}</span>
|
||||||
<span className="table-count">{t.count.toLocaleString()} 条</span>
|
<span className="table-count">{t.count.toLocaleString()} 条</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="detail-table-placeholder">
|
<div className="detail-table-placeholder">
|
||||||
{isLoadingDetailExtra ? '统计中...' : '暂无统计数据'}
|
{isLoadingDetailExtra ? '统计中...' : '暂无统计数据'}
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ interface ExportTask {
|
|||||||
title: string
|
title: string
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
settledSessionIds?: string[]
|
settledSessionIds?: string[]
|
||||||
|
sessionOutputPaths?: Record<string, string>
|
||||||
createdAt: number
|
createdAt: number
|
||||||
startedAt?: number
|
startedAt?: number
|
||||||
finishedAt?: number
|
finishedAt?: number
|
||||||
@@ -653,6 +654,32 @@ const formatPathBrief = (value: string, maxLength = 52): string => {
|
|||||||
return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`
|
return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveParentDir = (value: string): string => {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
if (!normalized) return ''
|
||||||
|
const noTrailing = normalized.replace(/[\\/]+$/, '')
|
||||||
|
if (!noTrailing) return normalized
|
||||||
|
const lastSlash = Math.max(noTrailing.lastIndexOf('/'), noTrailing.lastIndexOf('\\'))
|
||||||
|
if (lastSlash < 0) return normalized
|
||||||
|
if (lastSlash === 0) return noTrailing.slice(0, 1)
|
||||||
|
if (/^[A-Za-z]:$/.test(noTrailing.slice(0, lastSlash))) {
|
||||||
|
return `${noTrailing.slice(0, lastSlash)}\\`
|
||||||
|
}
|
||||||
|
return noTrailing.slice(0, lastSlash)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTaskOpenDir = (task: ExportTask): string => {
|
||||||
|
const sessionIds = Array.isArray(task.payload.sessionIds) ? task.payload.sessionIds : []
|
||||||
|
if (sessionIds.length === 1) {
|
||||||
|
const onlySessionId = String(sessionIds[0] || '').trim()
|
||||||
|
const outputPath = onlySessionId ? String(task.sessionOutputPaths?.[onlySessionId] || '').trim() : ''
|
||||||
|
if (outputPath) {
|
||||||
|
return resolveParentDir(outputPath) || task.payload.outputDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return task.payload.outputDir
|
||||||
|
}
|
||||||
|
|
||||||
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
|
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
|
||||||
if (!timestamp) return ''
|
if (!timestamp) return ''
|
||||||
const diff = Math.max(0, now - timestamp)
|
const diff = Math.max(0, now - timestamp)
|
||||||
@@ -2005,7 +2032,14 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
{isPerfExpanded ? '收起详情' : '性能详情'}
|
{isPerfExpanded ? '收起详情' : '性能详情'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="task-action-btn" onClick={() => task.payload.outputDir && void window.electronAPI.shell.openPath(task.payload.outputDir)}>
|
<button
|
||||||
|
className="task-action-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const openDir = resolveTaskOpenDir(task)
|
||||||
|
if (!openDir) return
|
||||||
|
void window.electronAPI.shell.openPath(openDir)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<FolderOpen size={14} /> 目录
|
<FolderOpen size={14} /> 目录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -5715,6 +5749,12 @@ function ExportPage() {
|
|||||||
...task,
|
...task,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
finishedAt: doneAt,
|
finishedAt: doneAt,
|
||||||
|
sessionOutputPaths: {
|
||||||
|
...(task.sessionOutputPaths || {}),
|
||||||
|
...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object')
|
||||||
|
? result.sessionOutputPaths
|
||||||
|
: {})
|
||||||
|
},
|
||||||
progress: {
|
progress: {
|
||||||
...task.progress,
|
...task.progress,
|
||||||
current: task.progress.total || next.payload.sessionIds.length,
|
current: task.progress.total || next.payload.sessionIds.length,
|
||||||
|
|||||||
@@ -281,10 +281,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-info,
|
||||||
.floating-delete {
|
.floating-delete {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -302,6 +302,18 @@
|
|||||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-info {
|
||||||
|
right: 10px;
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-delete {
|
||||||
|
right: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card:hover .floating-info,
|
||||||
|
.media-card:focus-within .floating-info,
|
||||||
.media-card:hover .floating-delete,
|
.media-card:hover .floating-delete,
|
||||||
.media-card:focus-within .floating-delete {
|
.media-card:focus-within .floating-delete {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -490,7 +502,9 @@
|
|||||||
.resource-dialog-mask {
|
.resource-dialog-mask {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(8, 11, 18, 0.24);
|
background: rgba(8, 11, 18, 0.46);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -498,11 +512,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resource-dialog {
|
.resource-dialog {
|
||||||
|
--dialog-surface: color-mix(in srgb, var(--bg-primary, #ffffff) 82%, var(--card-inner-bg, #ffffff) 18%);
|
||||||
|
--dialog-surface-header: color-mix(in srgb, var(--dialog-surface) 90%, var(--bg-secondary, #ffffff) 10%);
|
||||||
width: min(420px, calc(100% - 32px));
|
width: min(420px, calc(100% - 32px));
|
||||||
background: var(--card-bg, #ffffff);
|
background: var(--dialog-surface);
|
||||||
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.22);
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,7 +528,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||||
background: color-mix(in srgb, var(--bg-secondary) 85%, transparent);
|
background: var(--dialog-surface-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-body {
|
.dialog-body {
|
||||||
@@ -521,6 +537,34 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
background: var(--dialog-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-info-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-info-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-actions {
|
.dialog-actions {
|
||||||
@@ -528,6 +572,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
background: var(--dialog-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-btn {
|
.dialog-btn {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
|
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
|
||||||
import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
|
import { Calendar, Image as ImageIcon, Info, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
|
||||||
import { VirtuosoGrid } from 'react-virtuoso'
|
import { VirtuosoGrid } from 'react-virtuoso'
|
||||||
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
|
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
|
||||||
import './ResourcesPage.scss'
|
import './ResourcesPage.scss'
|
||||||
@@ -28,9 +28,10 @@ interface ContactOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DialogState = {
|
type DialogState = {
|
||||||
mode: 'alert' | 'confirm'
|
mode: 'alert' | 'confirm' | 'info'
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message?: string
|
||||||
|
infoRows?: Array<{ label: string; value: string }>
|
||||||
confirmText?: string
|
confirmText?: string
|
||||||
cancelText?: string
|
cancelText?: string
|
||||||
onConfirm?: (() => void) | null
|
onConfirm?: (() => void) | null
|
||||||
@@ -115,6 +116,12 @@ function formatTimeLabel(timestampSec: number): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatInfoValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '-'
|
||||||
|
const text = String(value).trim()
|
||||||
|
return text || '-'
|
||||||
|
}
|
||||||
|
|
||||||
function extractVideoTitle(content?: string): string {
|
function extractVideoTitle(content?: string): string {
|
||||||
const xml = String(content || '')
|
const xml = String(content || '')
|
||||||
if (!xml) return '视频'
|
if (!xml) return '视频'
|
||||||
@@ -152,6 +159,7 @@ const MediaCard = memo(function MediaCard({
|
|||||||
decrypting,
|
decrypting,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onShowInfo,
|
||||||
onImagePreviewAction,
|
onImagePreviewAction,
|
||||||
onUpdateImageQuality,
|
onUpdateImageQuality,
|
||||||
onOpenVideo,
|
onOpenVideo,
|
||||||
@@ -167,6 +175,7 @@ const MediaCard = memo(function MediaCard({
|
|||||||
decrypting: boolean
|
decrypting: boolean
|
||||||
onToggleSelect: (item: MediaStreamItem) => void
|
onToggleSelect: (item: MediaStreamItem) => void
|
||||||
onDelete: (item: MediaStreamItem) => void
|
onDelete: (item: MediaStreamItem) => void
|
||||||
|
onShowInfo: (item: MediaStreamItem) => void
|
||||||
onImagePreviewAction: (item: MediaStreamItem) => void
|
onImagePreviewAction: (item: MediaStreamItem) => void
|
||||||
onUpdateImageQuality: (item: MediaStreamItem) => void
|
onUpdateImageQuality: (item: MediaStreamItem) => void
|
||||||
onOpenVideo: (item: MediaStreamItem) => void
|
onOpenVideo: (item: MediaStreamItem) => void
|
||||||
@@ -178,6 +187,9 @@ const MediaCard = memo(function MediaCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`media-card ${selected ? 'selected' : ''} ${isDecryptingVisual ? 'decrypting' : ''}`}>
|
<article className={`media-card ${selected ? 'selected' : ''} ${isDecryptingVisual ? 'decrypting' : ''}`}>
|
||||||
|
<button type="button" className="floating-info" onClick={() => onShowInfo(item)} aria-label="查看资源信息">
|
||||||
|
<Info size={14} />
|
||||||
|
</button>
|
||||||
<button type="button" className="floating-delete" onClick={() => onDelete(item)} aria-label="删除资源">
|
<button type="button" className="floating-delete" onClick={() => onDelete(item)} aria-label="删除资源">
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -796,6 +808,93 @@ function ResourcesPage() {
|
|||||||
return md5
|
return md5
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const showMediaInfo = useCallback(async (item: MediaStreamItem) => {
|
||||||
|
const itemKey = getItemKey(item)
|
||||||
|
const mediaLabel = item.mediaType === 'image' ? '图片' : '视频'
|
||||||
|
const baseRows: Array<{ label: string; value: string }> = [
|
||||||
|
{ label: '资源类型', value: mediaLabel },
|
||||||
|
{ label: '会话 ID', value: formatInfoValue(item.sessionId) },
|
||||||
|
{ label: '消息 LocalId', value: formatInfoValue(item.localId) },
|
||||||
|
{ label: '消息时间', value: formatTimeLabel(item.createTime) },
|
||||||
|
{ label: '发送方', value: formatInfoValue(item.senderUsername) },
|
||||||
|
{ label: '是否我发送', value: item.isSend === 1 ? '是' : (item.isSend === 0 ? '否' : '-') }
|
||||||
|
]
|
||||||
|
|
||||||
|
setDialog({
|
||||||
|
mode: 'info',
|
||||||
|
title: `${mediaLabel}信息`,
|
||||||
|
infoRows: [...baseRows, { label: '状态', value: '正在读取缓存信息...' }],
|
||||||
|
confirmText: '关闭',
|
||||||
|
onConfirm: null
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (item.mediaType === 'image') {
|
||||||
|
const resolved = await window.electronAPI.image.resolveCache({
|
||||||
|
sessionId: item.sessionId,
|
||||||
|
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||||
|
imageDatName: getSafeImageDatName(item) || undefined,
|
||||||
|
createTime: Number(item.createTime || 0) || undefined,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
allowCacheIndex: true,
|
||||||
|
suppressEvents: true
|
||||||
|
})
|
||||||
|
const previewPath = previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey] || ''
|
||||||
|
const cachePath = String(resolved?.localPath || previewPath || '').trim()
|
||||||
|
const rows: Array<{ label: string; value: string }> = [
|
||||||
|
...baseRows,
|
||||||
|
{ label: 'imageMd5', value: formatInfoValue(normalizeMediaToken(item.imageMd5)) },
|
||||||
|
{ label: 'imageDatName', value: formatInfoValue(getSafeImageDatName(item)) },
|
||||||
|
{ label: '列表预览路径', value: formatInfoValue(previewPath) },
|
||||||
|
{ label: '缓存命中', value: resolved?.success && cachePath ? '是' : '否' },
|
||||||
|
{ label: '缓存路径', value: formatInfoValue(cachePath) },
|
||||||
|
{ label: '缓存可更新', value: resolved?.hasUpdate ? '是' : '否' },
|
||||||
|
{ label: '缓存状态', value: resolved?.success ? '可用' : formatInfoValue(resolved?.error || resolved?.failureKind || '未命中') }
|
||||||
|
]
|
||||||
|
setDialog({
|
||||||
|
mode: 'info',
|
||||||
|
title: '图片信息',
|
||||||
|
infoRows: rows,
|
||||||
|
confirmText: '关闭',
|
||||||
|
onConfirm: null
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedMd5 = await resolveItemVideoMd5(item)
|
||||||
|
const videoInfo = resolvedMd5
|
||||||
|
? await window.electronAPI.video.getVideoInfo(resolvedMd5, { includePoster: true, posterFormat: 'fileUrl' })
|
||||||
|
: null
|
||||||
|
const posterPath = videoPosterMapRef.current[itemKey] || posterPatchRef.current[itemKey] || ''
|
||||||
|
const rows: Array<{ label: string; value: string }> = [
|
||||||
|
...baseRows,
|
||||||
|
{ label: 'videoMd5(消息)', value: formatInfoValue(normalizeMediaToken(item.videoMd5)) },
|
||||||
|
{ label: 'videoMd5(解析)', value: formatInfoValue(resolvedMd5) },
|
||||||
|
{ label: '视频文件存在', value: videoInfo?.success && videoInfo.exists ? '是' : '否' },
|
||||||
|
{ label: '视频路径', value: formatInfoValue(videoInfo?.videoUrl) },
|
||||||
|
{ label: '同名封面路径', value: formatInfoValue(videoInfo?.coverUrl) },
|
||||||
|
{ label: '列表封面路径', value: formatInfoValue(posterPath) },
|
||||||
|
{ label: '视频状态', value: videoInfo?.success ? '可用' : formatInfoValue(videoInfo?.error || '未找到') }
|
||||||
|
]
|
||||||
|
setDialog({
|
||||||
|
mode: 'info',
|
||||||
|
title: '视频信息',
|
||||||
|
infoRows: rows,
|
||||||
|
confirmText: '关闭',
|
||||||
|
onConfirm: null
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
setDialog({
|
||||||
|
mode: 'info',
|
||||||
|
title: `${mediaLabel}信息`,
|
||||||
|
infoRows: [...baseRows, { label: '读取失败', value: formatInfoValue(String(e)) }],
|
||||||
|
confirmText: '关闭',
|
||||||
|
onConfirm: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [resolveItemVideoMd5])
|
||||||
|
|
||||||
const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => {
|
const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => {
|
||||||
if (item.mediaType !== 'video') return
|
if (item.mediaType !== 'video') return
|
||||||
const itemKey = getItemKey(item)
|
const itemKey = getItemKey(item)
|
||||||
@@ -815,7 +914,7 @@ function ResourcesPage() {
|
|||||||
attemptedVideoPosterKeysRef.current.add(itemKey)
|
attemptedVideoPosterKeysRef.current.add(itemKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const poster = String(info.coverUrl || info.thumbUrl || '')
|
const poster = String(info.coverUrl || '')
|
||||||
if (!poster) {
|
if (!poster) {
|
||||||
attemptedVideoPosterKeysRef.current.add(itemKey)
|
attemptedVideoPosterKeysRef.current.add(itemKey)
|
||||||
return
|
return
|
||||||
@@ -1371,6 +1470,7 @@ function ResourcesPage() {
|
|||||||
decrypting={decryptingKeys.has(itemKey)}
|
decrypting={decryptingKeys.has(itemKey)}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
onDelete={deleteOne}
|
onDelete={deleteOne}
|
||||||
|
onShowInfo={showMediaInfo}
|
||||||
onImagePreviewAction={onImagePreviewAction}
|
onImagePreviewAction={onImagePreviewAction}
|
||||||
onUpdateImageQuality={updateImageQuality}
|
onUpdateImageQuality={updateImageQuality}
|
||||||
onOpenVideo={openVideo}
|
onOpenVideo={openVideo}
|
||||||
@@ -1388,7 +1488,20 @@ function ResourcesPage() {
|
|||||||
<div className="resource-dialog-mask">
|
<div className="resource-dialog-mask">
|
||||||
<div className="resource-dialog" role="dialog" aria-modal="true" aria-label={dialog.title}>
|
<div className="resource-dialog" role="dialog" aria-modal="true" aria-label={dialog.title}>
|
||||||
<header className="dialog-header">{dialog.title}</header>
|
<header className="dialog-header">{dialog.title}</header>
|
||||||
<div className="dialog-body">{dialog.message}</div>
|
<div className="dialog-body">
|
||||||
|
{dialog.mode === 'info' ? (
|
||||||
|
<div className="dialog-info-list">
|
||||||
|
{(dialog.infoRows || []).map((row, idx) => (
|
||||||
|
<div className="dialog-info-row" key={`${row.label}-${idx}`}>
|
||||||
|
<span className="info-label">{row.label}</span>
|
||||||
|
<span className="info-value" title={row.value}>{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
dialog.message
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<footer className="dialog-actions">
|
<footer className="dialog-actions">
|
||||||
{dialog.mode === 'confirm' && (
|
{dialog.mode === 'confirm' && (
|
||||||
<button type="button" className="dialog-btn ghost" onClick={closeDialog}>
|
<button type="button" className="dialog-btn ghost" onClick={closeDialog}>
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -985,6 +985,7 @@ export interface ElectronAPI {
|
|||||||
pendingSessionIds?: string[]
|
pendingSessionIds?: string[]
|
||||||
successSessionIds?: string[]
|
successSessionIds?: string[]
|
||||||
failedSessionIds?: string[]
|
failedSessionIds?: string[]
|
||||||
|
sessionOutputPaths?: Record<string, string>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
||||||
|
|||||||
@@ -68,4 +68,5 @@ export interface SnsLinkCardData {
|
|||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
thumb?: string
|
thumb?: string
|
||||||
|
thumbKey?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user