mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-20 03:23:21 +08:00
249 lines
6.7 KiB
TypeScript
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()
|