计划优化 P2/5

This commit is contained in:
cc
2026-03-19 21:24:31 +08:00
parent 35e9ea13de
commit 48e5ce807d
9 changed files with 1207 additions and 506 deletions

View File

@@ -5,310 +5,539 @@ import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
export interface VideoInfo {
videoUrl?: string // 视频文件路径(用于 readFile
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略图 data URL
exists: boolean
videoUrl?: string // 视频文件路径(用于 readFile
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略图 data URL
exists: boolean
}
interface TimedCacheEntry<T> {
value: T
expiresAt: number
}
interface VideoIndexEntry {
videoPath?: string
coverPath?: string
thumbPath?: string
}
class VideoService {
private configService: ConfigService
private configService: ConfigService
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
private readonly videoIndexCacheTtlMs = 90 * 1000
private readonly maxCacheEntries = 2000
private readonly maxIndexEntries = 6
constructor() {
this.configService = new ConfigService()
constructor() {
this.configService = new ConfigService()
}
private log(message: string, meta?: Record<string, unknown>): void {
try {
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
} catch { }
}
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
const hit = cache.get(key)
if (!hit) return undefined
if (hit.expiresAt <= Date.now()) {
cache.delete(key)
return undefined
}
return hit.value
}
private writeTimedCache<T>(
cache: Map<string, TimedCacheEntry<T>>,
key: string,
value: T,
ttlMs: number,
maxEntries: number
): void {
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
if (cache.size <= maxEntries) return
const now = Date.now()
for (const [cacheKey, entry] of cache) {
if (entry.expiresAt <= now) {
cache.delete(cacheKey)
}
}
private log(message: string, meta?: Record<string, unknown>): void {
try {
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
} catch {}
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value as string | undefined
if (!oldestKey) break
cache.delete(oldestKey)
}
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
}
/**
* 清理 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
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
private getScopeKey(dbPath: string, wxid: string): string {
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
}
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
const cleanedWxid = this.cleanWxid(wxid)
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
return join(dbPath, 'msg', 'video')
}
return join(dbPath, wxid, 'msg', 'video')
}
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
return [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
}
/**
* 从 video_hardlink_info_v4 表查询视频文件名
* 使用 wcdb 专属接口查询加密的 hardlink.db
*/
private async resolveVideoHardlinks(
md5List: string[],
dbPath: string,
wxid: string,
cleanedWxid: string
): Promise<Map<string, string>> {
const scopeKey = this.getScopeKey(dbPath, wxid)
const normalizedList = Array.from(
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
)
const resolvedMap = new Map<string, string>()
let unresolved = [...normalizedList]
for (const md5 of normalizedList) {
const cacheKey = `${scopeKey}|${md5}`
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
if (cached === undefined) continue
if (cached) resolvedMap.set(md5, cached)
unresolved = unresolved.filter((item) => item !== md5)
}
/**
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.getCacheBasePath()
}
if (unresolved.length === 0) return resolvedMap
/**
* 清理 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 表查询视频文件名
* 使用 wcdb 专属接口查询加密的 hardlink.db
*/
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
if (!wxid) {
this.log('queryVideoFileName: wxid 为空')
return undefined
}
// 使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
const encryptedDbPaths: string[] = []
if (dbPathContainsWxid) {
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
}
for (const p of encryptedDbPaths) {
if (existsSync(p)) {
try {
this.log('尝试加密 hardlink.db', { path: p })
const result = await wcdbService.resolveVideoHardlinkMd5(md5, p)
if (result.success && result.data?.resolved_md5) {
const realMd5 = String(result.data.resolved_md5)
this.log('加密 hardlink.db 命中', { file_name: result.data.file_name, realMd5 })
return realMd5
}
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
} catch (e) {
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
}
} else {
this.log('加密 hardlink.db 不存在', { path: p })
}
}
}
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
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> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
if (!dbPath || !wxid || !videoMd5) {
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
// 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxid = this.cleanWxid(wxid)
let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
videoBaseDir = join(dbPath, 'msg', 'video')
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
for (const p of encryptedDbPaths) {
if (!existsSync(p) || unresolved.length === 0) continue
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
try {
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
if (batchResult.success && Array.isArray(batchResult.rows)) {
for (const row of batchResult.rows) {
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
const inputMd5 = index >= 0 && index < requests.length
? requests[index].md5
: String(row?.md5 || '').trim().toLowerCase()
if (!inputMd5) continue
const resolvedMd5 = row?.success && row?.data?.resolved_md5
? String(row.data.resolved_md5).trim().toLowerCase()
: ''
if (!resolvedMd5) continue
const cacheKey = `${scopeKey}|${inputMd5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
resolvedMap.set(inputMd5, resolvedMd5)
}
} else {
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
// 兼容不支持批量接口的版本,回退单条请求。
for (const req of requests) {
try {
const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath)
const resolvedMd5 = single.success && single.data?.resolved_md5
? String(single.data.resolved_md5).trim().toLowerCase()
: ''
if (!resolvedMd5) continue
const cacheKey = `${scopeKey}|${req.md5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
resolvedMap.set(req.md5, resolvedMd5)
} catch { }
}
}
} catch (e) {
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
}
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
if (!existsSync(videoBaseDir)) {
this.log('getVideoInfo: videoBaseDir 不存在')
return { exists: false }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a))
this.log('扫描目录', { dirs: yearMonthDirs })
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
if (existsSync(videoPath)) {
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw但封面不带
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const coverPath = join(dirPath, `${baseMd5}.jpg`)
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
// 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名
const allFiles = readdirSync(dirPath)
const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase()))
this.log('找到视频,相关文件列表', {
videoPath,
coverExists: existsSync(coverPath),
thumbExists: existsSync(thumbPath),
relatedFiles,
coverPath,
thumbPath
})
return {
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
}
// 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
this.log('未找到视频,开始全目录扫描', {
lookingForOriginal: `${videoMd5}.mp4`,
lookingForResolved: `${realVideoMd5}.mp4`,
hardlinkResolved: realVideoMd5 !== videoMd5
})
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
try {
const allFiles = readdirSync(dirPath)
const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10)
// 检查原始 md5 是否部分匹配前8位
const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase()))
this.log(`目录 ${yearMonth} 扫描结果`, {
totalFiles: allFiles.length,
mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length,
sampleMp4: mp4Files,
partialMatchByOriginalMd5: partialMatch
})
} catch (e) {
this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) })
}
}
} catch (e) {
this.log('getVideoInfo 遍历出错', { error: String(e) })
}
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
return { exists: false }
unresolved = unresolved.filter((md5) => !resolvedMap.has(md5))
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
if (!content) return undefined
for (const md5 of unresolved) {
const cacheKey = `${scopeKey}|${md5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
}
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
return resolvedMap
}
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath })
if (!normalizedMd5 || !wxid || !dbPath) {
this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath })
return undefined
}
const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid)
const resolved = resolvedMap.get(normalizedMd5)
if (resolved) {
this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved })
return resolved
}
return undefined
}
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
if (!dbPath || !wxid) return
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
}
/**
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
try {
if (!filePath || !existsSync(filePath)) return undefined
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
return undefined
}
}
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
if (cached) return cached
const index = new Map<string, VideoIndexEntry>()
const ensureEntry = (key: string): VideoIndexEntry => {
let entry = index.get(key)
if (!entry) {
entry = {}
index.set(key, entry)
}
return entry
}
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
let files: string[] = []
try {
// 收集所有 md5 相关属性,方便对比
const allMd5Attrs: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5Attrs.push(match[0])
}
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
// 方法1从 <videomsg md5="..."> 提取(收到的视频)
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMd5Match) {
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
return videoMsgMd5Match[1].toLowerCase()
}
// 方法2从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Match) {
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
return rawMd5Match[1].toLowerCase()
}
// 方法3任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
return attrMatch[1].toLowerCase()
}
// 方法4<md5>...</md5> 标签
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5TagMatch) {
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
return md5TagMatch[1].toLowerCase()
}
// 方法5兜底取 rawmd5 属性(任意位置)
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Fallback) {
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
return rawMd5Fallback[1].toLowerCase()
}
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
} catch (e) {
this.log('parseVideoMd5 异常', { error: String(e) })
files = readdirSync(dirPath)
} catch {
continue
}
return undefined
for (const file of files) {
const lower = file.toLowerCase()
const fullPath = join(dirPath, file)
if (lower.endsWith('.mp4')) {
const md5 = lower.slice(0, -4)
const entry = ensureEntry(md5)
if (!entry.videoPath) entry.videoPath = fullPath
if (md5.endsWith('_raw')) {
const baseMd5 = md5.replace(/_raw$/, '')
const baseEntry = ensureEntry(baseMd5)
if (!baseEntry.videoPath) baseEntry.videoPath = fullPath
}
continue
}
if (!lower.endsWith('.jpg')) continue
const jpgBase = lower.slice(0, -4)
if (jpgBase.endsWith('_thumb')) {
const baseMd5 = jpgBase.slice(0, -6)
const entry = ensureEntry(baseMd5)
if (!entry.thumbPath) entry.thumbPath = fullPath
} else {
const entry = ensureEntry(jpgBase)
if (!entry.coverPath) entry.coverPath = fullPath
}
}
}
for (const [key, entry] of index) {
if (!key.endsWith('_raw')) continue
const baseKey = key.replace(/_raw$/, '')
const baseEntry = index.get(baseKey)
if (!baseEntry) continue
if (!entry.coverPath) entry.coverPath = baseEntry.coverPath
if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath
}
} catch (e) {
this.log('构建视频索引失败', { videoBaseDir, error: String(e) })
}
this.writeTimedCache(
this.videoDirIndexCache,
videoBaseDir,
index,
this.videoIndexCacheTtlMs,
this.maxIndexEntries
)
return index
}
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string): VideoInfo | null {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!normalizedMd5) return null
const candidates = [normalizedMd5]
const baseMd5 = normalizedMd5.replace(/_raw$/, '')
if (baseMd5 !== normalizedMd5) {
candidates.push(baseMd5)
} else {
candidates.push(`${normalizedMd5}_raw`)
}
for (const key of candidates) {
const entry = index.get(key)
if (!entry?.videoPath) continue
if (!existsSync(entry.videoPath)) continue
return {
videoUrl: entry.videoPath,
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
exists: true
}
}
return null
}
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string): VideoInfo | null {
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
if (!existsSync(videoPath)) continue
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const coverPath = join(dirPath, `${baseMd5}.jpg`)
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
return {
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
} catch (e) {
this.log('fallback 扫描视频目录失败', { error: String(e) })
}
return null
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
if (!dbPath || !wxid || !normalizedMd5) {
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
return { exists: false }
}
const scopeKey = this.getScopeKey(dbPath, wxid)
const cacheKey = `${scopeKey}|${normalizedMd5}`
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
if (cachedInfo) return cachedInfo
const pending = this.pendingVideoInfo.get(cacheKey)
if (pending) return pending
const task = (async (): Promise<VideoInfo> => {
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
if (!existsSync(videoBaseDir)) {
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return miss
}
const index = this.getOrBuildVideoIndex(videoBaseDir)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5)
if (indexed) {
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return indexed
}
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5)
if (fallback) {
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return fallback
}
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
return miss
})()
this.pendingVideoInfo.set(cacheKey, task)
try {
return await task
} finally {
this.pendingVideoInfo.delete(cacheKey)
}
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
if (!content) return undefined
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
try {
// 收集所有 md5 相关属性,方便对比
const allMd5Attrs: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5Attrs.push(match[0])
}
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
// 方法1从 <videomsg md5="..."> 提取(收到的视频)
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMd5Match) {
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
return videoMsgMd5Match[1].toLowerCase()
}
// 方法2从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Match) {
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
return rawMd5Match[1].toLowerCase()
}
// 方法3任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
return attrMatch[1].toLowerCase()
}
// 方法4<md5>...</md5> 标签
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5TagMatch) {
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
return md5TagMatch[1].toLowerCase()
}
// 方法5兜底取 rawmd5 属性(任意位置)
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Fallback) {
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
return rawMd5Fallback[1].toLowerCase()
}
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
} catch (e) {
this.log('parseVideoMd5 异常', { error: String(e) })
}
return undefined
}
}
export const videoService = new VideoService()