From 51f62a9f784c778f31ecc503f64ba39eb3bb7cbd Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Sun, 15 Feb 2026 21:22:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E9=93=BA=E9=9D=A2=E6=97=B6=E7=9A=84=E8=AF=B8=E5=A4=9A?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 187 +++++++++--------- .gitignore | 1 + .../Music/MusicTransferController.cs | 134 ++++++++++--- .../BatchActionButton/remoteExport.ts | 140 ++++++++----- .../Front/src/utils/getSubDirFile.ts | 11 +- .../Front/src/utils/sanitizeFsName.ts | 29 +++ MaiChartManager/Program.cs | 11 +- MaiChartManager/Utils/AudioConvert.cs | 57 +++++- MaiChartManager/WannaCRI/WannaCRI.cs | 144 ++++++++++---- 9 files changed, 498 insertions(+), 216 deletions(-) create mode 100644 MaiChartManager/Front/src/utils/sanitizeFsName.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcdb5995..974783bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,99 +1,98 @@ name: Build Canary on: - push: - branches: [ "main" ] - paths-ignore: - - '**.md' - workflow_dispatch: + push: + branches: ["main"] + paths-ignore: + - "**.md" + workflow_dispatch: jobs: - build: - name: Build - runs-on: self-hosted - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive - - - name: Checkout Assets - uses: clansty/checkout@main - with: - repository: MuNET-OSS/AquaMai-Build-Assets - token: ${{ secrets.BUILD_ASSETS_PAT }} - path: build-assets - max-attempts: 50 - min-retry-interval: 1 - max-retry-interval: 5 - - - name: Copy Assets - shell: powershell - run: | - Write-Host "Copying assets..." - Copy-Item -Path "build-assets/SDEZ/*" -Destination "AquaMai/Libs/" -Recurse -Force - - - name: Setup Nuget - run: | - dotnet nuget list source - if (-not (dotnet nuget list source | Select-String "nuget.org")) { - dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org - } - - - name: Install Frontend Dependencies - shell: powershell - run: | - corepack enable - Push-Location MaiChartManager\Front - pnpm install - Pop-Location - - - - name: Build - shell: powershell - run: | - .\Packaging\Build.ps1 -Mode Canary - - - name: Upload to Alist - shell: powershell - env: - ALIST_TOKEN: ${{ secrets.ALIST_TOKEN }} - run: | - $alistUrl = "https://alist.c5y.moe" - - # 找到构建产物 - $appxFile = Get-ChildItem -Path "Packaging" -Filter "MaiChartManager_Canary_*.appx" | Select-Object -First 1 - if (-not $appxFile) { - throw "No appx file found!" - } - - Write-Host "Uploading $($appxFile.Name)..." - - # 上传文件 - $remotePath = "/SBGA/MaiChartManager Canary/$($appxFile.Name)" - $encodedPath = [System.Uri]::EscapeDataString($remotePath) - - $headers = @{ - "Authorization" = $env:ALIST_TOKEN - "File-Path" = $encodedPath - } - - Invoke-RestMethod -Uri "$alistUrl/api/fs/put" -Method Put -InFile $appxFile.FullName -Headers $headers - - Write-Host "Upload complete: $remotePath" - - - name: Update AppInstaller Config - shell: powershell - run: | - # 1. 获取构建好的 Appx 文件名来提取版本号 - $appxFile = Get-ChildItem -Path "Packaging" -Filter "MaiChartManager_Canary_*.appx" | Select-Object -First 1 - if (-not $appxFile) { throw "Appx file not found for version extraction!" } - - # 2. 提取版本号 (假设文件名格式为 MaiChartManager_Canary_1.2.3.4.appx) - $version = $appxFile.Name -replace 'MaiChartManager_Canary_', '' -replace '.appx', '' - Write-Host "Detected Version: $version" - - # 3. 执行更新脚本 - & "D:\sign\mcm-canary-update.ps1" -Version $version + build: + name: Build + runs-on: self-hosted + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Checkout Assets + uses: clansty/checkout@main + with: + repository: MuNET-OSS/AquaMai-Build-Assets + token: ${{ secrets.BUILD_ASSETS_PAT }} + path: build-assets + max-attempts: 50 + min-retry-interval: 1 + max-retry-interval: 5 + + - name: Copy Assets + shell: powershell + run: | + Write-Host "Copying assets..." + Copy-Item -Path "build-assets/SDEZ/*" -Destination "AquaMai/Libs/" -Recurse -Force + + - name: Setup Nuget + run: | + dotnet nuget list source + if (-not (dotnet nuget list source | Select-String "nuget.org")) { + dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org + } + + - name: Install Frontend Dependencies + shell: powershell + run: | + corepack enable + Push-Location MaiChartManager\Front + pnpm install + Pop-Location + + - name: Build + shell: powershell + run: | + .\Packaging\Build.ps1 -Mode Canary + + - name: Upload to Alist + shell: powershell + env: + ALIST_TOKEN: ${{ secrets.ALIST_TOKEN }} + run: | + $alistUrl = "https://alist.c5y.moe" + + # 找到构建产物 + $appxFile = Get-ChildItem -Path "Packaging" -Filter "MaiChartManager_Canary_*.appx" | Select-Object -First 1 + if (-not $appxFile) { + throw "No appx file found!" + } + + Write-Host "Uploading $($appxFile.Name)..." + + # 上传文件 + $remotePath = "/SBGA/MaiChartManager Canary/$($appxFile.Name)" + $encodedPath = [System.Uri]::EscapeDataString($remotePath) + + $headers = @{ + "Authorization" = $env:ALIST_TOKEN + "File-Path" = $encodedPath + } + + Invoke-RestMethod -Uri "$alistUrl/api/fs/put" -Method Put -InFile $appxFile.FullName -Headers $headers + + Write-Host "Upload complete: $remotePath" + + - name: Update AppInstaller Config + shell: powershell + run: | + # 1. 获取构建好的 Appx 文件名来提取版本号 + $appxFile = Get-ChildItem -Path "Packaging" -Filter "MaiChartManager_Canary_*.appx" | Select-Object -First 1 + if (-not $appxFile) { throw "Appx file not found for version extraction!" } + + # 2. 提取版本号 (假设文件名格式为 MaiChartManager_Canary_1.2.3.4.appx) + $version = $appxFile.Name -replace 'MaiChartManager_Canary_', '' -replace '.appx', '' + Write-Host "Detected Version: $version" + + # 3. 执行更新脚本 + & "D:\sign\mcm-canary-update.ps1" -Version $version diff --git a/.gitignore b/.gitignore index c25e408c..929b50e7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ riderModule.iml /_ReSharper.Caches/ .vs *.user +*.code-workspace /MaiChartManager/Resources/AquaMai.dll node_modules diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 07242c08..9251b292 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Text; +using MaiChartManager.Models; using MaiChartManager.Utils; using MaiLib; using Microsoft.AspNetCore.Mvc; @@ -18,6 +19,20 @@ public class MusicTransferController(StaticSettings settings, ILogger (int)(Math.Abs((long)it) % 10000)) + .Distinct() + .Select(it => it.ToString("000000"))); + return $"Failed to resolve audio ACB/AWB for music {music.Id} ({music.Name}), cueId={music.CueId:000000}, nonDxId={music.NonDxId:000000}, candidates=[{candidates}]."; + } + [HttpPost] [Route("/MaiChartManagerServlet/[action]Api")] public void RequestCopyTo(RequestCopyToRequest request) @@ -50,6 +65,12 @@ public void RequestCopyTo(RequestCopyToRequest request) var musicId = request.music[i]; var music = settings.GetMusic(musicId.Id, musicId.AssetDir); if (music is null) continue; + var musicDir = Path.GetDirectoryName(music.FilePath); + if (string.IsNullOrWhiteSpace(musicDir) || !Directory.Exists(musicDir)) + { + logger.LogWarning("Skip export for music {musicId}: invalid source directory from file path {filePath}", music.Id, music.FilePath); + continue; + } if (progress?.IsCancelled ?? false) { break; @@ -63,7 +84,7 @@ public void RequestCopyTo(RequestCopyToRequest request) // copy music Directory.CreateDirectory(Path.Combine(dest, "music")); - FileSystem.CopyDirectory(Path.GetDirectoryName(music.FilePath), Path.Combine(dest, $@"music\music{music.Id:000000}"), UIOption.OnlyErrorDialogs); + FileSystem.CopyDirectory(musicDir, Path.Combine(dest, $@"music\music{music.Id:000000}"), UIOption.OnlyErrorDialogs); if (request.removeEvents) { @@ -104,14 +125,16 @@ public void RequestCopyTo(RequestCopyToRequest request) // copy acbawb Directory.CreateDirectory(Path.Combine(dest, "SoundData")); - if (StaticSettings.AcbAwb.TryGetValue($"music{music.NonDxId:000000}.acb", out var acb)) + if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) + && acb is not null + && awb is not null) { - FileSystem.CopyFile(acb, Path.Combine(dest, $@"SoundData\music{music.NonDxId:000000}.acb"), UIOption.OnlyErrorDialogs); + FileSystem.CopyFile(acb, Path.Combine(dest, $@"SoundData\music{resolvedAudioId:000000}.acb"), UIOption.OnlyErrorDialogs); + FileSystem.CopyFile(awb, Path.Combine(dest, $@"SoundData\music{resolvedAudioId:000000}.awb"), UIOption.OnlyErrorDialogs); } - - if (StaticSettings.AcbAwb.TryGetValue($"music{music.NonDxId:000000}.awb", out var awb)) + else { - FileSystem.CopyFile(awb, Path.Combine(dest, $@"SoundData\music{music.NonDxId:000000}.awb"), UIOption.OnlyErrorDialogs); + logger.LogWarning("{message}", BuildAudioResolveErrorMessage(music)); } // copy movie data @@ -130,12 +153,19 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l { var music = settings.GetMusic(id, assetDir); if (music is null) return; + 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); + } var zipStream = HttpContext.Response.BodyWriter.AsStream(); using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true); // copy music - foreach (var file in Directory.EnumerateFiles(Path.GetDirectoryName(music.FilePath))) + foreach (var file in Directory.EnumerateFiles(musicDir)) { if (Path.GetFileName(file).Equals("Music.xml", StringComparison.InvariantCultureIgnoreCase) && removeEvents) { @@ -182,15 +212,14 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l } // copy acbawb - if (StaticSettings.AcbAwb.TryGetValue($"music{music.NonDxId:000000}.acb", out var acb)) - { - zipArchive.CreateEntryFromFile(acb, $"SoundData/music{music.NonDxId:000000}.acb"); - } - - if (StaticSettings.AcbAwb.TryGetValue($"music{music.NonDxId:000000}.awb", out var awb)) + if (!AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) || acb is null || awb is null) { - zipArchive.CreateEntryFromFile(awb, $"SoundData/music{music.NonDxId:000000}.awb"); + var message = BuildAudioResolveErrorMessage(music); + logger.LogError("{message}", message); + throw new FileNotFoundException(message); } + zipArchive.CreateEntryFromFile(acb, $"SoundData/music{resolvedAudioId:000000}.acb"); + zipArchive.CreateEntryFromFile(awb, $"SoundData/music{resolvedAudioId:000000}.awb"); // copy movie data if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) @@ -223,6 +252,13 @@ public void ModifyId(int id, [FromBody] int newId, string assetDir) if (IapManager.License != IapManager.LicenseStatus.Active) return; var music = settings.GetMusic(id, assetDir); if (music is null) return; + 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); + } var newNonDxId = newId % 10000; var abJacketTarget = Path.Combine(StaticSettings.StreamingAssets, assetDir, "AssetBundleImages", "jacket", $"ui_jacket_{newNonDxId:000000}.ab"); @@ -310,6 +346,13 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa { var music = settings.GetMusic(id, assetDir); if (music is null) return; + 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); + } await using var zipStream = HttpContext.Response.BodyWriter.AsStream(); using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true); @@ -377,29 +420,62 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa Comment = version?.GenreName, AlbumArt = img, }; - AudioConvert.ConvertWavPathToMp3Stream(await AudioConvert.GetCachedWavPath(id), soundStream, tag); + var wavPath = await AudioConvert.GetCachedWavPath(GetAudioCandidateIds(music)); + if (wavPath is null) + { + var message = BuildAudioResolveErrorMessage(music); + logger.LogError("{message}", message); + throw new FileNotFoundException(message); + } + + AudioConvert.ConvertWavPathToMp3Stream(wavPath, soundStream, tag); soundStream.Close(); if (!ignoreVideo && StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movieUsmPath)) { - string? pvMp4Path = null; - var ext = Path.GetExtension(movieUsmPath).ToLowerInvariant(); - - if (ext == ".dat" || ext == ".usm") + DirectoryInfo? tmpDir = null; + try { - var tmpDir = Directory.CreateTempSubdirectory(); - logger.LogInformation("Temp dir: {tmpDir}", tmpDir.FullName); - pvMp4Path = Path.Combine(tmpDir.FullName, "pv.mp4"); + string? pvMp4Path = null; + var ext = Path.GetExtension(movieUsmPath).ToLowerInvariant(); + + if (ext == ".dat" || ext == ".usm") + { + tmpDir = Directory.CreateTempSubdirectory(); + logger.LogInformation("Temp dir: {tmpDir}", tmpDir.FullName); + pvMp4Path = Path.Combine(tmpDir.FullName, "pv.mp4"); + + await VideoConvert.ConvertUsmToMp4(movieUsmPath, pvMp4Path); + } + else if (ext == ".mp4") + { + pvMp4Path = movieUsmPath; + } - await VideoConvert.ConvertUsmToMp4(movieUsmPath, pvMp4Path); + if (pvMp4Path is not null && System.IO.File.Exists(pvMp4Path)) + { + zipArchive.CreateEntryFromFile(pvMp4Path, "pv.mp4"); + } } - else if (ext == ".mp4") + catch (Exception ex) { - pvMp4Path = movieUsmPath; + logger.LogWarning(ex, "Failed to export pv.mp4 for music {musicId} ({name}), skipping video.", music.Id, music.Name); + } + finally + { + if (tmpDir is not null) + { + try + { + tmpDir.Delete(true); + } + catch + { + // ignore cleanup errors + } + } } - - if (pvMp4Path is not null) - zipArchive.CreateEntryFromFile(pvMp4Path, "pv.mp4"); } } -} \ No newline at end of file +} + diff --git a/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.ts b/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.ts index d0b5ce00..01b77cb4 100644 --- a/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.ts +++ b/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.ts @@ -7,33 +7,84 @@ import { MAIDATA_SUBDIR, OPTIONS } from "@/components/MusicList/BatchActionButto import { useNotification } from "naive-ui"; import { getUrl } from "@/client/api"; import { addVersionList, genreList } from "@/store/refs"; -import { t } from '@/locales'; +import { t } from "@/locales"; +import { sanitizeFsSegment } from "@/utils/sanitizeFsName"; export default async (setStep: (step: STEP) => void, musicList: MusicXmlWithABJacket[], action: OPTIONS, notify: ReturnType, dirOption: MAIDATA_SUBDIR) => { let folderHandle: FileSystemDirectoryHandle; try { folderHandle = await window.showDirectoryPicker({ - id: 'copyToSaveDir', - mode: 'readwrite' + id: "copyToSaveDir", + mode: "readwrite" }); } catch (e) { - console.log(e) + console.log(e); return; } + const usedDirNamesByParent = new Map>(); + const exportDirByMusic = new Map(); + + const getUniqueDirName = (parentDir: string, baseName: string) => { + let used = usedDirNamesByParent.get(parentDir); + if (!used) { + used = new Set(); + usedDirNamesByParent.set(parentDir, used); + } + + let candidate = baseName; + let index = 2; + while (used.has(candidate)) { + candidate = `${baseName} (${index})`; + index++; + } + used.add(candidate); + return candidate; + }; + + const getMaidataExportDir = (music: MusicXmlWithABJacket) => { + const musicKey = `${music.assetDir}:${music.id}`; + const cached = exportDirByMusic.get(musicKey); + if (cached) { + return cached; + } + + let parentDir = ""; + switch (dirOption) { + case MAIDATA_SUBDIR.Genre: + parentDir = genreList.value.find((genre) => genre.id === music.genreId)?.genreName || t("music.list.unknown"); + break; + case MAIDATA_SUBDIR.Version: + parentDir = addVersionList.value.find((version) => version.id === music.addVersionId)?.genreName || t("music.list.unknown"); + break; + } + + if (parentDir) { + parentDir = sanitizeFsSegment(parentDir, t("music.list.unknown")); + } + + const suffix = music.id! > 1e4 && music.id! < 2e4 ? " [DX]" : ""; + const safeTitle = sanitizeFsSegment(music.name || t("music.list.unknown"), t("music.list.unknown")); + const uniqueDir = getUniqueDirName(parentDir, `${safeTitle}${suffix}`); + const fullDir = parentDir ? `${parentDir}/${uniqueDir}` : uniqueDir; + exportDirByMusic.set(musicKey, fullDir); + return fullDir; + }; + progressCurrent.value = 0; progressAll.value = musicList.length; - currentProcessItem.value = ''; + currentProcessItem.value = ""; setStep(STEP.ProgressDisplay); for (let i = 0; i < musicList.length; i++) { const music = musicList[i]; + const musicName = music.name || t("music.list.unknown"); progressCurrent.value = i; - currentProcessItem.value = music.name!; + currentProcessItem.value = musicName; - let url = ''; + let url = ""; switch (action) { case OPTIONS.CreateNewOpt: url = `ExportOptApi/${music.assetDir}/${music.id}`; @@ -51,48 +102,49 @@ export default async (setStep: (step: STEP) => void, musicList: MusicXmlWithABJa url = `ExportAsMaidataApi/${music.assetDir}/${music.id}?ignoreVideo=true`; break; } - url = getUrl(url); - const zip = await fetch(url); - const zipReader = new ZipReader(zip.body!); - const entries = zipReader.getEntriesGenerator(); - for await (const entry of entries) { + + try { + const response = await fetch(getUrl(url)); + if (!response.ok || !response.body) { + throw new Error(`Export request failed: ${response.status} ${response.statusText}`); + } + + const zipReader = new ZipReader(response.body); try { - console.log(entry.filename); - if (entry.filename.endsWith('/')) { - continue; - } - let filename = entry.filename; - if (action === OPTIONS.ConvertToMaidata || action === OPTIONS.ConvertToMaidataIgnoreVideo) { - let dir = ''; - switch (dirOption) { - case MAIDATA_SUBDIR.Genre: - dir = genreList.value.find(genre => genre.id === music.genreId)?.genreName || t('music.list.unknown'); - break; - case MAIDATA_SUBDIR.Version: - dir = addVersionList.value.find(version => version.id === music.addVersionId)?.genreName || t('music.list.unknown'); - break; - } - if (dir) { - dir = sanitizeFilename(dir) + '/'; + const entries = zipReader.getEntriesGenerator(); + for await (const entry of entries) { + try { + if (entry.filename.endsWith("/")) { + continue; + } + + let filename = entry.filename; + if (action === OPTIONS.ConvertToMaidata || action === OPTIONS.ConvertToMaidataIgnoreVideo) { + filename = `${getMaidataExportDir(music)}/${filename}`; + } + + const fileHandle = await getSubDirFile(folderHandle, filename); + const writable = await fileHandle.createWritable(); + await entry.getData!(writable); + } catch (e) { + console.error(e); + notify.error({ + title: t("error.exportFailed"), + content: musicName + }); } - filename = `${dir}${sanitizeFilename(music.name!)}${music.id! > 1e4 && music.id! < 2e4 ? ' [DX]' : ''}/${filename}`; } - const fileHandle = await getSubDirFile(folderHandle, filename); - const writable = await fileHandle.createWritable(); - await entry.getData!(writable); - } catch (e) { - console.error(e); - notify.error({ - title: t('error.exportFailed'), - content: music.name!, - }) + } finally { + await zipReader.close(); } + } catch (e) { + console.error(e); + notify.error({ + title: t("error.exportFailed"), + content: musicName + }); } } setStep(STEP.None); -} - -const sanitizeFilename = (filename: string) => { - return filename.replace(/[\/:*?"<>|]/g, '_').replace(/[.\s]+$/, ''); -} +}; diff --git a/MaiChartManager/Front/src/utils/getSubDirFile.ts b/MaiChartManager/Front/src/utils/getSubDirFile.ts index bd15f5a4..fdbd11a1 100644 --- a/MaiChartManager/Front/src/utils/getSubDirFile.ts +++ b/MaiChartManager/Front/src/utils/getSubDirFile.ts @@ -1,5 +1,14 @@ +import { sanitizeFsSegment } from "@/utils/sanitizeFsName"; + export default async (folderHandle: FileSystemDirectoryHandle, fileName: string) => { - const pathParts = fileName.split('/'); + const pathParts = fileName + .split("/") + .filter((part) => part.length > 0) + .map((part) => sanitizeFsSegment(part)); + + if (pathParts.length === 0) { + throw new Error("Invalid file path"); + } let dirHandle = folderHandle; for (let i = 0; i < pathParts.length - 1; i++) { diff --git a/MaiChartManager/Front/src/utils/sanitizeFsName.ts b/MaiChartManager/Front/src/utils/sanitizeFsName.ts new file mode 100644 index 00000000..ce1c9157 --- /dev/null +++ b/MaiChartManager/Front/src/utils/sanitizeFsName.ts @@ -0,0 +1,29 @@ +const ILLEGAL_FS_CHAR_MAP: Record = { + "/": "\uFF0F", + "\\": "\uFF3C", + ":": "\uFF1A", + "*": "\uFF0A", + "?": "\uFF1F", + "\"": "\uFF02", + "<": "\uFF1C", + ">": "\uFF1E", + "|": "\uFF5C" +}; + +const sanitizeRawSegment = (segment: string) => { + return segment + .normalize("NFC") + .replace(/[\/\\:*?"<>|]/g, (char) => ILLEGAL_FS_CHAR_MAP[char] || char) + .replace(/[\u0000-\u001F]/g, "") + .replace(/[.\s]+$/g, ""); +}; + +export const sanitizeFsSegment = (segment: string, fallback = "untitled") => { + const sanitized = sanitizeRawSegment(segment || ""); + if (sanitized.length > 0) { + return sanitized; + } + + const fallbackSanitized = sanitizeRawSegment(fallback || "untitled"); + return fallbackSanitized.length > 0 ? fallbackSanitized : "untitled"; +}; diff --git a/MaiChartManager/Program.cs b/MaiChartManager/Program.cs index c832a7d7..e984d31f 100644 --- a/MaiChartManager/Program.cs +++ b/MaiChartManager/Program.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using SingleInstanceCore; +using WannaCriRunner = MaiChartManager.WannaCRI.WannaCRI; namespace MaiChartManager; @@ -12,10 +13,16 @@ public static partial class Program /// The main entry point for the application. /// [STAThread] - public static void Main() + public static void Main(string[] args) { SetConsoleOutputCP(65001); + if (args.Length > 0 && args[0].Equals("--run-wannacri", StringComparison.OrdinalIgnoreCase)) + { + Environment.ExitCode = WannaCriRunner.RunHelper(args[1..]); + return; + } + var app = new AppMain(); var isFirstInstance = app.InitializeAsFirstInstance("MaiChartManager"); @@ -31,4 +38,4 @@ public static void Main() } } } -} \ No newline at end of file +} diff --git a/MaiChartManager/Utils/AudioConvert.cs b/MaiChartManager/Utils/AudioConvert.cs index 2a6d8b38..db2b62d8 100644 --- a/MaiChartManager/Utils/AudioConvert.cs +++ b/MaiChartManager/Utils/AudioConvert.cs @@ -7,16 +7,61 @@ namespace MaiChartManager.Utils; public static class AudioConvert { - public static async Task GetCachedWavPath(int musicId) + private static IEnumerable GetDistinctAudioIds(IEnumerable musicIds) { - var awb = StaticSettings.AcbAwb.GetValueOrDefault($"music{(musicId % 10000):000000}.awb"); - if (awb is null) + var seen = new HashSet(); + foreach (var rawId in musicIds) + { + var musicId = (int)(Math.Abs((long)rawId) % 10000); + if (seen.Add(musicId)) + { + yield return musicId; + } + } + } + + public static bool TryResolveAcbAwb(IEnumerable musicIds, out int resolvedMusicId, out string? acbPath, out string? awbPath) + { + foreach (var musicId in GetDistinctAudioIds(musicIds)) + { + var acbKey = $"music{musicId:000000}.acb"; + var awbKey = $"music{musicId:000000}.awb"; + if (!StaticSettings.AcbAwb.TryGetValue(acbKey, out var acb) || string.IsNullOrEmpty(acb)) + { + continue; + } + + if (!StaticSettings.AcbAwb.TryGetValue(awbKey, out var awb) || string.IsNullOrEmpty(awb)) + { + continue; + } + + resolvedMusicId = musicId; + acbPath = acb; + awbPath = awb; + return true; + } + + resolvedMusicId = 0; + acbPath = null; + awbPath = null; + return false; + } + + public static async Task GetCachedWavPath(params int[] musicIds) + { + if (!TryResolveAcbAwb(musicIds, out _, out var acbPath, out var awbPath) || acbPath is null || awbPath is null) { return null; } + return await GetCachedWavPath(acbPath, awbPath); + } + + public static async Task GetCachedWavPath(string acbPath, string awbPath) + { string hash; - await using (var readStream = File.OpenRead(awb)) + await using (var readStream = File.OpenRead(awbPath)) { hash = (await xxHash64.ComputeHashAsync(readStream)).ToString(); } @@ -24,7 +69,7 @@ public static class AudioConvert var cachePath = Path.Combine(StaticSettings.tempPath, hash + ".wav"); if (File.Exists(cachePath)) return cachePath; - var wav = Audio.AcbToWav(StaticSettings.AcbAwb[$"music{(musicId % 10000):000000}.acb"]); + var wav = Audio.AcbToWav(acbPath); await File.WriteAllBytesAsync(cachePath, wav); return cachePath; } @@ -35,4 +80,4 @@ public static void ConvertWavPathToMp3Stream(string wavPath, Stream mp3Stream, I using var writer = new LameMP3FileWriter(mp3Stream, reader.WaveFormat, 256, tagData); reader.CopyTo(writer); } -} \ No newline at end of file +} diff --git a/MaiChartManager/WannaCRI/WannaCRI.cs b/MaiChartManager/WannaCRI/WannaCRI.cs index 7f72433f..803918e2 100644 --- a/MaiChartManager/WannaCRI/WannaCRI.cs +++ b/MaiChartManager/WannaCRI/WannaCRI.cs @@ -1,9 +1,12 @@ -using Python.Runtime; +using System.Diagnostics; +using Python.Runtime; namespace MaiChartManager.WannaCRI; public static class WannaCRI { + private const string DefaultKey = "0x7F4551499DF55E68"; + static WannaCRI() { Runtime.PythonDLL = Path.Combine(StaticSettings.exeDir, "Python", "python312.dll"); @@ -11,59 +14,120 @@ static WannaCRI() PythonEngine.PythonPath = $"{Path.Combine(StaticSettings.exeDir, "WannaCRI")};{Path.Combine(StaticSettings.exeDir, "Python")}"; } - private static void RunWannaCRIWithArgs(params string[] args) + private static void RunWannaCRIWithArgsInCurrentProcess(params string[] args) { PythonEngine.Initialize(); - using (Py.GIL()) + try { - using var scope = Py.CreateScope(); - - // Hook Popen - scope.Exec(""" - import subprocess - import os - - # 保存原始的 Popen 函数 - _orig_Popen = subprocess.Popen - - # 定义新的 Popen 函数 - def _Popen_no_window(*args, **kwargs): - # 添加 creationflags 参数,防止弹出 cmd 窗口 - if os.name == 'nt': # 仅在 Windows 上设置 - kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW - return _orig_Popen(*args, **kwargs) - - # 替换原始 Popen 函数 - subprocess.Popen = _Popen_no_window - """); - - var sys = scope.Import("sys"); - var argv = new PyList(); - argv.Append(new PyString("qwq")); - foreach (var arg in args) + using (Py.GIL()) { - argv.Append(new PyString(arg)); + using var scope = Py.CreateScope(); + + // Hook Popen + scope.Exec(""" + import subprocess + import os + + # 保存原始的 Popen 函数 + _orig_Popen = subprocess.Popen + + # 定义新的 Popen 函数 + def _Popen_no_window(*args, **kwargs): + # 添加 creationflags 参数,防止弹出 cmd 窗口 + if os.name == 'nt': # 仅在 Windows 上设置 + kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW + return _orig_Popen(*args, **kwargs) + + # 替换原始 Popen 函数 + subprocess.Popen = _Popen_no_window + """); + + var sys = scope.Import("sys"); + var argv = new PyList(); + argv.Append(new PyString("qwq")); + foreach (var arg in args) + { + argv.Append(new PyString(arg)); + } + + sys.SetAttr("argv", argv); + + var wannacri = scope.Import("wannacri"); + wannacri.GetAttr("main").Invoke(); } + } + finally + { + // 不然的话第二次转换会卡住 + PythonEngine.Shutdown(); + } + } + + private static void RunWannaCRIWithArgsInHelperProcess(params string[] args) + { + var processPath = Application.ExecutablePath; + if (string.IsNullOrEmpty(processPath)) + { + processPath = Environment.ProcessPath; + } - sys.SetAttr("argv", argv); + if (string.IsNullOrEmpty(processPath)) + { + throw new InvalidOperationException("Cannot locate current executable path."); + } - var wannacri = scope.Import("wannacri"); - wannacri.GetAttr("main").Invoke(); + var startInfo = new ProcessStartInfo + { + FileName = processPath, + WorkingDirectory = StaticSettings.exeDir, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true + }; + startInfo.ArgumentList.Add("--run-wannacri"); + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); } - // 不然的话第二次转换会卡住 - PythonEngine.Shutdown(); + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start WannaCRI helper process."); + var stderrTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + var stderr = stderrTask.GetAwaiter().GetResult().Trim(); + + if (process.ExitCode != 0) + { + throw new Exception($"WannaCRI helper failed with exit code {process.ExitCode}: {stderr}"); + } } - private const string defaultKey = "0x7F4551499DF55E68"; + public static int RunHelper(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("Missing WannaCRI command."); + return 2; + } + + try + { + RunWannaCRIWithArgsInCurrentProcess(args); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + return 1; + } + } - public static void CreateUsm(string src, string key = defaultKey) + public static void CreateUsm(string src, string key = DefaultKey) { - RunWannaCRIWithArgs("createusm", src, "--key", key, "--ffprobe", Path.Combine(StaticSettings.exeDir, "ffprobe.exe"), "--output", Path.GetDirectoryName(src)); + RunWannaCRIWithArgsInHelperProcess("createusm", src, "--key", key, "--ffprobe", Path.Combine(StaticSettings.exeDir, "ffprobe.exe"), "--output", Path.GetDirectoryName(src)!); } - public static void UnpackUsm(string src, string output, string key = defaultKey) + public static void UnpackUsm(string src, string output, string key = DefaultKey) { - RunWannaCRIWithArgs("extractusm", src, "--key", key, "--output", output); + RunWannaCRIWithArgsInHelperProcess("extractusm", src, "--key", key, "--output", output); } -} \ No newline at end of file +} From a0084890ce30c98d03f0b304059077d121786dff Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Sun, 15 Feb 2026 21:50:39 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(export):=20=E5=8A=A0=E5=9B=BA=20RemoteE?= =?UTF-8?q?xport=20=E5=92=8C=20HelperProcess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{remoteExport.ts => remoteExport.tsx} | 54 ++++++++++++++----- .../Front/src/utils/getSubDirFile.ts | 17 ++++-- MaiChartManager/WannaCRI/WannaCRI.cs | 14 ++--- 3 files changed, 62 insertions(+), 23 deletions(-) rename MaiChartManager/Front/src/components/MusicList/BatchActionButton/{remoteExport.ts => remoteExport.tsx} (74%) diff --git a/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.ts b/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.tsx similarity index 74% rename from MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.ts rename to MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.tsx index 01b77cb4..dcd39179 100644 --- a/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.ts +++ b/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.tsx @@ -1,21 +1,34 @@ import { STEP } from "@/components/MusicList/BatchActionButton/index"; -import { currentProcessItem, progressAll, progressCurrent } from "@/components/MusicList/BatchActionButton/ProgressDisplay"; +import { + currentProcessItem, + progressAll, + progressCurrent, +} from "@/components/MusicList/BatchActionButton/ProgressDisplay"; import { MusicXmlWithABJacket } from "@/client/apiGen"; import { ZipReader } from "@zip.js/zip.js"; import getSubDirFile from "@/utils/getSubDirFile"; -import { MAIDATA_SUBDIR, OPTIONS } from "@/components/MusicList/BatchActionButton/ChooseAction"; +import { + MAIDATA_SUBDIR, + OPTIONS, +} from "@/components/MusicList/BatchActionButton/ChooseAction"; import { useNotification } from "naive-ui"; import { getUrl } from "@/client/api"; import { addVersionList, genreList } from "@/store/refs"; import { t } from "@/locales"; import { sanitizeFsSegment } from "@/utils/sanitizeFsName"; -export default async (setStep: (step: STEP) => void, musicList: MusicXmlWithABJacket[], action: OPTIONS, notify: ReturnType, dirOption: MAIDATA_SUBDIR) => { +export default async ( + setStep: (step: STEP) => void, + musicList: MusicXmlWithABJacket[], + action: OPTIONS, + notify: ReturnType, + dirOption: MAIDATA_SUBDIR, +) => { let folderHandle: FileSystemDirectoryHandle; try { folderHandle = await window.showDirectoryPicker({ id: "copyToSaveDir", - mode: "readwrite" + mode: "readwrite", }); } catch (e) { console.log(e); @@ -52,10 +65,15 @@ export default async (setStep: (step: STEP) => void, musicList: MusicXmlWithABJa let parentDir = ""; switch (dirOption) { case MAIDATA_SUBDIR.Genre: - parentDir = genreList.value.find((genre) => genre.id === music.genreId)?.genreName || t("music.list.unknown"); + parentDir = + genreList.value.find((genre) => genre.id === music.genreId) + ?.genreName || t("music.list.unknown"); break; case MAIDATA_SUBDIR.Version: - parentDir = addVersionList.value.find((version) => version.id === music.addVersionId)?.genreName || t("music.list.unknown"); + parentDir = + addVersionList.value.find( + (version) => version.id === music.addVersionId, + )?.genreName || t("music.list.unknown"); break; } @@ -64,7 +82,10 @@ export default async (setStep: (step: STEP) => void, musicList: MusicXmlWithABJa } const suffix = music.id! > 1e4 && music.id! < 2e4 ? " [DX]" : ""; - const safeTitle = sanitizeFsSegment(music.name || t("music.list.unknown"), t("music.list.unknown")); + const safeTitle = sanitizeFsSegment( + music.name || t("music.list.unknown"), + t("music.list.unknown"), + ); const uniqueDir = getUniqueDirName(parentDir, `${safeTitle}${suffix}`); const fullDir = parentDir ? `${parentDir}/${uniqueDir}` : uniqueDir; exportDirByMusic.set(musicKey, fullDir); @@ -106,7 +127,9 @@ export default async (setStep: (step: STEP) => void, musicList: MusicXmlWithABJa try { const response = await fetch(getUrl(url)); if (!response.ok || !response.body) { - throw new Error(`Export request failed: ${response.status} ${response.statusText}`); + throw new Error( + `Export request failed: ${response.status} ${response.statusText}`, + ); } const zipReader = new ZipReader(response.body); @@ -119,18 +142,25 @@ export default async (setStep: (step: STEP) => void, musicList: MusicXmlWithABJa } let filename = entry.filename; - if (action === OPTIONS.ConvertToMaidata || action === OPTIONS.ConvertToMaidataIgnoreVideo) { + if ( + action === OPTIONS.ConvertToMaidata || + action === OPTIONS.ConvertToMaidataIgnoreVideo + ) { filename = `${getMaidataExportDir(music)}/${filename}`; } const fileHandle = await getSubDirFile(folderHandle, filename); const writable = await fileHandle.createWritable(); - await entry.getData!(writable); + try { + await entry.getData!(writable); + } finally { + await writable.close(); + } } catch (e) { console.error(e); notify.error({ title: t("error.exportFailed"), - content: musicName + content: musicName, }); } } @@ -141,7 +171,7 @@ export default async (setStep: (step: STEP) => void, musicList: MusicXmlWithABJa console.error(e); notify.error({ title: t("error.exportFailed"), - content: musicName + content: musicName, }); } } diff --git a/MaiChartManager/Front/src/utils/getSubDirFile.ts b/MaiChartManager/Front/src/utils/getSubDirFile.ts index fdbd11a1..cf50ef85 100644 --- a/MaiChartManager/Front/src/utils/getSubDirFile.ts +++ b/MaiChartManager/Front/src/utils/getSubDirFile.ts @@ -1,9 +1,12 @@ import { sanitizeFsSegment } from "@/utils/sanitizeFsName"; -export default async (folderHandle: FileSystemDirectoryHandle, fileName: string) => { +export default async ( + folderHandle: FileSystemDirectoryHandle, + fileName: string, +) => { const pathParts = fileName .split("/") - .filter((part) => part.length > 0) + .filter((part) => part.length > 0 && part !== "." && part !== "..") .map((part) => sanitizeFsSegment(part)); if (pathParts.length === 0) { @@ -12,7 +15,11 @@ export default async (folderHandle: FileSystemDirectoryHandle, fileName: string) let dirHandle = folderHandle; for (let i = 0; i < pathParts.length - 1; i++) { - dirHandle = await dirHandle.getDirectoryHandle(pathParts[i], {create: true}); + dirHandle = await dirHandle.getDirectoryHandle(pathParts[i], { + create: true, + }); } - return await dirHandle.getFileHandle(pathParts[pathParts.length - 1], {create: true}); -} + return await dirHandle.getFileHandle(pathParts[pathParts.length - 1], { + create: true, + }); +}; diff --git a/MaiChartManager/WannaCRI/WannaCRI.cs b/MaiChartManager/WannaCRI/WannaCRI.cs index 803918e2..8d3e9de3 100644 --- a/MaiChartManager/WannaCRI/WannaCRI.cs +++ b/MaiChartManager/WannaCRI/WannaCRI.cs @@ -65,11 +65,7 @@ def _Popen_no_window(*args, **kwargs): private static void RunWannaCRIWithArgsInHelperProcess(params string[] args) { - var processPath = Application.ExecutablePath; - if (string.IsNullOrEmpty(processPath)) - { - processPath = Environment.ProcessPath; - } + var processPath = Environment.ProcessPath; if (string.IsNullOrEmpty(processPath)) { @@ -123,7 +119,12 @@ public static int RunHelper(string[] args) public static void CreateUsm(string src, string key = DefaultKey) { - RunWannaCRIWithArgsInHelperProcess("createusm", src, "--key", key, "--ffprobe", Path.Combine(StaticSettings.exeDir, "ffprobe.exe"), "--output", Path.GetDirectoryName(src)!); + var outputDir = Path.GetDirectoryName(src); + if (string.IsNullOrEmpty(outputDir)) + { + throw new ArgumentException("Source path must be a full path to a file.", nameof(src)); + } + RunWannaCRIWithArgsInHelperProcess("createusm", src, "--key", key, "--ffprobe", Path.Combine(StaticSettings.exeDir, "ffprobe.exe"), "--output", outputDir); } public static void UnpackUsm(string src, string output, string key = DefaultKey) @@ -131,3 +132,4 @@ public static void UnpackUsm(string src, string output, string key = DefaultKey) RunWannaCRIWithArgsInHelperProcess("extractusm", src, "--key", key, "--output", output); } } + From c3ceea44797738bb52b726291b714f774f8593b1 Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Tue, 17 Feb 2026 15:22:30 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=B8=AD=E7=9A=84=E8=B7=AF=E5=BE=84=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=92=8C=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MaiChartManager/AppMain.g.cs | 4 +- .../Music/MusicTransferController.cs | 17 ++++-- .../src/components/CopyToButton/index.tsx | 32 ++++++---- .../BatchActionButton/remoteExport.tsx | 58 +++++++------------ 4 files changed, 57 insertions(+), 54 deletions(-) diff --git a/MaiChartManager/AppMain.g.cs b/MaiChartManager/AppMain.g.cs index d2c0d32d..6071ee73 100644 --- a/MaiChartManager/AppMain.g.cs +++ b/MaiChartManager/AppMain.g.cs @@ -1,7 +1,7 @@ - // Auto-generated file. Do not modify manually. + // Auto-generated file. Do not modify manually. namespace MaiChartManager; public partial class AppMain { - public const string Version = "1.7.5"; + public const string Version = "1.0.0.0"; } diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 9251b292..54bef3e8 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -377,14 +377,20 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa simaiFile.AppendLine("&ChartConvertTool=MaiChartManager"); simaiFile.AppendLine($"&ChartConvertToolVersion={Application.ProductVersion}"); - for (var i = 0; i < 5; i++) + for (var i = 0; i < music.Charts.Length; i++) { var chart = music.Charts[i]; - if (chart is null) continue; + if (chart is null || !chart.Enable || string.IsNullOrWhiteSpace(chart.Path)) continue; - var path = Path.Combine(Path.GetDirectoryName(music.FilePath)!, chart.Path); - if (!System.IO.File.Exists(path)) continue; - var ma2Content = await System.IO.File.ReadAllLinesAsync(path); + var chartPath = Path.Combine(musicDir, chart.Path); + if (!System.IO.File.Exists(chartPath)) + { + var fallbackPath = Path.Combine(musicDir, chart.Path.Replace(".ma2", "_L.ma2", StringComparison.OrdinalIgnoreCase)); + if (!System.IO.File.Exists(fallbackPath)) continue; + chartPath = fallbackPath; + } + + var ma2Content = await System.IO.File.ReadAllLinesAsync(chartPath); var ma2 = parser.ChartOfToken(ma2Content); var simai = ma2.Compose(ChartEnum.ChartVersion.SimaiFes); simaiFile.AppendLine($"&lv_{i + 2}={chart.Level}.{chart.LevelDecimal}"); @@ -478,4 +484,3 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } } } - diff --git a/MaiChartManager/Front/src/components/CopyToButton/index.tsx b/MaiChartManager/Front/src/components/CopyToButton/index.tsx index f13992c8..f553a3b8 100644 --- a/MaiChartManager/Front/src/components/CopyToButton/index.tsx +++ b/MaiChartManager/Front/src/components/CopyToButton/index.tsx @@ -2,7 +2,7 @@ import { computed, defineComponent, ref } from "vue"; import api, { getUrl } from "@/client/api"; import { globalCapture, selectedADir, selectedMusic, selectMusicId, showNeedPurchaseDialog, updateMusicList, version } from "@/store/refs"; import { NButton, NButtonGroup, NDropdown, useDialog, useMessage } from "naive-ui"; -import { ZipReader } from "@zip.js/zip.js"; +import { BlobWriter, ZipReader } from "@zip.js/zip.js"; import ChangeIdDialog from "./ChangeIdDialog"; import getSubDirFile from "@/utils/getSubDirFile"; import { useI18n } from 'vue-i18n'; @@ -106,17 +106,29 @@ export default defineComponent({ } const zip = await fetch(url) const zipReader = new ZipReader(zip.body!); - const entries = zipReader.getEntriesGenerator(); - for await (const entry of entries) { - console.log(entry.filename); - if (entry.filename.endsWith('/')) { - continue; + try { + const entries = zipReader.getEntriesGenerator(); + for await (const entry of entries) { + console.log(entry.filename); + if (entry.filename.endsWith('/')) { + continue; + } + if (!entry.getData) { + continue; + } + const fileHandle = await getSubDirFile(folderHandle, entry.filename); + const writable = await fileHandle.createWritable(); + try { + const blob = await entry.getData(new BlobWriter()); + await writable.write(blob); + } finally { + await writable.close(); + } } - const fileHandle = await getSubDirFile(folderHandle, entry.filename); - const writable = await fileHandle.createWritable(); - await entry.getData!(writable); + message.success(t('message.exportSuccess')); + } finally { + await zipReader.close(); } - message.success(t('message.exportSuccess')); } catch (e) { globalCapture(e, t('copy.exportError')) } finally { diff --git a/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.tsx b/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.tsx index dcd39179..5349ec79 100644 --- a/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.tsx +++ b/MaiChartManager/Front/src/components/MusicList/BatchActionButton/remoteExport.tsx @@ -5,7 +5,7 @@ import { progressCurrent, } from "@/components/MusicList/BatchActionButton/ProgressDisplay"; import { MusicXmlWithABJacket } from "@/client/apiGen"; -import { ZipReader } from "@zip.js/zip.js"; +import { BlobWriter, ZipReader } from "@zip.js/zip.js"; import getSubDirFile from "@/utils/getSubDirFile"; import { MAIDATA_SUBDIR, @@ -35,33 +35,7 @@ export default async ( return; } - const usedDirNamesByParent = new Map>(); - const exportDirByMusic = new Map(); - - const getUniqueDirName = (parentDir: string, baseName: string) => { - let used = usedDirNamesByParent.get(parentDir); - if (!used) { - used = new Set(); - usedDirNamesByParent.set(parentDir, used); - } - - let candidate = baseName; - let index = 2; - while (used.has(candidate)) { - candidate = `${baseName} (${index})`; - index++; - } - used.add(candidate); - return candidate; - }; - const getMaidataExportDir = (music: MusicXmlWithABJacket) => { - const musicKey = `${music.assetDir}:${music.id}`; - const cached = exportDirByMusic.get(musicKey); - if (cached) { - return cached; - } - let parentDir = ""; switch (dirOption) { case MAIDATA_SUBDIR.Genre: @@ -86,10 +60,8 @@ export default async ( music.name || t("music.list.unknown"), t("music.list.unknown"), ); - const uniqueDir = getUniqueDirName(parentDir, `${safeTitle}${suffix}`); - const fullDir = parentDir ? `${parentDir}/${uniqueDir}` : uniqueDir; - exportDirByMusic.set(musicKey, fullDir); - return fullDir; + const targetDir = `${safeTitle}${suffix}`; + return parentDir ? `${parentDir}/${targetDir}` : targetDir; }; progressCurrent.value = 0; @@ -134,6 +106,7 @@ export default async ( const zipReader = new ZipReader(response.body); try { + let hasEntryError = false; const entries = zipReader.getEntriesGenerator(); for await (const entry of entries) { try { @@ -149,21 +122,34 @@ export default async ( filename = `${getMaidataExportDir(music)}/${filename}`; } + if (!entry.getData) { + continue; + } + const fileHandle = await getSubDirFile(folderHandle, filename); const writable = await fileHandle.createWritable(); try { - await entry.getData!(writable); + const blob = await entry.getData(new BlobWriter()); + await writable.write(blob); } finally { await writable.close(); } } catch (e) { - console.error(e); - notify.error({ - title: t("error.exportFailed"), - content: musicName, + hasEntryError = true; + console.error("Failed to export zip entry", { + musicName, + sourceFile: entry.filename, + error: e, }); } } + + if (hasEntryError) { + notify.error({ + title: t("error.exportFailed"), + content: musicName, + }); + } } finally { await zipReader.close(); }