diff --git a/src/main/favorites-service.ts b/src/main/favorites-service.ts new file mode 100644 index 0000000..b8ad5d0 --- /dev/null +++ b/src/main/favorites-service.ts @@ -0,0 +1,43 @@ +import type { Message, ParsedContent } from '../shared/types' +import { favoriteRowToContent } from '../shared/favorites' +import type { Wcdb4Client } from './wcdb4-client' + +/** 收藏只读服务:favorite.db → 可读卡片 / 导出消息。 */ +export class FavoritesService { + constructor(private readonly wcdb4Client: Wcdb4Client) {} + + async listContents(limit = 200): Promise { + const rows = await this.wcdb4Client.listFavoriteItems(limit) + return rows.map((row) => favoriteRowToContent(row)) + } + + /** 收藏转成导出/搜索可用的 Message 列表(合成会话「收藏」)。 */ + async listExportMessages(limit = 200): Promise { + const rows = await this.wcdb4Client.listFavoriteItems(limit) + return rows.map((row, index) => { + const contentData = favoriteRowToContent(row) + const createTime = Number(row.update_time) || 0 + return { + id: `fav-${row.local_id ?? index}`, + from: 'favorite', + type: '收藏', + datetime: createTime ? new Date(createTime * 1000).toISOString() : '', + content: textOf(contentData), + isSender: false, + createTime, + name: '收藏', + contentData + } as Message + }) + } +} + +function textOf(content: ParsedContent): string { + if (content.type === 'text') return content.content + if (content.type === 'system') return content.content + if (content.type === 'share') return [content.title, content.des].filter(Boolean).join(' · ') + if (content.type === 'location') return content.poiname || content.label || '[位置]' + if (content.type === 'miniProgram') return content.title || '[小程序]' + if (content.type === 'forwardBundle') return content.title || '[聊天记录]' + return '[收藏]' +} diff --git a/src/main/voice-pipeline/audio-decoder.ts b/src/main/voice-pipeline/audio-decoder.ts index 6bac7ff..cf4a93a 100644 --- a/src/main/voice-pipeline/audio-decoder.ts +++ b/src/main/voice-pipeline/audio-decoder.ts @@ -126,5 +126,54 @@ export class AudioDecoderRegistry { } export function createDefaultAudioDecoderRegistry(): AudioDecoderRegistry { - return new AudioDecoderRegistry().register(new SilkAudioDecoder()) + return new AudioDecoderRegistry() + .register(new SilkAudioDecoder()) + .register(new AmrAudioDecoder()) +} + +/** + * V3 AMR:外部 `ffmpeg` 解码为 PCM(MultiMediaDyn `CAMRDecoder` 未导出)。 + */ +export class AmrAudioDecoder implements VoiceAudioDecoder { + readonly codec = 'amr' + + async decode(source: EncodedVoiceSource): Promise { + const { spawn } = await import('child_process') + const { tmpdir } = await import('os') + const { promises: fs } = await import('fs') + const { randomBytes } = await import('crypto') + const stamp = randomBytes(8).toString('hex') + const inPath = join(tmpdir(), `tracememo-amr-in-${stamp}.amr`) + const outPath = join(tmpdir(), `tracememo-amr-out-${stamp}.wav`) + await fs.writeFile(inPath, source.data) + try { + await new Promise((resolve, reject) => { + const child = spawn( + 'ffmpeg', + ['-y', '-i', inPath, '-f', 'wav', '-acodec', 'pcm_s16le', '-ar', '8000', '-ac', '1', outPath], + { stdio: ['ignore', 'ignore', 'pipe'] } + ) + let err = '' + child.stderr.on('data', (chunk) => { + err += String(chunk) + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`ffmpeg amr decode failed: ${err.slice(-200)}`)) + }) + }) + const wav = await fs.readFile(outPath) + const pcm = wav.length > 44 && wav.toString('ascii', 0, 4) === 'RIFF' ? wav.subarray(44) : wav + return { + pcm, + sampleRate: 8000, + channels: 1, + sourceHash: source.sourceHash + } + } finally { + await fs.rm(inPath, { force: true }) + await fs.rm(outPath, { force: true }) + } + } } diff --git a/src/main/voice-service.ts b/src/main/voice-service.ts index 8f7d7c8..036add2 100644 --- a/src/main/voice-service.ts +++ b/src/main/voice-service.ts @@ -328,25 +328,41 @@ const VOICE_DISK_DIRS = [ 'msg/History', 'Message', 'MsgAndFiles', - 'VoiceTemp' + 'VoiceTemp', + // 真机布局(2026-09-24):cache/YYYY-MM/Message//VoiceTemp/ + 'cache' ] -/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath 对齐)。 */ +/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath / VoiceTemp 对齐)。 */ export async function listVoiceDiskCandidates(accountRoot: string): Promise { const out: string[] = [] - for (const rel of VOICE_DISK_DIRS) { - const dir = join(accountRoot, rel) + const walk = async (dir: string, depth: number): Promise => { + if (out.length > 200 || depth > 6) return let names: string[] try { names = await fs.readdir(dir) } catch { - continue + return } for (const name of names) { + const full = join(dir, name) + let stat: Awaited> + try { + stat = await fs.stat(full) + } catch { + continue + } + if (stat.isDirectory()) { + await walk(full, depth + 1) + continue + } const ext = name.slice(name.lastIndexOf('.')).toLowerCase() if (!VOICE_DISK_EXTS.has(ext)) continue - out.push(join(dir, name)) + out.push(full) } + } + for (const rel of VOICE_DISK_DIRS) { + await walk(join(accountRoot, rel), 0) if (out.length > 200) break } return out diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index d111151..527f83c 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -2678,6 +2678,27 @@ export class Wcdb4Client { return this.accountRoot } + /** 只读列收藏(favorite.db / fav_db_item),供导出与搜索。 */ + async listFavoriteItems(limit = 200): Promise[]> { + if (!this.wcdbExecQuery) return [] + const sql = `SELECT local_id, server_id, type, update_time, content, fromusr, realchatname FROM fav_db_item ORDER BY update_time DESC LIMIT ${Math.max( + 1, + Math.min(1000, limit) + )}` + try { + const rows = await this.callJsonAsync[]>( + this.wcdbExecQuery as unknown as KoffiAsyncFunction, + 'favorite', + path.join(this.accountRoot, 'db_storage/favorite/favorite.db'), + sql + ) + return Array.isArray(rows) ? rows : [] + } catch (error) { + console.warn('[WCDB4] listFavoriteItems failed:', error) + return [] + } + } + getKey(): string { return this.key } diff --git a/src/shared/favorites.ts b/src/shared/favorites.ts index 6f4a239..56956cf 100644 --- a/src/shared/favorites.ts +++ b/src/shared/favorites.ts @@ -1,5 +1,79 @@ import type { ParsedContent } from './types' +function xmlValue(xml: string, tag: string): string | undefined { + const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(xml) + const raw = match?.[1]?.replace(/^$/, '$1').trim() + return raw || undefined +} + +function decodeXmlEntities(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') +} + +/** + * 解析 `fav_db_item.content` 的 `` XML → `FavoriteRecord`(只读)。 + * 覆盖真机 2026-09-24 样本形态:desc/title/link/locitem/dataitem/appbranditem/finderFeed。 + */ +export function parseFavItemXml(content: string | null | undefined): FavoriteRecord { + const xml = String(content || '') + const typeMatch = /]*\stype="(\d+)"/i.exec(xml) + const type = typeMatch?.[1] + const title = decodeXmlEntities(xmlValue(xml, 'title') || '') + const desc = decodeXmlEntities(xmlValue(xml, 'desc') || xmlValue(xml, 'datadesc') || '') + const datatitle = decodeXmlEntities(xmlValue(xml, 'datatitle') || '') + const link = decodeXmlEntities(xmlValue(xml, 'link') || xmlValue(xml, 'datacdnurl') || '') + const poiname = decodeXmlEntities(xmlValue(xml, 'poiname') || '') + const label = decodeXmlEntities(xmlValue(xml, 'label') || '') + const lat = Number(xmlValue(xml, 'lat')) + const lng = Number(xmlValue(xml, 'lng')) + const dataid = xmlValue(xml, 'dataid') + const datatype = xmlValue(xml, 'datatype') + const duration = Number(xmlValue(xml, 'duration') || xmlValue(xml, 'voicelength')) + const appname = + decodeXmlEntities( + xmlValue(xml, 'appbrandnick') || + xmlValue(xml, 'appname') || + xmlValue(xml, 'sourcedisplayname') || + '' + ) || undefined + return { + type, + dataType: datatype, + title: title || datatitle || undefined, + description: desc || undefined, + url: link || undefined, + appName: appname, + poiname: poiname || undefined, + label: label || undefined, + lat: Number.isFinite(lat) ? lat : undefined, + lng: Number.isFinite(lng) ? lng : undefined, + md5: dataid, + duration: Number.isFinite(duration) && duration > 0 ? duration : undefined, + raw: xml + } +} + +/** `fav_db_item` 行 → 可读卡片(导出/搜索共用)。 */ +export function favoriteRowToContent(row: { + local_id?: number | string + server_id?: number | string + type?: number | string + content?: string | null + update_time?: number | string + fromusr?: string | null + realchatname?: string | null +}): ParsedContent { + const record = parseFavItemXml(row.content) + if (!record.type && row.type !== undefined) record.type = row.type + record.favId = row.local_id ?? row.server_id + return favoriteRecordToContent(record) +} + /** * 收藏类型(`MM_FAV_ITEM_TYPE_*` / `MM_FAV_DATA_TYPE_*`)只读映射。 * 名称来自 wechat.dylib 导出名;数字为枚举顺序推断,未知值原样回退。 diff --git a/tests/unit/favorites.test.ts b/tests/unit/favorites.test.ts index a1c2d18..3c997c5 100644 --- a/tests/unit/favorites.test.ts +++ b/tests/unit/favorites.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { describeFavoriteType, favoriteRecordToContent } from '../../src/shared/favorites' +import { + describeFavoriteType, + favoriteRecordToContent, + favoriteRowToContent, + parseFavItemXml +} from '../../src/shared/favorites' describe('favorites type map', () => { it('labels known MM_FAV names and numeric fallbacks', () => { @@ -57,4 +62,35 @@ describe('favorites type map', () => { typeVal: 'note' }) }) + + it('parses favitem XML from favorite.db content', () => { + const xml = [ + '00', + '', + 'wxid_a', + '' + ].join('') + expect(parseFavItemXml(xml)).toMatchObject({ + type: '1', + description: '收藏文字' + }) + const web = [ + '', + '', + '' + ].join('') + expect(parseFavItemXml(web)).toMatchObject({ + type: '5', + title: '标题', + description: '摘要', + url: 'https://example.com/a' + }) + expect( + favoriteRowToContent({ + local_id: 9, + type: 5, + content: web + }) + ).toMatchObject({ type: 'share', title: '标题', typeVal: '5' }) + }) })