Merge pull request #332 from xunchahaha/dev

Dev
This commit is contained in:
cc
2026-02-28 17:56:20 +08:00
committed by GitHub
7 changed files with 366 additions and 90 deletions

View File

@@ -105,7 +105,8 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
"senderUsername": "wxid_sender", "senderUsername": "wxid_sender",
"mediaType": "image", "mediaType": "image",
"mediaFileName": "image_123.jpg", "mediaFileName": "image_123.jpg",
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" "mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
} }
] ]
} }
@@ -140,7 +141,7 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
"timestamp": 1738713600000, "timestamp": 1738713600000,
"type": 0, "type": 0,
"content": "消息内容", "content": "消息内容",
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" "mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
} }
], ],
"media": { "media": {
@@ -153,7 +154,59 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
--- ---
### 3. 获取会话列表 ### 3. 访问导出媒体文件
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
**请求**
```
GET /api/v1/media/{relativePath}
```
**路径参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` |
**支持的媒体类型**
| 扩展名 | Content-Type |
|--------|-------------|
| `.png` | image/png |
| `.jpg` / `.jpeg` | image/jpeg |
| `.gif` | image/gif |
| `.webp` | image/webp |
| `.wav` | audio/wav |
| `.mp3` | audio/mpeg |
| `.mp4` | video/mp4 |
**示例请求**
```bash
# 访问导出的图片
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg
# 访问导出的语音
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav
# 访问导出的视频
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
```
**响应**
成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。
失败时返回:
```json
{ "error": "Media not found" }
```
> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。
---
### 4. 获取会话列表
获取所有会话列表。 获取所有会话列表。

View File

@@ -665,7 +665,18 @@ class ExportService {
case 42: return '[名片]' case 42: return '[名片]'
case 43: return '[视频]' case 43: return '[视频]'
case 47: return '[动画表情]' case 47: return '[动画表情]'
case 48: return '[位置]' case 48: {
const normalized48 = this.normalizeAppMessageContent(content)
const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName')
const locLabel = this.extractXmlAttribute(normalized48, 'location', 'label') || this.extractXmlValue(normalized48, 'label')
const locLat = this.extractXmlAttribute(normalized48, 'location', 'x') || this.extractXmlAttribute(normalized48, 'location', 'latitude')
const locLng = this.extractXmlAttribute(normalized48, 'location', 'y') || this.extractXmlAttribute(normalized48, 'location', 'longitude')
const locParts: string[] = []
if (locPoiname) locParts.push(locPoiname)
if (locLabel && locLabel !== locPoiname) locParts.push(locLabel)
if (locLat && locLng) locParts.push(`(${locLat},${locLng})`)
return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
}
case 49: { case 49: {
const title = this.extractXmlValue(content, 'title') const title = this.extractXmlValue(content, 'title')
const type = this.extractXmlValue(content, 'type') const type = this.extractXmlValue(content, 'type')
@@ -776,12 +787,15 @@ class ExportService {
} }
if (localType === 48) { if (localType === 48) {
const normalized = this.normalizeAppMessageContent(safeContent) const normalized = this.normalizeAppMessageContent(safeContent)
const location = const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName')
this.extractXmlValue(normalized, 'label') || const locLabel = this.extractXmlAttribute(normalized, 'location', 'label') || this.extractXmlValue(normalized, 'label')
this.extractXmlValue(normalized, 'poiname') || const locLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude')
this.extractXmlValue(normalized, 'poiName') || const locLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude')
this.extractXmlValue(normalized, 'name') const locParts: string[] = []
return location ? `[定位]${location}` : '[定位]' if (locPoiname) locParts.push(locPoiname)
if (locLabel && locLabel !== locPoiname) locParts.push(locLabel)
if (locLat && locLng) locParts.push(`(${locLat},${locLng})`)
return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
} }
if (localType === 50) { if (localType === 50) {
return this.parseVoipMessage(safeContent) return this.parseVoipMessage(safeContent)
@@ -979,6 +993,12 @@ class ExportService {
return '' return ''
} }
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
const tagRegex = new RegExp(`<${tagName}\\s+[^>]*${attrName}\\s*=\\s*"([^"]*)"`, 'i')
const match = tagRegex.exec(xml)
return match ? match[1] : ''
}
private cleanSystemMessage(content: string): string { private cleanSystemMessage(content: string): string {
if (!content) return '[系统消息]' if (!content) return '[系统消息]'
@@ -2932,7 +2952,7 @@ class ExportService {
options.displayNamePreference || 'remark' options.displayNamePreference || 'remark'
) )
allMessages.push({ const msgObj: any = {
localId: allMessages.length + 1, localId: allMessages.length + 1,
createTime: msg.createTime, createTime: msg.createTime,
formattedTime: this.formatTimestamp(msg.createTime), formattedTime: this.formatTimestamp(msg.createTime),
@@ -2944,7 +2964,17 @@ class ExportService {
senderDisplayName, senderDisplayName,
source, source,
senderAvatarKey: msg.senderUsername senderAvatarKey: msg.senderUsername
}) }
// 位置消息:附加结构化位置字段
if (msg.localType === 48) {
if (msg.locationLat != null) msgObj.locationLat = msg.locationLat
if (msg.locationLng != null) msgObj.locationLng = msg.locationLng
if (msg.locationPoiname) msgObj.locationPoiname = msg.locationPoiname
if (msg.locationLabel) msgObj.locationLabel = msg.locationLabel
}
allMessages.push(msgObj)
} }
allMessages.sort((a, b) => a.createTime - b.createTime) allMessages.sort((a, b) => a.createTime - b.createTime)

View File

@@ -301,7 +301,7 @@ export class ImageDecryptService {
if (finalExt === '.hevc') { if (finalExt === '.hevc') {
return { return {
success: false, success: false,
error: '此图片为微信新格式(wxgf)需要安装 ffmpeg 才能显示', error: '此图片为微信新格式(wxgf)ffmpeg 转换失败,请检查日志',
isThumb: this.isThumbnailPath(datPath) isThumb: this.isThumbnailPath(datPath)
} }
} }
@@ -1833,21 +1833,24 @@ export class ImageDecryptService {
// 提取 HEVC NALU 裸流 // 提取 HEVC NALU 裸流
const hevcData = this.extractHevcNalu(buffer) const hevcData = this.extractHevcNalu(buffer)
if (!hevcData || hevcData.length < 100) { // 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据
return { data: buffer, isWxgf: true } const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4)
} this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
naluExtracted: !!(hevcData && hevcData.length >= 100),
feedSize: feedData.length
})
// 尝试用 ffmpeg 转换 // 尝试用 ffmpeg 转换
try { try {
const jpgData = await this.convertHevcToJpg(hevcData) const jpgData = await this.convertHevcToJpg(feedData)
if (jpgData && jpgData.length > 0) { if (jpgData && jpgData.length > 0) {
return { data: jpgData, isWxgf: false } return { data: jpgData, isWxgf: false }
} }
} catch { } catch (e) {
// ffmpeg 转换失败 this.logError('unwrapWxgf: ffmpeg 转换失败', e)
} }
return { data: hevcData, isWxgf: true } return { data: feedData, isWxgf: true }
} }
/** /**
@@ -1914,50 +1917,92 @@ export class ImageDecryptService {
/** /**
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG * 使用 ffmpeg 将 HEVC 裸流转换为 JPG
*/ */
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> { private async convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
const ffmpeg = this.getFfmpegPath() const ffmpeg = this.getFfmpegPath()
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
const tmpDir = join(app.getPath('temp'), 'weflow_hevc')
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
const ts = Date.now()
const tmpInput = join(tmpDir, `hevc_${ts}.hevc`)
const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`)
try {
await writeFile(tmpInput, hevcData)
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
const attempts: { label: string; inputArgs: string[] }[] = [
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
]
for (const attempt of attempts) {
// 清理上一轮的输出
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label)
if (result) return result
}
return null
} catch (e) {
this.logError('ffmpeg 转换异常', e)
return null
} finally {
try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {}
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
}
}
private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise<Buffer | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const { spawn } = require('child_process') const { spawn } = require('child_process')
const chunks: Buffer[] = []
const errChunks: Buffer[] = [] const errChunks: Buffer[] = []
const proc = spawn(ffmpeg, [ const args = [
'-hide_banner', '-hide_banner', '-loglevel', 'error',
'-loglevel', 'error', ...inputArgs,
'-f', 'hevc', '-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
'-i', 'pipe:0', ]
'-vframes', '1', this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') })
'-q:v', '3',
'-f', 'mjpeg', const proc = spawn(ffmpeg, args, {
'pipe:1' stdio: ['ignore', 'ignore', 'pipe'],
], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true windowsHide: true
}) })
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk)) proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
proc.on('close', (code: number) => { const timer = setTimeout(() => {
if (code === 0 && chunks.length > 0) { proc.kill('SIGKILL')
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length }) this.logError(`ffmpeg [${label}] 超时(15s)`)
resolve(Buffer.concat(chunks)) resolve(null)
} else { }, 15000)
const errMsg = Buffer.concat(errChunks).toString()
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
resolve(null)
}
})
proc.on('error', (err: Error) => { proc.on('close', (code: number) => {
this.logInfo('ffmpeg 进程错误', { error: err.message }) clearTimeout(timer)
if (code === 0 && existsSync(tmpOutput)) {
try {
const jpgBuf = readFileSync(tmpOutput)
if (jpgBuf.length > 0) {
this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length })
resolve(jpgBuf)
return
}
} catch (e) {
this.logError(`ffmpeg [${label}] 读取输出失败`, e)
}
}
const errMsg = Buffer.concat(errChunks).toString().trim()
this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg })
resolve(null) resolve(null)
}) })
proc.stdin.write(hevcData) proc.on('error', (err: Error) => {
proc.stdin.end() clearTimeout(timer)
this.logError(`ffmpeg [${label}] 进程错误`, err)
resolve(null)
})
}) })
} }

View File

@@ -855,7 +855,7 @@ export class KeyService {
} }
let found = null; let found = null;
for (let upper = end; upper > start; upper--) { for (let upper = end - 1; upper >= start; upper--) {
// 我就写 -- // 我就写 --
if (upper % 100000 === 0 && upper !== start) { if (upper % 100000 === 0 && upper !== start) {
parentPort.postMessage({ type: 'progress', scanned: 100000 }); parentPort.postMessage({ type: 'progress', scanned: 100000 });
@@ -943,18 +943,15 @@ export class KeyService {
cleanup() cleanup()
resolve(msg.key) resolve(msg.key)
} else if (msg.type === 'done') { } else if (msg.type === 'done') {
// 单个 worker 跑完了没有找到 // 单个 worker 跑完了没有找到(计数统一在 exit 事件处理)
activeWorkers--
if (activeWorkers === 0 && !resolved) resolve(null)
} }
}) })
worker.on('error', (err) => { worker.on('error', (err) => {
console.error('Worker error:', err) console.error('Worker error:', err)
activeWorkers--
if (activeWorkers === 0 && !resolved) resolve(null)
}) })
// 统一在 exit 事件中做完成计数,避免 done/error + exit 双重递减
worker.on('exit', () => { worker.on('exit', () => {
activeWorkers-- activeWorkers--
if (activeWorkers === 0 && !resolved) resolve(null) if (activeWorkers === 0 && !resolved) resolve(null)
@@ -984,7 +981,7 @@ export class KeyService {
onProgress?.('正在读取加密模板区块...') onProgress?.('正在读取加密模板区块...')
const ciphertexts = this.getCiphertextsFromTemplate(templateFiles) const ciphertexts = this.getCiphertextsFromTemplate(templateFiles)
if (ciphertexts.length === 0) return { success: false, error: '无法读取加密模板数据' } if (ciphertexts.length < 2) return { success: false, error: '可用的加密样本不足至少需要2个请确认账号目录下有足够的模板图片' }
onProgress?.(`成功提取 ${ciphertexts.length} 个特征样本,准备交叉校验...`) onProgress?.(`成功提取 ${ciphertexts.length} 个特征样本,准备交叉校验...`)
onProgress?.(`准备启动 ${os.cpus().length || 4} 线程并发爆破引擎 (基于 wxid: ${wxid})...`) onProgress?.(`准备启动 ${os.cpus().length || 4} 线程并发爆破引擎 (基于 wxid: ${wxid})...`)

View File

@@ -1,5 +1,6 @@
import { join } from 'path' import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs' import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } 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'
@@ -18,6 +19,16 @@ class VideoService {
this.configService = new ConfigService() 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 {}
}
/** /**
* 获取数据库根目录 * 获取数据库根目录
*/ */
@@ -69,7 +80,12 @@ class VideoService {
const wxid = this.getMyWxid() const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid) const cleanedWxid = this.cleanWxid(wxid)
if (!wxid) return undefined this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
if (!wxid) {
this.log('queryVideoFileName: wxid 为空')
return undefined
}
// 方法1优先在 cachePath 下查找解密后的 hardlink.db // 方法1优先在 cachePath 下查找解密后的 hardlink.db
if (cachePath) { if (cachePath) {
@@ -84,20 +100,23 @@ class VideoService {
for (const p of cacheDbPaths) { for (const p of cacheDbPaths) {
if (existsSync(p)) { if (existsSync(p)) {
try { try {
this.log('尝试缓存 hardlink.db', { path: p })
const db = new Database(p, { readonly: true }) const db = new Database(p, { readonly: true })
const row = db.prepare(` const row = db.prepare(`
SELECT file_name, md5 FROM video_hardlink_info_v4 SELECT file_name, md5 FROM video_hardlink_info_v4
WHERE md5 = ? WHERE md5 = ?
LIMIT 1 LIMIT 1
`).get(md5) as { file_name: string; md5: string } | undefined `).get(md5) as { file_name: string; md5: string } | undefined
db.close() db.close()
if (row?.file_name) { if (row?.file_name) {
const realMd5 = row.file_name.replace(/\.[^.]+$/, '') const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
return realMd5 return realMd5
} }
this.log('缓存 hardlink.db 未命中', { path: p })
} catch (e) { } catch (e) {
// 忽略错误 this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
} }
} }
} }
@@ -105,7 +124,6 @@ class VideoService {
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db // 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) { if (dbPath) {
// 检查 dbPath 是否已经包含 wxid
const dbPathLower = dbPath.toLowerCase() const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase() const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase() const cleanedWxidLower = cleanedWxid.toLowerCase()
@@ -113,10 +131,8 @@ class VideoService {
const encryptedDbPaths: string[] = [] const encryptedDbPaths: string[] = []
if (dbPathContainsWxid) { if (dbPathContainsWxid) {
// dbPath 已包含 wxid不需要再拼接
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')) encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else { } else {
// dbPath 不包含 wxid需要拼接
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db')) encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')) encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
} }
@@ -124,27 +140,29 @@ class VideoService {
for (const p of encryptedDbPaths) { for (const p of encryptedDbPaths) {
if (existsSync(p)) { if (existsSync(p)) {
try { try {
this.log('尝试加密 hardlink.db', { path: p })
const escapedMd5 = md5.replace(/'/g, "''") const escapedMd5 = md5.replace(/'/g, "''")
// 用 md5 字段查询,获取 file_name
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
const result = await wcdbService.execQuery('media', p, sql) const result = await wcdbService.execQuery('media', p, sql)
if (result.success && result.rows && result.rows.length > 0) { if (result.success && result.rows && result.rows.length > 0) {
const row = result.rows[0] const row = result.rows[0]
if (row?.file_name) { if (row?.file_name) {
// 提取不带扩展名的文件名作为实际视频 MD5
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
return realMd5 return realMd5
} }
} }
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
} catch (e) { } catch (e) {
// 忽略错误 this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
} }
} else {
this.log('加密 hardlink.db 不存在', { path: p })
} }
} }
} }
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
return undefined return undefined
} }
@@ -170,12 +188,16 @@ class VideoService {
const dbPath = this.getDbPath() const dbPath = this.getDbPath()
const wxid = this.getMyWxid() const wxid = this.getMyWxid()
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
if (!dbPath || !wxid || !videoMd5) { if (!dbPath || !wxid || !videoMd5) {
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
return { exists: false } return { exists: false }
} }
// 先尝试从数据库查询真正的视频文件名 // 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
// 检查 dbPath 是否已经包含 wxid避免重复拼接 // 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase() const dbPathLower = dbPath.toLowerCase()
@@ -184,50 +206,58 @@ class VideoService {
let videoBaseDir: string let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) { if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
// dbPath 已经包含 wxid直接使用
videoBaseDir = join(dbPath, 'msg', 'video') videoBaseDir = join(dbPath, 'msg', 'video')
} else { } else {
// dbPath 不包含 wxid需要拼接
videoBaseDir = join(dbPath, wxid, 'msg', 'video') videoBaseDir = join(dbPath, wxid, 'msg', 'video')
} }
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
if (!existsSync(videoBaseDir)) { if (!existsSync(videoBaseDir)) {
this.log('getVideoInfo: videoBaseDir 不存在')
return { exists: false } return { exists: false }
} }
// 遍历年月目录查找视频文件 // 遍历年月目录查找视频文件
try { try {
const allDirs = readdirSync(videoBaseDir) const allDirs = readdirSync(videoBaseDir)
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
const yearMonthDirs = allDirs const yearMonthDirs = allDirs
.filter(dir => { .filter(dir => {
const dirPath = join(videoBaseDir, dir) const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory() return statSync(dirPath).isDirectory()
}) })
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找 .sort((a, b) => b.localeCompare(a))
this.log('扫描目录', { dirs: yearMonthDirs })
for (const yearMonth of yearMonthDirs) { for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth) const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`) const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
// 检查视频文件是否存在
if (existsSync(videoPath)) { if (existsSync(videoPath)) {
this.log('找到视频', { videoPath })
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
return { return {
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true exists: true
} }
} }
} }
// 没找到,列出第一个目录里的文件帮助排查
if (yearMonthDirs.length > 0) {
const firstDir = join(videoBaseDir, yearMonthDirs[0])
const files = readdirSync(firstDir).filter(f => f.endsWith('.mp4')).slice(0, 5)
this.log('未找到视频,最新目录样本', { dir: yearMonthDirs[0], sampleFiles: files, lookingFor: `${realVideoMd5}.mp4` })
}
} catch (e) { } catch (e) {
// 忽略错误 this.log('getVideoInfo 遍历出错', { error: String(e) })
} }
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
return { exists: false } return { exists: false }
} }

View File

@@ -3331,9 +3331,12 @@
// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss // 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss
// 批量转写确认对话框 // 批量转写确认对话框
.batch-confirm-modal { .batch-modal-content.batch-confirm-modal {
width: 480px; width: 480px;
max-width: 90vw; max-width: 90vw;
max-height: none;
overflow: visible;
overflow-y: visible;
.batch-modal-header { .batch-modal-header {
display: flex; display: flex;
@@ -3470,6 +3473,74 @@
font-weight: 600; font-weight: 600;
color: var(--primary-color); color: var(--primary-color);
} }
.batch-concurrency-field {
position: relative;
.batch-concurrency-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 9999px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
}
svg {
color: var(--text-tertiary);
transition: transform 0.2s;
}
&.open svg {
transform: rotate(180deg);
}
}
.batch-concurrency-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 180px;
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 100;
}
.batch-concurrency-option {
width: 100%;
text-align: left;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
&:hover {
background: var(--bg-tertiary);
}
&.active {
color: var(--primary);
font-weight: 500;
}
}
}
} }
} }
@@ -3527,7 +3598,7 @@
&.btn-primary, &.btn-primary,
&.batch-transcribe-start-btn { &.batch-transcribe-start-btn {
background: var(--primary-color); background: var(--primary-color);
color: white; color: #000;
&:hover { &:hover {
opacity: 0.9; opacity: 0.9;

View File

@@ -345,6 +345,8 @@ function ChatPage(_props: ChatPageProps) {
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null) const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
const [batchImageDates, setBatchImageDates] = useState<string[]>([]) const [batchImageDates, setBatchImageDates] = useState<string[]>([])
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set()) const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
const [batchDecryptConcurrency, setBatchDecryptConcurrency] = useState(6)
const [showConcurrencyDropdown, setShowConcurrencyDropdown] = useState(false)
// 批量删除相关状态 // 批量删除相关状态
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
@@ -1662,29 +1664,44 @@ function ChatPage(_props: ChatPageProps) {
let successCount = 0 let successCount = 0
let failCount = 0 let failCount = 0
for (let i = 0; i < images.length; i++) { let completed = 0
const img = images[i] const concurrency = batchDecryptConcurrency
const decryptOne = async (img: typeof images[0]) => {
try { try {
const r = await window.electronAPI.image.decrypt({ const r = await window.electronAPI.image.decrypt({
sessionId: session.username, sessionId: session.username,
imageMd5: img.imageMd5, imageMd5: img.imageMd5,
imageDatName: img.imageDatName, imageDatName: img.imageDatName,
force: false force: true
}) })
if (r?.success) successCount++ if (r?.success) successCount++
else failCount++ else failCount++
} catch { } catch {
failCount++ failCount++
} }
completed++
updateDecryptProgress(i + 1, images.length) updateDecryptProgress(completed, images.length)
if (i % 5 === 0) {
await new Promise(resolve => setTimeout(resolve, 0))
}
} }
// 并发池:同时跑 concurrency 个任务
const pool: Promise<void>[] = []
for (const img of images) {
const p = decryptOne(img)
pool.push(p)
if (pool.length >= concurrency) {
await Promise.race(pool)
// 移除已完成的
for (let j = pool.length - 1; j >= 0; j--) {
const settled = await Promise.race([pool[j].then(() => true), Promise.resolve(false)])
if (settled) pool.splice(j, 1)
}
}
}
await Promise.all(pool)
finishDecrypt(successCount, failCount) finishDecrypt(successCount, failCount)
}, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) }, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
const batchImageCountByDate = useMemo(() => { const batchImageCountByDate = useMemo(() => {
const map = new Map<string, number>() const map = new Map<string, number>()
@@ -2623,6 +2640,39 @@ function ChatPage(_props: ChatPageProps) {
<span className="label">:</span> <span className="label">:</span>
<span className="value">{batchImageSelectedDates.size} {batchImageSelectedCount} </span> <span className="value">{batchImageSelectedDates.size} {batchImageSelectedCount} </span>
</div> </div>
<div className="info-item">
<span className="label">:</span>
<div className="batch-concurrency-field">
<button
type="button"
className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`}
onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)}
>
<span>{batchDecryptConcurrency === 1 ? '1最慢最稳' : batchDecryptConcurrency === 6 ? '6推荐' : batchDecryptConcurrency === 20 ? '20最快可能卡顿' : String(batchDecryptConcurrency)}</span>
<ChevronDown size={14} />
</button>
{showConcurrencyDropdown && (
<div className="batch-concurrency-dropdown">
{[
{ value: 1, label: '1最慢最稳' },
{ value: 3, label: '3' },
{ value: 6, label: '6推荐' },
{ value: 10, label: '10' },
{ value: 20, label: '20最快可能卡顿' },
].map(opt => (
<button
key={opt.value}
type="button"
className={`batch-concurrency-option ${batchDecryptConcurrency === opt.value ? 'active' : ''}`}
onClick={() => { setBatchDecryptConcurrency(opt.value); setShowConcurrencyDropdown(false) }}
>
{opt.label}
</button>
))}
</div>
)}
</div>
</div>
</div> </div>
<div className="batch-warning"> <div className="batch-warning">
<AlertCircle size={16} /> <AlertCircle size={16} />