Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f85c7bf
feat: 完善了乐曲切换流派至宴会场的自动更改id并切换铺面
HuimanQAQ Oct 27, 2026
566359c
fix: 流派切换改为后端原子接口,修正宴会场转换的文件处理与提示文案
HuimanQAQ Oct 27, 2026
3b49046
fix: 校验谱面路径,并让改 ID 时一并重命名双人谱文件
HuimanQAQ Oct 27, 2026
79c163c
fix: 含 L/R 双人谱的宴会场乐曲拒绝转换为普通谱面
HuimanQAQ Oct 27, 2026
7314ecf
fix: 修正流派切换的失败回滚与错误提示
HuimanQAQ Oct 27, 2026
4a131b4
fix: 流派改动改由下拉框触发,避免换歌时误弹宴会场转换框
clansty Oct 28, 2026
d5beb49
feat: 宴会场 ID 被占用时顺延序号,不再覆盖已有乐曲
clansty Oct 28, 2026
d6c9a9a
fix: 切出宴会场前先判断当前 ID 是否为宴会场 ID
clansty Oct 28, 2026
8b41926
fix: 流派切换遇到 unexpected 时补回 Sentry 上报
clansty Oct 28, 2026
486affd
fix: 改 ID 失败时返回专门的错误码
clansty Oct 28, 2026
817879b
refactor: 流派切换接口统一用乐曲自己的 assetDir 调用
clansty Oct 28, 2026
9bda2fb
fix: 统计启用难度时忽略第 6 个非难度槽位
clansty Oct 28, 2026
7a67a4c
chore: 撤掉 MusicController.cs 末尾换行的无意义改动
clansty Oct 28, 2026
7e17855
refactor: UtageConverter 移到 Music/Services 领域内服务层
clansty Oct 28, 2026
a1dc94d
fix: 导入谱面切换流派时按后四位计算宴会场 ID
clansty Oct 28, 2026
a058f2d
fix: 宴会场 ID 只在序号 10、11 时才能推回普通曲 ID
clansty Oct 28, 2026
b5b9a86
fix: 切到宴会场时记录原 ID,切回时还原 STD/DX
clansty Oct 28, 2026
4c3351d
fix: 导入时切换流派不再改写手输的普通曲 ID
clansty Oct 28, 2026
7d547bd
feat: 导入宴会场谱面时一并记录原曲 ID
clansty Oct 28, 2026
799f935
fix: 原 ID 记录的后四位对不上时不再采用
clansty Oct 28, 2026
6bc08a9
fix: 改 ID 时同步宴会场原曲 ID 记录
clansty Oct 28, 2026
7ddad3d
fix: 导入宴会场谱面时等谱面写完再记录原曲 ID
clansty Oct 28, 2026
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
3 changes: 2 additions & 1 deletion MaiChartManager/Controllers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ ASP.NET Core REST API 控制器,按业务领域分 7 个子目录。所有控
| `Charts/Services/` | 谱面导入业务逻辑(领域内私有服务) | IMaidataImportService, MaidataImportService, LegacyMaidataImportService |
| `Mod/` | AquaMai mod 配置、安装、路径管理 | ConfigurationController, InstallationController, ManualInstallController, MuModController |
| `Music/` | 音乐 CRUD、音频/视频转换、批量操作、迁移、VRC 处理 | MusicController, MusicBatchController, CueConvertController, MovieConvertController, MusicTransferController, VrcProcessController |
| `Music/Services/` | 音乐领域内私有逻辑(宴会场谱面转换等) | UtageConverter |
| `Tools/` | 独立音频/视频/图片转换工具接口 | AudioConvertToolController, VideoConvertToolController, ImageToAbToolController |

## WHERE TO LOOK
Expand Down Expand Up @@ -52,6 +53,6 @@ ASP.NET Core REST API 控制器,按业务领域分 7 个子目录。所有控
- `Catagory/` 是历史 typo(正确拼写应为 Category),已是既定命名,保持一致,勿重命名
- 前端 API client(`Front/src/client/apiGen.ts`)由 `genClient.ts` 从这些控制器自动生成,修改接口后需重新运行 `pnpm genClient`
- `Mod/ModPaths.cs` 不是控制器,是 Mod 子目录的路径常量辅助类
- `Charts/Services/` 是领域内私有服务层(非全局 `Services/`),仅 Charts 控制器使用
- `Charts/Services/`、`Music/Services/` 是领域内私有服务层(非全局 `Services/`),仅各自的控制器使用
- 控制器通过构造注入获取服务,返回强类型 DTO(非统一包装),部分 action 自行 try/catch 转业务结果
- `ChartController.ReplaceChart()` 根据 `StaticSettings.Config.UseLegacyMaiLib` 在新旧导入实现间切换
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,11 @@ public ImportChartResult ImportMaidata(
File.Delete(basePath.Replace(".ma2", "_L.ma2"));
File.Delete(basePath.Replace(".ma2", "_R.ma2"));
}

// 谱面都写完了再记原曲 ID:宴会场 ID 里只有后四位、没有 STD/DX 信息,
// 把谱面类型补上,以后切回普通流派时才能还原成原来那一条。
// 放在这里而不是前面的 if (isUtage) 里,是为了半路 Fatal 时不会把缓存实例里的记录改脏。
music.UtageBaseId = id % 10000 + (validCharts.Any(chart => chart.IsDxChart) ? 10000 : 0);
}

if (!isReplacement)
Expand Down
202 changes: 195 additions & 7 deletions MaiChartManager/Controllers/Music/MusicTransferController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Text.RegularExpressions;
using MaiChartManager.Models;
using MaiChartManager.Platform;
using MaiChartManager.Controllers.Music.Services;
using MaiChartManager.Utils;
using Microsoft.AspNetCore.Mvc;
using MuConvert.mai;
Expand All @@ -22,6 +23,8 @@ public partial class MusicTransferController(
IProgressController progressController) : ControllerBase
{
public record RequestCopyToRequest(MusicBatchController.MusicIdAndAssetDirPair[] music, bool removeEvents, bool legacyFormat);
public record GenreChangeResult(bool Success, string? Error, int NewId);
public record GenreChangeRequest(int GenreId);

public enum MaidataSubdirMode
{
Expand Down Expand Up @@ -454,6 +457,11 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l
}
}

/// <summary>
/// 双人谱的文件名:xxx.ma2 -> xxx_L.ma2
/// </summary>
private static string WithSide(string path, string side) => path.Replace(".ma2", side + ".ma2");

private void DeleteIfExists(params string[] path)
{
foreach (var p in path)
Expand Down Expand Up @@ -583,42 +591,222 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir)
#endif
var music = settings.GetMusic(id, assetDir);
if (music is null) return;
var error = await ModifyIdCore(music, newId, assetDir);
if (error is not null) throw new InvalidOperationException(error);
}

/// <summary>
/// 改乐曲 ID 的实现。返回 null 表示成功,否则是错误信息。
/// </summary>
private async Task<string?> ModifyIdCore(MusicXmlWithABJacket music, int newId, string assetDir)
{
var musicDir = Path.GetDirectoryName(music.FilePath);
if (string.IsNullOrWhiteSpace(musicDir) || !Directory.Exists(musicDir))
{
var message = $"Invalid source directory for music {music.Id}: {music.FilePath}";
logger.LogError("{message}", message);
throw new DirectoryNotFoundException(message);
return message;
}
if (music.Id == newId) return;
if (music.Id == newId) return null;

var newMusicDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "music", $"music{newId:000000}");
DeleteIfExists(newMusicDir);

MoveJacketSoundVideo(music, newId, assetDir); // 移动封面图、音频、视频

// 谱面
var oldMusicDir = Path.GetDirectoryName(music.FilePath)!;
for (var i = 0; i < 6; i++)
for (var i = 0; i < music.Charts.Length; i++)
{
var chart = music.Charts[i];
if (!chart.Enable) continue;
if (!System.IO.File.Exists(Path.Combine(oldMusicDir, chart.Path))) continue;

var oldFilePath = Path.Combine(oldMusicDir, chart.Path);
var newFileName = $"{newId:000000}_0{i}.ma2";
var newFilePath = Path.Combine(oldMusicDir, newFileName);

// 双人谱只有 _L / _R 两个文件,没有单谱文件,任意一种存在就算有谱面
if (!System.IO.File.Exists(oldFilePath)
&& !System.IO.File.Exists(WithSide(oldFilePath, "_L"))
&& !System.IO.File.Exists(WithSide(oldFilePath, "_R")))
continue;

logger.LogInformation("Move chart: {chart.Path} -> {newFileName}", chart.Path, newFileName);
PlatformFile.MoveFile(Path.Combine(oldMusicDir, chart.Path), Path.Combine(oldMusicDir, newFileName));
if (System.IO.File.Exists(oldFilePath)) PlatformFile.MoveFile(oldFilePath, newFilePath);
// 双人谱的左右谱面文件一起改名
foreach (var side in new[] { "_L", "_R" })
{
var oldSideFile = WithSide(oldFilePath, side);
if (System.IO.File.Exists(oldSideFile))
PlatformFile.MoveFile(oldSideFile, WithSide(newFilePath, side));
}

chart.Path = newFileName;
}

// 保存 XML
music.Id = newId;
SyncUtageBaseId(music, newId);
music.Save();
Directory.CreateDirectory(Path.Combine(StaticSettings.StreamingAssets, assetDir, "music"));
logger.LogInformation("Move music dir: {oldMusicDir} -> {newMusicDir}", oldMusicDir, newMusicDir);
PlatformFile.MoveDirectory(oldMusicDir, newMusicDir);

// 重新扫描全部
await settings.RescanAll();
return null;
}

/// <summary>
/// 改完 ID 后同步宴会场用的原曲 ID 记录:新 ID 还是宴会场 ID 就跟着换后四位(保留 STD/DX),
/// 改回普通 ID 就清掉,避免以后切流派时还原到错误的 ID。
/// </summary>
private static void SyncUtageBaseId(MusicXmlWithABJacket music, int newId)
{
if (music.UtageBaseId is not { } utageBaseId) return;
music.UtageBaseId = UtageConverter.IsUtageId(newId)
? newId % 10000 + (utageBaseId >= 10000 ? 10000 : 0)
: null;
}

/// <summary>
/// 切换乐曲流派。切到/切回宴会场时,ID 变更(宴会场 ID 固定后四位、顺延序号)和谱面转换
/// 都在这个接口里一次做完,避免前端分步调用留下半改状态。
/// 返回 Error 为 null 表示成功,NewId 是处理后的乐曲 ID。
/// </summary>
[HttpPost]
public async Task<GenreChangeResult> ChangeMusicGenre(int id, [FromBody] GenreChangeRequest request, string assetDir)
{
var genreId = request.GenreId;
var music = settings.GetMusic(id, assetDir);
if (music is null) return new GenreChangeResult(false, GenreChangeError.MusicNotFound, id);

var wasUtage = music.GenreId == UtageConverter.UtageGenreId;
var toUtage = genreId == UtageConverter.UtageGenreId;

// 和宴会场无关的流派切换保持原来的行为:只改流派
if (wasUtage == toUtage)
{
music.GenreId = genreId;
music.Save();
return new GenreChangeResult(true, null, id);
}

var newId = id;
var chartIndex = -1;
// 切到宴会场时记下的原 ID,宴会场 ID 里没有 STD/DX 信息,切回普通流派时只靠 ID 还原不回去
int? utageBaseId = null;
if (toUtage)
{
// 宴会场只认 Basic 谱面,先确认只有一个启用的难度
var error = UtageConverter.FindSingleEnabledChart(music, out chartIndex);
if (error is not null) return new GenreChangeResult(false, error, id);
if (UtageConverter.IsUtageId(id))
{
// 已经是宴会场 ID 了,只改流派
newId = id;
}
else
{
// 目标 ID 被占用时顺延一个序号(后四位不变),而不是覆盖掉已有的宴会场谱面
var freeId = UtageConverter.FindFreeUtageId(id,
candidate => settings.GetMusic(candidate, assetDir) is not null);
if (freeId is null) return new GenreChangeResult(false, GenreChangeError.NoAvailableId, id);
newId = freeId.Value;
utageBaseId = id;
}
}
else
{
// 含宴会场特有 L/R 双人谱的乐曲无法转成普通谱面
if (UtageConverter.HasSideCharts(music))
return new GenreChangeResult(false, GenreChangeError.DoublePlayerChart, id);
// 流派是宴会场但 ID 不是宴会场 ID(历史数据、手动改过)时没有可恢复的 ID,保持 ID 不动
if (UtageConverter.IsUtageId(id))
{
// 优先还原切到宴会场时记录的原 ID(能保住 STD/DX)。
// 记录值的后四位必须和当前宴会场 ID 对得上,否则说明中途改过 ID,记录已经不可信了。
int? restored = null;
if (music.UtageBaseId is { } recorded
&& !UtageConverter.IsUtageId(recorded)
&& recorded % 10000 == id % 10000)
restored = recorded;
restored ??= UtageConverter.RestoreNormalId(id);
if (restored is null)
return new GenreChangeResult(false, GenreChangeError.UtageIdNotRestorable, id);
newId = restored.Value;
}
}

if (newId != id)
{
#if WINDOWS
if (IapManager.License != IapManager.LicenseStatus.Active)
return new GenreChangeResult(false, GenreChangeError.LicenseRequired, id);
#endif
// 切到宴会场时挑的本来就是空闲 ID,只有切回普通流派时目标 ID 可能已被占用
if (settings.GetMusic(newId, assetDir) is not null)
return new GenreChangeResult(false, GenreChangeError.TargetIdExists, id);
}

var converted = false;
try
{
if (toUtage && chartIndex > 0)
{
var error = UtageConverter.MoveToBasic(music, chartIndex, logger);
if (error is not null) return new GenreChangeResult(false, error, id);
converted = true;
}

var targetId = id;
if (newId != id)
{
if (utageBaseId is not null) music.UtageBaseId = utageBaseId;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
var error = await ModifyIdCore(music, newId, assetDir);
if (error is not null)
{
TryRollback();
logger.LogError("切换流派时改 ID 失败: {error}", error);
return new GenreChangeResult(false, GenreChangeError.IdChangeFailed, id);
}
targetId = newId;
}

var target = settings.GetMusic(targetId, assetDir);
if (target is null)
{
TryRollback();
return new GenreChangeResult(false, GenreChangeError.MusicNotFound, id);
}

target.GenreId = genreId;
// 离开宴会场后原 ID 记录就没用了,清掉避免下次切回来时还原到错误的 ID
if (!toUtage) target.UtageBaseId = null;
target.Save();
return new GenreChangeResult(true, null, targetId);
}
catch (Exception e)
{
logger.LogError(e, "切换流派失败: {id} -> {genreId}", id, genreId);
TryRollback();
return new GenreChangeResult(false, GenreChangeError.Unexpected, id);
}

// 谱面已经转换成功、但后续步骤失败时,把谱面换回去
void TryRollback()
{
if (!converted) return;
try
{
var error = UtageConverter.RestoreFromBasic(music, chartIndex, logger);
if (error is not null) logger.LogError("回滚谱面转换失败: {error}", error);
}
catch (Exception rollbackError)
{
logger.LogError(rollbackError, "回滚谱面转换失败");
}
}
}

[HttpGet]
Expand Down Expand Up @@ -950,4 +1138,4 @@ public async Task RequestExportMaidata([FromBody] RequestExportMaidataRequest re

[GeneratedRegex(@"VERSION\t0.00.00\t1.(\d\d).00")]
private static partial Regex MA2VersionRegex();
}
}
Loading
Loading