feat: wire favorites read path, AMR ffmpeg decode, voice cache/VoiceTemp walk

This commit is contained in:
wuyouMaster
2026-09-24 21:27:48 +08:00
parent 784bed0ee0
commit 19bf497af0
6 changed files with 247 additions and 8 deletions
+43
View File
@@ -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<ParsedContent[]> {
const rows = await this.wcdb4Client.listFavoriteItems(limit)
return rows.map((row) => favoriteRowToContent(row))
}
/** 收藏转成导出/搜索可用的 Message 列表(合成会话「收藏」)。 */
async listExportMessages(limit = 200): Promise<Message[]> {
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 '[收藏]'
}
+50 -1
View File
@@ -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<DecodedVoiceAudio> {
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<void>((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 })
}
}
}
+22 -6
View File
@@ -328,25 +328,41 @@ const VOICE_DISK_DIRS = [
'msg/History',
'Message',
'MsgAndFiles',
'VoiceTemp'
'VoiceTemp',
// 真机布局(2026-09-24):cache/YYYY-MM/Message/<md5>/VoiceTemp/
'cache'
]
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath 对齐)。 */
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath / VoiceTemp 对齐)。 */
export async function listVoiceDiskCandidates(accountRoot: string): Promise<string[]> {
const out: string[] = []
for (const rel of VOICE_DISK_DIRS) {
const dir = join(accountRoot, rel)
const walk = async (dir: string, depth: number): Promise<void> => {
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<ReturnType<typeof fs.stat>>
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
+21
View File
@@ -2678,6 +2678,27 @@ export class Wcdb4Client {
return this.accountRoot
}
/** 只读列收藏(favorite.db / fav_db_item),供导出与搜索。 */
async listFavoriteItems(limit = 200): Promise<Record<string, unknown>[]> {
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<Record<string, unknown>[]>(
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
}
+74
View File
@@ -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(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1').trim()
return raw || undefined
}
function decodeXmlEntities(value: string): string {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
}
/**
* 解析 `fav_db_item.content` 的 `<favitem>` 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 = /<favitem[^>]*\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 导出名;数字为枚举顺序推断,未知值原样回退。
+37 -1
View File
@@ -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 = [
'<favitem type="1"><ctrlflag>0</ctrlflag><version>0</version>',
'<desc><![CDATA[收藏文字]]></desc>',
'<source sourcetype="22"><fromusr>wxid_a</fromusr></source>',
'</favitem>'
].join('')
expect(parseFavItemXml(xml)).toMatchObject({
type: '1',
description: '收藏文字'
})
const web = [
'<favitem type="5"><source><link><![CDATA[https://example.com/a]]></link></source>',
'<datalist count="1"><dataitem datatype="5"><datatitle><![CDATA[标题]]></datatitle>',
'<datadesc><![CDATA[摘要]]></datadesc></dataitem></datalist></favitem>'
].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' })
})
})