diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 00000000..88c35215 --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,236 @@ +name: Dev Release Build + +on: + workflow_dispatch: + inputs: + platform: + description: 'Target platform' + required: true + type: choice + options: + - macos + - linux + - windows + version: + description: 'Version to set (optional)' + required: false + type: string + +jobs: + build-macos: + runs-on: macos-latest + if: ${{ inputs.platform == 'macos' }} + + 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 macOS package + run: | + pnpm build + 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: macos-dev-artifacts + path: | + 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 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/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) | 为英语学习者量身打造的视频播放器 | --- 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", 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 '' } /**