Files
CipherTalk/electron/services/videoService.ts
T

249 lines
6.7 KiB
TypeScript

import { dirname, join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { ConfigService } from './config'
import Database from 'better-sqlite3'
import { app } from 'electron'
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 {
const cachePath = this.configService.get('cachePath')
if (cachePath) return cachePath
return this.getDefaultCachePath()
}
private getDefaultCachePath(): string {
if (process.env.VITE_DEV_SERVER_URL) {
const documentsPath = app.getPath('documents')
return join(documentsPath, 'CipherTalkData')
}
const exePath = app.getPath('exe')
const installDir = dirname(exePath)
const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\')
if (isOnCDrive) {
const documentsPath = app.getPath('documents')
return join(documentsPath, 'CipherTalkData')
}
return join(installDir, 'CipherTalkData')
}
/**
* æ¸…ç† 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 表查询视频文件å
*/
private queryVideoFileName(md5: string): string | undefined {
const cachePath = this.getCachePath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
const dbPath = this.getDbPath()
if (!cachePath || !wxid) return undefined
// hardlink.db å¯èƒ½åœ¨å¤šä¸ªä½ç½?
const possiblePaths = new Set<string>([
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')
])
if (dbPath) {
const baseCandidates = new Set<string>([
dbPath,
join(dbPath, wxid),
join(dbPath, cleanedWxid)
])
for (const base of baseCandidates) {
possiblePaths.add(join(base, 'hardlink.db'))
possiblePaths.add(join(base, 'msg', 'hardlink.db'))
}
}
let hardlinkDbPath: string | undefined
for (const p of possiblePaths) {
if (existsSync(p)) {
hardlinkDbPath = p
break
}
}
if (!hardlinkDbPath) return undefined
try {
const db = new Database(hardlinkDbPath, { 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) {
// æå–ä¸å¸¦æ‰©å±•å的文件å作ä¸?MD5
return row.file_name.replace(/\.[^.]+$/, '')
}
} catch {
// 忽略错误
}
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
*/
getVideoInfo(videoMd5: string): VideoInfo {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
if (!dbPath || !wxid || !videoMd5) {
return { exists: false }
}
// å…ˆå°è¯•从数æ®åº“查询真正的视频文件å?
const realVideoMd5 = this.queryVideoFileName(videoMd5) || videoMd5
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
if (!existsSync(videoBaseDir)) {
return { exists: false }
}
// é历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
// 支æŒå¤šç§ç›®å½•æ ¼å¼: 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`)
// 检查视频文件是å¦å­˜åœ?
if (existsSync(videoPath)) {
return {
videoUrl: videoPath, // 返回文件路径,å‰ç«¯é€šè¿‡ readFile 读å
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
}
} catch {
// 忽略错误
}
return { exists: false }
}
/**
* æ ¹æ®æ¶ˆæ¯å†…容解æžè§†é¢‘MD5
*/
parseVideoMd5(content: string): string | undefined {
if (!content) return undefined
try {
// å°è¯•从XML中æå–md5
// æ ¼å¼å¯èƒ½æ˜? <md5>xxx</md5> æˆ?md5="xxx"
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5Match) {
return md5Match[1].toLowerCase()
}
const attrMatch = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
return attrMatch[1].toLowerCase()
}
// å°è¯•从videomsg标签中æå?
const videoMsgMatch = /<videomsg[^>]*md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {
return videoMsgMatch[1].toLowerCase()
}
} catch (e) {
console.error('è§£æžè§†é¢‘MD5失败:', e)
}
return undefined
}
}
export const videoService = new VideoService()