diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 15eaa8a..f9f04ed 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -1152,11 +1152,10 @@ export async function runExport( ].filter((value): value is string => Boolean(value)) if (!videoService) { keepMediaError(request, message, '数据库未连接,无法定位本地视频') - } else if (hashes.length === 0) { - keepMediaError(request, message, '视频标识不完整,无法定位本地视频') } else { const resolved = await videoService.resolve(hashes, { createTime: message.createTime, + byteLength: message.contentData.byteLength, duration: message.contentData.duration, width: message.contentData.width, height: message.contentData.height diff --git a/src/main/index.ts b/src/main/index.ts index 35d6bf9..7de6595 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1339,7 +1339,13 @@ app.whenReady().then(async () => { async ( _, hashes: string[], - options?: { createTime?: number; duration?: number; width?: number; height?: number } + options?: { + createTime?: number + byteLength?: number + duration?: number + width?: number + height?: number + } ) => { if (!videoAssetService) { const client = chat.getChatDb()?.getWcdb4Client() diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index fcb937a..aa6e4c1 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -65,6 +65,7 @@ type VideoContent = { md5?: string newMd5?: string rawMd5?: string + byteLength?: number duration?: number width?: number height?: number @@ -160,12 +161,11 @@ function parseVideoMessage(content: string): ParsedContent { const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5')) const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5')) const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5')) - if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content } - + const byteLength = Number(extractXmlAttribute(decoded, 'videomsg', 'length')) || undefined const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined - return { type: 'video', md5, newMd5, rawMd5, duration, width, height } + return { type: 'video', md5, newMd5, rawMd5, byteLength, duration, width, height } } function parseSystemMessage(content: string): ParsedContent { diff --git a/src/main/video-asset-service.ts b/src/main/video-asset-service.ts index 2ea672d..3577105 100644 --- a/src/main/video-asset-service.ts +++ b/src/main/video-asset-service.ts @@ -10,6 +10,7 @@ type VideoAsset = { export type VideoResolveOptions = { createTime?: number + byteLength?: number duration?: number width?: number height?: number @@ -52,7 +53,13 @@ export class VideoAssetService { .filter((value) => /^[a-f0-9]{32}$/.test(value)) ) ) - if (candidates.length === 0) return { success: false, error: '视频标识为空' } + const hasMetadata = + Number(options.byteLength) > 0 || + Number(options.duration) > 0 || + (Number(options.width) > 0 && Number(options.height) > 0) + if (candidates.length === 0 && !hasMetadata) { + return { success: false, error: '视频标识为空' } + } const hardlinkDb = path.join( this.client.getAccountRoot(), @@ -178,6 +185,21 @@ export class VideoAssetService { let narrowed = assets let appliedCriteria = 0 + const byteLength = Number(options.byteLength) + if (byteLength > 0) { + const matches = narrowed.filter((asset) => { + try { + return fs.statSync(asset.filePath).size === byteLength + } catch { + return false + } + }) + if (matches.length > 0) { + narrowed = matches + appliedCriteria += 1 + } + } + const width = Number(options.width) const height = Number(options.height) if (width > 0 && height > 0) { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index de0c44b..b0a0b02 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -102,6 +102,7 @@ export type ParsedContent = md5?: string newMd5?: string rawMd5?: string + byteLength?: number duration?: number width?: number height?: number @@ -278,7 +279,13 @@ declare global { }> getVideo: ( hashes: string[], - options?: { createTime?: number; duration?: number; width?: number; height?: number } + options?: { + createTime?: number + byteLength?: number + duration?: number + width?: number + height?: number + } ) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }> getSticker: ( cdnUrl?: string, diff --git a/src/preload/index.ts b/src/preload/index.ts index a997f81..a0e87eb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -179,7 +179,13 @@ const api = { ) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options), getVideo: ( hashes: string[], - options?: { createTime?: number; duration?: number; width?: number; height?: number } + options?: { + createTime?: number + byteLength?: number + duration?: number + width?: number + height?: number + } ) => ipcRenderer.invoke('db:getVideo', hashes, options), getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request), diff --git a/src/renderer/src/components/VideoBubble.tsx b/src/renderer/src/components/VideoBubble.tsx index ff23765..7f17ab6 100644 --- a/src/renderer/src/components/VideoBubble.tsx +++ b/src/renderer/src/components/VideoBubble.tsx @@ -5,6 +5,7 @@ interface VideoBubbleProps { newMd5?: string rawMd5?: string createTime?: number + byteLength?: number duration?: number width?: number height?: number @@ -15,6 +16,7 @@ export function VideoBubble({ newMd5, rawMd5, createTime, + byteLength, duration, width, height @@ -28,7 +30,7 @@ export function VideoBubble({ useEffect(() => { let cancelled = false window.api - .getVideo(hashes, { createTime, duration, width, height }) + .getVideo(hashes, { createTime, byteLength, duration, width, height }) .then((result) => { if (!cancelled) setMedia(result.success ? result : { error: result.error }) }) @@ -38,7 +40,7 @@ export function VideoBubble({ return () => { cancelled = true } - }, [createTime, duration, hashes, height, width]) + }, [byteLength, createTime, duration, hashes, height, width]) if (!media.url) { return
{media.error || '视频加载中…'}
diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx index 10c70b3..71044e6 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -82,6 +82,7 @@ export function MessageBubble({ newMd5={message.contentData.newMd5} rawMd5={message.contentData.rawMd5} createTime={message.createTime} + byteLength={message.contentData.byteLength} duration={message.contentData.duration} width={message.contentData.width} height={message.contentData.height} diff --git a/src/shared/types.ts b/src/shared/types.ts index 1d8c122..b31498d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -111,6 +111,7 @@ type VideoContent = { md5?: string newMd5?: string rawMd5?: string + byteLength?: number duration?: number width?: number height?: number diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index 5d68efe..1ab42c8 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -30,6 +30,7 @@ const state = vi.hoisted(() => ({ voiceLookups: [] as number[], videoLookups: [] as { createTime?: number + byteLength?: number duration?: number width?: number height?: number @@ -163,7 +164,13 @@ vi.mock('../../src/main/video-asset-service', () => ({ VideoAssetService: class { resolve( _hashes: string[], - options?: { createTime?: number; duration?: number; width?: number; height?: number } + options?: { + createTime?: number + byteLength?: number + duration?: number + width?: number + height?: number + } ): { success: boolean; url: string } { state.videoLookups.push(options || {}) return { success: true, url: 'wxe-media://local/fixture-video' } @@ -255,7 +262,7 @@ describe('media export flow', () => { type: '视频', contentData: { type: 'video', - md5: 'b'.repeat(32), + byteLength: 6402169, duration: 68, width: 279, height: 630 @@ -326,6 +333,7 @@ describe('media export flow', () => { }) expect(state.videoLookups[0]).toEqual({ createTime: 1_785_549_600, + byteLength: 6402169, duration: 68, width: 279, height: 630 diff --git a/tests/unit/message-parser.test.ts b/tests/unit/message-parser.test.ts index 40978d9..dc27d68 100644 --- a/tests/unit/message-parser.test.ts +++ b/tests/unit/message-parser.test.ts @@ -16,6 +16,24 @@ describe('message parser', () => { ).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' }) }) + it('keeps video metadata when WeChat omits every MD5 field', () => { + const parsed = parseMessageContent( + '', + 43 + ) + + expect(parsed).toEqual({ + type: 'video', + md5: undefined, + newMd5: undefined, + rawMd5: undefined, + byteLength: 6402169, + duration: 30, + width: 224, + height: 398 + }) + }) + it('parses merged forwards and preserves nested visible text', () => { const parsed = parseMessageContent( '19转发多条内容测试成员脱敏内容', diff --git a/tests/unit/video-asset-service.test.ts b/tests/unit/video-asset-service.test.ts index 17b45a1..2b4894d 100644 --- a/tests/unit/video-asset-service.test.ts +++ b/tests/unit/video-asset-service.test.ts @@ -119,6 +119,30 @@ describe('VideoAssetService local fallback', () => { expect(service.pathForUrl(result.poster!)).toBe(posterPath) }) + it('finds an MD5-less video by byte length, thumbnail size, and duration', async () => { + const { accountRoot, service } = createService() + const month = join(accountRoot, 'msg', 'video', '2026-08') + mkdirSync(month, { recursive: true }) + const targetStem = '9a3b17272822a65ce6e396ecded9ccdc' + const otherStem = '47e890579eacf48c46187ae998332202' + const target = mp4Fixture(30, 'target-video-with-distinct-byte-length') + writeFileSync(join(month, `${targetStem}.mp4`), target) + writeFileSync(join(month, `${targetStem}_thumb.jpg`), jpegFixture(224, 398)) + writeFileSync(join(month, `${otherStem}.mp4`), mp4Fixture(30, 'other')) + writeFileSync(join(month, `${otherStem}_thumb.jpg`), jpegFixture(224, 398)) + + const result = await service.resolve([], { + createTime: monthTimestamp(2026, 8), + byteLength: target.length, + duration: 30, + width: 224, + height: 398 + }) + + expect(result.success).toBe(true) + expect(service.pathForUrl(result.url!)).toBe(join(month, `${targetStem}.mp4`)) + }) + it('does not guess when multiple files match the same metadata', async () => { const { accountRoot, service } = createService() const month = join(accountRoot, 'msg', 'video', '2026-02')