From 66a6ee3e32d2bcc109b54ca57a655dd4f59d92a7 Mon Sep 17 00:00:00 2001 From: "majun.jason" Date: Tue, 4 Aug 2026 21:00:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=97=B6=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + scripts/after-pack.cjs | 30 +++ src/main/export-service.ts | 7 +- src/main/index.ts | 21 +- src/main/video-asset-service.ts | 234 +++++++++++++++++- src/preload/index.d.ts | 3 +- src/preload/index.ts | 5 +- src/renderer/src/components/VideoBubble.tsx | 12 +- .../src/components/chat/MessageBubble.tsx | 3 + tests/integration/export-media-flow.test.ts | 27 +- tests/unit/runtime-packaging.test.ts | 26 +- tests/unit/video-asset-service.test.ts | 140 +++++++++++ 12 files changed, 481 insertions(+), 28 deletions(-) create mode 100644 tests/unit/video-asset-service.test.ts diff --git a/package.json b/package.json index e6419f4..2b25fdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "wechatexplorer", "version": "2.1.8", + "packageManager": "pnpm@7.33.7", "description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手", "keywords": [ "wechat", diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 83295a1..4b2a382 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -1,9 +1,21 @@ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */ const { chmodSync, existsSync, renameSync } = require('node:fs') const { execFileSync } = require('node:child_process') const path = require('node:path') +const asar = require('@electron/asar') const COMPATIBILITY_NAME = 'Electron' const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)'] +const REQUIRED_RUNTIME_PACKAGES = [ + '@electron-toolkit/preload', + '@electron-toolkit/utils', + 'archiver', + 'electron-updater', + 'ffmpeg-static', + 'fs-extra', + 'jsonrepair', + 'koffi' +] function getRuntimeResources(context) { const productName = context.packager.appInfo.productFilename @@ -41,12 +53,29 @@ function validateFfmpegRuntime(runtimeResources, platform = process.platform) { return ffmpegPath } +function validateAsarRuntimeDependencies(runtimeResources) { + const asarPath = path.join(runtimeResources, 'app.asar') + if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`) + + const entries = new Set(asar.listPackage(asarPath)) + const missingPackages = REQUIRED_RUNTIME_PACKAGES.filter( + (packageName) => !entries.has(`/node_modules/${packageName}/package.json`) + ) + if (missingPackages.length > 0) { + throw new Error( + `Missing packaged runtime dependencies: ${missingPackages.join(', ')}. ` + + 'Use pnpm 7.33.7 so electron-builder can read pnpm-lock.yaml.' + ) + } +} + function setPlistValue(plistPath, key, value) { execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath]) } exports.default = async function afterPack(context) { const runtimeResources = getRuntimeResources(context) + validateAsarRuntimeDependencies(runtimeResources) validateSilkWasmRuntime(runtimeResources) const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName) @@ -117,5 +146,6 @@ exports.default = async function afterPack(context) { } exports.getRuntimeResources = getRuntimeResources +exports.validateAsarRuntimeDependencies = validateAsarRuntimeDependencies exports.validateFfmpegRuntime = validateFfmpegRuntime exports.validateSilkWasmRuntime = validateSilkWasmRuntime diff --git a/src/main/export-service.ts b/src/main/export-service.ts index bfdf78c..7b39afb 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -728,7 +728,12 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } else if (hashes.length === 0) { keepMediaError(request, message, '视频标识不完整,无法定位本地视频') } else { - const resolved = videoService.resolve(hashes) + const resolved = await videoService.resolve(hashes, { + createTime: message.createTime, + duration: message.contentData.duration, + width: message.contentData.width, + height: message.contentData.height + }) const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined if (!resolved.success || !source) { keepMediaError(request, message, resolved.error || '视频文件缺失或已移动') diff --git a/src/main/index.ts b/src/main/index.ts index 26921a9..06d3c21 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1176,14 +1176,21 @@ app.whenReady().then(async () => { return stickerService.resolveSticker(cdnUrl, md5) }) - ipcMain.handle('db:getVideo', async (_, hashes: string[]) => { - if (!videoAssetService) { - const client = chat.getChatDb()?.getWcdb4Client() - if (!client) return { success: false, error: '数据库尚未连接' } - videoAssetService = new VideoAssetService(client) + ipcMain.handle( + 'db:getVideo', + async ( + _, + hashes: string[], + options?: { createTime?: number; duration?: number; width?: number; height?: number } + ) => { + if (!videoAssetService) { + const client = chat.getChatDb()?.getWcdb4Client() + if (!client) return { success: false, error: '数据库尚未连接' } + videoAssetService = new VideoAssetService(client) + } + return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [], options) } - return videoAssetService.resolve(Array.isArray(hashes) ? hashes : []) - }) + ) // -------- Settings & API service -------- diff --git a/src/main/video-asset-service.ts b/src/main/video-asset-service.ts index 645f6de..2ea672d 100644 --- a/src/main/video-asset-service.ts +++ b/src/main/video-asset-service.ts @@ -8,14 +8,39 @@ type VideoAsset = { posterPath?: string } +export type VideoResolveOptions = { + createTime?: number + duration?: number + width?: number + height?: number +} + +type ImageDimensions = { + width: number + height: number +} + +type Mp4Box = { + type: string + size: number + contentOffset: number +} + export class VideoAssetService { private readonly urlTokens = new Map() private readonly fileTokens = new Map() + private readonly monthAssets = new Map() + private readonly fileHashes = new Map>() + private readonly videoDurations = new Map() + private readonly imageDimensions = new Map() private index: Map | null = null constructor(private readonly client: Wcdb4Client) {} - resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } { + async resolve( + hashes: string[], + options: VideoResolveOptions = {} + ): Promise<{ success: boolean; url?: string; poster?: string; error?: string }> { const candidates = Array.from( new Set( hashes @@ -53,6 +78,15 @@ export class VideoAssetService { poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined } } + + const fallback = await this.resolveFromLocalMetadata(candidates, options) + if (fallback) { + return { + success: true, + url: this.createLocalMediaUrl(fallback.filePath), + poster: fallback.posterPath ? this.createLocalMediaUrl(fallback.posterPath) : undefined + } + } return { success: false, error: '本地未找到该视频文件' } } @@ -105,22 +139,206 @@ export class VideoAssetService { for (const month of fs.readdirSync(root)) { const monthPath = path.join(root, month) if (!fs.statSync(monthPath).isDirectory()) continue + const monthly = new Map() for (const name of fs.readdirSync(monthPath)) { - const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name) + const videoMatch = /^([a-f0-9]{32})(?:(_raw))?\.mp4$/i.exec(name) + const posterMatch = /^([a-f0-9]{32})(?:(_raw))?(?:_thumb)?\.jpg$/i.exec(name) + const match = videoMatch || posterMatch if (!match) continue const key = `${match[1].toLowerCase()}${match[2] || ''}` const fullPath = path.join(monthPath, name) - const existing = result.get(key) || { filePath: '' } - if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath + const existing = monthly.get(key) || { filePath: '' } + if (videoMatch) existing.filePath = fullPath else if (!existing.posterPath) existing.posterPath = fullPath - result.set(key, existing) + monthly.set(key, existing) } - } - for (const [key, asset] of result) { - if (!asset.filePath) result.delete(key) + const assets: VideoAsset[] = [] + for (const [key, asset] of monthly) { + if (!asset.filePath) continue + result.set(key, asset) + assets.push(asset) + } + this.monthAssets.set(month, assets) } this.index = result return result } + + private async resolveFromLocalMetadata( + hashes: string[], + options: VideoResolveOptions + ): Promise { + const month = this.monthForCreateTime(options.createTime) + if (!month) return undefined + + this.getIndex() + const assets = this.monthAssets.get(month) || [] + if (assets.length === 0) return undefined + + let narrowed = assets + let appliedCriteria = 0 + const width = Number(options.width) + const height = Number(options.height) + if (width > 0 && height > 0) { + const matches = narrowed.filter((asset) => { + const dimensions = asset.posterPath ? this.readImageDimensions(asset.posterPath) : undefined + return dimensions?.width === width && dimensions.height === height + }) + if (matches.length > 0) { + narrowed = matches + appliedCriteria += 1 + } + } + + const duration = Number(options.duration) + if (duration > 0) { + const matches = narrowed.filter((asset) => { + const actual = this.readMp4Duration(asset.filePath) + return actual !== undefined && Math.abs(actual - duration) <= 1.5 + }) + if (matches.length > 0) { + narrowed = matches + appliedCriteria += 1 + } + } + + if (appliedCriteria >= 2 && narrowed.length === 1) return narrowed[0] + + const hashPool = narrowed.length > 0 ? narrowed : assets + const contentMatches: VideoAsset[] = [] + for (const asset of hashPool) { + const contentHash = await this.hashFile(asset.filePath) + if (contentHash && hashes.includes(contentHash)) contentMatches.push(asset) + } + return contentMatches.length === 1 ? contentMatches[0] : undefined + } + + private monthForCreateTime(createTime?: number): string | undefined { + const raw = Number(createTime) + if (!Number.isFinite(raw) || raw <= 0) return undefined + const date = new Date(raw > 10_000_000_000 ? raw : raw * 1000) + if (Number.isNaN(date.getTime())) return undefined + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + } + + private hashFile(filePath: string): Promise { + const cached = this.fileHashes.get(filePath) + if (cached) return cached + const pending = new Promise((resolve) => { + const hash = crypto.createHash('md5') + const stream = fs.createReadStream(filePath) + stream.on('data', (chunk) => hash.update(chunk)) + stream.on('error', () => resolve(undefined)) + stream.on('end', () => resolve(hash.digest('hex'))) + }) + this.fileHashes.set(filePath, pending) + return pending + } + + private readImageDimensions(filePath: string): ImageDimensions | undefined { + if (this.imageDimensions.has(filePath)) return this.imageDimensions.get(filePath) + let dimensions: ImageDimensions | undefined + try { + const data = fs.readFileSync(filePath) + if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) { + let offset = 2 + const startOfFrame = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf + ]) + while (offset + 8 < data.length) { + if (data[offset] !== 0xff) { + offset += 1 + continue + } + while (offset < data.length && data[offset] === 0xff) offset += 1 + const marker = data[offset] + offset += 1 + if (marker === 0xd8 || marker === 0x01) continue + if (marker === 0xd9 || marker === 0xda || offset + 2 > data.length) break + const length = data.readUInt16BE(offset) + if (length < 2 || offset + length > data.length) break + if (startOfFrame.has(marker) && length >= 7) { + dimensions = { + height: data.readUInt16BE(offset + 3), + width: data.readUInt16BE(offset + 5) + } + break + } + offset += length + } + } + } catch { + dimensions = undefined + } + this.imageDimensions.set(filePath, dimensions) + return dimensions + } + + private readMp4Duration(filePath: string): number | undefined { + if (this.videoDurations.has(filePath)) return this.videoDurations.get(filePath) + let duration: number | undefined + let descriptor: number | undefined + try { + descriptor = fs.openSync(filePath, 'r') + const fileSize = fs.fstatSync(descriptor).size + const moov = this.findMp4Box(descriptor, 0, fileSize, 'moov') + const mvhd = moov + ? this.findMp4Box(descriptor, moov.contentOffset, moov.contentOffset + moov.size, 'mvhd') + : undefined + if (mvhd) { + const header = Buffer.alloc(32) + const bytesRead = fs.readSync(descriptor, header, 0, header.length, mvhd.contentOffset) + const version = header[0] + if (version === 0 && bytesRead >= 20) { + const timescale = header.readUInt32BE(12) + const ticks = header.readUInt32BE(16) + if (timescale > 0) duration = ticks / timescale + } else if (version === 1 && bytesRead >= 32) { + const timescale = header.readUInt32BE(20) + const ticks = Number(header.readBigUInt64BE(24)) + if (timescale > 0 && Number.isSafeInteger(ticks)) duration = ticks / timescale + } + } + } catch { + duration = undefined + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor) + } + this.videoDurations.set(filePath, duration) + return duration + } + + private findMp4Box( + descriptor: number, + start: number, + end: number, + target: string + ): Mp4Box | undefined { + let offset = start + const header = Buffer.alloc(16) + while (offset + 8 <= end) { + const bytesRead = fs.readSync(descriptor, header, 0, header.length, offset) + if (bytesRead < 8) return undefined + const size32 = header.readUInt32BE(0) + const type = header.toString('ascii', 4, 8) + let headerSize = 8 + let size = size32 + if (size32 === 1) { + if (bytesRead < 16) return undefined + const extendedSize = header.readBigUInt64BE(8) + if (extendedSize > BigInt(Number.MAX_SAFE_INTEGER)) return undefined + size = Number(extendedSize) + headerSize = 16 + } else if (size32 === 0) { + size = end - offset + } + if (size < headerSize || offset + size > end) return undefined + if (type === target) { + return { type, size: size - headerSize, contentOffset: offset + headerSize } + } + offset += size + } + return undefined + } } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index bcc3f49..9644d42 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -219,7 +219,8 @@ declare global { filePath?: string }> getVideo: ( - hashes: string[] + hashes: string[], + options?: { createTime?: 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 322013f..61bdef7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -82,7 +82,10 @@ const api = { sessionId?: string, options?: { force?: boolean; preferThumbnail?: boolean; priority?: number } ) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options), - getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes), + getVideo: ( + hashes: string[], + options?: { createTime?: 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), cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId), diff --git a/src/renderer/src/components/VideoBubble.tsx b/src/renderer/src/components/VideoBubble.tsx index f503f94..ff23765 100644 --- a/src/renderer/src/components/VideoBubble.tsx +++ b/src/renderer/src/components/VideoBubble.tsx @@ -4,14 +4,20 @@ interface VideoBubbleProps { md5?: string newMd5?: string rawMd5?: string + createTime?: number duration?: number + width?: number + height?: number } export function VideoBubble({ md5, newMd5, rawMd5, - duration + createTime, + duration, + width, + height }: VideoBubbleProps): React.ReactElement { const hashes = useMemo( () => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)), @@ -22,7 +28,7 @@ export function VideoBubble({ useEffect(() => { let cancelled = false window.api - .getVideo(hashes) + .getVideo(hashes, { createTime, duration, width, height }) .then((result) => { if (!cancelled) setMedia(result.success ? result : { error: result.error }) }) @@ -32,7 +38,7 @@ export function VideoBubble({ return () => { cancelled = true } - }, [hashes]) + }, [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 76943db..10c70b3 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -81,7 +81,10 @@ export function MessageBubble({ md5={message.contentData.md5} newMd5={message.contentData.newMd5} rawMd5={message.contentData.rawMd5} + createTime={message.createTime} duration={message.contentData.duration} + width={message.contentData.width} + height={message.contentData.height} /> ) : isRichMedia && message.contentData ? ( ({ videoPath: '', messages: [] as Message[], messagesByUser: {} as Record, + videoLookups: [] as { + createTime?: number + duration?: number + width?: number + height?: number + }[], imageLookups: [] as { allowThumbnail?: boolean preferThumbnail?: boolean @@ -120,7 +126,11 @@ vi.mock('../../src/main/image-decrypt-service', () => ({ })) vi.mock('../../src/main/video-asset-service', () => ({ VideoAssetService: class { - resolve(): { success: boolean; url: string } { + resolve( + _hashes: string[], + options?: { createTime?: number; duration?: number; width?: number; height?: number } + ): { success: boolean; url: string } { + state.videoLookups.push(options || {}) return { success: true, url: 'wxe-media://local/fixture-video' } } pathForUrl(): string { @@ -175,6 +185,7 @@ describe('media export flow', () => { Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex') ) state.imageLookups = [] + state.videoLookups = [] state.messagesByUser = {} const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08') mkdirSync(fileMonth, { recursive: true }) @@ -203,7 +214,13 @@ describe('media export flow', () => { message({ id: 'video', type: '视频', - contentData: { type: 'video', md5: 'b'.repeat(32) } + contentData: { + type: 'video', + md5: 'b'.repeat(32), + duration: 68, + width: 279, + height: 630 + } }), message({ id: 'file', @@ -264,6 +281,12 @@ describe('media export flow', () => { sessionMd5: 'fixture-user', createTime: 1_785_549_600 }) + expect(state.videoLookups[0]).toEqual({ + createTime: 1_785_549_600, + duration: 68, + width: 279, + height: 630 + }) expect(progress.length).toBeGreaterThan(0) }) diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts index 37ee4ba..05366dc 100644 --- a/tests/unit/runtime-packaging.test.ts +++ b/tests/unit/runtime-packaging.test.ts @@ -5,12 +5,15 @@ import { dirname, join, resolve } from 'path' import { afterAll, describe, expect, it } from 'vitest' const nodeRequire = createRequire(import.meta.url) -const { validateFfmpegRuntime, validateSilkWasmRuntime } = nodeRequire( - '../../scripts/after-pack.cjs' -) as { - validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void - validateSilkWasmRuntime: (runtimeResources: string) => void +const asar = nodeRequire('@electron/asar') as { + createPackage: (source: string, destination: string) => Promise } +const { validateAsarRuntimeDependencies, validateFfmpegRuntime, validateSilkWasmRuntime } = + nodeRequire('../../scripts/after-pack.cjs') as { + validateAsarRuntimeDependencies: (runtimeResources: string) => void + validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void + validateSilkWasmRuntime: (runtimeResources: string) => void + } const root = mkdtempSync(join(tmpdir(), 'wxe-runtime-package-')) describe('production runtime packaging', () => { @@ -32,6 +35,19 @@ describe('production runtime packaging', () => { expect(config).toContain('node_modules/silk-wasm/**') }) + it('rejects an app archive with missing runtime dependencies', async () => { + const resources = join(root, 'asar-resources') + const source = join(root, 'asar-source') + mkdirSync(source, { recursive: true }) + writeFileSync(join(source, 'package.json'), '{}') + mkdirSync(resources, { recursive: true }) + await asar.createPackage(source, join(resources, 'app.asar')) + + expect(() => validateAsarRuntimeDependencies(resources)).toThrow( + /Missing packaged runtime dependencies:.*@electron-toolkit\/utils/ + ) + }) + it('requires and unpacks the bundled ffmpeg-static executable', () => { const resources = join(root, 'ffmpeg-resources') const ffmpegPath = join( diff --git a/tests/unit/video-asset-service.test.ts b/tests/unit/video-asset-service.test.ts new file mode 100644 index 0000000..17b45a1 --- /dev/null +++ b/tests/unit/video-asset-service.test.ts @@ -0,0 +1,140 @@ +import { createHash } from 'crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { VideoAssetService } from '../../src/main/video-asset-service' + +const temporaryDirectories: string[] = [] + +const box = (type: string, payload: Buffer): Buffer => { + const header = Buffer.alloc(8) + header.writeUInt32BE(header.length + payload.length, 0) + header.write(type, 4, 4, 'ascii') + return Buffer.concat([header, payload]) +} + +const mp4Fixture = (durationSeconds: number, marker: string): Buffer => { + const movieHeader = Buffer.alloc(20) + movieHeader.writeUInt32BE(1000, 12) + movieHeader.writeUInt32BE(Math.round(durationSeconds * 1000), 16) + return Buffer.concat([ + box('ftyp', Buffer.from('isom0000', 'ascii')), + box('moov', box('mvhd', movieHeader)), + box('mdat', Buffer.from(marker, 'utf8')) + ]) +} + +const jpegFixture = (width: number, height: number): Buffer => + Buffer.from([ + 0xff, + 0xd8, + 0xff, + 0xc0, + 0x00, + 0x11, + 0x08, + (height >> 8) & 0xff, + height & 0xff, + (width >> 8) & 0xff, + width & 0xff, + 0x03, + 0x01, + 0x11, + 0x00, + 0x02, + 0x11, + 0x00, + 0x03, + 0x11, + 0x00, + 0xff, + 0xd9 + ]) + +const createService = (): { + accountRoot: string + service: VideoAssetService +} => { + const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-video-assets-')) + temporaryDirectories.push(accountRoot) + return { + accountRoot, + service: new VideoAssetService({ + getAccountRoot: () => accountRoot, + resolveVideoHardlink: () => null + } as never) + } +} + +const monthTimestamp = (year: number, month: number): number => + Math.floor(new Date(year, month - 1, 15, 12).getTime() / 1000) + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('VideoAssetService local fallback', () => { + it('finds a video by its content MD5 when the hardlink mapping is absent', async () => { + const { accountRoot, service } = createService() + const month = join(accountRoot, 'msg', 'video', '2026-07') + mkdirSync(month, { recursive: true }) + const content = mp4Fixture(22, 'content-md5-match') + const filePath = join(month, `${'2'.repeat(32)}.mp4`) + writeFileSync(filePath, content) + const contentHash = createHash('md5').update(content).digest('hex') + + const result = await service.resolve([contentHash], { + createTime: monthTimestamp(2026, 7) + }) + + expect(result.success).toBe(true) + expect(service.pathForUrl(result.url!)).toBe(filePath) + }) + + it('finds a uniquely matching video by month, thumbnail size, and duration', async () => { + const { accountRoot, service } = createService() + const month = join(accountRoot, 'msg', 'video', '2025-11') + mkdirSync(month, { recursive: true }) + const stem = '66cecd68e095d87175fb5ed138de3cef' + const filePath = join(month, `${stem}.mp4`) + const posterPath = join(month, `${stem}_thumb.jpg`) + writeFileSync(filePath, mp4Fixture(68.441, 'metadata-match')) + writeFileSync(posterPath, jpegFixture(279, 630)) + + const result = await service.resolve( + ['c92c54c8eae4471be9cc18396daf8015', '021e8a18a765ce14f4c54f40065db98e'], + { + createTime: monthTimestamp(2025, 11), + duration: 68, + width: 279, + height: 630 + } + ) + + expect(result.success).toBe(true) + expect(service.pathForUrl(result.url!)).toBe(filePath) + expect(service.pathForUrl(result.poster!)).toBe(posterPath) + }) + + it('does not guess when multiple files match the same metadata', async () => { + const { accountRoot, service } = createService() + const month = join(accountRoot, 'msg', 'video', '2026-02') + mkdirSync(month, { recursive: true }) + for (const stem of ['a'.repeat(32), 'b'.repeat(32)]) { + writeFileSync(join(month, `${stem}.mp4`), mp4Fixture(12, stem)) + writeFileSync(join(month, `${stem}_thumb.jpg`), jpegFixture(224, 398)) + } + + const result = await service.resolve(['c'.repeat(32)], { + createTime: monthTimestamp(2026, 2), + duration: 12, + width: 224, + height: 398 + }) + + expect(result).toEqual({ success: false, error: '本地未找到该视频文件' }) + }) +})