mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: 新增了聊天页面播放视频的功能
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
// sessionId(1对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))
|
||||
}
|
||||
|
||||
|
||||
289
electron/services/videoService.ts
Normal file
289
electron/services/videoService.ts
Normal 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)
|
||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
||||
// 格式: 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()
|
||||
Reference in New Issue
Block a user