mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
修复了图片解密失败的问题
This commit is contained in:
@@ -852,9 +852,9 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 视频相关
|
// 视频相关
|
||||||
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
|
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, sessionId?: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await videoService.getVideoInfo(videoMd5)
|
const result = await videoService.getVideoInfo(videoMd5, sessionId)
|
||||||
return { success: true, ...result }
|
return { success: true, ...result }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e), exists: false }
|
return { success: false, error: String(e), exists: false }
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 视频
|
// 视频
|
||||||
video: {
|
video: {
|
||||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
getVideoInfo: (videoMd5: string, sessionId?: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, sessionId),
|
||||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -4478,27 +4478,77 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||||
const normalized = dbPath.replace(/[\\\\/]+$/, '')
|
const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase()
|
||||||
|
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||||
|
|
||||||
// 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件)
|
const candidates: { path: string; mtime: number }[] = []
|
||||||
// 则向上回溯到账号目录
|
|
||||||
if (basename(normalized).toLowerCase() === 'db_storage') {
|
// 检查直接路径
|
||||||
return dirname(normalized)
|
const direct = join(normalized, cleanedWxid)
|
||||||
}
|
if (existsSync(direct) && this.isAccountDir(direct)) {
|
||||||
const dir = dirname(normalized)
|
candidates.push({ path: direct, mtime: this.getDirMtime(direct) })
|
||||||
if (basename(dir).toLowerCase() === 'db_storage') {
|
|
||||||
return dirname(dir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 否则,dbPath 应该是数据库根目录(如 xwechat_files)
|
// 检查 dbPath 本身是否就是账号目录
|
||||||
// 账号目录应该是 {dbPath}/{wxid}
|
if (this.isAccountDir(normalized)) {
|
||||||
const accountDirWithWxid = join(normalized, wxid)
|
candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) })
|
||||||
if (existsSync(accountDirWithWxid)) {
|
|
||||||
return accountDirWithWxid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录)
|
// 扫描 dbPath 下的所有子目录寻找匹配的 wxid
|
||||||
return normalized
|
try {
|
||||||
|
if (existsSync(normalized) && statSync(normalized).isDirectory()) {
|
||||||
|
const entries = readdirSync(normalized)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(normalized, entry)
|
||||||
|
try {
|
||||||
|
if (!statSync(entryPath).isDirectory()) continue
|
||||||
|
} catch { continue }
|
||||||
|
|
||||||
|
const lowerEntry = entry.toLowerCase()
|
||||||
|
if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) {
|
||||||
|
if (this.isAccountDir(entryPath)) {
|
||||||
|
if (!candidates.some(c => c.path === entryPath)) {
|
||||||
|
candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
|
||||||
|
// 按修改时间降序排序,取最新的
|
||||||
|
candidates.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
return candidates[0].path
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAccountDir(dirPath: string): boolean {
|
||||||
|
return (
|
||||||
|
existsSync(join(dirPath, 'db_storage')) ||
|
||||||
|
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
||||||
|
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
|
||||||
|
existsSync(join(dirPath, 'msg', 'attach'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDirMtime(dirPath: string): number {
|
||||||
|
try {
|
||||||
|
const stat = statSync(dirPath)
|
||||||
|
let mtime = stat.mtimeMs
|
||||||
|
const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image']
|
||||||
|
for (const sub of subDirs) {
|
||||||
|
const fullPath = join(dirPath, sub)
|
||||||
|
if (existsSync(fullPath)) {
|
||||||
|
try {
|
||||||
|
mtime = Math.max(mtime, statSync(fullPath).mtimeMs)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mtime
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ export class DbPathService {
|
|||||||
return (
|
return (
|
||||||
existsSync(join(entryPath, 'db_storage')) ||
|
existsSync(join(entryPath, 'db_storage')) ||
|
||||||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
|
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
|
||||||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
|
existsSync(join(entryPath, 'FileStorage', 'Image2')) ||
|
||||||
|
existsSync(join(entryPath, 'msg', 'attach'))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,22 +95,21 @@ export class DbPathService {
|
|||||||
const accountStat = statSync(entryPath)
|
const accountStat = statSync(entryPath)
|
||||||
let latest = accountStat.mtimeMs
|
let latest = accountStat.mtimeMs
|
||||||
|
|
||||||
const dbPath = join(entryPath, 'db_storage')
|
const checkSubDirs = [
|
||||||
if (existsSync(dbPath)) {
|
'db_storage',
|
||||||
const dbStat = statSync(dbPath)
|
join('FileStorage', 'Image'),
|
||||||
latest = Math.max(latest, dbStat.mtimeMs)
|
join('FileStorage', 'Image2'),
|
||||||
}
|
join('msg', 'attach')
|
||||||
|
]
|
||||||
|
|
||||||
const imagePath = join(entryPath, 'FileStorage', 'Image')
|
for (const sub of checkSubDirs) {
|
||||||
if (existsSync(imagePath)) {
|
const fullPath = join(entryPath, sub)
|
||||||
const imageStat = statSync(imagePath)
|
if (existsSync(fullPath)) {
|
||||||
latest = Math.max(latest, imageStat.mtimeMs)
|
try {
|
||||||
}
|
const s = statSync(fullPath)
|
||||||
|
latest = Math.max(latest, s.mtimeMs)
|
||||||
const image2Path = join(entryPath, 'FileStorage', 'Image2')
|
} catch { }
|
||||||
if (existsSync(image2Path)) {
|
}
|
||||||
const image2Stat = statSync(image2Path)
|
|
||||||
latest = Math.max(latest, image2Stat.mtimeMs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return latest
|
return latest
|
||||||
|
|||||||
@@ -329,28 +329,78 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase()
|
||||||
const normalized = dbPath.replace(/[\\/]+$/, '')
|
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||||
|
|
||||||
|
const candidates: { path: string; mtime: number }[] = []
|
||||||
|
|
||||||
|
// 检查直接路径
|
||||||
const direct = join(normalized, cleanedWxid)
|
const direct = join(normalized, cleanedWxid)
|
||||||
if (existsSync(direct)) return direct
|
if (existsSync(direct) && this.isAccountDir(direct)) {
|
||||||
|
candidates.push({ path: direct, mtime: this.getDirMtime(direct) })
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isAccountDir(normalized)) return normalized
|
// 检查 dbPath 本身是否就是账号目录
|
||||||
|
if (this.isAccountDir(normalized)) {
|
||||||
|
candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描 dbPath 下的所有子目录寻找匹配的 wxid
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(normalized)
|
if (existsSync(normalized) && this.isDirectory(normalized)) {
|
||||||
const lowerWxid = cleanedWxid.toLowerCase()
|
const entries = readdirSync(normalized)
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(normalized, entry)
|
const entryPath = join(normalized, entry)
|
||||||
if (!this.isDirectory(entryPath)) continue
|
if (!this.isDirectory(entryPath)) continue
|
||||||
const lowerEntry = entry.toLowerCase()
|
|
||||||
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
|
const lowerEntry = entry.toLowerCase()
|
||||||
if (this.isAccountDir(entryPath)) return entryPath
|
// 匹配原 wxid 或带有后缀的 wxid (如 wxid_xxx_1234)
|
||||||
|
if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) {
|
||||||
|
if (this.isAccountDir(entryPath)) {
|
||||||
|
if (!candidates.some(c => c.path === entryPath)) {
|
||||||
|
candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return null
|
if (candidates.length === 0) return null
|
||||||
|
|
||||||
|
// 按修改时间降序排序,取最新的(最可能是当前活跃的)
|
||||||
|
candidates.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
|
||||||
|
if (candidates.length > 1) {
|
||||||
|
this.logInfo('找到多个候选账号目录,选择最新修改的一个', {
|
||||||
|
selected: candidates[0].path,
|
||||||
|
all: candidates.map(c => c.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0].path
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDirMtime(dirPath: string): number {
|
||||||
|
try {
|
||||||
|
const stat = statSync(dirPath)
|
||||||
|
let mtime = stat.mtimeMs
|
||||||
|
|
||||||
|
// 检查几个关键子目录的修改时间,以更准确地反映活动状态
|
||||||
|
const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image']
|
||||||
|
for (const sub of subDirs) {
|
||||||
|
const fullPath = join(dirPath, sub)
|
||||||
|
if (existsSync(fullPath)) {
|
||||||
|
try {
|
||||||
|
mtime = Math.max(mtime, statSync(fullPath).mtimeMs)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mtime
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -744,7 +744,8 @@ export class KeyService {
|
|||||||
return (
|
return (
|
||||||
existsSync(join(dirPath, 'db_storage')) ||
|
existsSync(join(dirPath, 'db_storage')) ||
|
||||||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
||||||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
|
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
|
||||||
|
existsSync(join(dirPath, 'msg', 'attach'))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,8 +762,8 @@ export class KeyService {
|
|||||||
private listAccountDirs(rootDir: string): string[] {
|
private listAccountDirs(rootDir: string): string[] {
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(rootDir)
|
const entries = readdirSync(rootDir)
|
||||||
const high: string[] = []
|
const candidates: { path: string; mtime: number; isAccount: boolean }[] = []
|
||||||
const low: string[] = []
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(rootDir, entry)
|
const fullPath = join(rootDir, entry)
|
||||||
try {
|
try {
|
||||||
@@ -775,18 +776,48 @@ export class KeyService {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isAccountDir(fullPath)) {
|
const isAccount = this.isAccountDir(fullPath)
|
||||||
high.push(fullPath)
|
candidates.push({
|
||||||
} else {
|
path: fullPath,
|
||||||
low.push(fullPath)
|
mtime: this.getDirMtime(fullPath),
|
||||||
}
|
isAccount
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return high.length ? high.sort() : low.sort()
|
|
||||||
|
// 优先选择有效账号目录,然后按修改时间从新到旧排序
|
||||||
|
return candidates
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isAccount !== b.isAccount) return a.isAccount ? -1 : 1
|
||||||
|
return b.mtime - a.mtime
|
||||||
|
})
|
||||||
|
.map(c => c.path)
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDirMtime(dirPath: string): number {
|
||||||
|
try {
|
||||||
|
const stat = statSync(dirPath)
|
||||||
|
let mtime = stat.mtimeMs
|
||||||
|
|
||||||
|
// 检查几个关键子目录的修改时间,以更准确地反映活动状态
|
||||||
|
const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image']
|
||||||
|
for (const sub of subDirs) {
|
||||||
|
const fullPath = join(dirPath, sub)
|
||||||
|
if (existsSync(fullPath)) {
|
||||||
|
try {
|
||||||
|
mtime = Math.max(mtime, statSync(fullPath).mtimeMs)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mtime
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeExistingDir(inputPath: string): string | null {
|
private normalizeExistingDir(inputPath: string): string | null {
|
||||||
const trimmed = inputPath.replace(/[\\\\/]+$/, '')
|
const trimmed = inputPath.replace(/[\\\\/]+$/, '')
|
||||||
if (!existsSync(trimmed)) return null
|
if (!existsSync(trimmed)) return null
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { join } from 'path'
|
import { join, basename, extname, dirname } from 'path'
|
||||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, writeFileSync } from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
videoUrl?: string // 视频文件路径
|
||||||
coverUrl?: string // 封面 data URL
|
coverUrl?: string // 封面 data URL
|
||||||
thumbUrl?: string // 缩略图 data URL
|
thumbUrl?: string // 缩略图 data URL
|
||||||
exists: boolean
|
exists: boolean
|
||||||
@@ -13,266 +15,379 @@ export interface VideoInfo {
|
|||||||
|
|
||||||
class VideoService {
|
class VideoService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
|
private resolvedCache = new Map<string, string>() // md5 -> localPath
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||||
* 获取数据库根目录
|
if (!this.configService.get('logEnabled')) return
|
||||||
*/
|
const timestamp = new Date().toISOString()
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const logLine = `[${timestamp}] [VideoService] ${message}${metaStr}\n`
|
||||||
|
this.writeLog(logLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private logError(message: string, error?: unknown, meta?: Record<string, unknown>): void {
|
||||||
|
if (!this.configService.get('logEnabled')) return
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const errorStr = error ? ` Error: ${String(error)}` : ''
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const logLine = `[${timestamp}] [VideoService] ERROR: ${message}${errorStr}${metaStr}\n`
|
||||||
|
console.error(`[VideoService] ${message}`, error, meta)
|
||||||
|
this.writeLog(logLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeLog(line: string): void {
|
||||||
|
try {
|
||||||
|
const logDir = join(app.getPath('userData'), 'logs')
|
||||||
|
if (!existsSync(logDir)) {
|
||||||
|
mkdirSync(logDir, { recursive: true })
|
||||||
|
}
|
||||||
|
appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('写入日志失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getDbPath(): string {
|
private getDbPath(): string {
|
||||||
return this.configService.get('dbPath') || ''
|
return this.configService.get('dbPath') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前用户的wxid
|
|
||||||
*/
|
|
||||||
private getMyWxid(): string {
|
private getMyWxid(): string {
|
||||||
return this.configService.get('myWxid') || ''
|
return this.configService.get('myWxid') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private getCacheBasePath(): string {
|
||||||
* 获取缓存目录(解密后的数据库存放位置)
|
|
||||||
*/
|
|
||||||
private getCachePath(): string {
|
|
||||||
return this.configService.getCacheBasePath()
|
return this.configService.getCacheBasePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理 wxid 目录名(去掉后缀)
|
|
||||||
*/
|
|
||||||
private cleanWxid(wxid: string): string {
|
private cleanWxid(wxid: string): string {
|
||||||
const trimmed = wxid.trim()
|
const trimmed = wxid.trim()
|
||||||
if (!trimmed) return trimmed
|
if (!trimmed) return trimmed
|
||||||
|
|
||||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
if (match) return match[1]
|
if (match) return match[1]
|
||||||
return trimmed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
if (suffixMatch) return suffixMatch[1]
|
if (suffixMatch) return suffixMatch[1]
|
||||||
|
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
if (!dbPath || !wxid) return null
|
||||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
const cleanedWxid = this.cleanWxid(wxid).toLowerCase()
|
||||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||||
*/
|
const candidates: { path: string; mtime: number }[] = []
|
||||||
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)
|
|
||||||
|
|
||||||
if (!wxid) return undefined
|
const checkDir = (p: string) => {
|
||||||
|
if (existsSync(p) && (existsSync(join(p, 'db_storage')) || existsSync(join(p, 'msg', 'video')) || existsSync(join(p, 'msg', 'attach')))) {
|
||||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
candidates.push({ path: p, mtime: this.getDirMtime(p) })
|
||||||
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)) {
|
|
||||||
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(/\.[^.]+$/, '')
|
|
||||||
return realMd5
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
checkDir(join(normalized, wxid))
|
||||||
if (dbPath) {
|
checkDir(join(normalized, cleanedWxid))
|
||||||
// 检查 dbPath 是否已经包含 wxid
|
checkDir(normalized)
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
|
||||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
|
||||||
|
|
||||||
const encryptedDbPaths: string[] = []
|
try {
|
||||||
if (dbPathContainsWxid) {
|
if (existsSync(normalized) && statSync(normalized).isDirectory()) {
|
||||||
// dbPath 已包含 wxid,不需要再拼接
|
const entries = readdirSync(normalized)
|
||||||
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
for (const entry of entries) {
|
||||||
} else {
|
const entryPath = join(normalized, entry)
|
||||||
// dbPath 不包含 wxid,需要拼接
|
|
||||||
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 {
|
try {
|
||||||
const escapedMd5 = md5.replace(/'/g, "''")
|
if (!statSync(entryPath).isDirectory()) continue
|
||||||
|
} catch { continue }
|
||||||
// 用 md5 字段查询,获取 file_name
|
const lowerEntry = entry.toLowerCase()
|
||||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) {
|
||||||
|
checkDir(entryPath)
|
||||||
const result = await wcdbService.execQuery('media', p, sql)
|
|
||||||
|
|
||||||
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(/\.[^.]+$/, '')
|
|
||||||
return realMd5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
candidates.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
return candidates[0].path
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDirMtime(dirPath: string): number {
|
||||||
|
try {
|
||||||
|
let mtime = statSync(dirPath).mtimeMs
|
||||||
|
const subs = ['db_storage', 'msg/video', 'msg/attach']
|
||||||
|
for (const sub of subs) {
|
||||||
|
const p = join(dirPath, sub)
|
||||||
|
if (existsSync(p)) mtime = Math.max(mtime, statSync(p).mtimeMs)
|
||||||
|
}
|
||||||
|
return mtime
|
||||||
|
} catch { return 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureWcdbReady(): Promise<boolean> {
|
||||||
|
if (wcdbService.isReady()) return true
|
||||||
|
const dbPath = this.configService.get('dbPath')
|
||||||
|
const decryptKey = this.configService.get('decryptKey')
|
||||||
|
const wxid = this.configService.get('myWxid')
|
||||||
|
if (!dbPath || !decryptKey || !wxid) return false
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
return await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算会话哈希(对应磁盘目录名)
|
||||||
|
*/
|
||||||
|
private md5Hash(text: string): string {
|
||||||
|
return crypto.createHash('md5').update(text).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveHardlinkPath(accountDir: string, md5: string): Promise<string | null> {
|
||||||
|
const dbPath = join(accountDir, 'db_storage', 'hardlink', 'hardlink.db')
|
||||||
|
if (!existsSync(dbPath)) {
|
||||||
|
this.logInfo('hardlink.db 不存在', { dbPath })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await this.ensureWcdbReady()
|
||||||
|
if (!ready) return null
|
||||||
|
|
||||||
|
const tableResult = await wcdbService.execQuery('media', dbPath,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'video_hardlink_info%' ORDER BY name DESC LIMIT 1")
|
||||||
|
|
||||||
|
if (!tableResult.success || !tableResult.rows?.length) return null
|
||||||
|
const tableName = tableResult.rows[0].name
|
||||||
|
|
||||||
|
const escapedMd5 = md5.replace(/'/g, "''")
|
||||||
|
const rowResult = await wcdbService.execQuery('media', dbPath,
|
||||||
|
`SELECT dir1, dir2, file_name FROM ${tableName} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`)
|
||||||
|
|
||||||
|
if (!rowResult.success || !rowResult.rows?.length) return null
|
||||||
|
|
||||||
|
const row = rowResult.rows[0]
|
||||||
|
const dir1 = row.dir1 ?? row.DIR1
|
||||||
|
const dir2 = row.dir2 ?? row.DIR2
|
||||||
|
const file_name = row.file_name ?? row.fileName ?? row.FILE_NAME
|
||||||
|
|
||||||
|
if (dir1 === undefined || dir2 === undefined || !file_name) return null
|
||||||
|
|
||||||
|
const dirTableResult = await wcdbService.execQuery('media', dbPath,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1")
|
||||||
|
if (!dirTableResult.success || !dirTableResult.rows?.length) return null
|
||||||
|
const dirTable = dirTableResult.rows[0].name
|
||||||
|
|
||||||
|
const getDirName = async (id: number) => {
|
||||||
|
const res = await wcdbService.execQuery('media', dbPath, `SELECT username FROM ${dirTable} WHERE rowid = ${id} LIMIT 1`)
|
||||||
|
return res.success && res.rows?.length ? String(res.rows[0].username) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir1Name = await getDirName(Number(dir1))
|
||||||
|
const dir2Name = await getDirName(Number(dir2))
|
||||||
|
if (!dir1Name || !dir2Name) return null
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Video', file_name),
|
||||||
|
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, file_name),
|
||||||
|
join(accountDir, 'msg', 'video', dir2Name, file_name)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (existsSync(p)) {
|
||||||
|
this.logInfo('hardlink 命中', { path: p })
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logError('resolveHardlinkPath 异常', e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchVideoFile(accountDir: string, md5: string, sessionId?: string): Promise<string | null> {
|
||||||
|
const lowerMd5 = md5.toLowerCase()
|
||||||
|
|
||||||
|
// 策略 1: 基于 sessionId 哈希的精准搜索 (XWeChat 核心逻辑)
|
||||||
|
if (sessionId) {
|
||||||
|
const sessHash = this.md5Hash(sessionId)
|
||||||
|
const attachRoot = join(accountDir, 'msg', 'attach', sessHash)
|
||||||
|
if (existsSync(attachRoot)) {
|
||||||
|
try {
|
||||||
|
const monthDirs = readdirSync(attachRoot).filter(d => /^\d{4}-\d{2}$/.test(d))
|
||||||
|
for (const m of monthDirs) {
|
||||||
|
const videoDir = join(attachRoot, m, 'Video')
|
||||||
|
if (existsSync(videoDir)) {
|
||||||
|
// 尝试精确名和带数字后缀的名
|
||||||
|
const files = readdirSync(videoDir)
|
||||||
|
const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4'))
|
||||||
|
if (match) return join(videoDir, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略 2: 概率搜索所有 session 目录 (针对最近 3 个月)
|
||||||
|
const attachRoot = join(accountDir, 'msg', 'attach')
|
||||||
|
if (existsSync(attachRoot)) {
|
||||||
|
try {
|
||||||
|
const sessionDirs = readdirSync(attachRoot).filter(d => d.length === 32)
|
||||||
|
const now = new Date()
|
||||||
|
const months = []
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sess of sessionDirs) {
|
||||||
|
for (const month of months) {
|
||||||
|
const videoDir = join(attachRoot, sess, month, 'Video')
|
||||||
|
if (existsSync(videoDir)) {
|
||||||
|
const files = readdirSync(videoDir)
|
||||||
|
const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4'))
|
||||||
|
if (match) return join(videoDir, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略 3: 传统 msg/video 目录
|
||||||
|
const videoRoot = join(accountDir, 'msg', 'video')
|
||||||
|
if (existsSync(videoRoot)) {
|
||||||
|
try {
|
||||||
|
const monthDirs = readdirSync(videoRoot).sort().reverse()
|
||||||
|
for (const m of monthDirs) {
|
||||||
|
const dirPath = join(videoRoot, m)
|
||||||
|
const files = readdirSync(dirPath)
|
||||||
|
const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4'))
|
||||||
|
if (match) return join(dirPath, match)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private getXorKey(): number | undefined {
|
||||||
|
const raw = this.configService.get('imageXorKey')
|
||||||
|
if (typeof raw === 'number') return raw
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const t = raw.trim()
|
||||||
|
return t.toLowerCase().startsWith('0x') ? parseInt(t, 16) : parseInt(t, 10)
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private isEncrypted(buffer: Buffer, xorKey: number, type: 'video' | 'image'): boolean {
|
||||||
* 将文件转换为 data URL
|
if (buffer.length < 8) return false
|
||||||
*/
|
const first = buffer[0] ^ xorKey
|
||||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
const second = buffer[1] ^ xorKey
|
||||||
try {
|
|
||||||
if (!existsSync(filePath)) return undefined
|
if (type === 'image') {
|
||||||
const buffer = readFileSync(filePath)
|
return (first === 0xFF && second === 0xD8) || (first === 0x89 && second === 0x50) || (first === 0x47 && second === 0x49)
|
||||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
} else {
|
||||||
} catch {
|
// MP4 头部通常包含 'ftyp'
|
||||||
return undefined
|
const f = buffer[4] ^ xorKey
|
||||||
|
const t = buffer[5] ^ xorKey
|
||||||
|
const y = buffer[6] ^ xorKey
|
||||||
|
const p = buffer[7] ^ xorKey
|
||||||
|
return (f === 0x66 && t === 0x74 && y === 0x79 && p === 0x70) || // 'ftyp'
|
||||||
|
(buffer[0] ^ xorKey) === 0x00 && (buffer[1] ^ xorKey) === 0x00 // 一些 mp4 以 00 00 开头
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private filePathToUrl(filePath: string): string {
|
||||||
* 根据视频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()
|
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
|
||||||
return { exists: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || 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())) {
|
|
||||||
// dbPath 已经包含 wxid,直接使用
|
|
||||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
|
||||||
} else {
|
|
||||||
// dbPath 不包含 wxid,需要拼接
|
|
||||||
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(videoBaseDir)) {
|
|
||||||
return { exists: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
|
||||||
try {
|
try {
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
const { pathToFileURL } = require('url')
|
||||||
|
const url = pathToFileURL(filePath).toString()
|
||||||
|
const s = statSync(filePath)
|
||||||
|
return `${url}?v=${Math.floor(s.mtimeMs)}`
|
||||||
|
} catch {
|
||||||
|
return `file:///${filePath.replace(/\\/g, '/')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
private handleFile(filePath: string, type: 'video' | 'image', sessionId?: string): string | undefined {
|
||||||
const yearMonthDirs = allDirs
|
if (!existsSync(filePath)) return undefined
|
||||||
.filter(dir => {
|
const xorKey = this.getXorKey()
|
||||||
const dirPath = join(videoBaseDir, dir)
|
|
||||||
return statSync(dirPath).isDirectory()
|
try {
|
||||||
})
|
const buffer = readFileSync(filePath)
|
||||||
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
const isEnc = xorKey !== undefined && !Number.isNaN(xorKey) && this.isEncrypted(buffer, xorKey, type)
|
||||||
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
if (isEnc) {
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
const decrypted = Buffer.alloc(buffer.length)
|
||||||
|
for (let i = 0; i < buffer.length; i++) decrypted[i] = buffer[i] ^ xorKey!
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
|
||||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
if (type === 'image') {
|
||||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
return `data:image/jpeg;base64,${decrypted.toString('base64')}`
|
||||||
|
} else {
|
||||||
// 检查视频文件是否存在
|
const cacheDir = join(this.getCacheBasePath(), 'Videos', this.cleanWxid(sessionId || 'unknown'))
|
||||||
if (existsSync(videoPath)) {
|
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true })
|
||||||
return {
|
const outPath = join(cacheDir, `${basename(filePath)}`)
|
||||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
if (!existsSync(outPath) || statSync(outPath).size !== decrypted.length) {
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
writeFileSync(outPath, decrypted)
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
|
||||||
exists: true
|
|
||||||
}
|
}
|
||||||
|
return this.filePathToUrl(outPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'image') {
|
||||||
|
return `data:image/jpeg;base64,${buffer.toString('base64')}`
|
||||||
|
}
|
||||||
|
return this.filePathToUrl(filePath)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略错误
|
this.logError(`处理${type}文件异常: ${filePath}`, e)
|
||||||
|
return type === 'image' ? undefined : this.filePathToUrl(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVideoInfo(videoMd5: string, sessionId?: string): Promise<VideoInfo> {
|
||||||
|
this.logInfo('获取视频信息', { videoMd5, sessionId })
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
if (!dbPath || !wxid || !videoMd5) return { exists: false }
|
||||||
|
|
||||||
|
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||||
|
if (!accountDir) {
|
||||||
|
this.logError('未找到账号目录', undefined, { dbPath, wxid })
|
||||||
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. 通过 hardlink 映射
|
||||||
|
let videoPath = await this.resolveHardlinkPath(accountDir, videoMd5)
|
||||||
|
|
||||||
|
// 2. 启发式搜索
|
||||||
|
if (!videoPath) {
|
||||||
|
videoPath = await this.searchVideoFile(accountDir, videoMd5, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoPath && existsSync(videoPath)) {
|
||||||
|
this.logInfo('定位成功', { videoPath })
|
||||||
|
const base = videoPath.slice(0, -4)
|
||||||
|
const coverPath = `${base}.jpg`
|
||||||
|
const thumbPath = `${base}_thumb.jpg`
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoUrl: this.handleFile(videoPath, 'video', sessionId),
|
||||||
|
coverUrl: this.handleFile(coverPath, 'image', sessionId),
|
||||||
|
thumbUrl: this.handleFile(thumbPath, 'image', sessionId),
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logInfo('定位失败', { videoMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据消息内容解析视频MD5
|
|
||||||
*/
|
|
||||||
parseVideoMd5(content: string): string | undefined {
|
parseVideoMd5(content: string): string | undefined {
|
||||||
|
|
||||||
// 打印前500字符看看 XML 结构
|
|
||||||
|
|
||||||
if (!content) return undefined
|
if (!content) return undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 提取所有可能的 md5 值进行日志
|
const m = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) ||
|
||||||
const allMd5s: string[] = []
|
/\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) ||
|
||||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
/<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
let match
|
return m ? m[1].toLowerCase() : undefined
|
||||||
while ((match = md5Regex.exec(content)) !== null) {
|
} catch { return undefined }
|
||||||
allMd5s.push(`${match[0]}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 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) {
|
|
||||||
return videoMsgMatch[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (attrMatch) {
|
|
||||||
return attrMatch[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
|
||||||
if (md5Match) {
|
|
||||||
return md5Match[1].toLowerCase()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -80,6 +80,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -2909,6 +2910,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3055,6 +3057,7 @@
|
|||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -3994,6 +3997,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -5103,6 +5107,7 @@
|
|||||||
"integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==",
|
"integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "25.1.8",
|
"app-builder-lib": "25.1.8",
|
||||||
"builder-util": "25.1.7",
|
"builder-util": "25.1.7",
|
||||||
@@ -5290,6 +5295,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
|
||||||
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.3.0",
|
"tslib": "2.3.0",
|
||||||
"zrender": "5.6.1"
|
"zrender": "5.6.1"
|
||||||
@@ -5376,7 +5382,6 @@
|
|||||||
"integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==",
|
"integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "25.1.8",
|
"app-builder-lib": "25.1.8",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
@@ -5390,7 +5395,6 @@
|
|||||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
@@ -5406,7 +5410,6 @@
|
|||||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -5420,7 +5423,6 @@
|
|||||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
@@ -9150,6 +9152,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9159,6 +9162,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -9593,6 +9597,7 @@
|
|||||||
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"immutable": "^5.0.2",
|
"immutable": "^5.0.2",
|
||||||
@@ -10434,6 +10439,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -10881,6 +10887,7 @@
|
|||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -10970,7 +10977,8 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
"resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/fdir": {
|
"node_modules/vite/node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
@@ -10996,6 +11004,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3543,7 +3543,7 @@ function MessageBubble({
|
|||||||
videoLoadingRef.current = true
|
videoLoadingRef.current = true
|
||||||
setVideoLoading(true)
|
setVideoLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.video.getVideoInfo(videoMd5)
|
const result = await window.electronAPI.video.getVideoInfo(videoMd5, session.username)
|
||||||
if (result && result.success && result.exists) {
|
if (result && result.success && result.exists) {
|
||||||
setVideoInfo({
|
setVideoInfo({
|
||||||
exists: result.exists,
|
exists: result.exists,
|
||||||
@@ -3560,7 +3560,7 @@ function MessageBubble({
|
|||||||
videoLoadingRef.current = false
|
videoLoadingRef.current = false
|
||||||
setVideoLoading(false)
|
setVideoLoading(false)
|
||||||
}
|
}
|
||||||
}, [videoMd5])
|
}, [videoMd5, session.username])
|
||||||
|
|
||||||
// 视频进入视野时自动加载
|
// 视频进入视野时自动加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user