mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
修复bug,针对部分没有MD5名字的视频也能支持导出
This commit is contained in:
@@ -1152,11 +1152,10 @@ export async function runExport(
|
|||||||
].filter((value): value is string => Boolean(value))
|
].filter((value): value is string => Boolean(value))
|
||||||
if (!videoService) {
|
if (!videoService) {
|
||||||
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
|
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
|
||||||
} else if (hashes.length === 0) {
|
|
||||||
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
|
|
||||||
} else {
|
} else {
|
||||||
const resolved = await videoService.resolve(hashes, {
|
const resolved = await videoService.resolve(hashes, {
|
||||||
createTime: message.createTime,
|
createTime: message.createTime,
|
||||||
|
byteLength: message.contentData.byteLength,
|
||||||
duration: message.contentData.duration,
|
duration: message.contentData.duration,
|
||||||
width: message.contentData.width,
|
width: message.contentData.width,
|
||||||
height: message.contentData.height
|
height: message.contentData.height
|
||||||
|
|||||||
+7
-1
@@ -1339,7 +1339,13 @@ app.whenReady().then(async () => {
|
|||||||
async (
|
async (
|
||||||
_,
|
_,
|
||||||
hashes: string[],
|
hashes: string[],
|
||||||
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
options?: {
|
||||||
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!videoAssetService) {
|
if (!videoAssetService) {
|
||||||
const client = chat.getChatDb()?.getWcdb4Client()
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type VideoContent = {
|
|||||||
md5?: string
|
md5?: string
|
||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -160,12 +161,11 @@ function parseVideoMessage(content: string): ParsedContent {
|
|||||||
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
||||||
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
||||||
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
|
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 duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
|
||||||
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
||||||
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || 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 {
|
function parseSystemMessage(content: string): ParsedContent {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type VideoAsset = {
|
|||||||
|
|
||||||
export type VideoResolveOptions = {
|
export type VideoResolveOptions = {
|
||||||
createTime?: number
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -52,7 +53,13 @@ export class VideoAssetService {
|
|||||||
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
.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(
|
const hardlinkDb = path.join(
|
||||||
this.client.getAccountRoot(),
|
this.client.getAccountRoot(),
|
||||||
@@ -178,6 +185,21 @@ export class VideoAssetService {
|
|||||||
|
|
||||||
let narrowed = assets
|
let narrowed = assets
|
||||||
let appliedCriteria = 0
|
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 width = Number(options.width)
|
||||||
const height = Number(options.height)
|
const height = Number(options.height)
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
|
|||||||
Vendored
+8
-1
@@ -102,6 +102,7 @@ export type ParsedContent =
|
|||||||
md5?: string
|
md5?: string
|
||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -278,7 +279,13 @@ declare global {
|
|||||||
}>
|
}>
|
||||||
getVideo: (
|
getVideo: (
|
||||||
hashes: string[],
|
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 }>
|
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||||
getSticker: (
|
getSticker: (
|
||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
|
|||||||
@@ -179,7 +179,13 @@ const api = {
|
|||||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
getVideo: (
|
getVideo: (
|
||||||
hashes: string[],
|
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),
|
) => ipcRenderer.invoke('db:getVideo', hashes, options),
|
||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface VideoBubbleProps {
|
|||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -15,6 +16,7 @@ export function VideoBubble({
|
|||||||
newMd5,
|
newMd5,
|
||||||
rawMd5,
|
rawMd5,
|
||||||
createTime,
|
createTime,
|
||||||
|
byteLength,
|
||||||
duration,
|
duration,
|
||||||
width,
|
width,
|
||||||
height
|
height
|
||||||
@@ -28,7 +30,7 @@ export function VideoBubble({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
window.api
|
window.api
|
||||||
.getVideo(hashes, { createTime, duration, width, height })
|
.getVideo(hashes, { createTime, byteLength, duration, width, height })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
||||||
})
|
})
|
||||||
@@ -38,7 +40,7 @@ export function VideoBubble({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [createTime, duration, hashes, height, width])
|
}, [byteLength, createTime, duration, hashes, height, width])
|
||||||
|
|
||||||
if (!media.url) {
|
if (!media.url) {
|
||||||
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function MessageBubble({
|
|||||||
newMd5={message.contentData.newMd5}
|
newMd5={message.contentData.newMd5}
|
||||||
rawMd5={message.contentData.rawMd5}
|
rawMd5={message.contentData.rawMd5}
|
||||||
createTime={message.createTime}
|
createTime={message.createTime}
|
||||||
|
byteLength={message.contentData.byteLength}
|
||||||
duration={message.contentData.duration}
|
duration={message.contentData.duration}
|
||||||
width={message.contentData.width}
|
width={message.contentData.width}
|
||||||
height={message.contentData.height}
|
height={message.contentData.height}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ type VideoContent = {
|
|||||||
md5?: string
|
md5?: string
|
||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const state = vi.hoisted(() => ({
|
|||||||
voiceLookups: [] as number[],
|
voiceLookups: [] as number[],
|
||||||
videoLookups: [] as {
|
videoLookups: [] as {
|
||||||
createTime?: number
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -163,7 +164,13 @@ vi.mock('../../src/main/video-asset-service', () => ({
|
|||||||
VideoAssetService: class {
|
VideoAssetService: class {
|
||||||
resolve(
|
resolve(
|
||||||
_hashes: string[],
|
_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 } {
|
): { success: boolean; url: string } {
|
||||||
state.videoLookups.push(options || {})
|
state.videoLookups.push(options || {})
|
||||||
return { success: true, url: 'wxe-media://local/fixture-video' }
|
return { success: true, url: 'wxe-media://local/fixture-video' }
|
||||||
@@ -255,7 +262,7 @@ describe('media export flow', () => {
|
|||||||
type: '视频',
|
type: '视频',
|
||||||
contentData: {
|
contentData: {
|
||||||
type: 'video',
|
type: 'video',
|
||||||
md5: 'b'.repeat(32),
|
byteLength: 6402169,
|
||||||
duration: 68,
|
duration: 68,
|
||||||
width: 279,
|
width: 279,
|
||||||
height: 630
|
height: 630
|
||||||
@@ -326,6 +333,7 @@ describe('media export flow', () => {
|
|||||||
})
|
})
|
||||||
expect(state.videoLookups[0]).toEqual({
|
expect(state.videoLookups[0]).toEqual({
|
||||||
createTime: 1_785_549_600,
|
createTime: 1_785_549_600,
|
||||||
|
byteLength: 6402169,
|
||||||
duration: 68,
|
duration: 68,
|
||||||
width: 279,
|
width: 279,
|
||||||
height: 630
|
height: 630
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ describe('message parser', () => {
|
|||||||
).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' })
|
).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps video metadata when WeChat omits every MD5 field', () => {
|
||||||
|
const parsed = parseMessageContent(
|
||||||
|
'<msg><videomsg length="6402169" playlength="30" cdnthumbwidth="224" cdnthumbheight="398" aeskey="25201cc658042689d1ad6747cea2b240" rawmd5="" /></msg>',
|
||||||
|
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', () => {
|
it('parses merged forwards and preserves nested visible text', () => {
|
||||||
const parsed = parseMessageContent(
|
const parsed = parseMessageContent(
|
||||||
'<appmsg><type>19</type><title>转发多条内容</title><recorditem><dataitem datatype="1"><sourcename>测试成员</sourcename><datadesc>脱敏内容</datadesc></dataitem></recorditem></appmsg>',
|
'<appmsg><type>19</type><title>转发多条内容</title><recorditem><dataitem datatype="1"><sourcename>测试成员</sourcename><datadesc>脱敏内容</datadesc></dataitem></recorditem></appmsg>',
|
||||||
|
|||||||
@@ -119,6 +119,30 @@ describe('VideoAssetService local fallback', () => {
|
|||||||
expect(service.pathForUrl(result.poster!)).toBe(posterPath)
|
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 () => {
|
it('does not guess when multiple files match the same metadata', async () => {
|
||||||
const { accountRoot, service } = createService()
|
const { accountRoot, service } = createService()
|
||||||
const month = join(accountRoot, 'msg', 'video', '2026-02')
|
const month = join(accountRoot, 'msg', 'video', '2026-02')
|
||||||
|
|||||||
Reference in New Issue
Block a user