Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
63594da
fix(player): apply playback rate change through orchestrator when cyc…
mkdir700 Oct 13, 2025
6fc1916
feat(player): HLS session progress polling with media server integrat…
mkdir700 Oct 13, 2025
11c8d94
feat(media-server): add transcode cache cleanup for deleted videos
mkdir700 Oct 13, 2025
76c351e
fix(player): remove HLS player missing error handling
mkdir700 Oct 13, 2025
843b9b6
fix(codec-compatibility): handle missing codec information gracefully
mkdir700 Oct 13, 2025
a21b995
fix(UvBootstrapperService): ensure temp directory cleanup after download
mkdir700 Oct 13, 2025
13c6b7e
fix(VolumeIndicator): skip indicator display on initial render
mkdir700 Oct 13, 2025
08b96eb
fix(UvBootstrapperService): enhance UV download logic with cached pat…
mkdir700 Oct 13, 2025
382f6bf
fix(FFprobeSection): ensure timeout cleanup after download success
mkdir700 Oct 13, 2025
90082f8
fix(FFprobeSection): standardize spacing in styled components
mkdir700 Oct 13, 2025
2905bf6
fix(FFmpegSection): manage completion timeout for download process
mkdir700 Oct 13, 2025
07f4f3d
fix(TranscodeLoadingIndicator): remove logging for loading indicator …
mkdir700 Oct 13, 2025
ca04424
fix(FFprobeSection): add return statement to download progress pollin…
mkdir700 Oct 13, 2025
0100591
fix(FFprobeSection): manage success timeout for download completion
mkdir700 Oct 13, 2025
0303acb
fix(FFprobeSection): standardize font size using theme constants
mkdir700 Oct 13, 2025
290cabe
fix(MediaServerService): replace fs.existsSync with async stat for fi…
mkdir700 Oct 13, 2025
ef08d35
fix(UvBootstrapperService): prevent concurrent downloads by checking …
mkdir700 Oct 13, 2025
1cee791
fix(MediaServerService): enhance error handling for file existence check
mkdir700 Oct 13, 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
1 change: 1 addition & 0 deletions packages/shared/IpcChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export enum IpcChannel {
MediaServer_GetInfo = 'media-server:get-info',
MediaServer_GetPort = 'media-server:get-port',
MediaServer_CheckHealth = 'media-server:check-health',
MediaServer_CleanupCachesForFile = 'media-server:cleanup-caches-for-file',
MediaServer_PortChanged = 'media-server:port-changed', // 端口变更事件

// MediaInfo 相关 IPC 通道 / MediaInfo related IPC channels
Expand Down
3 changes: 3 additions & 0 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.MediaServer_GetPort, async () => {
return mediaServerService.getPort()
})
ipcMain.handle(IpcChannel.MediaServer_CleanupCachesForFile, async (_, filePath: string) => {
return await mediaServerService.cleanupCachesForFile(filePath)
})

// MediaParser (Remotion)
ipcMain.handle(IpcChannel.MediaInfo_CheckExists, async () => {
Expand Down
110 changes: 110 additions & 0 deletions src/main/services/MediaServerService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { createHash } from 'node:crypto'
import { open, rm, stat } from 'node:fs/promises'

import { IpcChannel } from '@shared/IpcChannel'
import type { MediaServerInfo, MediaServerStatus } from '@shared/types'
import { ChildProcess, spawn } from 'child_process'
Expand Down Expand Up @@ -54,6 +57,20 @@ export interface MediaServerConfig {
audioTrackTtlHours?: number // 音频轨道 TTL(小时)
}

export interface TranscodeCacheCleanupResult {
assetHash: string
hls: {
path: string
removed: boolean
error?: string
}
audio: {
path: string
removed: boolean
error?: string
}
}

/**
* Media Server 管理服务
* 负责启动、停止和监控 Media Server 进程
Expand Down Expand Up @@ -513,6 +530,99 @@ export class MediaServerService {
return this.port
}

/**
* 清理指定文件对应的转码缓存
* @param filePath 原始视频文件路径
*/
public async cleanupCachesForFile(filePath: string): Promise<TranscodeCacheCleanupResult> {
const resolvedPath = path.resolve(filePath)
try {
await stat(resolvedPath)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'ENOENT') {
throw new Error(`文件不存在: ${resolvedPath}`)
}
throw err
}

const assetHash = await this.calculateAssetHash(resolvedPath)
const dataPath = getDataPath()
const mediaServerCachePath = path.join(dataPath, 'MediaServerCache')
const hlsDir = path.join(mediaServerCachePath, 'hls-segments', assetHash)
const audioDir = path.join(mediaServerCachePath, 'audio-cache', assetHash)

logger.info('清理转码缓存', {
filePath: resolvedPath,
assetHash,
hlsDir,
audioDir
})

const hlsResult = await this.removeDirectoryIfExists(hlsDir)
const audioResult = await this.removeDirectoryIfExists(audioDir)

return {
assetHash,
hls: {
path: hlsDir,
...hlsResult
},
audio: {
path: audioDir,
...audioResult
}
}
}

private async calculateAssetHash(filePath: string): Promise<string> {
const resolvedPath = path.resolve(filePath)
const fileStat = await stat(resolvedPath)
const hash = createHash('sha256')

hash.update(resolvedPath)
hash.update(fileStat.size.toString())
hash.update(Math.floor(fileStat.mtimeMs / 1000).toString())

const sampleSize = 8 * 1024 * 1024
const fileSize = Number(fileStat.size)
const fileHandle = await open(resolvedPath, 'r')

try {
if (fileSize <= sampleSize * 2) {
const buffer = Buffer.alloc(fileSize)
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, 0)
hash.update(buffer.subarray(0, bytesRead))
} else {
const headBuffer = Buffer.alloc(sampleSize)
await fileHandle.read(headBuffer, 0, sampleSize, 0)
hash.update(headBuffer)

const tailBuffer = Buffer.alloc(sampleSize)
await fileHandle.read(tailBuffer, 0, sampleSize, fileSize - sampleSize)
hash.update(tailBuffer)
}
} finally {
await fileHandle.close()
}

return hash.digest('hex').slice(0, 16)
}

private async removeDirectoryIfExists(
dirPath: string
): Promise<{ removed: boolean; error?: string }> {
try {
await rm(dirPath, { recursive: true, force: true })
logger.info('已删除转码缓存目录', { dirPath })
return { removed: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn('删除转码缓存目录失败', { dirPath, error: message })
return { removed: false, error: message }
}
}
Comment on lines +612 to +624
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.

⚠️ Potential issue | 🟠 Major

removeDirectoryIfExists 对不存在目录也返回 removed:true,语义不准确

rm(..., { force: true }) 在路径不存在时不会抛错,当前实现会误报“已删除”。建议先检测存在性,区分“未找到”与“删除失败”。

应用此修正:

-  private async removeDirectoryIfExists(
-    dirPath: string
-  ): Promise<{ removed: boolean; error?: string }> {
-    try {
-      await rm(dirPath, { recursive: true, force: true })
-      logger.info('已删除转码缓存目录', { dirPath })
-      return { removed: true }
-    } catch (error) {
-      const message = error instanceof Error ? error.message : String(error)
-      logger.warn('删除转码缓存目录失败', { dirPath, error: message })
-      return { removed: false, error: message }
-    }
-  }
+  private async removeDirectoryIfExists(
+    dirPath: string
+  ): Promise<{ removed: boolean; error?: string }> {
+    try {
+      await stat(dirPath) // 不存在将抛 ENOENT
+    } catch (err) {
+      if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+        return { removed: false }
+      }
+      const message = err instanceof Error ? err.message : String(err)
+      logger.warn('检查转码缓存目录时出错', { dirPath, error: message })
+      return { removed: false, error: message }
+    }
+    try {
+      await rm(dirPath, { recursive: true, force: true })
+      logger.info('已删除转码缓存目录', { dirPath })
+      return { removed: true }
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error)
+      logger.warn('删除转码缓存目录失败', { dirPath, error: message })
+      return { removed: false, error: message }
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async removeDirectoryIfExists(
dirPath: string
): Promise<{ removed: boolean; error?: string }> {
try {
await rm(dirPath, { recursive: true, force: true })
logger.info('已删除转码缓存目录', { dirPath })
return { removed: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn('删除转码缓存目录失败', { dirPath, error: message })
return { removed: false, error: message }
}
}
private async removeDirectoryIfExists(
dirPath: string
): Promise<{ removed: boolean; error?: string }> {
try {
await stat(dirPath) // 不存在将抛 ENOENT
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { removed: false }
}
const message = err instanceof Error ? err.message : String(err)
logger.warn('检查转码缓存目录时出错', { dirPath, error: message })
return { removed: false, error: message }
}
try {
await rm(dirPath, { recursive: true, force: true })
logger.info('已删除转码缓存目录', { dirPath })
return { removed: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn('删除转码缓存目录失败', { dirPath, error: message })
return { removed: false, error: message }
}
}
🤖 Prompt for AI Agents
In src/main/services/MediaServerService.ts around lines 612-624, the current
removeDirectoryIfExists uses rm(..., { force: true }) which returns removed:
true even when the path doesn't exist; change the logic to first check for
directory existence (e.g., fs.promises.stat or fs.promises.access) and if the
path is missing log and return { removed: false, error: 'not found' } (or
similar sentinel) so callers can distinguish "not found" vs "deleted"; if it
exists proceed to rm(..., { recursive: true, force: true }), log success and
return { removed: true }, and on catch capture and return the error message as
currently done.


/**
* 等待服务器启动
*/
Expand Down
31 changes: 19 additions & 12 deletions src/main/services/UvBootstrapperService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,15 +413,24 @@ export class UvBootstrapperService {
): Promise<boolean> {
const key = `${platform}-${arch}`

// 检查是否已存在
const installation = await this.checkUvInstallation()
if (installation.exists && installation.isDownloaded) {
logger.info('uv 已存在,跳过下载', { platform, arch, path: installation.path })
// 检查目标平台的缓存二进制
const downloadedPath = this.getDownloadedUvPath(platform, arch)
if (downloadedPath) {
logger.info('uv 已存在,跳过下载', { platform, arch, path: downloadedPath })
return true
}

// 仅当请求的平台与当前进程一致时,才额外使用缓存的系统检测结果
if (platform === process.platform && arch === process.arch) {
const installation = await this.checkUvInstallation()
if (installation.exists && installation.isDownloaded) {
logger.info('uv 已存在,跳过下载', { platform, arch, path: installation.path })
return true
}
}

// 检查是否正在下载
if (this.downloadProgress.has(key)) {
if (this.downloadProgress.has(key) || this.downloadController.has(key)) {
logger.warn('uv 正在下载中', { platform, arch })
return false
}
Expand Down Expand Up @@ -466,12 +475,12 @@ export class UvBootstrapperService {

this.downloadProgress.set(key, progress)

const platformDir = `${version.version}-${platform}-${arch}`
const targetDir = path.join(this.binariesDir, platformDir)
const tempDir = path.join(this.binariesDir, '.temp', key)

Comment on lines +478 to +481
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.

⚠️ Potential issue | 🟠 Major

缺少下载件完整性校验(SHA‑256),建议增加校验并在不匹配时中止与清理

当前直接解压运行下载产物,存在供应链风险。建议若 UvVersion.sha256 提供时,下载后先校验哈希,不匹配则清理并报错。

可在“下载文件”与“解压文件”之间插入校验逻辑:

// 下载文件后,添加以下校验(若提供 sha256)
if (version.sha256) {
  const computed = await new Promise<string>((resolve, reject) => {
    const { createHash } = require('node:crypto')
    const { createReadStream } = require('node:fs')
    const hash = createHash('sha256')
    const rs = createReadStream(downloadPath)
    rs.on('data', (c: Buffer) => hash.update(c))
    rs.on('error', reject)
    rs.on('end', () => resolve(hash.digest('hex')))
  })
  if (computed.toLowerCase() !== version.sha256.toLowerCase()) {
    logger.error('uv 包校验失败,哈希不匹配', {
      expected: version.sha256,
      actual: computed
    })
    // 失败时清理临时目录并中止
    this.cleanupTempDir(tempDir)
    throw new Error('uv 下载包校验失败(SHA-256 不匹配)')
  }
}

同时可考虑:

  • 将最终二进制移动采用 rename(同盘更快),失败再回退 copy。
  • 对下载超时/重试策略做指数退避(可后续迭代)。

Also applies to: 488-502, 503-566, 580-581

🤖 Prompt for AI Agents
In src/main/services/UvBootstrapperService.ts around lines 478-481 (also apply
same change to 488-502, 503-566, 580-581): after the download step and before
any extraction/run, compute the SHA-256 of the downloaded file when
version.sha256 is present, compare it case-insensitively to version.sha256, and
if they differ log an error with expected/actual, clean up the temporary
download directory (call existing cleanupTempDir or equivalent) and throw an
Error to abort; ensure the hash computation streams the file (no buffering whole
file), and perform the check in each code path that proceeds to extract or move
the downloaded artifact (replace direct extract/move with the guarded version).

try {
// 创建目标目录
const platformDir = `${version.version}-${platform}-${arch}`
const targetDir = path.join(this.binariesDir, platformDir)
const tempDir = path.join(this.binariesDir, '.temp', key)

this.ensureDir(targetDir)
this.ensureDir(tempDir)

Expand Down Expand Up @@ -550,9 +559,6 @@ export class UvBootstrapperService {

logger.info('uv 下载完成', { platform, arch, finalPath })

// 清理临时文件
this.cleanupTempDir(tempDir)

// 清除缓存以便重新检测
UvBootstrapperService.clearUvCache(platform, arch)

Expand All @@ -571,6 +577,7 @@ export class UvBootstrapperService {
} finally {
this.downloadProgress.delete(key)
this.downloadController.delete(key)
this.cleanupTempDir(tempDir)
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,9 @@ const api = {
uptime?: number
error?: string
}> => ipcRenderer.invoke(IpcChannel.MediaServer_GetInfo),
getPort: (): Promise<number | null> => ipcRenderer.invoke(IpcChannel.MediaServer_GetPort)
getPort: (): Promise<number | null> => ipcRenderer.invoke(IpcChannel.MediaServer_GetPort),
cleanupCachesForFile: (filePath: string) =>
ipcRenderer.invoke(IpcChannel.MediaServer_CleanupCachesForFile, filePath)
},
Comment on lines +335 to 338
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

为 cleanupCachesForFile 补齐强类型定义,提升调用端类型安全

建议在 packages/shared 导出 TranscodeCacheCleanupResult,preload 侧将返回值声明为该类型,renderer 获得完善的类型提示。

示例:

  • 在 packages/shared/types/media-server.ts 导出类型
  • 此处改为:
cleanupCachesForFile: (filePath: string): Promise<TranscodeCacheCleanupResult> =>
  ipcRenderer.invoke(IpcChannel.MediaServer_CleanupCachesForFile, filePath)
🤖 Prompt for AI Agents
In src/preload/index.ts around lines 335-338, the cleanupCachesForFile IPC
wrapper lacks a strong return type; export TranscodeCacheCleanupResult from
packages/shared/types/media-server.ts and change the preload declaration to
return Promise<TranscodeCacheCleanupResult> so the renderer gets full type hints
— update imports in preload to reference the shared type and annotate
cleanupCachesForFile accordingly.

fs: {
checkFileExists: (filePath: string): Promise<boolean> =>
Expand Down
32 changes: 32 additions & 0 deletions src/renderer/src/pages/home/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { loggerService } from '@logger'
import FFmpegDownloadPrompt from '@renderer/components/FFmpegDownloadPrompt'
import FileManager from '@renderer/services/FileManager'
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.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

删除流程增强方向正确,但需两点完善:避免闭包更新与替换 Emoji 图标

  • 状态更新避免闭包旧值:
    • setCachedVideos(cachedVideos.filter(...)) 可能用到过期的 cachedVideos。改为基于最新 store 值计算:
      const { cachedVideos: curr } = useVideoListStore.getState()
      setCachedVideos(curr.filter((v) => v.id !== video.id))
  • UI 规范:项目禁止使用 Emoji 作为图标,建议用 lucide-react 的 AlertTriangle 并替换内联硬编码尺寸为设计 token/styled。
    • 引入:
      import { AlertTriangle } from 'lucide-react'
    • 将确认弹窗中的 “⚠️ …” 替换为:
      <p style={{ color: 'var(--ant-color-warning)', fontSize: '14px', marginTop: '8px' }}>
        <AlertTriangle size={14} style={{ marginRight: 6 }} />
        {t('home.delete.confirm_warning')}
      </p>
    如需去除内联 px,可抽出 styled 并用主题 token。

另外,请确认 FileManager.getFile(record.fileId) 在 renderer 层存在并返回包含 path 的对象。

为验证 FileManager.getFile 的可用性,可在仓库根运行:

Also applies to: 169-181, 193-207


🏁 Script executed:

#!/bin/bash
# 查找 renderer 侧 FileManager 并确认导出 getFile
fd -t f -a "FileManager.(ts|tsx|js)" src/renderer 2>/dev/null
rg -n -C2 "class\\s+FileManager|export\\s+(default\\s+)?class\\s+FileManager|export\\s+(const|function|class)\\s+FileManager" -g "src/renderer/**"
rg -n -C2 "getFile\\s*\\(" -g "src/renderer/**"

Length of output: 2995


为验证 setCachedVideos 闭包更新和 Emoji 警告图标使用,请运行:


🏁 Script executed:

#!/bin/bash
rg -n -C3 "setCachedVideos" -g "src/renderer/src/pages/home/HomePage.tsx"
rg -n -C3 "cachedVideos" -g "src/renderer/src/pages/home/HomePage.tsx"
rg -n "⚠️" -g "src/renderer/src/pages/home/HomePage.tsx"

Length of output: 4760


优化删除流程:函数式更新 store 与替换 Emoji 图标

  • 使用函数式更新避免过期缓存:

    setCachedVideos(curr => curr.filter(v => v.id !== video.id))

    替代 cachedVideos.filter(...),确保始终基于最新状态。

  • 按项目规范用 lucide-react 图标替换 Emoji:

    1. 引入:
      import { AlertTriangle } from 'lucide-react'
    2. 将页面中 ⚠️ {t('home.delete.confirm_warning')} 抽成 styled 组件,例如:
      const Warning = styled.p`
        display: flex;
        color: var(--ant-color-warning);
        font-size: ${FONT_SIZES.md};
        margin-top: ${SPACING.sm};
      `
      
      <Warning>
        <AlertTriangle size={FONT_SIZES.md} style={{ marginRight: SPACING.xs }} />
        {t('home.delete.confirm_warning')}
      </Warning>

FileManager.getFile 已验证,无需改动。

🤖 Prompt for AI Agents
In src/renderer/src/pages/home/HomePage.tsx around line 3, update the delete
flow to use a functional state update and replace the emoji warning with a
lucide-react icon: change any setCachedVideos(cachedVideos.filter(...)) to the
functional form setCachedVideos(curr => curr.filter(v => v.id !== video.id)),
add an import for AlertTriangle from 'lucide-react' at the top, create a small
styled Warning component (flex layout, warning color, font-size and margin using
existing FONT_SIZES and SPACING constants) and render <AlertTriangle ... />
inside that Warning with spacing to the left of the translated warning text
instead of the emoji; no changes needed to FileManager.getFile.

import HomePageVideoService, { type HomePageVideoItem } from '@renderer/services/HomePageVideos'
import { VideoLibraryService } from '@renderer/services/VideoLibrary'
import { useSettingsStore } from '@renderer/state/stores/settings.store'
Expand Down Expand Up @@ -164,6 +165,21 @@ export function HomePage(): React.JSX.Element {
onOk: async () => {
try {
const videoLibraryService = new VideoLibraryService()
let filePath: string | null = null

try {
const record = await videoLibraryService.getRecordById(video.id)
if (record?.fileId) {
const fileInfo = await FileManager.getFile(record.fileId)
filePath = fileInfo?.path ?? null
}
} catch (lookupError) {
logger.warn('获取视频文件信息失败,跳过转码缓存清理', {
error: lookupError,
videoId: video.id
})
}

await videoLibraryService.deleteRecord(video.id)

// 从本地状态中移除该视频
Expand All @@ -173,6 +189,22 @@ export function HomePage(): React.JSX.Element {
setCachedVideos(cachedVideos.filter((v) => v.id !== video.id))

message.success(t('home.delete.success_message'))

if (filePath) {
void window.api.mediaServer
.cleanupCachesForFile(filePath)
.then((result) => {
logger.info('已清理转码缓存', { result, videoId: video.id })
})
.catch((cleanupError) => {
logger.warn('清理转码缓存失败', {
error:
cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
filePath,
videoId: video.id
})
})
}
} catch (error) {
logger.error('删除视频记录失败', { error })
message.error(t('home.delete.error_message'))
Expand Down
Loading
Loading