From bfabb4487fc2c3a9f273715f892690b90c80e558 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Fri, 12 Sep 2025 09:51:02 +0800 Subject: [PATCH 1/7] fix(windows): resolve file extension validation requiring double dots (.mp4 vs ..mp4) (#126) Fixes issue where Windows users were required to use invalid double-dot extensions (..mp4, ..mov) instead of standard single-dot extensions (.mp4, .mov). **Root Cause:** - Inconsistent file extension handling between main/renderer processes - Windows path separators causing parsing issues - Electron dialog extension format mismatch **Changes:** - Enhanced getFileExt() with Windows path normalization and validation - Unified extension processing across main/renderer processes - Standardized Electron dialog extension configuration - Added comprehensive Windows-specific test coverage - Improved error handling and platform-aware logging **Testing:** - 24 new tests covering Windows path edge cases - Cross-platform compatibility validation - Regression tests for GitHub issue #118 - All existing tests remain passing Fixes #118 --- packages/shared/config/constant.ts | 17 ++ src/main/services/FileStorage.ts | 126 +++++++--- .../utils/__tests__/dialog-extensions.test.ts | 138 +++++++++++ src/main/utils/__tests__/file.windows.test.ts | 234 ++++++++++++++++++ src/main/utils/file.ts | 53 +++- src/renderer/src/hooks/useVideoFileSelect.ts | 4 +- .../player/components/VideoErrorRecovery.tsx | 3 +- src/renderer/src/utils/file.ts | 29 ++- 8 files changed, 562 insertions(+), 42 deletions(-) create mode 100644 src/main/utils/__tests__/dialog-extensions.test.ts create mode 100644 src/main/utils/__tests__/file.windows.test.ts diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index a1c1c154..edd318bb 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -1,6 +1,23 @@ export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv'] export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac'] +/** + * 将扩展名数组转换为 Electron dialog 所需的格式(不含点) + * @param extArray 扩展名数组(可包含或不包含点) + * @returns 不含点的扩展名数组 + */ +export function toDialogExtensions(extArray: string[]): string[] { + return extArray.map((ext) => (ext.startsWith('.') ? ext.slice(1) : ext)) +} + +/** + * 获取用于 Electron dialog 的视频文件扩展名数组 + * @returns 不含点的视频扩展名数组 + */ +export function getVideoDialogExtensions(): string[] { + return toDialogExtensions(videoExts) +} + export const KB = 1024 export const MB = 1024 * KB export const GB = 1024 * MB diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index d4bc0711..7eae9039 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -1,5 +1,6 @@ import { loggerService } from '@logger' import { + getFileExt, getFilesDir, getFileType, getTempDir, @@ -73,7 +74,7 @@ class FileStorage { ]) if (originalHash === storedHash) { - const ext = path.extname(file) + const ext = getFileExt(file) const id = path.basename(file, ext) return { id, @@ -96,36 +97,99 @@ class FileStorage { _: Electron.IpcMainInvokeEvent, options?: OpenDialogOptions ): Promise => { - const defaultOptions: OpenDialogOptions = { - properties: ['openFile'] - } + try { + const defaultOptions: OpenDialogOptions = { + properties: ['openFile'] + } - const dialogOptions = { ...defaultOptions, ...options } + const dialogOptions = { ...defaultOptions, ...options } - const result = await dialog.showOpenDialog(dialogOptions) + // 记录平台和对话框配置信息 + logger.info('打开文件选择对话框', { + platform: process.platform, + dialogOptions, + hasFilters: !!options?.filters?.length, + filterCount: options?.filters?.length || 0 + }) - if (result.canceled || result.filePaths.length === 0) { - return null - } + const result = await dialog.showOpenDialog(dialogOptions) - const fileMetadataPromises = result.filePaths.map(async (filePath) => { - const stats = fs.statSync(filePath) - const ext = path.extname(filePath) - const fileType = getFileType(ext) + if (result.canceled) { + logger.info('用户取消了文件选择') + return null + } - return { - id: uuidv4(), - origin_name: path.basename(filePath), - name: path.basename(filePath), - path: filePath, - created_at: stats.birthtime, - size: stats.size, - ext: ext, - type: fileType + if (result.filePaths.length === 0) { + logger.warn('文件选择对话框返回了空的文件路径列表') + return null } - }) - return Promise.all(fileMetadataPromises) + logger.info('用户选择了文件', { + fileCount: result.filePaths.length, + filePaths: result.filePaths.map((p) => ({ + path: p, + platform: process.platform, + // 在日志中显示原始路径和标准化路径对比 + normalized: process.platform === 'win32' ? p.replace(/\\/g, '/') : p + })) + }) + + const fileMetadataPromises = result.filePaths.map(async (filePath, index) => { + try { + logger.info(`处理文件 ${index + 1}/${result.filePaths.length}`, { filePath }) + + const stats = fs.statSync(filePath) + const ext = getFileExt(filePath) + const fileType = getFileType(ext) + + const metadata = { + id: uuidv4(), + origin_name: path.basename(filePath), + name: path.basename(filePath), + path: filePath, + created_at: stats.birthtime, + size: stats.size, + ext: ext, + type: fileType + } + + // 详细的文件信息日志 + logger.info(`文件信息解析完成`, { + filePath, + originalExt: path.extname(filePath), // 对比原生方法 + enhancedExt: ext, // 我们的增强方法 + fileType, + size: `${Math.round(stats.size / 1024)}KB`, + platform: process.platform + }) + + return metadata + } catch (error) { + logger.error('处理单个文件时出错', { + filePath, + error: error as Error, + platform: process.platform + }) + throw error + } + }) + + const results = await Promise.all(fileMetadataPromises) + + logger.info('文件选择和处理完成', { + successCount: results.length, + platform: process.platform + }) + + return results + } catch (error) { + logger.error('文件选择过程出错', { + error: error as Error, + platform: process.platform, + options + }) + throw error + } } public uploadFile = async ( @@ -140,7 +204,7 @@ class FileStorage { const uuid = uuidv4() const origin_name = path.basename(file.path) - const ext = path.extname(origin_name).toLowerCase() + const ext = getFileExt(origin_name).toLowerCase() const destPath = path.join(this.storageDir, uuid + ext) logger.info(`[FileStorage] Uploading file: ${file.path}`) @@ -181,7 +245,7 @@ class FileStorage { } const stats = fs.statSync(filePath) - const ext = path.extname(filePath) + const ext = getFileExt(filePath) const fileType = getFileType(ext) const fileInfo: FileMetadata = { @@ -285,7 +349,7 @@ class FileStorage { } const stats = fs.statSync(fullPath) - const ext = path.extname(name).toLowerCase() + const ext = getFileExt(name).toLowerCase() if (normalizedExts.length > 0 && !normalizedExts.includes(ext)) continue const type = getFileType(ext) @@ -338,7 +402,7 @@ class FileStorage { const filePath = path.join(this.storageDir, id) const data = await fs.promises.readFile(filePath) const base64 = data.toString('base64') - const ext = path.extname(filePath).slice(1) == 'jpg' ? 'jpeg' : path.extname(filePath).slice(1) + const ext = getFileExt(filePath).slice(1) == 'jpg' ? 'jpeg' : getFileExt(filePath).slice(1) const mime = `image/${ext}` return { mime, @@ -401,7 +465,7 @@ class FileStorage { const filePath = path.join(this.storageDir, id) const buffer = await fs.promises.readFile(filePath) const base64 = buffer.toString('base64') - const mime = `application/${path.extname(filePath).slice(1)}` + const mime = `application/${getFileExt(filePath).slice(1)}` return { data: base64, mime } } @@ -411,7 +475,7 @@ class FileStorage { ): Promise<{ data: Buffer; mime: string }> => { const filePath = path.join(this.storageDir, id) const data = await fs.promises.readFile(filePath) - const mime = `image/${path.extname(filePath).slice(1)}` + const mime = `image/${getFileExt(filePath).slice(1)}` return { data, mime } } @@ -587,7 +651,7 @@ class FileStorage { } const uuid = uuidv4() - const ext = path.extname(filename) + const ext = getFileExt(filename) const destPath = path.join(this.storageDir, uuid + ext) // 将响应内容写入文件 diff --git a/src/main/utils/__tests__/dialog-extensions.test.ts b/src/main/utils/__tests__/dialog-extensions.test.ts new file mode 100644 index 00000000..48f25875 --- /dev/null +++ b/src/main/utils/__tests__/dialog-extensions.test.ts @@ -0,0 +1,138 @@ +import { getVideoDialogExtensions, toDialogExtensions, videoExts } from '@shared/config/constant' +import { describe, expect, it } from 'vitest' + +describe('文件对话框扩展名配置测试', () => { + describe('toDialogExtensions', () => { + it('应该移除扩展名数组中的前导点', () => { + const input = ['.mp4', '.avi', '.mov'] + const expected = ['mp4', 'avi', 'mov'] + const result = toDialogExtensions(input) + expect(result).toEqual(expected) + }) + + it('应该保持已经没有点的扩展名不变', () => { + const input = ['mp4', 'avi', 'mov'] + const expected = ['mp4', 'avi', 'mov'] + const result = toDialogExtensions(input) + expect(result).toEqual(expected) + }) + + it('应该处理混合格式的扩展名数组', () => { + const input = ['.mp4', 'avi', '.mov', 'wmv'] + const expected = ['mp4', 'avi', 'mov', 'wmv'] + const result = toDialogExtensions(input) + expect(result).toEqual(expected) + }) + + it('应该处理空数组', () => { + const input: string[] = [] + const expected: string[] = [] + const result = toDialogExtensions(input) + expect(result).toEqual(expected) + }) + + it('应该处理包含多个点的扩展名', () => { + const input = ['..mp4', '...avi', '.mov'] + const expected = ['.mp4', '..avi', 'mov'] + const result = toDialogExtensions(input) + expect(result).toEqual(expected) + }) + }) + + describe('getVideoDialogExtensions', () => { + it('应该返回所有支持的视频扩展名(不含点)', () => { + const result = getVideoDialogExtensions() + + // 验证返回的扩展名都不含前导点 + result.forEach((ext) => { + expect(ext).not.toMatch(/^\./) + }) + + // 验证包含所有预期的视频格式 + const expectedFormats = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'] + expectedFormats.forEach((format) => { + expect(result).toContain(format) + }) + }) + + it('应该与 videoExts 配置保持同步', () => { + const result = getVideoDialogExtensions() + const expectedFromVideoExts = toDialogExtensions(videoExts) + + expect(result).toEqual(expectedFromVideoExts) + }) + + it('返回的扩展名应该适合 Electron dialog 使用', () => { + const result = getVideoDialogExtensions() + + // Electron dialog 的 extensions 数组要求: + // 1. 不能包含前导点 + // 2. 不能包含通配符(除了 '*') + // 3. 不能包含路径分隔符 + result.forEach((ext) => { + expect(ext).not.toMatch(/^\./) // 不能有前导点 + expect(ext).not.toMatch(/[/\\]/) // 不能有路径分隔符 + expect(ext).not.toMatch(/^\*$/) // 不应该是通配符(除非专门指定) + expect(ext.length).toBeGreaterThan(0) // 不能为空 + }) + }) + }) + + describe('Issue #118 回归测试', () => { + it('确保扩展名格式能正确防止 Windows 双点问题', () => { + const dialogExtensions = getVideoDialogExtensions() + + // 这些扩展名将被用于 Electron dialog + // 确保它们的格式不会导致双点问题 + dialogExtensions.forEach((ext) => { + // 验证扩展名格式本身,确保不会导致双点问题 + expect(ext).toMatch(/^[a-z0-9]+$/) // 只包含字母和数字,无特殊字符 + expect(ext).not.toMatch(/^\./) // 确保没有前导点 + }) + }) + + it('验证所有支持的视频格式都包含在内', () => { + const dialogExtensions = getVideoDialogExtensions() + + // 根据 GitHub issue #118,确保包含所有主要视频格式 + const criticalFormats = ['mp4', 'mov', 'avi', 'mkv', 'wmv'] + + criticalFormats.forEach((format) => { + expect(dialogExtensions).toContain(format) + }) + }) + + it('验证扩展名在不同平台上的一致性', () => { + // 这个测试确保我们的扩展名配置在所有平台上都一致 + const dialogExtensions = getVideoDialogExtensions() + + // 应该返回相同的扩展名,无论在什么平台上 + expect(dialogExtensions).toEqual(['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv']) + + // 长度应该匹配原始 videoExts 数组 + expect(dialogExtensions.length).toBe(videoExts.length) + }) + }) + + describe('性能和内存测试', () => { + it('应该缓存结果以提高性能', () => { + const result1 = getVideoDialogExtensions() + const result2 = getVideoDialogExtensions() + + // 验证返回的是相同的数组引用或至少内容相同 + expect(result1).toEqual(result2) + }) + + it('应该处理大量扩展名而不出现性能问题', () => { + const largeExtArray = Array.from({ length: 1000 }, (_, i) => `.ext${i}`) + + const start = performance.now() + const result = toDialogExtensions(largeExtArray) + const end = performance.now() + + // 处理时间应该在合理范围内(小于100ms) + expect(end - start).toBeLessThan(100) + expect(result.length).toBe(1000) + }) + }) +}) diff --git a/src/main/utils/__tests__/file.windows.test.ts b/src/main/utils/__tests__/file.windows.test.ts new file mode 100644 index 00000000..43194096 --- /dev/null +++ b/src/main/utils/__tests__/file.windows.test.ts @@ -0,0 +1,234 @@ +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { getFileExt } from '../file' + +// Mock process.platform for Windows testing +const originalPlatform = process.platform + +describe('Windows 文件扩展名处理测试', () => { + beforeEach(() => { + // Mock Windows platform + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true + }) + }) + + afterEach(() => { + // Restore original platform + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true + }) + }) + + describe('getFileExt - Windows 路径处理', () => { + it('应该正确处理 Windows 反斜杠路径', () => { + const testCases = [ + { input: 'C:\\Users\\user\\Videos\\video.mp4', expected: '.mp4' }, + { input: 'C:\\path\\to\\file.mov', expected: '.mov' }, + { input: 'D:\\media\\test.avi', expected: '.avi' }, + { input: '\\\\server\\share\\video.wmv', expected: '.wmv' }, + { input: 'C:\\folder\\file.mkv', expected: '.mkv' } + ] + + testCases.forEach(({ input, expected }) => { + const result = getFileExt(input) + expect(result).toBe(expected) + }) + }) + + it('应该正确处理混合路径分隔符', () => { + const testCases = [ + { input: 'C:\\Users/user\\Videos/video.mp4', expected: '.mp4' }, + { input: 'C:/Users\\user/Videos\\video.mov', expected: '.mov' }, + { input: 'C:\\folder/subfolder\\file.flv', expected: '.flv' } + ] + + testCases.forEach(({ input, expected }) => { + const result = getFileExt(input) + expect(result).toBe(expected) + }) + }) + + it('应该处理 Windows 驱动器路径前的斜杠', () => { + const testCases = [ + { input: '/C:/Users/user/video.mp4', expected: '.mp4' }, + { input: '/D:/media/file.mov', expected: '.mov' }, + { input: '/E:/data/test.avi', expected: '.avi' } + ] + + testCases.forEach(({ input, expected }) => { + const result = getFileExt(input) + expect(result).toBe(expected) + }) + }) + + it('应该修复双点扩展名问题 (GitHub Issue #118)', () => { + // 模拟可能导致双点的路径情况 + const testCases = [ + { input: 'C:\\Users\\user\\video.mp4', expected: '.mp4', description: '标准路径' }, + { + input: 'C:\\folder\\.hidden\\video.mp4', + expected: '.mp4', + description: '包含隐藏文件夹' + }, + { + input: 'C:\\path\\file.name.with.dots.mp4', + expected: '.mp4', + description: '文件名包含多个点' + }, + { + input: 'C:\\Users\\user\\My.Videos\\test.mov', + expected: '.mov', + description: '目录名包含点' + } + ] + + testCases.forEach(({ input, expected }) => { + const result = getFileExt(input) + expect(result).toBe(expected) + // 确保不会出现双点 + expect(result).not.toMatch(/^\.\..+/) + // 确保只有一个前导点 + if (result.startsWith('.')) { + expect(result.match(/^\./g)?.length).toBe(1) + } + }) + }) + + it('应该处理边缘情况', () => { + const testCases = [ + { input: '', expected: '', description: '空字符串' }, + { input: 'C:\\noext', expected: '', description: '无扩展名' }, + { input: 'C:\\folder\\', expected: '', description: '目录路径' }, + { input: 'C:\\file.', expected: '', description: '扩展名为空' }, + { + input: ' C:\\Users\\user\\video.mp4 ', + expected: '.mp4', + description: '带空格的路径' + }, + { + input: 'C:\\Users\\user\\video.MP4', + expected: '.mp4', + description: '大写扩展名应转为小写' + } + ] + + testCases.forEach(({ input, expected }) => { + const result = getFileExt(input) + expect(result).toBe(expected) + }) + }) + + it('应该与 path.extname 的结果进行对比验证', () => { + const testPaths = [ + 'C:\\Users\\user\\video.mp4', + 'C:/Users/user/video.mov', + '/C:/data/test.avi' + ] + + testPaths.forEach((testPath) => { + const ourResult = getFileExt(testPath) + const pathResult = path.extname(testPath.replace(/\\/g, '/')) + + // 对于正常路径,两种方法应该得到相同结果 + if (!testPath.startsWith('/C:')) { + expect(ourResult.toLowerCase()).toBe(pathResult.toLowerCase()) + } + + // 确保我们的方法总是返回小写 + if (ourResult) { + expect(ourResult).toBe(ourResult.toLowerCase()) + } + }) + }) + + it('应该正确识别所有支持的视频格式', () => { + const videoFormats = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv'] + + videoFormats.forEach((format) => { + const testPath = `C:\\Users\\user\\video${format}` + const result = getFileExt(testPath) + expect(result).toBe(format) + }) + }) + }) + + describe('路径安全性验证', () => { + it('应该拒绝包含路径分隔符的扩展名', () => { + const maliciousPaths = [ + 'C:\\folder\\file.exe/malicious', + 'C:\\folder\\file.txt\\..\\malicious', + 'C:\\folder\\file.mp4/../../../etc/passwd' + ] + + maliciousPaths.forEach((maliciousPath) => { + const result = getFileExt(maliciousPath) + // 确保扩展名不包含路径分隔符 + if (result) { + expect(result).not.toMatch(/[/\\]/) + } + }) + }) + + it('应该处理超长路径', () => { + const longPath = 'C:\\' + 'very\\'.repeat(100) + 'long\\path\\video.mp4' + const result = getFileExt(longPath) + expect(result).toBe('.mp4') + }) + }) +}) + +describe('跨平台兼容性测试', () => { + it('macOS 路径应该正常工作', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true + }) + + const macPaths = [ + '/Users/user/Videos/video.mp4', + '/Volumes/External/video.mov', + '/Applications/Video Player.app/Contents/Resources/sample.avi' + ] + + macPaths.forEach((macPath) => { + const result = getFileExt(macPath) + const expected = path.extname(macPath).toLowerCase() + expect(result).toBe(expected) + }) + + // Restore Windows for other tests + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true + }) + }) + + it('Linux 路径应该正常工作', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true + }) + + const linuxPaths = [ + '/home/user/videos/video.mp4', + '/media/usb/video.mov', + '/tmp/test video with spaces.avi' + ] + + linuxPaths.forEach((linuxPath) => { + const result = getFileExt(linuxPath) + const expected = path.extname(linuxPath).toLowerCase() + expect(result).toBe(expected) + }) + + // Restore Windows for other tests + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true + }) + }) +}) diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 48dea8f9..3c8ffaac 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -60,13 +60,60 @@ export function getFileName(filePath: string) { } /** - * Returns the extension of a file path. + * Returns the extension of a file path with enhanced Windows compatibility. * * @param filePath - The path or filename to inspect. * @returns The file extension including the leading `.` (e.g. `.txt`), or an empty string if the path has no extension. */ -export function getFileExt(filePath: string) { - return path.extname(filePath) +export function getFileExt(filePath: string): string { + if (!filePath) { + return '' + } + + // 标准化路径分隔符,Windows 兼容性处理 + let normalizedPath = filePath.trim() + + // Windows 平台特殊处理 + if (process.platform === 'win32') { + // 将反斜杠转换为正斜杠 + normalizedPath = normalizedPath.replace(/\\/g, '/') + + // 处理 Windows 驱动器路径 (如 /C:/path -> C:/path) + if (normalizedPath.startsWith('/') && normalizedPath.match(/^\/[A-Za-z]:/)) { + normalizedPath = normalizedPath.substring(1) + } + } + + // 使用 Node.js 标准方法获取扩展名 + let ext = path.extname(normalizedPath).toLowerCase() + + // 备用方法:如果 path.extname 返回空但文件名包含点 + if (!ext && normalizedPath.includes('.')) { + const parts = normalizedPath.split('.') + if (parts.length > 1) { + const lastPart = parts[parts.length - 1].toLowerCase() + // 确保最后一部分不是空的且不包含路径分隔符 + if (lastPart && !lastPart.includes('/') && !lastPart.includes('\\')) { + ext = '.' + lastPart + } + } + } + + // 清理异常情况:将多个点转换为单个点 (..mp4 -> .mp4) + if (ext.length > 1) { + const cleanedExt = ext.replace(/^\.+/, '') + if (cleanedExt) { + ext = '.' + cleanedExt + } else { + // 如果清理后为空,则返回空字符串 + ext = '' + } + } else if (ext === '.') { + // 单独的点应该返回空字符串 + ext = '' + } + + return ext } /** diff --git a/src/renderer/src/hooks/useVideoFileSelect.ts b/src/renderer/src/hooks/useVideoFileSelect.ts index 4002170c..1836d951 100644 --- a/src/renderer/src/hooks/useVideoFileSelect.ts +++ b/src/renderer/src/hooks/useVideoFileSelect.ts @@ -3,7 +3,7 @@ import FileManager from '@renderer/services/FileManager' import { VideoLibraryService } from '@renderer/services/VideoLibrary' import { ParallelVideoProcessor } from '@renderer/utils/ParallelVideoProcessor' import { createPerformanceMonitor } from '@renderer/utils/PerformanceMonitor' -import { videoExts } from '@shared/config/constant' +import { getVideoDialogExtensions } from '@shared/config/constant' import { message } from 'antd' import type { FileMetadata, VideoLibraryRecord } from 'packages/shared/types/database' import { useCallback, useState } from 'react' @@ -176,7 +176,7 @@ export function useVideoFileSelect( filters: [ { name: 'Video Files', - extensions: videoExts + extensions: getVideoDialogExtensions() } ] }) diff --git a/src/renderer/src/pages/player/components/VideoErrorRecovery.tsx b/src/renderer/src/pages/player/components/VideoErrorRecovery.tsx index 43445877..cb9b561e 100644 --- a/src/renderer/src/pages/player/components/VideoErrorRecovery.tsx +++ b/src/renderer/src/pages/player/components/VideoErrorRecovery.tsx @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { getVideoDialogExtensions } from '@shared/config/constant' import { Button, Modal, Space } from 'antd' import { AlertTriangle, FileSearch, RotateCcw, Trash2 } from 'lucide-react' import { useCallback, useState } from 'react' @@ -85,7 +86,7 @@ function VideoErrorRecovery({ filters: [ { name: t('player.errorRecovery.fileDialog.videoFiles'), - extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm'] + extensions: getVideoDialogExtensions() }, { name: t('player.errorRecovery.fileDialog.allFiles'), extensions: ['*'] } ] diff --git a/src/renderer/src/utils/file.ts b/src/renderer/src/utils/file.ts index 0c0f5039..f3fea243 100644 --- a/src/renderer/src/utils/file.ts +++ b/src/renderer/src/utils/file.ts @@ -12,17 +12,36 @@ export function getFileDirectory(filePath: string): string { } /** - * 从文件路径中提取文件扩展名。 + * 从文件路径中提取文件扩展名,增强 Windows 兼容性。 * @param {string} filePath 文件路径 - * @returns {string} 文件扩展名(小写),如果没有则返回 '.' + * @returns {string} 文件扩展名(小写),如果没有则返回空字符串 */ export function getFileExtension(filePath: string): string { - const parts = filePath.split('.') + if (!filePath) { + return '' + } + + // 标准化路径分隔符和清理路径 + let normalizedPath = filePath.trim() + + // 处理反斜杠(Windows 路径) + normalizedPath = normalizedPath.replace(/\\/g, '/') + + // 获取文件名部分(移除路径) + const fileName = normalizedPath.split('/').pop() || normalizedPath + + // 分割文件名以获取扩展名 + const parts = fileName.split('.') if (parts.length > 1) { const extension = parts.slice(-1)[0].toLowerCase() - return '.' + extension + + // 验证扩展名有效性:不能为空,不能包含路径分隔符 + if (extension && !extension.includes('/') && !extension.includes('\\')) { + return '.' + extension + } } - return '.' + + return '' } /** From 3f6d7b52742d2aa77d59af71542a4715e7e5d2eb Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 12 Sep 2025 00:49:20 +0000 Subject: [PATCH 2/7] chore(release): 1.0.0-alpha.9 # [1.0.0-alpha.9](https://github.com/mkdir700/EchoPlayer/compare/v1.0.0-alpha.8...v1.0.0-alpha.9) (2025-09-12) ### Bug Fixes * **homepage:** Fix UI desynchronization issue after deleting video records + i18n support ([#120](https://github.com/mkdir700/EchoPlayer/issues/120)) ([7879ef4](https://github.com/mkdir700/EchoPlayer/commit/7879ef442b888d6956a74739c3c0c1c54bb87387)) * **player:** Fix subtitle navigation when activeCueIndex is -1 ([#119](https://github.com/mkdir700/EchoPlayer/issues/119)) ([b4ad16f](https://github.com/mkdir700/EchoPlayer/commit/b4ad16f2115d324aabd34b08b2a05ca98a3de101)) * **player:** Fix subtitle overlay dragging to bottom and improve responsive design ([#122](https://github.com/mkdir700/EchoPlayer/issues/122)) ([d563c92](https://github.com/mkdir700/EchoPlayer/commit/d563c924c9471caeeab45a3d2ddd6dda5520fcac)) * **player:** Prevent subtitle overlay interactions from triggering video play/pause ([#128](https://github.com/mkdir700/EchoPlayer/issues/128)) ([9730ba1](https://github.com/mkdir700/EchoPlayer/commit/9730ba1184cffe2012dd30e9207a562e88eec140)) * **ui:** Remove white border shadow from modal buttons in dark mode ([#124](https://github.com/mkdir700/EchoPlayer/issues/124)) ([29f70f6](https://github.com/mkdir700/EchoPlayer/commit/29f70f66806f3dc0e3e473a9b3b27867cf67ac0d)) ### Features * **performance:** implement video import performance optimization with parallel processing and warmup strategies ([#121](https://github.com/mkdir700/EchoPlayer/issues/121)) ([2c65f5a](https://github.com/mkdir700/EchoPlayer/commit/2c65f5ae92460391302c24f3fb291f386043e7cd)) * **player:** Implement fullscreen toggle functionality with keyboard shortcuts ([#127](https://github.com/mkdir700/EchoPlayer/issues/127)) ([78d3629](https://github.com/mkdir700/EchoPlayer/commit/78d3629c7d5a14e8bc378967a7f161135c5b5042)) * **scripts:** optimize FFmpeg download progress display ([#125](https://github.com/mkdir700/EchoPlayer/issues/125)) ([be33316](https://github.com/mkdir700/EchoPlayer/commit/be33316f0a66f7b5b2de64d275d7166f12f50379)) --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 100e8ff4..5aa32ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# [1.0.0-alpha.9](https://github.com/mkdir700/EchoPlayer/compare/v1.0.0-alpha.8...v1.0.0-alpha.9) (2025-09-12) + +### Bug Fixes + +- **homepage:** Fix UI desynchronization issue after deleting video records + i18n support ([#120](https://github.com/mkdir700/EchoPlayer/issues/120)) ([7879ef4](https://github.com/mkdir700/EchoPlayer/commit/7879ef442b888d6956a74739c3c0c1c54bb87387)) +- **player:** Fix subtitle navigation when activeCueIndex is -1 ([#119](https://github.com/mkdir700/EchoPlayer/issues/119)) ([b4ad16f](https://github.com/mkdir700/EchoPlayer/commit/b4ad16f2115d324aabd34b08b2a05ca98a3de101)) +- **player:** Fix subtitle overlay dragging to bottom and improve responsive design ([#122](https://github.com/mkdir700/EchoPlayer/issues/122)) ([d563c92](https://github.com/mkdir700/EchoPlayer/commit/d563c924c9471caeeab45a3d2ddd6dda5520fcac)) +- **player:** Prevent subtitle overlay interactions from triggering video play/pause ([#128](https://github.com/mkdir700/EchoPlayer/issues/128)) ([9730ba1](https://github.com/mkdir700/EchoPlayer/commit/9730ba1184cffe2012dd30e9207a562e88eec140)) +- **ui:** Remove white border shadow from modal buttons in dark mode ([#124](https://github.com/mkdir700/EchoPlayer/issues/124)) ([29f70f6](https://github.com/mkdir700/EchoPlayer/commit/29f70f66806f3dc0e3e473a9b3b27867cf67ac0d)) + +### Features + +- **performance:** implement video import performance optimization with parallel processing and warmup strategies ([#121](https://github.com/mkdir700/EchoPlayer/issues/121)) ([2c65f5a](https://github.com/mkdir700/EchoPlayer/commit/2c65f5ae92460391302c24f3fb291f386043e7cd)) +- **player:** Implement fullscreen toggle functionality with keyboard shortcuts ([#127](https://github.com/mkdir700/EchoPlayer/issues/127)) ([78d3629](https://github.com/mkdir700/EchoPlayer/commit/78d3629c7d5a14e8bc378967a7f161135c5b5042)) +- **scripts:** optimize FFmpeg download progress display ([#125](https://github.com/mkdir700/EchoPlayer/issues/125)) ([be33316](https://github.com/mkdir700/EchoPlayer/commit/be33316f0a66f7b5b2de64d275d7166f12f50379)) + # [1.0.0-alpha.8](https://github.com/mkdir700/EchoPlayer/compare/v1.0.0-alpha.7...v1.0.0-alpha.8) (2025-09-10) ### Features diff --git a/package.json b/package.json index ba9cec76..92a27f60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "echoplayer", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "description": "EchoPlayer is a video player designed for language learners, helping users learn foreign languages efficiently through sentence-by-sentence intensive listening.", "main": "./out/main/index.js", "author": "echoplayer.cc", From 5e487c40b33eabbf16760a7371af855d4d336687 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Mon, 8 Sep 2025 20:43:14 +0800 Subject: [PATCH 3/7] Update README.md --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 496826a4..e50518f1 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,10 @@ pnpm test:ui ## 🙏 致谢 -- [Cherry Studio](https://github.com/CherryHQ/cherry-studio) - 一款为创造而生的 AI 助手 +| 项目名 | 简介 | +| --- | --- | +| [Cherry Studio](https://github.com/CherryHQ/cherry-studio) | 一款为创造而生的 AI 助手 | +| [DashPlayer](https://github.com/solidSpoon/DashPlayer) | 为英语学习者量身打造的视频播放器 | --- From 1906dfee0f7af82211fbd114e04980728b78c2b7 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Fri, 12 Sep 2025 10:49:38 +0800 Subject: [PATCH 4/7] ci(sync): add workflow to sync main to beta (#132) --- .github/workflows/sync-main-to-beta.yml | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/sync-main-to-beta.yml diff --git a/.github/workflows/sync-main-to-beta.yml b/.github/workflows/sync-main-to-beta.yml new file mode 100644 index 00000000..3785b4e6 --- /dev/null +++ b/.github/workflows/sync-main-to-beta.yml @@ -0,0 +1,37 @@ +name: Sync main -> beta + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create sync branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin beta + git checkout -b chore/sync-main-to-beta origin/beta + # 线性历史优先:尝试 fast-forward 或 rebase + git merge --ff-only origin/main || git rebase origin/main + git push -u origin chore/sync-main-to-beta + + - name: Open PR main -> beta + uses: peter-evans/create-pull-request@v6 + with: + title: 'chore(sync): main -> beta' + body: 'Auto sync main into beta after a merge to main.' + base: beta + branch: chore/sync-main-to-beta + labels: sync, automated + draft: false From 5c03602fcc39c157c0d7e5ffba5f95ab3c10c213 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Fri, 12 Sep 2025 10:52:35 +0800 Subject: [PATCH 5/7] ci: add dev release workflow (#131) --- .github/workflows/dev-release.yml | 85 +++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/dev-release.yml diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 00000000..ac75ca01 --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,85 @@ +name: Dev Release + +run-name: '🚀 Dev Release' + +on: + workflow_dispatch: + inputs: + version: + description: 'Version for dev release (optional)' + required: false + default: '' + platform: + description: 'Target platform for dev release' + required: true + default: macos + type: choice + options: + - macos + - linux + - windows + +permissions: + contents: write + pull-requests: read + +jobs: + dev-release: + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - platform: macos + os: macos-latest + target: --mac + - platform: linux + os: ubuntu-latest + target: --linux + - platform: windows + os: windows-latest + target: --win + if: matrix.platform == github.event.inputs.platform + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + + - name: Install dependencies + run: pnpm install + + - name: Set version + if: ${{ github.event.inputs.version != '' }} + run: | + node -e "const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json','utf8'));pkg.version='${{ github.event.inputs.version }}';fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)+'\n');" + + - name: Build dev package + run: | + pnpm build + pnpm exec electron-builder ${{ matrix.target }} --publish never + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ELECTRON_BUILDER_CHANNEL: dev + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.platform }}-dev-artifacts + path: | + dist/*.exe + dist/*.dmg + dist/*.zip + dist/*.AppImage + dist/*.deb + dist/*.yml + dist/*.yaml + dist/*.blockmap + retention-days: 30 From 901bfdd290259b419c0e0a89ef35dbdc4db13d54 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Fri, 12 Sep 2025 11:36:58 +0800 Subject: [PATCH 6/7] ci: Update dev-release.yml --- .github/workflows/dev-release.yml | 219 +++++++++++++++++++++++++----- 1 file changed, 185 insertions(+), 34 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index ac75ca01..88c35215 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -1,44 +1,26 @@ -name: Dev Release - -run-name: '🚀 Dev Release' +name: Dev Release Build on: workflow_dispatch: inputs: - version: - description: 'Version for dev release (optional)' - required: false - default: '' platform: - description: 'Target platform for dev release' + description: 'Target platform' required: true - default: macos type: choice options: - macos - linux - windows - -permissions: - contents: write - pull-requests: read + version: + description: 'Version to set (optional)' + required: false + type: string jobs: - dev-release: - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - platform: macos - os: macos-latest - target: --mac - - platform: linux - os: ubuntu-latest - target: --linux - - platform: windows - os: windows-latest - target: --win - if: matrix.platform == github.event.inputs.platform + build-macos: + runs-on: macos-latest + if: ${{ inputs.platform == 'macos' }} + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -52,34 +34,203 @@ jobs: uses: pnpm/action-setup@v3 with: version: 8 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- - name: Install dependencies run: pnpm install - name: Set version - if: ${{ github.event.inputs.version != '' }} + if: ${{ inputs.version != '' }} + shell: bash run: | - node -e "const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json','utf8'));pkg.version='${{ github.event.inputs.version }}';fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)+'\n');" + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '${{ inputs.version }}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + console.log(\`Version set to \${pkg.version}\`); + " - - name: Build dev package + - name: Build macOS package run: | pnpm build - pnpm exec electron-builder ${{ matrix.target }} --publish never + pnpm exec electron-builder --mac --publish never env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ELECTRON_BUILDER_CHANNEL: dev + - name: List build artifacts + shell: bash + run: | + echo "Build artifacts:" + find dist -type f -name "*" | head -20 + - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: ${{ matrix.platform }}-dev-artifacts + name: macos-dev-artifacts path: | - dist/*.exe dist/*.dmg dist/*.zip + dist/*.yml + dist/*.yaml + dist/*.blockmap + retention-days: 30 + if-no-files-found: warn + + build-linux: + runs-on: ubuntu-latest + if: ${{ inputs.platform == 'linux' }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Set version + if: ${{ inputs.version != '' }} + shell: bash + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '${{ inputs.version }}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + console.log(\`Version set to \${pkg.version}\`); + " + + - name: Build Linux package + run: | + pnpm build + pnpm exec electron-builder --linux --publish never + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ELECTRON_BUILDER_CHANNEL: dev + + - name: List build artifacts + shell: bash + run: | + echo "Build artifacts:" + find dist -type f -name "*" | head -20 + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-dev-artifacts + path: | dist/*.AppImage dist/*.deb dist/*.yml dist/*.yaml dist/*.blockmap retention-days: 30 + if-no-files-found: warn + + build-windows: + runs-on: windows-latest + if: ${{ inputs.platform == 'windows' }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Set version + if: ${{ inputs.version != '' }} + shell: bash + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '${{ inputs.version }}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + console.log(\`Version set to \${pkg.version}\`); + " + + - name: Build Windows package + run: | + pnpm build + pnpm exec electron-builder --win --publish never + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ELECTRON_BUILDER_CHANNEL: dev + + - name: List build artifacts + shell: bash + run: | + echo "Build artifacts:" + find dist -type f -name "*" | head -20 + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-dev-artifacts + path: | + dist/*.exe + dist/*.yml + dist/*.yaml + dist/*.blockmap + retention-days: 30 + if-no-files-found: warn From 336309645838693e1ae4ce3fcb92a3386c53e424 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Fri, 12 Sep 2025 12:14:54 +0800 Subject: [PATCH 7/7] Revert "ci(sync): add workflow to sync main to beta (#132)" This reverts commit b75ffc9033ad1079204847246e71573dccd5d7a6. --- .github/workflows/sync-main-to-beta.yml | 37 ------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/sync-main-to-beta.yml diff --git a/.github/workflows/sync-main-to-beta.yml b/.github/workflows/sync-main-to-beta.yml deleted file mode 100644 index 3785b4e6..00000000 --- a/.github/workflows/sync-main-to-beta.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Sync main -> beta - -on: - push: - branches: [main] - -permissions: - contents: write - pull-requests: write - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Create sync branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git fetch origin beta - git checkout -b chore/sync-main-to-beta origin/beta - # 线性历史优先:尝试 fast-forward 或 rebase - git merge --ff-only origin/main || git rebase origin/main - git push -u origin chore/sync-main-to-beta - - - name: Open PR main -> beta - uses: peter-evans/create-pull-request@v6 - with: - title: 'chore(sync): main -> beta' - body: 'Auto sync main into beta after a merge to main.' - base: beta - branch: chore/sync-main-to-beta - labels: sync, automated - draft: false