Skip to content

feat: Add batch PV conversion tool#63

Open
munet-rei[bot] wants to merge 2 commits into
mainfrom
feat/batch-convert-pv
Open

feat: Add batch PV conversion tool#63
munet-rei[bot] wants to merge 2 commits into
mainfrom
feat/batch-convert-pv

Conversation

@munet-rei
Copy link
Copy Markdown

@munet-rei munet-rei Bot commented May 21, 2026

概要

工具页新增一个批量 PV 转换的入口,可以一次性把游戏内 StreamingAssets/A###/MovieData 下的所有 PV 在两种格式之间互转:

  • USM/DAT → MP4:在原目录生成同名 .mp4,源文件保留;
  • MP4 → USM/DAT:先输出到临时文件并验证,再原子替换出 .dat,源 .mp4 移入回收站(而非永久删除)。

整体进度(已处理 / 总数)与当前文件进度通过 SSE 实时推送,配套有取消按钮(在当前文件完成后生效)。沿用现有 VideoConvertModalfetchEventSource 模式。

改动

后端

  • Controllers/Tools/VideoConvertToolController.cs 新增 BatchConvertPvTool([FromQuery] BatchConvertPvDirection direction)
  • 通过 StaticSettings.AssetsDirs 直接枚举磁盘,避开 MovieDataMap 同 ID 去重导致的漏处理
  • 所有 SSE 写入走单写者 Channel<string>,避免 Xabe 同步进度事件并发触发 Response.WriteAsync 交织
  • MP4 → USM 走 "临时文件 → 校验 → 原子替换 → 回收站" 的安全路径,取消(RequestAborted)发生时不会破坏源文件
  • 处理完毕在 finally 里再 ScanMovieData() 一次同步 MovieDataMap
  • 赞助功能门控(IapManager.License == Active
  • 进度 payload 用 JsonNamingPolicy.CamelCase,与前端命名习惯一致

前端

  • 新增 Front/src/views/Tools/BatchVideoConvertModal.tsx:三步态 Modal(Configure → Progress → Done),双进度条 + 失败文件折叠列表 + 取消按钮
  • Front/src/views/Tools/index.tsx 增加第四张工具卡 tools.batchPv.label
  • "无文件 / 需要赞助" 这类已知错误用 toast 提示,不进 globalCapture

i18n

  • zh.yaml / zh-TW.yaml / en.yaml 新增 tools.batchPv.*
  • Locale.resx / Locale.zh-hans.resx / Locale.zh-hant.resx 新增 BatchConvertPvNeedLicense / BatchConvertPvNoFiles
  • Locale.Designer.cs 已手工同步对应的属性

验证

  • ✅ Front pnpm build 通过(rolldown 输出 wwwroot 资产无错)
  • ⚠️ 后端 dotnet build 在本环境无法完整跑(Linux 上缺 .NETFramework 4.7 / 4.8.1 reference assemblies,submodule 才需要),主项目代码已审阅并与现有 VideoConvertToolController / MovieConvertController 风格保持一致

设计抉择 / 注意事项

  1. 取消语义:受 Xabe.FFmpeg 进度回调签名所限,取消只能在文件之间生效。UI 上明确标注 "取消会在当前文件转换完成后生效"
  2. MP4 → DAT 销毁前的取消窗口:转换完成且校验通过后会先检查 RequestAborted.ThrowIfCancellationRequested() 再覆盖 / 回收,所以中途断开不会损毁源 MP4
  3. 回收站:使用 Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(..., RecycleOption.SendToRecycleBin),保留误操作的恢复余地
  4. apiGen.ts:本次没有运行 pnpm genClient(需要本地起后端),新端点直接用 getUrl + fetchEventSource 调用,与 VideoConvertTool / ImageToAbTool 行为一致,所以不阻塞合并;合并后建议跑一次 pnpm genClientapiGen.ts 包含新签名

由 Oracle 审过一轮,下列原 blocker 已全部修复:Locale.Designer.cs 同步、文件系统直接枚举、processed/failed 计数语义、取消时不再破坏源 MP4、SSE 写入串行化、MP4 改为送回收站。

Summary by Sourcery

添加批量 PV 转换功能,可在确保文件安全和使用 SSE 驱动进度汇报的前提下,将所有 PV 文件在 USM/DAT 与 MP4 之间转换,并在 UI 中作为新工具对外提供。

New Features:

  • 引入后端批量 PV 转换接口,对 StreamingAssets 中的所有 PV 文件在 USM/DAT 与 MP4 之间进行处理,支持赞助方门控(sponsor gating)及使用 Server-Sent Events 报告进度。
  • 在工具页面新增 “Batch Video Convert” 模态窗口,用于配置和监控批量 PV 转换,提供双进度条、可取消的运行以及按文件粒度的错误报告。
  • 为新的批量 PV 转换工具提供本地化文本,覆盖英文、简体中文和繁体中文。

Enhancements:

  • 直接从资源目录枚举 PV 文件,并在批量转换后重新扫描 MovieData,以保持元数据与磁盘状态同步。
  • 通过通道序列化 SSE 写入,避免进度事件交错,提高在客户端断连情况下的健壮性。
Original summary in English

Summary by Sourcery

Add a batch PV conversion capability that converts all PV files between USM/DAT and MP4 with SSE-driven progress reporting and safe file handling, exposed as a new tool in the UI.

New Features:

  • Introduce a backend batch PV conversion endpoint that processes all PV files in StreamingAssets between USM/DAT and MP4 with sponsor gating and server-sent events for progress.
  • Add a Batch Video Convert modal in the tools page to configure and monitor batch PV conversions with dual progress bars, cancellable runs, and per-file error reporting.
  • Provide localized text for the new batch PV conversion tool across English, Simplified Chinese, and Traditional Chinese.

Enhancements:

  • Enumerate PV files directly from asset directories and rescan MovieData after batch conversion to keep metadata in sync with disk state.
  • Serialize SSE writes through a channel to avoid interleaved progress events and improve robustness against client disconnects.

A new tool in the Tools page that bulk converts every PV (Promotional
Video) under StreamingAssets/A###/MovieData in both directions:

- USM/DAT -> MP4: writes a sibling .mp4 next to each source, original
  kept untouched
- MP4 -> USM/DAT: writes to a temp file, validates non-empty, atomically
  swaps in the final .dat, then sends the source .mp4 to the recycle bin

Implementation notes:
- New endpoint VideoConvertToolController.BatchConvertPvTool, SSE with
  events Progress / FileError / Success / Cancelled / Error.
- Progress payload is JSON (camelCase) with processed / total /
  fileProgress / fileName / failed.
- All SSE frames go through a single-writer Channel<string> so that
  Xabe's synchronous OnProgress events from inside FFmpeg cannot
  interleave Response.WriteAsync calls.
- Direct file system enumeration via StaticSettings.AssetsDirs rather
  than MovieDataMap, so files that share an ID across multiple asset
  dirs (or .mp4 + .dat siblings) are not silently skipped.
- Cancellation is checked before destructive file ops; a partial output
  in temp is removed before throwing, so cancel cannot corrupt the
  source.
- Sponsored feature, gated on IapManager.License == Active.
- Final settings.ScanMovieData() in the finally block resynchronizes
  MovieDataMap with on-disk state.

Frontend: src/views/Tools/BatchVideoConvertModal.tsx, a 3-step modal
(Configure direction -> live Progress with overall + current-file bars
and a collapsible per-file error list -> Done with summary). Uses
fetchEventSource + AbortController, matching the existing single-file
VideoConvertModal pattern. Surfaces 'no files' and 'needs sponsor' as
friendly toasts instead of crash reports.

Notes:
- Locale.Designer.cs was updated by hand to expose the two new resource
  strings.
- Front/src/client/apiGen.ts was NOT regenerated (requires a running
  backend on localhost:5181). The new endpoint is consumed directly via
  getUrl + fetchEventSource, matching the other SSE tool endpoints,
  so no client regen is strictly required, but a follow-up
  `pnpm genClient` is recommended after merging.
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented May 21, 2026

审阅者指南

添加了一个新的赞助用户专用批量 PV 转换工具,该工具直接从 StreamingAssets 枚举 PV 文件,通过使用序列化的 Channel 写入器以 SSE 方式流式传输批处理进度,并在前端提供一个三步模态框,包含双进度条、取消功能、错误提示(toast)以及 i18n 支持。

批量 PV 转换 SSE 流程时序图

sequenceDiagram
    actor User
    participant BatchVideoConvertModal
    participant fetchEventSource
    participant VideoConvertToolController
    participant Channel_string as Channel_string
    participant WriteSseFrames
    participant VideoConvert

    User ->> BatchVideoConvertModal: trigger()
    BatchVideoConvertModal ->> fetchEventSource: fetchEventSource(BatchConvertPvToolApi)
    fetchEventSource ->> VideoConvertToolController: BatchConvertPvTool(direction)
    VideoConvertToolController ->> VideoConvertToolController: EnumerateMoviePvs()
    VideoConvertToolController ->> WriteSseFrames: WriteSseFrames(ChannelReader, RequestAborted)
    activate WriteSseFrames

    loop each file
        VideoConvertToolController ->> Channel_string: EnqueueProgress(Processed, Total, 0, FileName, Failed)
        Channel_string ->> WriteSseFrames: ReadAllAsync()
        WriteSseFrames ->> VideoConvertToolController: Response.WriteAsync(frame)

        alt direction == UsmToMp4
            VideoConvertToolController ->> VideoConvert: ConvertUsmToMp4(inputPath, outputPath, OnProgress)
        else direction == Mp4ToUsm
            VideoConvertToolController ->> VideoConvert: ConvertVideo(VideoConvertOptions)
        end

        Note over VideoConvertToolController,Channel_string: OnProgress -> EnqueueProgressFireAndForget

        VideoConvertToolController ->> Channel_string: EnqueueProgress(Processed, Total, 100, FileName, Failed)
    end

    alt success
        VideoConvertToolController ->> Channel_string: EnqueueEvent(Success, "processed/total|failed")
    else cancelled
        VideoConvertToolController ->> Channel_string: EnqueueEvent(Cancelled, "processed/total")
    else error
        VideoConvertToolController ->> Channel_string: EnqueueEvent(Error, Locale.ConvertFailed)
    end

    Channel_string ->> WriteSseFrames: Complete
    deactivate WriteSseFrames

    WriteSseFrames ->> fetchEventSource: SSE events (Progress, FileError, Success, Cancelled, Error)
    fetchEventSource ->> BatchVideoConvertModal: onmessage(e)
    BatchVideoConvertModal ->> BatchVideoConvertModal: update state / finishKind

    User ->> BatchVideoConvertModal: cancel()
    BatchVideoConvertModal ->> fetchEventSource: AbortController.abort()
    fetchEventSource ->> VideoConvertToolController: RequestAborted
    VideoConvertToolController ->> VideoConvertToolController: ThrowIfCancellationRequested()
    VideoConvertToolController ->> Channel_string: EnqueueEvent(Cancelled, "processed/total")
Loading

文件级变更

变更 详情 文件
引入后端批量 PV 转换 API,在支持取消的前提下,安全地在 USM/DAT 与 MP4 之间转换所有 PV,并通过序列化的 SSE 报告进度。
  • 扩展 VideoConvertToolController,添加对 StaticSettings 的依赖,以及新的使用 BatchConvertPvDirection 枚举的 BatchConvertPvTool POST 动作。
  • 直接从 StreamingAssets/MovieData 中使用 StaticSettings.AssetsDirs 枚举 PV 文件,并按扩展名/ID 过滤,以避免 MovieDataMap 去重带来的缺口。
  • 实现批量转换循环,对每个文件单独 try/catch,处理 OperationCanceledException,跟踪 processed/failed 计数器,并在最后执行 ScanMovieData 同步。
  • 新增 MP4 到 USM/DAT 的转换路径:先转换到临时 DAT,进行校验,再原子替换目标文件,并将源 MP4 移动到回收站,在取消时保留文件。
  • 使用 Channel 加上 WriteSseFrames 来串行化 SSE 写入,对 Progress/FileError/Success/Cancelled/Error 事件输出 camelCase JSON 负载以及本地化错误消息。
MaiChartManager/Controllers/Tools/VideoConvertToolController.cs
MaiChartManager/Locale.resx
MaiChartManager/Locale.zh-hans.resx
MaiChartManager/Locale.zh-hant.resx
MaiChartManager/Locale.Designer.cs
添加前端批量 PV 转换模态框,并通过基于 fetchEventSource 的 SSE 处理将其接入工具页面,提供友好的取消和错误体验。
  • 创建 BatchVideoConvertModal.tsx,实现一个三步(配置/进度/完成)模态框,包含方向选择、双进度条、文件错误列表以及取消按钮。
  • 将 BatchVideoConvertModal 集成到工具首页,作为新的工具卡片和模态引用,并接入现有工具布局。
  • 使用 fetchEventSource 对 BatchConvertPvToolApi 发起 POST 请求,解析 Progress/FileError/Success/Cancelled/Error 事件为响应式状态,并将 AbortError 视为用户取消。
  • 将已知的后端错误消息(无文件 / 需要许可证)以警告 toast 形式提示,将意外失败交由 globalCapture 处理,并基于许可证状态控制访问。
MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx
MaiChartManager/Front/src/views/Tools/index.tsx
为新的批量 PV 工具在所有支持的语言中提供本地化字符串。
  • 在 zh、zh-TW 和 en 的 YAML 本地化文件中添加 tools.batchPv.* 键(标签、提示、摘要、错误)以支持新的批量 PV UI。
  • 在 Locale.resx 及各语言特定的 resx 文件中新增 BatchConvertPvNeedLicense 和 BatchConvertPvNoFiles 资源,并通过 Locale.Designer.cs 暴露这些资源。
MaiChartManager/Front/src/locales/en.yaml
MaiChartManager/Front/src/locales/zh.yaml
MaiChartManager/Front/src/locales/zh-TW.yaml
MaiChartManager/Locale.resx
MaiChartManager/Locale.zh-hans.resx
MaiChartManager/Locale.zh-hant.resx
MaiChartManager/Locale.Designer.cs

提示与命令

与 Sourcery 交互

  • 触发新审阅: 在拉取请求中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的审阅评论。
  • 从审阅评论生成 GitHub issue: 通过回复某条审阅评论来让 Sourcery 从该评论创建一个 issue。你也可以回复审阅评论 @sourcery-ai issue 来从中创建 issue。
  • 生成拉取请求标题: 在拉取请求标题中任意位置写入 @sourcery-ai,即可随时生成标题。你也可以在拉取请求中评论 @sourcery-ai title 来(重新)生成标题。
  • 生成拉取请求摘要: 在拉取请求正文中任意位置写入 @sourcery-ai summary,即可在该位置生成 PR 摘要。你也可以在拉取请求中评论 @sourcery-ai summary 来(重新)生成摘要。
  • 生成审阅者指南: 在拉取请求中评论 @sourcery-ai guide,即可(重新)生成审阅者指南。
  • 解决所有 Sourcery 评论: 在拉取请求中评论 @sourcery-ai resolve 来解决所有 Sourcery 评论。如果你已经处理完所有评论且不希望再看到它们,这会很有用。
  • 忽略所有 Sourcery 审阅: 在拉取请求中评论 @sourcery-ai dismiss 来忽略所有现有的 Sourcery 审阅。特别适合在你希望从头开始新的审阅时使用——别忘了随后评论 @sourcery-ai review 来触发新的审阅!

自定义你的体验

访问你的 控制面板 以:

  • 启用或禁用审阅特性,例如 Sourcery 生成的拉取请求摘要、审阅者指南等。
  • 更改审阅语言。
  • 添加、删除或编辑自定义审阅指令。
  • 调整其他审阅设置。

获取帮助

Original review guide in English

Reviewer's Guide

Adds a new sponsor-gated batch PV conversion tool that enumerates PV files directly from StreamingAssets, streams batch progress via SSE using a serialized Channel writer, and exposes a three-step front-end modal with dual progress bars, cancellation, error toasts, and i18n support.

Sequence diagram for batch PV conversion SSE flow

sequenceDiagram
    actor User
    participant BatchVideoConvertModal
    participant fetchEventSource
    participant VideoConvertToolController
    participant Channel_string as Channel_string
    participant WriteSseFrames
    participant VideoConvert

    User ->> BatchVideoConvertModal: trigger()
    BatchVideoConvertModal ->> fetchEventSource: fetchEventSource(BatchConvertPvToolApi)
    fetchEventSource ->> VideoConvertToolController: BatchConvertPvTool(direction)
    VideoConvertToolController ->> VideoConvertToolController: EnumerateMoviePvs()
    VideoConvertToolController ->> WriteSseFrames: WriteSseFrames(ChannelReader, RequestAborted)
    activate WriteSseFrames

    loop each file
        VideoConvertToolController ->> Channel_string: EnqueueProgress(Processed, Total, 0, FileName, Failed)
        Channel_string ->> WriteSseFrames: ReadAllAsync()
        WriteSseFrames ->> VideoConvertToolController: Response.WriteAsync(frame)

        alt direction == UsmToMp4
            VideoConvertToolController ->> VideoConvert: ConvertUsmToMp4(inputPath, outputPath, OnProgress)
        else direction == Mp4ToUsm
            VideoConvertToolController ->> VideoConvert: ConvertVideo(VideoConvertOptions)
        end

        Note over VideoConvertToolController,Channel_string: OnProgress -> EnqueueProgressFireAndForget

        VideoConvertToolController ->> Channel_string: EnqueueProgress(Processed, Total, 100, FileName, Failed)
    end

    alt success
        VideoConvertToolController ->> Channel_string: EnqueueEvent(Success, "processed/total|failed")
    else cancelled
        VideoConvertToolController ->> Channel_string: EnqueueEvent(Cancelled, "processed/total")
    else error
        VideoConvertToolController ->> Channel_string: EnqueueEvent(Error, Locale.ConvertFailed)
    end

    Channel_string ->> WriteSseFrames: Complete
    deactivate WriteSseFrames

    WriteSseFrames ->> fetchEventSource: SSE events (Progress, FileError, Success, Cancelled, Error)
    fetchEventSource ->> BatchVideoConvertModal: onmessage(e)
    BatchVideoConvertModal ->> BatchVideoConvertModal: update state / finishKind

    User ->> BatchVideoConvertModal: cancel()
    BatchVideoConvertModal ->> fetchEventSource: AbortController.abort()
    fetchEventSource ->> VideoConvertToolController: RequestAborted
    VideoConvertToolController ->> VideoConvertToolController: ThrowIfCancellationRequested()
    VideoConvertToolController ->> Channel_string: EnqueueEvent(Cancelled, "processed/total")
Loading

File-Level Changes

Change Details Files
Introduce backend batch PV conversion API that safely converts all PVs between USM/DAT and MP4 with serialized SSE progress reporting and cancellation support.
  • Extend VideoConvertToolController with StaticSettings dependency and new BatchConvertPvTool POST action using a BatchConvertPvDirection enum.
  • Enumerate PV files directly from StreamingAssets/MovieData using StaticSettings.AssetsDirs and filter by extension/id to avoid MovieDataMap de-duplication gaps.
  • Implement batch conversion loop with per-file try/catch, OperationCanceledException handling, processed/failed counters, and final ScanMovieData sync.
  • Add MP4-to-USM/DAT path that converts into a temp DAT, validates it, atomically replaces the target, and moves the source MP4 to the recycle bin, preserving files on cancellation.
  • Use a Channel plus WriteSseFrames to serialize SSE writes for Progress/FileError/Success/Cancelled/Error events with camelCase JSON payloads and localized error messages.
MaiChartManager/Controllers/Tools/VideoConvertToolController.cs
MaiChartManager/Locale.resx
MaiChartManager/Locale.zh-hans.resx
MaiChartManager/Locale.zh-hant.resx
MaiChartManager/Locale.Designer.cs
Add front-end batch PV conversion modal and wire it into the tools page using fetchEventSource-based SSE handling and user-friendly cancellation and error UX.
  • Create BatchVideoConvertModal.tsx implementing a three-step (Configure/Progress/Done) modal with direction selection, dual progress bars, file error list, and cancel button.
  • Integrate BatchVideoConvertModal into the tools index page as a new tool card and modal reference wired to the existing tools layout.
  • Use fetchEventSource to POST to BatchConvertPvToolApi, parse Progress/FileError/Success/Cancelled/Error events into reactive state, and handle AbortError as user cancel.
  • Surface known backend error messages (no files / need license) as warning toasts and route unexpected failures through globalCapture, while gating access on license status.
MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx
MaiChartManager/Front/src/views/Tools/index.tsx
Provide localization strings for the new batch PV tool across all supported languages.
  • Add tools.batchPv.* keys (labels, hints, summaries, errors) to zh, zh-TW, and en YAML locale files for the new batch PV UI.
  • Introduce BatchConvertPvNeedLicense and BatchConvertPvNoFiles resources in Locale.resx and language-specific resx files and expose them via Locale.Designer.cs.
MaiChartManager/Front/src/locales/en.yaml
MaiChartManager/Front/src/locales/zh.yaml
MaiChartManager/Front/src/locales/zh-TW.yaml
MaiChartManager/Locale.resx
MaiChartManager/Locale.zh-hans.resx
MaiChartManager/Locale.zh-hant.resx
MaiChartManager/Locale.Designer.cs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - 我发现了两个问题,并给出了一些高层次的反馈:

  • BatchVideoConvertModal 中,通用错误路径里的 console.log(e) 会把内部错误细节泄露到浏览器控制台;建议移除它,或者改为通过已有的 globalCapture/日志系统上报,以保持与其他工具一致的行为。
  • finishSummary 中的汇总解析逻辑(按 /| 分割并计算成功/失败数量)在 closeDonerenderDone 之间是重复的;建议抽取一个小的辅助函数来集中这段解析逻辑,从而减少两处实现逐渐产生偏差的风险。
给 AI Agent 的提示词
Please address the comments from this code review:

## Overall Comments
-`BatchVideoConvertModal` 中,通用错误路径里的 `console.log(e)` 会把内部错误细节泄露到浏览器控制台;建议移除它,或者改为通过已有的 `globalCapture`/日志系统上报,以保持与其他工具一致的行为。
- `finishSummary` 中的汇总解析逻辑(按 `/``|` 分割并计算成功/失败数量)在 `closeDone``renderDone` 之间是重复的;建议抽取一个小的辅助函数来集中这段解析逻辑,从而减少两处实现逐渐产生偏差的风险。

## Individual Comments

### Comment 1
<location path="MaiChartManager/Controllers/Tools/VideoConvertToolController.cs" line_range="158-164" />
<code_context>
+        var cancellationToken = HttpContext.RequestAborted;
+
+        // 单写者 Channel:所有 SSE 帧(不论来自循环还是 OnProgress)都进入这条队列
+        var sseChannel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions
+        {
+            SingleReader = true,
+            SingleWriter = false,
+        });
+
+        var writer = WriteSseFrames(sseChannel.Reader, cancellationToken);
+
+        try
</code_context>
<issue_to_address>
**suggestion (bug_risk):** 建议对 SSE 进度通道进行限界或节流,以避免在客户端较慢或进度事件非常频繁的情况下导致内存无限增长。

由于这里使用的是无界的 `Channel<string>`,并结合 `EnqueueProgressFireAndForget`,当客户端处理速度较慢或被阻塞、且进度回调很频繁时,尤其是在长时间运行的批量任务中,队列可能会无限增长。建议考虑使用有界 Channel,并对进度事件采用丢弃/覆盖策略,或者对进度更新进行节流/合并(例如只在进度变化 ≥1% 或按固定时间间隔入队),从而控制内存使用。

Suggested implementation:

```csharp
        // 单写者 Channel:所有 SSE 帧(不论来自循环还是 OnProgress)都进入这条队列
        // 使用有界 Channel,并在队列满时丢弃最旧的进度帧,防止在慢客户端/高频进度回调下无限占用内存
        var sseChannel = Channel.CreateBounded<string>(new BoundedChannelOptions(100)
        {
            SingleReader = true,
            SingleWriter = false,
            FullMode = BoundedChannelFullMode.DropOldest,
        });

```

1. 如果 `EnqueueProgress`/`EnqueueProgressFireAndForget` 当前使用的是 `WriteAsync` 这类可能阻塞的写入方式,则需要改为使用 `TryWrite`,或对 `WriteAsync` 的失败/取消做容错处理,以适配 `BoundedChannelFullMode.DropOldest` 的策略。
2. 如果希望进一步节流/合并进度事件(例如仅在进度提升 ≥1% 或按固定时间间隔发送),可以在 `EnqueueProgress` 内部记录上一次发送的进度百分比/时间戳,在未满足阈值时直接返回而不写入 Channel。
</issue_to_address>

### Comment 2
<location path="MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx" line_range="167-171" />
<code_context>
+        }
+        // 已知的友好错误(无文件 / 需要赞助):toast 提示并回到 Configure,不上报
+        const message: string = e?.message ?? '';
+        const friendlyMessages = [
+          t('tools.batchPv.noFiles'),
+          t('tools.batchPv.needLicense'),
+        ];
+        if (friendlyMessages.includes(message)) {
+          addToast({ message, type: 'warning' });
+          step.value = STEP.Configure;
</code_context>
<issue_to_address>
**issue (bug_risk):** 在服务器和客户端之间通过错误文案比对来判断逻辑是很脆弱的;更推荐使用结构化错误码或事件类型。

这里的 `friendlyMessages` 会将 `t('tools.batchPv.noFiles')` / `t('tools.batchPv.needLicense')` 与来自 SSE 层的 `e.message` 进行对比,而服务端则使用的是 `Locale.BatchConvertPvNoFiles` / `BatchConvertPvNeedLicense` 这两条文案。只要文案、标点或 `SanitizeSseLine` 的处理有任何差异,这种匹配就会失效,从而把已知情况降级为通用错误。建议改为由后端输出稳定的错误码或特定 SSE 事件类型(例如 `code: NO_FILES`),然后由前端根据这个错误码来选择 toast 内容和流程,而不是依赖本地化后的错误文案文本。
</issue_to_address>

Sourcery 对开源项目免费 —— 如果你觉得这次 Review 有帮助,可以考虑分享一下 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会依据你的反馈改进后续的代码审查。
Original comment in English

Hey - I've found 2 issues, and left some high level feedback:

  • In BatchVideoConvertModal, console.log(e) in the generic error path will leak internal error details into the browser console; consider removing it or routing through your existing globalCapture/logging instead to keep behavior consistent with other tools.
  • The summary parsing logic from finishSummary (splitting on / and | and computing success/failed counts) is duplicated between closeDone and renderDone; consider extracting a small helper to centralize this parsing to reduce the chance of the two branches drifting apart.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `BatchVideoConvertModal`, `console.log(e)` in the generic error path will leak internal error details into the browser console; consider removing it or routing through your existing `globalCapture`/logging instead to keep behavior consistent with other tools.
- The summary parsing logic from `finishSummary` (splitting on `/` and `|` and computing success/failed counts) is duplicated between `closeDone` and `renderDone`; consider extracting a small helper to centralize this parsing to reduce the chance of the two branches drifting apart.

## Individual Comments

### Comment 1
<location path="MaiChartManager/Controllers/Tools/VideoConvertToolController.cs" line_range="158-164" />
<code_context>
+        var cancellationToken = HttpContext.RequestAborted;
+
+        // 单写者 Channel:所有 SSE 帧(不论来自循环还是 OnProgress)都进入这条队列
+        var sseChannel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions
+        {
+            SingleReader = true,
+            SingleWriter = false,
+        });
+
+        var writer = WriteSseFrames(sseChannel.Reader, cancellationToken);
+
+        try
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider bounding or throttling the SSE progress channel to avoid unbounded memory growth under slow clients or very frequent progress events.

Because this is an unbounded `Channel<string>` combined with `EnqueueProgressFireAndForget`, a slow or blocked client plus frequent progress callbacks can cause unbounded queue growth, especially for long-running batch jobs. Consider either using a bounded channel with a drop/overwrite policy for progress events, or throttling/coalescing updates (e.g., only enqueue on ≥1% change or at a fixed interval) to keep memory usage controlled.

Suggested implementation:

```csharp
        // 单写者 Channel:所有 SSE 帧(不论来自循环还是 OnProgress)都进入这条队列
        // 使用有界 Channel,并在队列满时丢弃最旧的进度帧,防止在慢客户端/高频进度回调下无限占用内存
        var sseChannel = Channel.CreateBounded<string>(new BoundedChannelOptions(100)
        {
            SingleReader = true,
            SingleWriter = false,
            FullMode = BoundedChannelFullMode.DropOldest,
        });

```

1. `EnqueueProgress`/`EnqueueProgressFireAndForget` 目前如果使用 `WriteAsync` 之类的阻塞写入,需要调整为使用 `TryWrite` 或对 `WriteAsync` 的失败/取消做容错处理,以配合 `BoundedChannelFullMode.DropOldest` 的策略。
2. 如果希望进一步节流/合并进度事件(例如仅在进度提升 ≥1% 或按固定时间间隔发送),可以在 `EnqueueProgress` 内部记录上一次发送的进度百分比/时间戳,在未满足阈值时直接返回而不写入 Channel。
</issue_to_address>

### Comment 2
<location path="MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx" line_range="167-171" />
<code_context>
+        }
+        // 已知的友好错误(无文件 / 需要赞助):toast 提示并回到 Configure,不上报
+        const message: string = e?.message ?? '';
+        const friendlyMessages = [
+          t('tools.batchPv.noFiles'),
+          t('tools.batchPv.needLicense'),
+        ];
+        if (friendlyMessages.includes(message)) {
+          addToast({ message, type: 'warning' });
+          step.value = STEP.Configure;
</code_context>
<issue_to_address>
**issue (bug_risk):** Comparing error messages across server and client locales is fragile; prefer structured error codes or event types.

Here `friendlyMessages` compares `t('tools.batchPv.noFiles')` / `t('tools.batchPv.needLicense')` with `e.message` from the SSE layer, while the server uses separate `Locale.BatchConvertPvNoFiles` / `BatchConvertPvNeedLicense` strings. Any divergence in wording, punctuation, or sanitization (`SanitizeSseLine`) will break this match and downgrade known conditions to generic errors. Instead, have the backend emit a stable code or specific SSE event type (e.g., `code: NO_FILES`), and let the frontend choose the toast and flow based on that code rather than the localized message text.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +158 to +164
var sseChannel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});

var writer = WriteSseFrames(sseChannel.Reader, cancellationToken);
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.

suggestion (bug_risk): 建议对 SSE 进度通道进行限界或节流,以避免在客户端较慢或进度事件非常频繁的情况下导致内存无限增长。

由于这里使用的是无界的 Channel<string>,并结合 EnqueueProgressFireAndForget,当客户端处理速度较慢或被阻塞、且进度回调很频繁时,尤其是在长时间运行的批量任务中,队列可能会无限增长。建议考虑使用有界 Channel,并对进度事件采用丢弃/覆盖策略,或者对进度更新进行节流/合并(例如只在进度变化 ≥1% 或按固定时间间隔入队),从而控制内存使用。

Suggested implementation:

        // 单写者 Channel:所有 SSE 帧(不论来自循环还是 OnProgress)都进入这条队列
        // 使用有界 Channel,并在队列满时丢弃最旧的进度帧,防止在慢客户端/高频进度回调下无限占用内存
        var sseChannel = Channel.CreateBounded<string>(new BoundedChannelOptions(100)
        {
            SingleReader = true,
            SingleWriter = false,
            FullMode = BoundedChannelFullMode.DropOldest,
        });
  1. 如果 EnqueueProgress/EnqueueProgressFireAndForget 当前使用的是 WriteAsync 这类可能阻塞的写入方式,则需要改为使用 TryWrite,或对 WriteAsync 的失败/取消做容错处理,以适配 BoundedChannelFullMode.DropOldest 的策略。
  2. 如果希望进一步节流/合并进度事件(例如仅在进度提升 ≥1% 或按固定时间间隔发送),可以在 EnqueueProgress 内部记录上一次发送的进度百分比/时间戳,在未满足阈值时直接返回而不写入 Channel。
Original comment in English

suggestion (bug_risk): Consider bounding or throttling the SSE progress channel to avoid unbounded memory growth under slow clients or very frequent progress events.

Because this is an unbounded Channel<string> combined with EnqueueProgressFireAndForget, a slow or blocked client plus frequent progress callbacks can cause unbounded queue growth, especially for long-running batch jobs. Consider either using a bounded channel with a drop/overwrite policy for progress events, or throttling/coalescing updates (e.g., only enqueue on ≥1% change or at a fixed interval) to keep memory usage controlled.

Suggested implementation:

        // 单写者 Channel:所有 SSE 帧(不论来自循环还是 OnProgress)都进入这条队列
        // 使用有界 Channel,并在队列满时丢弃最旧的进度帧,防止在慢客户端/高频进度回调下无限占用内存
        var sseChannel = Channel.CreateBounded<string>(new BoundedChannelOptions(100)
        {
            SingleReader = true,
            SingleWriter = false,
            FullMode = BoundedChannelFullMode.DropOldest,
        });
  1. EnqueueProgress/EnqueueProgressFireAndForget 目前如果使用 WriteAsync 之类的阻塞写入,需要调整为使用 TryWrite 或对 WriteAsync 的失败/取消做容错处理,以配合 BoundedChannelFullMode.DropOldest 的策略。
  2. 如果希望进一步节流/合并进度事件(例如仅在进度提升 ≥1% 或按固定时间间隔发送),可以在 EnqueueProgress 内部记录上一次发送的进度百分比/时间戳,在未满足阈值时直接返回而不写入 Channel。

Comment on lines +167 to +171
const friendlyMessages = [
t('tools.batchPv.noFiles'),
t('tools.batchPv.needLicense'),
];
if (friendlyMessages.includes(message)) {
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.

issue (bug_risk): 在服务器和客户端之间通过错误文案比对来判断逻辑是很脆弱的;更推荐使用结构化错误码或事件类型。

这里的 friendlyMessages 会将 t('tools.batchPv.noFiles') / t('tools.batchPv.needLicense') 与来自 SSE 层的 e.message 进行对比,而服务端则使用的是 Locale.BatchConvertPvNoFiles / BatchConvertPvNeedLicense 这两条文案。只要文案、标点或 SanitizeSseLine 的处理有任何差异,这种匹配就会失效,从而把已知情况降级为通用错误。建议改为由后端输出稳定的错误码或特定 SSE 事件类型(例如 code: NO_FILES),然后由前端根据这个错误码来选择 toast 内容和流程,而不是依赖本地化后的错误文案文本。

Original comment in English

issue (bug_risk): Comparing error messages across server and client locales is fragile; prefer structured error codes or event types.

Here friendlyMessages compares t('tools.batchPv.noFiles') / t('tools.batchPv.needLicense') with e.message from the SSE layer, while the server uses separate Locale.BatchConvertPvNoFiles / BatchConvertPvNeedLicense strings. Any divergence in wording, punctuation, or sanitization (SanitizeSseLine) will break this match and downgrade known conditions to generic errors. Instead, have the backend emit a stable code or specific SSE event type (e.g., code: NO_FILES), and let the frontend choose the toast and flow based on that code rather than the localized message text.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx">

<violation number="1" location="MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx:167">
P2: The `friendlyMessages` comparison will never match for the `needLicense` case (and `noFiles` in zh-TW) because the backend `Locale.*` strings differ from the frontend `t(...)` strings. For example, the backend sends `"Batch PV conversion requires sponsor activation"` while the frontend compares against `"This feature requires sponsor activation"`. This causes known-condition errors to fall through to `globalCapture` instead of showing a friendly toast.

Either align the strings exactly across backend `.resx` and frontend `.yaml`, or (more robustly) have the backend emit a stable error code/event type and let the frontend select the user-facing message based on that code.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

}
// 已知的友好错误(无文件 / 需要赞助):toast 提示并回到 Configure,不上报
const message: string = e?.message ?? '';
const friendlyMessages = [
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.

P2: The friendlyMessages comparison will never match for the needLicense case (and noFiles in zh-TW) because the backend Locale.* strings differ from the frontend t(...) strings. For example, the backend sends "Batch PV conversion requires sponsor activation" while the frontend compares against "This feature requires sponsor activation". This causes known-condition errors to fall through to globalCapture instead of showing a friendly toast.

Either align the strings exactly across backend .resx and frontend .yaml, or (more robustly) have the backend emit a stable error code/event type and let the frontend select the user-facing message based on that code.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx, line 167:

<comment>The `friendlyMessages` comparison will never match for the `needLicense` case (and `noFiles` in zh-TW) because the backend `Locale.*` strings differ from the frontend `t(...)` strings. For example, the backend sends `"Batch PV conversion requires sponsor activation"` while the frontend compares against `"This feature requires sponsor activation"`. This causes known-condition errors to fall through to `globalCapture` instead of showing a friendly toast.

Either align the strings exactly across backend `.resx` and frontend `.yaml`, or (more robustly) have the backend emit a stable error code/event type and let the frontend select the user-facing message based on that code.</comment>

<file context>
@@ -0,0 +1,325 @@
+        }
+        // 已知的友好错误(无文件 / 需要赞助):toast 提示并回到 Configure,不上报
+        const message: string = e?.message ?? '';
+        const friendlyMessages = [
+          t('tools.batchPv.noFiles'),
+          t('tools.batchPv.needLicense'),
</file context>

Per review feedback, batch PV conversion no longer scans the game's
StreamingAssets/A###/MovieData. The user picks an arbitrary folder via
the existing OpenFolderDialog endpoint and we convert every matching
file in that folder in place.

Backend (VideoConvertToolController.cs):
- BatchConvertPvTool now takes folderPath in addition to direction.
- Direct Directory.EnumerateFiles on the chosen folder, filtered by
  direction-derived source extensions. Removed the numeric-filename
  filter and EnumerateMoviePvs helper since arbitrary user folders
  contain arbitrarily named files.
- Removed StaticSettings dependency: no AssetsDirs scan, no MovieDataMap
  mutation, no settings.ScanMovieData() call in finally. Constructor
  no longer takes StaticSettings.
- Returns the new BatchConvertPvFolderNotFound error when the path is
  missing or does not exist.
- All preserved: license gate, single-writer Channel SSE serialization,
  cancellation-before-delete, temp->validate->atomic-move, source MP4
  to recycle bin, JSON camelCase payload.

Frontend (BatchVideoConvertModal.tsx):
- trigger() now opens the native folder picker via api.OpenFolderDialog
  (reusing the OobeController endpoint) before showing the modal. If
  the user cancels the picker, no modal is opened.
- Configure step displays the selected path with a Change folder button
  that re-opens the picker.
- start() includes folderPath as a query param when opening the SSE.
- folderNotFound joins the friendly-error allowlist that surfaces as a
  toast instead of going through globalCapture.

i18n: drop the 'same directory' wording from the direction hints, add
selectFolder / changeFolder / selectedFolder / folderNotFound for zh,
zh-TW, en. Backend Locale resx (and Designer.cs) gets the matching
BatchConvertPvFolderNotFound entry.
@munet-rei
Copy link
Copy Markdown
Author

munet-rei Bot commented May 21, 2026

📝 重构:批量 PV 不再扫描游戏目录,改为让用户选一个文件夹

按反馈调整:之前的实现扫描的是游戏的 StreamingAssets/A###/MovieData,但需求其实是让用户任意选一个文件夹然后在里面就地转换。

用户流程

  1. 点工具卡 → 直接弹出系统原生文件夹选择对话框(复用现有 OobeController.OpenFolderDialog
  2. 选完文件夹 → 弹出 Configure 模态:显示所选路径 + 「重新选择」按钮 + 方向单选(USM/DAT → MP4 或 MP4 → USM)+ 开始按钮
  3. 开始 → Progress(双进度条 + 错误折叠 + 取消按钮)
  4. Done(结果汇总)

后端改动

  • BatchConvertPvTool([FromQuery] string folderPath, [FromQuery] BatchConvertPvDirection direction)
  • Directory.EnumerateFiles(folderPath) 直接枚举所选文件夹(非递归),按方向选源扩展名
  • 删掉 EnumerateMoviePvs 辅助方法和数字文件名过滤(任意文件夹不再做这种限制)
  • 不再依赖 StaticSettings:移除构造注入、AssetsDirs 扫描、MovieDataMap 写入、finally 里的 ScanMovieData()
  • 新增 BatchConvertPvFolderNotFound 本地化字符串(resx + Designer.cs)

前端改动

  • trigger() 先调 api.OpenFolderDialog();用户取消就什么都不做(不会弹出空模态)
  • Configure 步骤增加 "已选择的文件夹" 展示 + 「重新选择」按钮
  • SSE URL 加上 folderPath=${encodeURIComponent(...)}
  • folderNotFound 也走友好 toast,不进 Sentry

完整保留

赞助门控、Channel 串行化 SSE 写入、删除前的取消检查、temp → 验证 → 原子 move、源 MP4 进回收站、camelCase JSON、所有进度/取消语义。

构建:pnpm build 通过 ✓

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 9 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="MaiChartManager/Controllers/Tools/VideoConvertToolController.cs">

<violation number="1">
P2: Batch conversion no longer refreshes `MovieDataMap` after file changes, which can leave metadata out of sync with disk state until another scan runs.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@@ -1,5 +1,8 @@
using System.Text.Json;
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.

P2: Batch conversion no longer refreshes MovieDataMap after file changes, which can leave metadata out of sync with disk state until another scan runs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At MaiChartManager/Controllers/Tools/VideoConvertToolController.cs, line 11:

<comment>Batch conversion no longer refreshes `MovieDataMap` after file changes, which can leave metadata out of sync with disk state until another scan runs.</comment>

<file context>
@@ -8,7 +8,7 @@ namespace MaiChartManager.Controllers.Tools;
 [ApiController]
 [Route("MaiChartManagerServlet/[action]Api")]
-public class VideoConvertToolController(ILogger<VideoConvertToolController> logger, StaticSettings settings) : ControllerBase
+public class VideoConvertToolController(ILogger<VideoConvertToolController> logger) : ControllerBase
 {
     public enum VideoConvertEventType
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants