Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0d93d76
feat(media): add subtitle stream handling and extraction
mkdir700 Oct 16, 2025
629af64
feat(player): enhance subtitle import with caching
mkdir700 Oct 17, 2025
0da82d9
📝 Add docstrings to `feat/import-subtitle-from-stream` (#222)
coderabbitai[bot] Oct 17, 2025
09295a5
refactor: make subtitle temp cleanup async
mkdir700 Oct 17, 2025
7796059
refactor: replace blocking fs.existsSync with async fs.promises.acces…
mkdir700 Oct 17, 2025
366924c
refactor: replace blocking fs.existsSync with async fs.promises in cr…
mkdir700 Oct 17, 2025
953ef09
fix: add context object to subtitle detection logger for searchability
mkdir700 Oct 17, 2025
4dc9eec
refactor: consolidate path conversion logic to use PathConverter
mkdir700 Oct 17, 2025
ed0c685
refactor(MediaParser): fix ffprobe service usage and prevent double s…
mkdir700 Oct 17, 2025
d7e5604
refactor(player): replace hardcoded Chinese text with i18n in subtitl…
mkdir700 Oct 17, 2025
63d59d5
feat(SubtitleExtractor): implement temporary subtitle file cleanup fu…
mkdir700 Oct 17, 2025
398ea66
docs: update CLAUDE.md with testing and resource management guidelines
mkdir700 Oct 17, 2025
7a00ab6
fix(SubtitleExtractorService): refactor cleanupTempFiles to use async…
mkdir700 Oct 17, 2025
7d3d9a3
test(FFmpegService): refactor integration tests to use async/await fo…
mkdir700 Oct 17, 2025
a85b241
refactor(SubtitleExtractorService): enhance temporary subtitle filena…
mkdir700 Oct 17, 2025
7075f83
refactor(ipc): enhance cleanupTemp IPC handler with logging and retur…
mkdir700 Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ const StyledComponent = styled.div`
# Test

- 使用 vitest 作为测试框架
- **重要**:全局测试配置文件 `tests/setup.ts` 中 mock 了 `node:fs` 和 `node:fs/promises` 模块,这会影响需要真实文件系统操作的测试
- 对于需要真实文件系统操作的测试(如文件清理、文件读写等),必须在测试文件开头使用 `vi.unmock('node:fs')` 和 `vi.unmock('node:fs/promises')` 来取消全局 mock
- 示例:

```typescript
// 在测试文件开头
vi.unmock('node:fs')
vi.unmock('node:fs/promises')

// 然后导入真实的 fs
import * as fs from 'fs'
```
Comment thread
mkdir700 marked this conversation as resolved.

# Package Management

Expand All @@ -82,10 +94,32 @@ const StyledComponent = styled.div`
- 包管理器工具请使用 pnpm
- logger 的使用例子: `logger.error('Error in MediaClock listener:', { error: error })`, 第二参数必须接收为 `{}`

## Resource Management & Cleanup

- **临时文件清理规范**:所有生成临时文件的服务都应实现集中清理机制,参考 `FFmpegDownloadService.cleanupTempFiles()` 和 `SubtitleExtractorService.cleanupTempFiles()` 的实现模式
- 临时文件清理的最佳实践:
1. 在服务中实现 `cleanupTempFiles()` 方法,扫描并删除符合特定模式的临时文件
2. 在 `src/main/index.ts` 的 `app.on('will-quit')` 事件中调用清理方法
3. 通过 IPC 通道暴露清理接口,允许渲染进程手动触发清理(如 `SubtitleExtractor_CleanupTemp`)
4. 使用正则表达式精确匹配临时文件模式,避免误删其他文件
5. 包含完整的错误处理和日志记录,跳过正在使用或无法删除的文件
- 临时文件命名规范:使用 `<prefix>_<timestamp>_<random>.<ext>` 格式(如 `subtitle_1234567890_abc123.srt`),便于模式匹配和清理

Comment on lines +97 to +107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

给出“临时文件”匹配的正则示例,降低实现偏差

当前只描述了命名约定,建议补充一个推荐正则模板,便于一致实现与审查。

示例补充:

^subtitle_\d{10}_[a-z0-9]{6}\.(srt|vtt|ass|ssa|sup)$

配合前缀可扩展:

^(subtitle|ffmpeg|probe)_\d{10}_[a-z0-9]{6}\.[a-z0-9]+$

并在文档中强调排除锁文件/进行中文件(如 *.part, *.lock)。

🤖 Prompt for AI Agents
In CLAUDE.md around lines 97 to 107, the doc describes temporary file naming but
lacks concrete regex examples; add recommended regex templates for common
prefixes (e.g. subtitle/ffmpeg/probe) and a more specific subtitle pattern, plus
guidance to exclude lock/partial files and other safe-guards; update the section
to show the two example regexes and a short note to explicitly exclude patterns
like *.part and *.lock and to prefer anchored regexes and lowercase hex for
deterministic matching.

## File System Operations

- **禁止使用同步文件操作**:在主进程中必须使用异步文件 API(`fs.promises.*`),避免阻塞事件循环导致应用冻结
- 文件操作最佳实践:
- ❌ 错误示例:`fs.readdirSync()`, `fs.unlinkSync()`, `fs.readFileSync()`
- ✅ 正确示例:`await fs.promises.readdir()`, `await fs.promises.unlink()`, `await fs.promises.readFile()`
- 批量文件操作使用 `Promise.all()` 并行执行,提升性能
- 所有文件操作方法应声明为 `async` 并返回 `Promise`

## Issues & Solutions

1. DictionaryPopover 组件主题兼容性问题已修复:将硬编码的深色主题颜色(白色文字、深色背景)替换为 Ant Design CSS 变量(如 `var(--ant-color-text)`、`var(--ant-color-bg-elevated)`),实现浅色和深色主题的自动适配,包括文字颜色、背景色、边框、滚动条和交互状态的完整主题化。

2. SubtitleExtractorService 临时文件清理机制已实现:在应用退出时自动清理系统临时目录中的字幕临时文件,防止磁盘空间浪费;支持通过 IPC 通道手动触发清理。

## Task Master AI Instructions

**Import Task Master's development workflow commands and guidelines, treat as if import is in the main CLAUDE.md file.**
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/IpcChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ export enum IpcChannel {
MediaInfo_GetVideoInfo = 'mediainfo:get-video-info',
MediaInfo_GetVideoInfoWithStrategy = 'mediainfo:get-video-info-with-strategy',

// 字幕轨道相关 IPC 通道 / Subtitle stream related IPC channels
Media_GetSubtitleStreams = 'media:get-subtitle-streams',
Media_ExtractSubtitle = 'media:extract-subtitle',
SubtitleExtractor_CleanupTemp = 'subtitle-extractor:cleanup-temp',

// 文件系统相关 IPC 通道 / File system related IPC channels
Fs_CheckFileExists = 'fs:check-file-exists',
Fs_ReadFile = 'fs:read-file',
Expand Down
10 changes: 10 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,16 @@ if (!app.requestSingleInstanceLock()) {
})

app.on('will-quit', async () => {
// Cleanup temporary subtitle files
try {
const SubtitleExtractorService = (await import('./services/SubtitleExtractorService')).default
const subtitleExtractorService = new SubtitleExtractorService()
await subtitleExtractorService.cleanupTempFiles()
logger.info('Temporary subtitle files cleaned up')
} catch (error) {
logger.error('Error cleaning up temporary subtitle files:', { error })
}
Comment on lines +185 to +193
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

已 await 清理:符合先前建议;可选增加超时护栏

will-quit 中已 await 清理,OK。为防极端阻塞退出,可包一层超时(如 2s)或在 before-quit 执行并设置兜底。当前改动可先合。

🤖 Prompt for AI Agents
In src/main/index.ts around lines 185 to 193, the awaited cleanup of temporary
subtitle files in the will-quit handler should be protected with a timeout to
avoid blocking app shutdown; wrap the await
subtitleExtractorService.cleanupTempFiles() in a cancellable/timeout promise
(e.g., race it against a 2000ms timeout) and if the timeout wins, log a warning
and proceed, ensuring the handler always resolves promptly; alternatively move
the cleanup to before-quit and keep a 2s fallback timeout to guarantee the
process exits.


// Close database connections
try {
const { closeDatabase } = await import('./db/index')
Expand Down
56 changes: 44 additions & 12 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { mediaServerService } from './services/MediaServerService'
import NotificationService from './services/NotificationService'
import { pythonVenvService } from './services/PythonVenvService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import SubtitleExtractorService from './services/SubtitleExtractorService'
import { themeService } from './services/ThemeService'
import { uvBootstrapperService } from './services/UvBootstrapperService'
import { calculateDirectorySize, getResourcePath } from './utils'
Expand All @@ -41,18 +42,18 @@ const fileManager = new FileStorage()
const dictionaryService = new DictionaryService()
const ffmpegService = new FFmpegService()
const mediaParserService = new MediaParserService()
const subtitleExtractorService = new SubtitleExtractorService()

/**
* Register all IPC handlers used by the main process.
* Registers all ipcMain handlers used by the main process.
*
* Initializes updater and notification services and wires a comprehensive set of ipcMain.handle
* handlers exposing application control, system info, theming/language, spell-check, cache and
* file operations, dictionary lookups, FFmpeg operations, shortcut management, and database DAOs
* (Files, VideoLibrary, SubtitleLibrary) to renderer processes.
* Exposes application control, system information, theming/language, spell-check, cache and file
* operations, media tooling (FFmpeg, media parser, subtitle extraction), shortcuts, and database
* DAOs to renderer processes via ipcMain handlers.
*
* This function has side effects: it registers handlers on ipcMain, may attach an app 'before-quit'
* listener when requested, and mutates Electron state (e.g., app paths, sessions). Call from the
* Electron main process once (typically during app initialization).
* This function mutates Electron state (registers handlers on ipcMain, may add/remove an app
* 'before-quit' listener, and updates sessions/paths) and must be invoked once from the main
* process during application initialization.
*/
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
Expand Down Expand Up @@ -681,14 +682,45 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
)

// 字幕轨道相关 IPC 处理程序 / Subtitle stream-related IPC handlers
ipcMain.handle(IpcChannel.Media_GetSubtitleStreams, async (_, inputPath: string) => {
return await mediaParserService.getSubtitleStreams(inputPath)
})
Comment thread
mkdir700 marked this conversation as resolved.

ipcMain.handle(
IpcChannel.Media_ExtractSubtitle,
async (
_,
options: {
videoPath: string
streamIndex: number
outputFormat?: string
subtitleCodec?: string
}
) => {
return await subtitleExtractorService.extractSubtitle({
videoPath: options.videoPath,
streamIndex: options.streamIndex,
outputFormat: (options.outputFormat as 'srt' | 'ass' | 'vtt') || 'srt',
subtitleCodec: options.subtitleCodec
})
}
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

ipcMain.handle(IpcChannel.SubtitleExtractor_CleanupTemp, async () => {
const count = await subtitleExtractorService.cleanupTempFiles()
logger.info('触发字幕临时文件清理', { count })
return count
})

// 文件系统相关 IPC 处理程序 / File system-related IPC handlers
ipcMain.handle(IpcChannel.Fs_CheckFileExists, async (_, filePath: string) => {
try {
const exists = fs.existsSync(filePath)
logger.debug('检查文件存在性', { filePath, exists })
return exists
await fs.promises.access(filePath, fs.constants.F_OK)
logger.debug('检查文件存在性', { filePath, exists: true })
return true
} catch (error) {
logger.error('检查文件存在性时出错', { filePath, error })
logger.debug('检查文件存在性', { filePath, exists: false })
return false
}
Comment on lines 717 to 725
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Fs_CheckFileExists 可提前过滤空路径以减少无意义 I/O

微优化:空字符串/非字符串直接返回 false 并打点一次 debug。

   ipcMain.handle(IpcChannel.Fs_CheckFileExists, async (_, filePath: string) => {
-    try {
+    if (!filePath || typeof filePath !== 'string') {
+      logger.debug('检查文件存在性', { filePath, exists: false, reason: 'invalid-arg' })
+      return false
+    }
+    try {
       await fs.promises.access(filePath, fs.constants.F_OK)
       logger.debug('检查文件存在性', { filePath, exists: true })
       return true

})
Expand Down
118 changes: 31 additions & 87 deletions src/main/services/FFmpegService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PathConverter } from '@shared/utils/PathConverter'
import { PerformanceMonitor } from '@shared/utils/PerformanceMonitor'
import { spawn } from 'child_process'
import { app } from 'electron'
Expand Down Expand Up @@ -38,81 +39,6 @@ class FFmpegService {
// 构造函数可以用于初始化操作
}

// 将file://URL转换为本地文件路径
private convertFileUrlToLocalPath(inputPath: string): string {
// 如果是file://URL,需要转换为本地路径
if (inputPath.startsWith('file://')) {
try {
const url = new URL(inputPath)
let localPath = decodeURIComponent(url.pathname)

// Windows路径处理:移除开头的斜杠
if (process.platform === 'win32' && localPath.startsWith('/')) {
localPath = localPath.substring(1)
}

// 添加详细的调试信息
logger.info('🔄 URL路径转换详情', {
原始路径: inputPath,
'URL.pathname': url.pathname,
解码前路径: url.pathname,
解码后路径: localPath,
平台: process.platform,
文件是否存在: fs.existsSync(localPath)
})

// 额外验证:尝试列出目录内容来确认文件是否真的存在
if (!fs.existsSync(localPath)) {
const dirPath = path.dirname(localPath)
const fileName = path.basename(localPath)

logger.info('🔍 文件不存在,检查目录内容', {
目录路径: dirPath,
期望文件名: fileName,
目录是否存在: fs.existsSync(dirPath)
})

if (fs.existsSync(dirPath)) {
try {
const filesInDir = fs.readdirSync(dirPath)
logger.info('📁 目录中的文件', {
目录路径: dirPath,
文件列表: filesInDir,
文件数量: filesInDir.length
})

// 查找可能的匹配文件(大小写不敏感匹配)
const matchingFiles = filesInDir.filter(
(file) =>
file.toLowerCase().includes('老友记') ||
file.toLowerCase().includes('h265') ||
file.toLowerCase().includes(fileName.toLowerCase())
)

if (matchingFiles.length > 0) {
logger.info('🎯 找到可能匹配的文件', { matchingFiles })
}
} catch (error) {
logger.error(
'无法读取目录内容:',
error instanceof Error ? error : new Error(String(error))
)
}
}
}

return localPath
} catch (error) {
logger.error('URL路径转换失败:', error instanceof Error ? error : new Error(String(error)))
// 如果转换失败,返回原路径
return inputPath
}
}

// 如果不是file://URL,直接返回
return inputPath
}

// 获取内置 FFmpeg 路径
private getBundledFFmpegPath(): string | null {
try {
Expand Down Expand Up @@ -243,20 +169,29 @@ class FFmpegService {
}
}

// 获取 FFprobe 路径
public getFFprobePath(): string {
try {
return ffmpegDownloadService.getFFprobePath()
} catch (error) {
logger.warn('获取下载的 FFprobe 路径失败,使用系统 FFprobe', {
error: error instanceof Error ? error.message : String(error)
})
// 降级到系统 FFprobe
return 'ffprobe'
}
}
Comment thread
mkdir700 marked this conversation as resolved.

// 快速检查 FFmpeg 是否存在(文件系统级别检查)
public fastCheckFFmpegExists(): boolean {
public async fastCheckFFmpegExists(): Promise<boolean> {
const startTime = Date.now()
const ffmpegPath = this.getFFmpegPath()

try {
// 检查文件是否存在
if (!fs.existsSync(ffmpegPath)) {
logger.info('⚡ 快速检查: FFmpeg 文件不存在', { ffmpegPath })
return false
}
// 检查文件是否存在并获取文件信息
const stats = await fs.promises.stat(ffmpegPath)

// 检查是否为文件(非目录)
const stats = fs.statSync(ffmpegPath)
if (!stats.isFile()) {
logger.info('⚡ 快速检查: FFmpeg 路径不是文件', { ffmpegPath })
return false
Expand Down Expand Up @@ -319,7 +254,7 @@ class FFmpegService {
})

try {
const fastCheckPassed = this.fastCheckFFmpegExists()
const fastCheckPassed = await this.fastCheckFFmpegExists()
if (!fastCheckPassed) {
// 快速检查失败,直接缓存结果并返回
FFmpegService.ffmpegAvailabilityCache[ffmpegPath] = false
Expand Down Expand Up @@ -483,12 +418,21 @@ class FFmpegService {

try {
// 转换路径
pm.startTiming(this.convertFileUrlToLocalPath)
const localInputPath = this.convertFileUrlToLocalPath(inputPath)
pm.endTiming(this.convertFileUrlToLocalPath)
pm.startTiming('convertToLocalPath')
const pathResult = PathConverter.convertToLocalPath(inputPath)
pm.endTiming('convertToLocalPath')

if (!pathResult.isValid) {
logger.error(`❌ 路径转换失败: ${pathResult.error}`)
return null
}

const localInputPath = pathResult.localPath

// 检查文件是否存在
if (!fs.existsSync(localInputPath)) {
try {
await fs.promises.access(localInputPath, fs.constants.F_OK)
} catch {
logger.error(`❌ 文件不存在: ${localInputPath}`)
return null
}
Expand Down
Loading