feat: 新增了聊天页面播放视频的功能

This commit is contained in:
xuncha
2026-01-18 23:19:58 +08:00
parent d4c7e86e05
commit 240514f1e5
11 changed files with 1245 additions and 51 deletions

View File

@@ -42,6 +42,7 @@ export interface Message {
senderUsername: string | null
parsedContent: string
rawContent: string
content?: string // 原始XML内容与rawContent相同供前端使用
// 表情包相关
emojiCdnUrl?: string
emojiMd5?: string
@@ -52,6 +53,7 @@ export interface Message {
// 图片/视频相关
imageMd5?: string
imageDatName?: string
videoMd5?: string
aesKey?: string
encrypVer?: number
cdnThumbUrl?: string
@@ -151,10 +153,10 @@ class ChatService {
}
this.connected = true
// 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接)
this.warmupMediaDbsCache()
return { success: true }
} catch (e) {
console.error('ChatService: 连接数据库失败:', e)
@@ -743,6 +745,7 @@ class ChatService {
let quotedSender: string | undefined
let imageMd5: string | undefined
let imageDatName: string | undefined
let videoMd5: string | undefined
let aesKey: string | undefined
let encrypVer: number | undefined
let cdnThumbUrl: string | undefined
@@ -759,6 +762,9 @@ class ChatService {
encrypVer = imageInfo.encrypVer
cdnThumbUrl = imageInfo.cdnThumbUrl
imageDatName = this.parseImageDatNameFromRow(row)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = this.parseVideoMd5(content)
} else if (localType === 34 && content) {
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
@@ -783,6 +789,7 @@ class ChatService {
quotedSender,
imageMd5,
imageDatName,
videoMd5,
voiceDurationSeconds,
aesKey,
encrypVer,
@@ -964,6 +971,26 @@ class ChatService {
}
}
/**
* 解析视频MD5
* 注意:提取 md5 字段用于查询 hardlink.db获取实际视频文件名
*/
private parseVideoMd5(content: string): string | undefined {
if (!content) return undefined
try {
// 提取 md5这是用于查询 hardlink.db 的值
const md5 =
this.extractXmlAttribute(content, 'videomsg', 'md5') ||
this.extractXmlValue(content, 'md5') ||
undefined
return md5?.toLowerCase()
} catch {
return undefined
}
}
/**
* 解析通话消息
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
@@ -1446,13 +1473,10 @@ class ChatService {
}
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
const tagRegex = new RegExp(`<${tagName}[^>]*>`, 'i')
const tagMatch = tagRegex.exec(xml)
if (!tagMatch) return ''
const attrRegex = new RegExp(`${attrName}\\s*=\\s*['"]([^'"]*)['"]`, 'i')
const attrMatch = attrRegex.exec(tagMatch[0])
return attrMatch ? attrMatch[1] : ''
// 匹配 <tagName ... attrName="value" ... /> 或 <tagName ... attrName="value" ...>
const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}\\s*=\\s*['"]([^'"]*)['"']`, 'i')
const match = regex.exec(xml)
return match ? match[1] : ''
}
private cleanSystemMessage(content: string): string {
@@ -2193,7 +2217,7 @@ class ChatService {
const msgResult = await this.getMessageByLocalId(sessionId, localId)
const t2 = Date.now()
console.log(`[Voice] getMessageByLocalId: ${t2 - t1}ms`)
if (msgResult.success && msgResult.message) {
const msg = msgResult.message as any
msgCreateTime = msg.createTime
@@ -2233,17 +2257,17 @@ class ChatService {
// 构建查找候选
const candidates: string[] = []
const myWxid = this.configService.get('myWxid') as string
// 如果有 senderWxid优先使用群聊中最重要
if (senderWxid) {
candidates.push(senderWxid)
}
// sessionId1对1聊天时是对方wxid群聊时是群id
if (sessionId && !candidates.includes(sessionId)) {
candidates.push(sessionId)
}
// 我的wxid兜底
if (myWxid && !candidates.includes(myWxid)) {
candidates.push(myWxid)
@@ -2254,7 +2278,7 @@ class ChatService {
const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates)
const t4 = Date.now()
console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`)
if (!silkData) {
return { success: false, error: '未找到语音数据' }
}
@@ -2264,7 +2288,7 @@ class ChatService {
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
const t6 = Date.now()
console.log(`[Voice] decodeSilkToPcm: ${t6 - t5}ms`)
if (!pcmData) {
return { success: false, error: 'Silk 解码失败' }
}
@@ -2298,7 +2322,7 @@ class ChatService {
if (!existsSync(voiceCacheDir)) {
mkdirSync(voiceCacheDir, { recursive: true })
}
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
writeFileSync(wavFilePath, wavData)
} catch (e) {
@@ -2323,11 +2347,11 @@ class ChatService {
const mediaDbsResult = await wcdbService.listMediaDbs()
const t2 = Date.now()
console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`)
if (!mediaDbsResult.success || !mediaDbsResult.data || mediaDbsResult.data.length === 0) {
return null
}
mediaDbFiles = mediaDbsResult.data as string[]
this.mediaDbsCache = mediaDbFiles // 永久缓存
}
@@ -2337,33 +2361,33 @@ class ChatService {
try {
// 检查缓存
let schema = this.mediaDbSchemaCache.get(dbPath)
if (!schema) {
const t3 = Date.now()
// 第一次查询,获取表结构并缓存
const tablesResult = await wcdbService.execQuery('media', dbPath,
const tablesResult = await wcdbService.execQuery('media', dbPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'"
)
const t4 = Date.now()
console.log(`[Voice] 查询VoiceInfo表: ${t4 - t3}ms`)
if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) {
continue
}
const voiceTable = tablesResult.rows[0].name
const t5 = Date.now()
const columnsResult = await wcdbService.execQuery('media', dbPath,
const columnsResult = await wcdbService.execQuery('media', dbPath,
`PRAGMA table_info('${voiceTable}')`
)
const t6 = Date.now()
console.log(`[Voice] 查询表结构: ${t6 - t5}ms`)
if (!columnsResult.success || !columnsResult.rows) {
continue
}
// 创建列名映射(原始名称 -> 小写名称)
const columnMap = new Map<string, string>()
for (const c of columnsResult.rows) {
@@ -2372,23 +2396,23 @@ class ChatService {
columnMap.set(name.toLowerCase(), name)
}
}
// 查找数据列(使用原始列名)
const dataColumnLower = ['voice_data', 'buf', 'voicebuf', 'data'].find(n => columnMap.has(n))
const dataColumn = dataColumnLower ? columnMap.get(dataColumnLower) : undefined
if (!dataColumn) {
continue
}
// 查找 chat_name_id 列
const chatNameIdColumnLower = ['chat_name_id', 'chatnameid', 'chat_nameid'].find(n => columnMap.has(n))
const chatNameIdColumn = chatNameIdColumnLower ? columnMap.get(chatNameIdColumnLower) : undefined
// 查找时间列
const timeColumnLower = ['create_time', 'createtime', 'time'].find(n => columnMap.has(n))
const timeColumn = timeColumnLower ? columnMap.get(timeColumnLower) : undefined
const t7 = Date.now()
// 查找 Name2Id 表
const name2IdTablesResult = await wcdbService.execQuery('media', dbPath,
@@ -2396,11 +2420,11 @@ class ChatService {
)
const t8 = Date.now()
console.log(`[Voice] 查询Name2Id表: ${t8 - t7}ms`)
const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0)
? name2IdTablesResult.rows[0].name
: undefined
schema = {
voiceTable,
dataColumn,
@@ -2408,11 +2432,11 @@ class ChatService {
timeColumn,
name2IdTable
}
// 缓存表结构
this.mediaDbSchemaCache.set(dbPath, schema)
}
// 策略1: 通过 chat_name_id + create_time 查找(最准确)
if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) {
const t9 = Date.now()
@@ -2423,12 +2447,12 @@ class ChatService {
)
const t10 = Date.now()
console.log(`[Voice] 查询chat_name_id: ${t10 - t9}ms`)
if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) {
// 构建 chat_name_id 列表
const chatNameIds = name2IdResult.rows.map((r: any) => r.rowid)
const chatNameIdsStr = chatNameIds.join(',')
const t11 = Date.now()
// 一次查询所有可能的语音
const voiceResult = await wcdbService.execQuery('media', dbPath,
@@ -2436,7 +2460,7 @@ class ChatService {
)
const t12 = Date.now()
console.log(`[Voice] 策略1查询语音: ${t12 - t11}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data)
@@ -2447,7 +2471,7 @@ class ChatService {
}
}
}
// 策略2: 只通过 create_time 查找(兜底)
if (schema.timeColumn) {
const t13 = Date.now()
@@ -2456,7 +2480,7 @@ class ChatService {
)
const t14 = Date.now()
console.log(`[Voice] 策略2查询语音: ${t14 - t13}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data)
@@ -2466,7 +2490,7 @@ class ChatService {
}
}
}
// 策略3: 时间范围查找±5秒处理时间戳不精确的情况
if (schema.timeColumn) {
const t15 = Date.now()
@@ -2475,7 +2499,7 @@ class ChatService {
)
const t16 = Date.now()
console.log(`[Voice] 策略3查询语音: ${t16 - t15}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data)
@@ -2711,18 +2735,18 @@ class ChatService {
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const startTime = Date.now()
console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`)
try {
let msgCreateTime = createTime
let serverId: string | number | undefined
// 如果前端没传 createTime才需要查询消息这个很慢
if (!msgCreateTime) {
const t1 = Date.now()
const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10))
const t2 = Date.now()
console.log(`[Transcribe] getMessageById: ${t2 - t1}ms`)
if (msgResult.success && msgResult.message) {
msgCreateTime = msgResult.message.createTime
serverId = msgResult.message.serverId
@@ -2738,7 +2762,7 @@ class ChatService {
// 使用正确的 cacheKey包含 createTime
const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime)
console.log(`[Transcribe] cacheKey=${cacheKey}`)
// 检查转写缓存
const cached = this.voiceTranscriptCache.get(cacheKey)
if (cached) {
@@ -2774,7 +2798,7 @@ class ChatService {
}
}
}
if (!wavData) {
console.log(`[Transcribe] WAV缓存未命中调用 getVoiceData`)
const t3 = Date.now()
@@ -2782,7 +2806,7 @@ class ChatService {
const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId)
const t4 = Date.now()
console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`)
if (!voiceResult.success || !voiceResult.data) {
console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`)
return { success: false, error: voiceResult.error || '语音解码失败' }
@@ -2800,14 +2824,14 @@ class ChatService {
})
const t6 = Date.now()
console.log(`[Transcribe] transcribeWavBuffer: ${t6 - t5}ms, success=${result.success}`)
if (result.success && result.transcript) {
console.log(`[Transcribe] 转写成功: ${result.transcript}`)
this.cacheVoiceTranscript(cacheKey, result.transcript)
} else {
console.error(`[Transcribe] 转写失败: ${result.error}`)
}
console.log(`[Transcribe] 总耗时: ${Date.now() - startTime}ms`)
return result
} catch (error) {
@@ -2918,6 +2942,7 @@ class ChatService {
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null,
rawContent: rawContent,
content: rawContent, // 添加原始内容供视频MD5解析使用
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
}

View File

@@ -0,0 +1,289 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { ConfigService } from './config'
import Database from 'better-sqlite3'
import { wcdbService } from './wcdbService'
export interface VideoInfo {
videoUrl?: string // 视频文件路径(用于 readFile
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略图 data URL
exists: boolean
}
class VideoService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
}
/**
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.get('cachePath') || ''
}
/**
* 清理 wxid 目录名(去掉后缀)
*/
private cleanWxid(wxid: string): string {
const trimmed = wxid.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
/**
* 从 video_hardlink_info_v4 表查询视频文件名
* 优先使用 cachePath 中解密后的 hardlink.db使用 better-sqlite3
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
*/
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const cachePath = this.getCachePath()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
console.log('[VideoService] queryVideoFileName called with MD5:', md5)
console.log('[VideoService] cachePath:', cachePath, 'dbPath:', dbPath, 'wxid:', wxid, 'cleanedWxid:', cleanedWxid)
if (!wxid) return undefined
// 方法1优先在 cachePath 下查找解密后的 hardlink.db
if (cachePath) {
const cacheDbPaths = [
join(cachePath, cleanedWxid, 'hardlink.db'),
join(cachePath, wxid, 'hardlink.db'),
join(cachePath, 'hardlink.db'),
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
join(cachePath, 'databases', wxid, 'hardlink.db')
]
for (const p of cacheDbPaths) {
if (existsSync(p)) {
console.log('[VideoService] Found decrypted hardlink.db at:', p)
try {
const db = new Database(p, { readonly: true })
const row = db.prepare(`
SELECT file_name, md5 FROM video_hardlink_info_v4
WHERE md5 = ?
LIMIT 1
`).get(md5) as { file_name: string; md5: string } | undefined
db.close()
if (row?.file_name) {
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
console.log('[VideoService] Found video filename via cache:', realMd5)
return realMd5
}
} catch (e) {
console.log('[VideoService] Failed to query cached hardlink.db:', e)
}
}
}
}
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
const encryptedDbPaths = [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
for (const p of encryptedDbPaths) {
if (existsSync(p)) {
console.log('[VideoService] Found encrypted hardlink.db at:', p)
try {
const escapedMd5 = md5.replace(/'/g, "''")
// 用 md5 字段查询,获取 file_name
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
console.log('[VideoService] Query SQL:', sql)
const result = await wcdbService.execQuery('media', p, sql)
console.log('[VideoService] Query result:', result)
if (result.success && result.rows && result.rows.length > 0) {
const row = result.rows[0]
if (row?.file_name) {
// 提取不带扩展名的文件名作为实际视频 MD5
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
console.log('[VideoService] Found video filename:', realMd5)
return realMd5
}
}
} catch (e) {
console.log('[VideoService] Failed to query encrypted hardlink.db via wcdbService:', e)
}
}
}
}
console.log('[VideoService] No matching video found in hardlink.db')
return undefined
}
/**
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
try {
if (!existsSync(filePath)) return undefined
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
return undefined
}
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
console.log('[VideoService] getVideoInfo called with MD5:', videoMd5)
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
console.log('[VideoService] Config - dbPath:', dbPath, 'wxid:', wxid)
if (!dbPath || !wxid || !videoMd5) {
console.log('[VideoService] Missing required params')
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
console.log('[VideoService] Real video MD5:', realVideoMd5)
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
console.log('[VideoService] Video base dir:', videoBaseDir)
if (!existsSync(videoBaseDir)) {
console.log('[VideoService] Video base dir does not exist')
return { exists: false }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
console.log('[VideoService] Found year-month dirs:', allDirs)
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
console.log('[VideoService] Checking:', videoPath)
// 检查视频文件是否存在
if (existsSync(videoPath)) {
console.log('[VideoService] Video file found!')
return {
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
}
console.log('[VideoService] Video file not found in any directory')
} catch (e) {
console.error('[VideoService] Error searching for video:', e)
}
return { exists: false }
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
console.log('[VideoService] parseVideoMd5 called, content length:', content?.length)
// 打印前500字符看看 XML 结构
console.log('[VideoService] XML preview:', content?.substring(0, 500))
if (!content) return undefined
try {
// 提取所有可能的 md5 值进行日志
const allMd5s: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5s.push(`${match[0]}`)
}
console.log('[VideoService] All MD5 attributes found:', allMd5s)
// 提取 md5用于查询 hardlink.db
// 注意:不是 rawmd5rawmd5 是另一个值
// 格式: md5="xxx" 或 <md5>xxx</md5>
// 尝试从videomsg标签中提取md5
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {
console.log('[VideoService] Found MD5 via videomsg:', videoMsgMatch[1])
return videoMsgMatch[1].toLowerCase()
}
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
console.log('[VideoService] Found MD5 via attribute:', attrMatch[1])
return attrMatch[1].toLowerCase()
}
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5Match) {
console.log('[VideoService] Found MD5 via <md5> tag:', md5Match[1])
return md5Match[1].toLowerCase()
}
console.log('[VideoService] No MD5 found in content')
} catch (e) {
console.error('[VideoService] 解析视频MD5失败:', e)
}
return undefined
}
}
export const videoService = new VideoService()