From 36660519c18cfa61bb2332534fcd71aeb0c0d10a Mon Sep 17 00:00:00 2001 From: Misaki Date: Mon, 8 Jun 2026 22:09:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90Web=20ASM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 7 + .gitignore | 8 + Dockerfile | 30 ++++ README.md | 133 +++++++---------- build-wasm.sh | 22 +++ cmd/server/main.go | 56 +++++++ cmd/wasm/main.go | 114 ++++++++++++++ docker-compose.yml | 7 + go.mod | 9 +- ncmcrypt/embed.go | 44 ++++++ ncmcrypt/ncmcrypt.go | 224 ++++++++++++++++------------ web/index.html | 16 ++ web/package.json | 26 ++++ web/postcss.config.js | 6 + web/src/App.tsx | 200 +++++++++++++++++++++++++ web/src/components/FileList.tsx | 28 ++++ web/src/components/FileRow.tsx | 83 +++++++++++ web/src/components/FolderPicker.tsx | 67 +++++++++ web/src/components/Header.tsx | 19 +++ web/src/components/StatusBar.tsx | 22 +++ web/src/index.css | 29 ++++ web/src/main.tsx | 10 ++ web/src/types.ts | 30 ++++ web/src/wasm.ts | 66 ++++++++ web/tailwind.config.js | 35 +++++ web/tsconfig.json | 21 +++ web/tsconfig.tsbuildinfo | 1 + web/vite.config.ts | 13 ++ 28 files changed, 1148 insertions(+), 178 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100755 build-wasm.sh create mode 100644 cmd/server/main.go create mode 100644 cmd/wasm/main.go create mode 100644 docker-compose.yml create mode 100644 ncmcrypt/embed.go create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/src/App.tsx create mode 100644 web/src/components/FileList.tsx create mode 100644 web/src/components/FileRow.tsx create mode 100644 web/src/components/FolderPicker.tsx create mode 100644 web/src/components/Header.tsx create mode 100644 web/src/components/StatusBar.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/types.ts create mode 100644 web/src/wasm.ts create mode 100644 web/tailwind.config.js create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.tsbuildinfo create mode 100644 web/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..98d68e1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +web/node_modules/ +web/dist/ +cmd/server/static/dist/ +build/ +.env +*.log diff --git a/.gitignore b/.gitignore index a87ec03..f3b9fb8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,14 @@ config.yaml # build files build/* +# web frontend +web/node_modules/ +cmd/server/static/dist/ + +# wasm artifacts +web/public/ncmdump.wasm +web/public/wasm_exec.js + # database files *.sqlite3 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa17ae3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-alpine AS frontend +WORKDIR /app/web +COPY web/package.json ./ +RUN npm install +COPY web/ ./ +RUN npm run build + +FROM golang:1.23-alpine AS wasm +WORKDIR /app +COPY go.mod ./ +RUN go mod download +COPY . . +RUN GOOS=js GOARCH=wasm go build -o web/public/ncmdump.wasm ./cmd/wasm +RUN cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" web/public/ + +FROM golang:1.23-alpine AS backend +WORKDIR /app +COPY go.mod ./ +RUN go mod download +COPY . . +COPY --from=frontend /app/cmd/server/static/dist ./cmd/server/static/dist +COPY --from=wasm /app/web/public/ncmdump.wasm ./cmd/server/static/dist/ncmdump.wasm +COPY --from=wasm /app/web/public/wasm_exec.js ./cmd/server/static/dist/wasm_exec.js +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./cmd/server + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=backend /app/server /usr/local/bin/ncmdump-server +EXPOSE 8080 +ENTRYPOINT ["ncmdump-server"] diff --git a/README.md b/README.md index 47d06be..c550df1 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,91 @@ # ncmdump-go -基于 https://github.com/taurusxin/ncmdump 的 Golang 移植版 +基于 [ncmdump](https://github.com/taurusxin/ncmdump) 的 Golang 移植版,支持网易云音乐 `.ncm` 文件解密为 MP3 / FLAC。 -支持网易云音乐最新的 3.x 版本,但需要注意:从 3. x开始的某些网易云音乐版本不再在 ncm 文件中内置封面图片,本项目支持从网易服务器上自动下载对应歌曲的封面图并写入到最终的音乐文件中 +提供两种使用方式:**Web 图形界面** 和 **命令行工具**。 -你也可以去 https://git.taurusxin.com/taurusxin/ncmdump-gui 下载基于本项目的 gui 可视化图形应用,只需简单点击即可自动转换。 +## Web 界面(WASM,推荐) -## 如何提 Issue +所有加解密在浏览器本地完成,**不会上传你的文件到任何服务器**。解密引擎使用 Go 编译为 WebAssembly,性能接近原生。 -由于本站恶意机器人注册过多,已关闭账号注册,如果需要提 Issue 请前往 [GitHub](https://github.com/taurusxin/ncmdump),必须注明 Issue 的主题为 ncmdump-go,敬请谅解。 +### 使用 -## 安装 +1. 打开网页 +2. 选择包含 `.ncm` 文件的**输入文件夹** +3. 选择**输出文件夹** +4. 点击「开始转换」 -你可以使用去 [releases](https://git.taurusxin.com/taurusxin/ncmdump-go/releases/latest) 下载最新版预编译好的二进制文件,或者你也可以用包管理器来安装 +歌曲名、歌手、专辑信息以及封面图片会自动写入转换后的文件中。 + +> 要求:Chrome / Edge 浏览器,通过 `localhost` 或 HTTPS 访问。 + +### Docker 部署 ```shell -# Windows Scoop -scoop bucket add taurusxin https://git.taurusxin.com/taurusxin/scoop-bucket.git # 添加 scoop 源 -scoop install ncmdump-go # 安装 ncmdump-go - -# macOS & Linux 之后会支持 +docker compose up -d --build ``` -## 使用方法 +然后浏览器访问 `http://localhost:8080`。 -使用 `-h` 或 `--help` 参数来打印帮助 +### 本地开发 ```shell -ncmdump-go -h +./build-wasm.sh # 编译 WASM + 前端 + 服务器 +./ncmdump-web --port 8080 ``` -使用 `-v` 或 `--version` 参数来打印版本信息 +## 命令行 + +### 安装 ```shell -ncmdump-go -v +go install git.taurusxin.com/taurusxin/ncmdump-go@latest ``` -处理单个或多个文件 +### 使用 ```shell -ncmdump-go 1.ncm 2.ncm... -``` +# 单文件 +ncmdump-go song.ncm -使用 `-d` 参数来指定一个文件夹,对文件夹下的所有以 ncm 为扩展名的文件进行批量处理 - -```shell +# 批量处理目录(不递归子目录) ncmdump-go -d source_dir + +# 递归处理 + 指定输出目录 +ncmdump-go -d source_dir -r -o output_dir ``` -使用 `-r` 配合 `-d` 参数来递归处理文件夹下的所有以 ncm 为扩展名的文件 +### 作为库使用 -```shell -ncmdump-go -d source_dir -r ``` - -使用 `-o` 参数来指定输出目录,将转换后的文件输出到指定目录,该参数支持与 `-r` 参数一起使用 - -```shell -# 处理单个或多个文件并输出到指定目录 -ncmdump-go 1.ncm 2.ncm -o output_dir - -# 处理文件夹下的所有以 ncm 为扩展名并输出到指定目录,不包含子文件夹 -ncmdump-go -d source_dir -o output_dir - -# 递归处理文件夹并输出到指定目录,并保留目录结构 -ncmdump-go -d source_dir -o output_dir -r -``` - -## 开发 - -使用 go module 下载 ncmdump-go 包 - -```shell go get -u git.taurusxin.com/taurusxin/ncmdump-go ``` -导入并使用 - ```go -package main +import "git.taurusxin.com/taurusxin/ncmdump-go/ncmcrypt" -import ( - "fmt" - "git.taurusxin.com/taurusxin/ncmdump-go/ncmcrypt" -) - -func main() { - filePath := "test.ncm" - - // 创建实例 - ncm, err := ncmcrypt.NewNeteaseCloudMusic(filePath) - if err != nil { - fmt.Printf("Reading '%s' failed: '%s'\n", filePath, err.Error()) - return - } - - // 转换格式,若目标文件夹为空,则保存在原目录 - dumpResult, err := ncm.Dump("") - if err != nil { - fmt.Printf("Processing '%s' failed: '%s'\n", filePath, err.Error()) - } - if dumpResult { - // 使用源文件的元数据修补转换后的音乐文件 - // 注意:自网易云音乐 3.0 版本开始,ncm 文件中不再内嵌专辑封面图片,参数若为 true 则表示从网易服务器上下载图片并嵌入到目标音乐文件(需要联网) - metadata, err := ncm.FixMetadata(true) - if !metadata { - fmt.Printf("Fix metadata for '%s' failed: '%s'", filePath, err.Error()) - } - fmt.Printf("'%s' -> '%s'\n", filePath, ncm.GetDumpFilePath()) - } -} +ncm, _ := ncmcrypt.NewNeteaseCloudMusic("song.ncm") +ncm.Dump("/path/to/output") +ncm.FixMetadata(true) +fmt.Println(ncm.GetDumpFilePath()) ``` +## 项目结构 + +``` +├── main.go # CLI 命令行入口 +├── ncmcrypt/ # 核心加解密库 +│ ├── ncmcrypt.go # NCM 格式解析 / RC4 解密 +│ ├── metadata.go # 元数据解析(歌名/歌手/封面URL) +│ └── embed.go # 内存写入 ID3 / FLAC 标签 +├── utils/ # AES / 文件工具 +├── cmd/ +│ ├── wasm/main.go # WASM 编译入口(浏览器端) +│ └── server/main.go # 静态文件服务器 +├── web/ # 前端(React + Tailwind) +└── Dockerfile # 多阶段构建 +``` + +## License + +MIT diff --git a/build-wasm.sh b/build-wasm.sh new file mode 100755 index 0000000..c3dd996 --- /dev/null +++ b/build-wasm.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -e + +VERSION=1.8.0 + +echo "=== Building ncmdump WASM ===" +GOOS=js GOARCH=wasm go build -o web/public/ncmdump.wasm ./cmd/wasm + +echo "=== Copying wasm_exec.js ===" +cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" web/public/ + +echo "=== Building frontend ===" +cd web +npm install +npm run build +cd .. + +echo "=== Building server ===" +CGO_ENABLED=0 go build -ldflags="-w -s" -o ncmdump-web ./cmd/server + +echo "=== Done ===" +echo "Run: ./ncmdump-web --port 8080" diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..4056d74 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "embed" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" +) + +//go:embed all:static/dist +var staticFiles embed.FS + +func main() { + port := flag.Int("port", 8080, "server port") + flag.Parse() + + distFS, err := fs.Sub(staticFiles, "static/dist") + if err != nil { + log.Fatalf("failed to open embedded files: %v", err) + } + + mux := http.NewServeMux() + + stripped, err := fs.Sub(distFS, ".") + if err != nil { + log.Fatalf("failed to sub: %v", err) + } + fileServer := http.FileServer(http.FS(stripped)) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, ".wasm") { + w.Header().Set("Content-Type", "application/wasm") + } + fileServer.ServeHTTP(w, r) + }) + + go func() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("shutting down...") + os.Exit(0) + }() + + log.Printf("ncmdump-web (WASM mode) starting on :%d", *port) + addr := fmt.Sprintf(":%d", *port) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go new file mode 100644 index 0000000..facc748 --- /dev/null +++ b/cmd/wasm/main.go @@ -0,0 +1,114 @@ +//go:build js && wasm + +package main + +import ( + "bytes" + "syscall/js" + + "git.taurusxin.com/taurusxin/ncmdump-go/ncmcrypt" +) + +func convertNcm(this js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return errorResult("no data provided") + } + + dataLen := args[0].Get("byteLength").Int() + if dataLen == 0 { + return errorResult("empty data") + } + + data := make([]byte, dataLen) + js.CopyBytesToGo(data, args[0]) + + ncm, err := ncmcrypt.NewNeteaseCloudMusicFromBytes(data) + if err != nil { + return errorResult(err.Error()) + } + defer ncm.Close() + + var buf bytes.Buffer + if err := ncm.DumpStream(&buf, nil); err != nil { + return errorResult(err.Error()) + } + + format := ncm.GetFormat() + result := js.Global().Get("Uint8Array").New(js.ValueOf(buf.Len())) + js.CopyBytesToJS(result, buf.Bytes()) + + meta := ncm.GetMetadata() + + return js.ValueOf(map[string]interface{}{ + "data": result, + "format": format, + "title": meta["title"], + "artist": meta["artist"], + "album": meta["album"], + "coverUrl": meta["coverUrl"], + }) +} + +func embedMetadata(this js.Value, args []js.Value) interface{} { + if len(args) < 2 { + return errorResult("insufficient arguments") + } + + audioLen := args[0].Get("byteLength").Int() + audio := make([]byte, audioLen) + js.CopyBytesToGo(audio, args[0]) + + format := args[1].String() + title := "" + artist := "" + album := "" + var coverData []byte + + if len(args) > 2 && args[2].Type() == js.TypeObject { + opts := args[2] + if v := opts.Get("title"); v.Type() == js.TypeString { + title = v.String() + } + if v := opts.Get("artist"); v.Type() == js.TypeString { + artist = v.String() + } + if v := opts.Get("album"); v.Type() == js.TypeString { + album = v.String() + } + if v := opts.Get("coverData"); v.Type() != js.TypeUndefined && v.Type() != js.TypeNull { + coverLen := v.Get("byteLength").Int() + coverData = make([]byte, coverLen) + js.CopyBytesToGo(coverData, v) + } + } + + result, err := ncmcrypt.EmbedMetadata(audio, format, title, artist, album, coverData) + if err != nil { + return errorResult(err.Error()) + } + + out := js.Global().Get("Uint8Array").New(js.ValueOf(len(result))) + js.CopyBytesToJS(out, result) + return js.ValueOf(map[string]interface{}{ + "data": out, + "format": format, + }) +} + +func errorResult(msg string) js.Value { + return js.ValueOf(map[string]interface{}{ + "error": msg, + }) +} + +func main() { + js.Global().Set("ncmdumpConvert", js.FuncOf(convertNcm)) + js.Global().Set("ncmdumpEmbed", js.FuncOf(embedMetadata)) + + ready := js.Global().Get("ncmdumpOnReady") + if ready.Type() == js.TypeFunction { + ready.Invoke() + } + + select {} +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6dbf7d5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + ncmdump: + build: . + container_name: ncmdump-web + ports: + - "8080:8080" + restart: unless-stopped diff --git a/go.mod b/go.mod index e5a29e4..f2da998 100644 --- a/go.mod +++ b/go.mod @@ -2,17 +2,14 @@ module git.taurusxin.com/taurusxin/ncmdump-go go 1.23.0 -require github.com/tidwall/gjson v1.17.3 - -require github.com/spf13/pflag v1.0.5 - -require github.com/bogem/id3v2/v2 v2.1.4 - require ( github.com/TwiN/go-color v1.4.1 + github.com/bogem/id3v2/v2 v2.1.4 github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/go-flac v1.0.0 + github.com/spf13/pflag v1.0.5 + github.com/tidwall/gjson v1.17.3 ) require ( diff --git a/ncmcrypt/embed.go b/ncmcrypt/embed.go new file mode 100644 index 0000000..3d2510c --- /dev/null +++ b/ncmcrypt/embed.go @@ -0,0 +1,44 @@ +package ncmcrypt + +import ( + "fmt" + "os" +) + +func EmbedMetadata(audio []byte, format NcmFormat, title, artist, album string, coverData []byte) ([]byte, error) { + tmpFile, err := os.CreateTemp("", "ncmdump-embed-*") + if err != nil { + return nil, fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.Write(audio); err != nil { + tmpFile.Close() + return nil, fmt.Errorf("write temp: %w", err) + } + tmpFile.Close() + + ncm := &NeteaseCloudMusic{ + mPng: [8]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, + mFormat: format, + mDumpFilePath: tmpPath, + mImageData: coverData, + mMetadata: &NeteaseClousMusicMetadata{ + mName: title, + mArtist: artist, + mAlbum: album, + }, + } + + if _, err := ncm.FixMetadata(false); err != nil { + return nil, fmt.Errorf("write metadata: %w", err) + } + + result, err := os.ReadFile(tmpPath) + if err != nil { + return nil, fmt.Errorf("read result: %w", err) + } + + return result, nil +} diff --git a/ncmcrypt/ncmcrypt.go b/ncmcrypt/ncmcrypt.go index 7361739..5f458c8 100644 --- a/ncmcrypt/ncmcrypt.go +++ b/ncmcrypt/ncmcrypt.go @@ -20,9 +20,7 @@ import ( type NcmFormat = string const ( - Mp3 NcmFormat = "mp3" -) -const ( + Mp3 NcmFormat = "mp3" Flac NcmFormat = "flac" ) @@ -31,56 +29,37 @@ type NeteaseCloudMusic struct { sModifyKey [17]byte mPng [8]byte - mFilePath string - + mFilePath string mDumpFilePath string mFormat NcmFormat mImageData []byte - mFileStream *os.File + mReader io.ReadSeeker mKeyBox [256]byte mMetadata *NeteaseClousMusicMetadata mAlbumPicUrl string } +func (ncm *NeteaseCloudMusic) Close() { + if closer, ok := ncm.mReader.(io.Closer); ok { + closer.Close() + } + ncm.mReader = nil +} + func (ncm *NeteaseCloudMusic) read(buffer *[]byte, size int) int { if len(*buffer) < size { *buffer = make([]byte, size) } - res, err := ncm.mFileStream.Read(*buffer) + res, err := ncm.mReader.Read((*buffer)[:size]) if err != nil { return 0 } return res } -func (ncm *NeteaseCloudMusic) openFile() bool { - file, err := os.Open(ncm.mFilePath) - if err != nil { - return false - } - ncm.mFileStream = file - return true -} - -func (ncm *NeteaseCloudMusic) isNcmFile() bool { - header := make([]byte, 4) - - // check magic header 4E455443 4D414446 - if ncm.read(&header, 4) != 4 { - return false - } - if int(binary.LittleEndian.Uint32(header)) != 0x4E455443 { - return false - } - if ncm.read(&header, 4) != 4 { - return false - } - - if int(binary.LittleEndian.Uint32(header)) != 0x4D414446 { - return false - } - - return true +func (ncm *NeteaseCloudMusic) skip(n int64) error { + _, err := ncm.mReader.Seek(n, io.SeekCurrent) + return err } func (ncm *NeteaseCloudMusic) buildKeyBox(key []byte, keyLen int) { @@ -113,7 +92,6 @@ func (ncm *NeteaseCloudMusic) mimeType() string { return "image/jpeg" } -// Dump encrypted ncm file to normal music file. If `targetDir` is "", the converted file will be saved to the original directory. func (ncm *NeteaseCloudMusic) Dump(targetDir string) (bool, error) { ncm.mDumpFilePath = ncm.mFilePath var outputStream *os.File @@ -123,7 +101,6 @@ func (ncm *NeteaseCloudMusic) Dump(targetDir string) (bool, error) { for { n := ncm.read(&buffer, len(buffer)) - if n == 0 { break } @@ -141,7 +118,7 @@ func (ncm *NeteaseCloudMusic) Dump(targetDir string) (bool, error) { ncm.mFormat = Flac ncm.mDumpFilePath = utils.ReplaceExtension(ncm.mDumpFilePath, ".flac") } - if targetDir != "" { // change save dir + if targetDir != "" { ncm.mDumpFilePath = filepath.Join(targetDir, filepath.Base(ncm.mDumpFilePath)) } findFormatFlag = true @@ -160,12 +137,63 @@ func (ncm *NeteaseCloudMusic) Dump(targetDir string) (bool, error) { return true, nil } -// FixMetadata will fix the missing metadata for target music file, the source of the metadata comes from origin ncm file. -// Since NeteaseCloudMusic version 3.0, the album cover image is no longer embedded in the ncm file. If the parameter is true, it means downloading the image from the NetEase server and embedding it into the target music file (network connection required) +func (ncm *NeteaseCloudMusic) DumpStream(w io.Writer, onFormat func(NcmFormat)) error { + buffer := make([]byte, 0x8000) + findFormatFlag := false + + for { + n := ncm.read(&buffer, len(buffer)) + if n == 0 { + break + } + + for i := 0; i < n; i++ { + j := (i + 1) & 0xff + buffer[i] ^= ncm.mKeyBox[(ncm.mKeyBox[j]+ncm.mKeyBox[(int(ncm.mKeyBox[j])+j)&0xff])&0xff] + } + + if !findFormatFlag { + if buffer[0] == 0x49 && buffer[1] == 0x44 && buffer[2] == 0x33 { + ncm.mFormat = Mp3 + } else { + ncm.mFormat = Flac + } + findFormatFlag = true + if onFormat != nil { + onFormat(ncm.mFormat) + } + } + + if _, err := w.Write(buffer[:n]); err != nil { + return fmt.Errorf("stream write failed: %w", err) + } + } + + return nil +} + +func (ncm *NeteaseCloudMusic) GetFormat() NcmFormat { + return ncm.mFormat +} + +func (ncm *NeteaseCloudMusic) GetMetadata() map[string]string { + m := map[string]string{} + if ncm.mMetadata != nil { + m["title"] = ncm.mMetadata.mName + m["artist"] = ncm.mMetadata.mArtist + m["album"] = ncm.mMetadata.mAlbum + } + if ncm.mAlbumPicUrl != "" { + m["coverUrl"] = ncm.mAlbumPicUrl + } + return m +} + func (ncm *NeteaseCloudMusic) FixMetadata(fetchAlbumImageFromRemote bool) (bool, error) { - // only fetch album image from remote when it's not embedded in the ncm file + if ncm.mMetadata == nil { + return true, nil + } if len(ncm.mImageData) <= 0 && fetchAlbumImageFromRemote { - // get the album pic from url resp, err := http.Get(ncm.mAlbumPicUrl) if err != nil { return false, err @@ -237,7 +265,6 @@ func (ncm *NeteaseCloudMusic) FixMetadata(fetchAlbumImageFromRemote bool) (bool, cmts = flacvorbis.New() } - // flac 可能自带元数据 当且仅当没有该项时才向目标添加元数据 if res, _ := cmts.Get(flacvorbis.FIELD_TITLE); len(res) == 0 { _ = cmts.Add(flacvorbis.FIELD_TITLE, ncm.mMetadata.mName) } @@ -257,7 +284,6 @@ func (ncm *NeteaseCloudMusic) FixMetadata(fetchAlbumImageFromRemote bool) (bool, } err = audioFile.Save(ncm.mDumpFilePath) - if err != nil { return false, err } @@ -265,7 +291,6 @@ func (ncm *NeteaseCloudMusic) FixMetadata(fetchAlbumImageFromRemote bool) (bool, return true, nil } -// GetDumpFilePath returns the absolute path of dumped music file func (ncm *NeteaseCloudMusic) GetDumpFilePath() string { path, err := filepath.Abs(ncm.mDumpFilePath) if err != nil { @@ -274,38 +299,32 @@ func (ncm *NeteaseCloudMusic) GetDumpFilePath() string { return path } -// NewNeteaseCloudMusic returns a new NeteaseCloudMusic instance, if the format of the file is incorrect, the error will be returned. -func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) { - ncm := &NeteaseCloudMusic{ - sCoreKey: [17]byte{0x68, 0x7A, 0x48, 0x52, 0x41, 0x6D, 0x73, 0x6F, 0x35, 0x6B, 0x49, 0x6E, 0x62, 0x61, 0x78, 0x57, 0}, - sModifyKey: [17]byte{0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28, 0}, - mPng: [8]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, +func parseNcmHeader(ncm *NeteaseCloudMusic) error { + header := make([]byte, 4) - mFilePath: filePath, + if ncm.read(&header, 4) != 4 { + return fmt.Errorf("read header failed") + } + if int(binary.LittleEndian.Uint32(header)) != 0x4E455443 { + return fmt.Errorf("not a ncm file") + } + if ncm.read(&header, 4) != 4 { + return fmt.Errorf("read header failed") + } + if int(binary.LittleEndian.Uint32(header)) != 0x4D414446 { + return fmt.Errorf("not a ncm file") } - if !ncm.openFile() { - return nil, fmt.Errorf("open file failed") + if err := ncm.skip(2); err != nil { + return fmt.Errorf("seek version failed") } - if !ncm.isNcmFile() { - return nil, fmt.Errorf("not a ncm file") - } - - // actually this 2 bytes is the version, now we just skip it - if _, err := ncm.mFileStream.Seek(2, 1); err != nil { - return nil, fmt.Errorf("seek version failed") - } - - // the length of the RC4 key, encrypted by AES128 n := make([]byte, 4) - if ncm.read(&n, len(n)) != 4 { - return nil, fmt.Errorf("read key len failed") + return fmt.Errorf("read key len failed") } keyLen := int(binary.LittleEndian.Uint32(n)) - keydata := make([]byte, keyLen) ncm.read(&keydata, keyLen) @@ -314,25 +333,19 @@ func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) { } mKeyData, err := utils.AesEcbDecrypt(ncm.sCoreKey[:16], keydata) - if err != nil { - return nil, fmt.Errorf("decrypt key failed") + return fmt.Errorf("decrypt key failed") } - // build the key box ncm.buildKeyBox(mKeyData[17:], len(mKeyData)-17) if ncm.read(&n, len(n)) != 4 { - return nil, fmt.Errorf("read metadata len failed") + return fmt.Errorf("read metadata len failed") } metadataLen := int(binary.LittleEndian.Uint32(n)) - if metadataLen <= 0 { - // process meta here - ncm.mMetadata = nil - } else { - // read metadata + if metadataLen > 0 { modifyData := make([]byte, metadataLen) ncm.read(&modifyData, metadataLen) @@ -340,43 +353,32 @@ func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) { modifyData[i] ^= 0x63 } - // escape `163 key(Don't modify):` swapModifyData := string(modifyData[22:]) - modifyOutData, err := base64.StdEncoding.DecodeString(swapModifyData) if err != nil { - panic("base64 decode modify data failed") + return fmt.Errorf("base64 decode modify data failed") } modifyDecryptData, err := utils.AesEcbDecrypt(ncm.sModifyKey[:16], modifyOutData) - if err != nil { - panic("decrypt modify data failed") + return fmt.Errorf("decrypt modify data failed") } - // escape `music:` mMetadataString := string(modifyDecryptData[6:]) - - // extract the album pic url ncm.mAlbumPicUrl = GetAlbumPicUrl(mMetadataString) - ncm.mMetadata = NewNeteaseCloudMusicMetadata(mMetadataString) } - // skip the 5 bytes gap - if _, err := ncm.mFileStream.Seek(5, 1); err != nil { - return nil, fmt.Errorf("seek gap failed") + if err := ncm.skip(5); err != nil { + return fmt.Errorf("seek gap failed") } - // read the cover frame coverFrameLen := make([]byte, 4) - if ncm.read(&coverFrameLen, len(coverFrameLen)) != 4 { - return nil, fmt.Errorf("read cover frame len failed") + return fmt.Errorf("read cover frame len failed") } - if ncm.read(&n, len(n)) != 4 { - return nil, fmt.Errorf("read cover frame data len failed") + return fmt.Errorf("read cover frame data len failed") } coverFrameLenInt := int(binary.LittleEndian.Uint32(coverFrameLen)) @@ -386,7 +388,41 @@ func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) { ncm.read(&ncm.mImageData, coverFrameDataLen) } - ncm.mFileStream.Seek(int64(coverFrameLenInt)-int64(coverFrameDataLen), 1) + ncm.skip(int64(coverFrameLenInt) - int64(coverFrameDataLen)) + return nil +} +func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open file failed") + } + + ncm := &NeteaseCloudMusic{ + sCoreKey: [17]byte{0x68, 0x7A, 0x48, 0x52, 0x41, 0x6D, 0x73, 0x6F, 0x35, 0x6B, 0x49, 0x6E, 0x62, 0x61, 0x78, 0x57, 0}, + sModifyKey: [17]byte{0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28, 0}, + mPng: [8]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, + mFilePath: filePath, + mReader: f, + } + + if err := parseNcmHeader(ncm); err != nil { + f.Close() + return nil, err + } + return ncm, nil +} + +func NewNeteaseCloudMusicFromBytes(data []byte) (*NeteaseCloudMusic, error) { + ncm := &NeteaseCloudMusic{ + sCoreKey: [17]byte{0x68, 0x7A, 0x48, 0x52, 0x41, 0x6D, 0x73, 0x6F, 0x35, 0x6B, 0x49, 0x6E, 0x62, 0x61, 0x78, 0x57, 0}, + sModifyKey: [17]byte{0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28, 0}, + mPng: [8]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, + mReader: bytes.NewReader(data), + } + + if err := parseNcmHeader(ncm); err != nil { + return nil, err + } return ncm, nil } diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..bb2f36c --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + + NCM Dump + + + + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..99759be --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "ncmdump-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-dropzone": "^14.2.3" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..7a0a4a8 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,200 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import Header from './components/Header' +import FolderPicker from './components/FolderPicker' +import FileList from './components/FileList' +import StatusBar from './components/StatusBar' +import { initWasm, convertNcm, embedMetadata, isWasmSupported } from './wasm' +import type { FileItem } from './types' + +export default function App() { + const [wasmReady, setWasmReady] = useState(false) + const [wasmError, setWasmError] = useState(null) + const [inputFiles, setInputFiles] = useState>(new Map()) + const [outputHandle, setOutputHandle] = useState(null) + const [fileItems, setFileItems] = useState([]) + const [converting, setConverting] = useState(false) + const [progress, setProgress] = useState({ done: 0, total: 0 }) + const abortRef = useRef(false) + + useEffect(() => { + if (isWasmSupported()) { + initWasm() + .then(() => setWasmReady(true)) + .catch((e) => setWasmError(`WASM 加载失败: ${e.message}`)) + } else { + setWasmError('浏览器不支持 WebAssembly') + } + }, []) + + const handleInputFolder = useCallback(async (dirHandle: any) => { + const files = new Map() + for await (const entry of dirHandle.values()) { + if (entry.kind === 'file' && entry.name.toLowerCase().endsWith('.ncm')) { + const file = await entry.getFile() + files.set(entry.name, { file, name: entry.name }) + } + } + setInputFiles(files) + setFileItems( + Array.from(files.values()).map((f) => ({ + name: f.name, + status: 'pending' as const, + })) + ) + }, []) + + const handleOutputFolder = useCallback((handle: any) => { + setOutputHandle(handle) + }, []) + + const handleConvert = useCallback(async () => { + if (!outputHandle || inputFiles.size === 0 || !wasmReady) return + + abortRef.current = false + setConverting(true) + setProgress({ done: 0, total: inputFiles.size }) + + const entries = Array.from(inputFiles.values()) + + for (let i = 0; i < entries.length; i++) { + if (abortRef.current) break + + const entry = entries[i] + + const setStatus = (status: FileItem['status'], extra?: Partial) => + setFileItems((prev) => prev.map((item, idx) => + idx === i ? { ...item, status, ...extra } : item + )) + + setStatus('converting') + + try { + const buf = await entry.file.arrayBuffer() + const result = await convertNcm(buf) + let audio = result.data + + // Fetch cover image if available + let coverData: Uint8Array | undefined + if (result.coverUrl) { + setStatus('fetching_cover') + try { + const resp = await fetch(result.coverUrl) + if (resp.ok) { + const coverBuf = await resp.arrayBuffer() + coverData = new Uint8Array(coverBuf) + } + } catch { + // cover fetch failed, proceed without it + } + } + + // Embed metadata + cover + if (result.title || result.artist || result.album || coverData) { + setStatus('writing') + audio = await embedMetadata( + audio, + result.format, + { title: result.title, artist: result.artist, album: result.album }, + coverData + ) + } else { + setStatus('writing') + } + + const base = entry.name.replace(/\.ncm$/i, '') + const outName = `${base}.${result.format}` + + const fileHandle = await outputHandle.getFileHandle(outName, { create: true }) + const writable = await fileHandle.createWritable() + await writable.write(audio) + await writable.close() + + setStatus('done', { format: result.format }) + } catch (e: any) { + setStatus('error', { error: e.message }) + } + + setProgress({ done: i + 1, total: entries.length }) + } + + setConverting(false) + }, [inputFiles, outputHandle, wasmReady]) + + const handleAbort = useCallback(() => { + abortRef.current = true + setConverting(false) + }, []) + + const canConvert = inputFiles.size > 0 && outputHandle !== null && wasmReady && !converting + + return ( +
+
+
+ + {wasmError && ( +
+ {wasmError} +
+ )} + + {!wasmError && !wasmReady && ( +
+ + + + + 正在加载 WASM 解密模块... +
+ )} + + {wasmReady && ( + <> +
+ + +
+ + {inputFiles.size > 0 && ( +
+ + + {converting && ( + + )} +
+ )} + + {fileItems.length > 0 && ( + + )} + + )} +
+
+ ) +} diff --git a/web/src/components/FileList.tsx b/web/src/components/FileList.tsx new file mode 100644 index 0000000..9aa4133 --- /dev/null +++ b/web/src/components/FileList.tsx @@ -0,0 +1,28 @@ +import type { FileItem as FileItemType } from '../types' +import FileRow from './FileRow' + +interface Props { + items: FileItemType[] +} + +export default function FileList({ items }: Props) { + const done = items.filter((f) => f.status === 'done').length + const error = items.filter((f) => f.status === 'error').length + const total = items.length + + return ( +
+
+

文件列表

+ + {done + error}/{total} + +
+
+ {items.map((item, idx) => ( + + ))} +
+
+ ) +} diff --git a/web/src/components/FileRow.tsx b/web/src/components/FileRow.tsx new file mode 100644 index 0000000..9a1b0c7 --- /dev/null +++ b/web/src/components/FileRow.tsx @@ -0,0 +1,83 @@ +import type { FileItem } from '../types' + +interface Props { + item: FileItem +} + +export default function FileRow({ item }: Props) { + const ext = item.name.replace(/\.ncm$/i, item.format ? `.${item.format}` : '') + + return ( +
+
+
+

+ {item.name} +

+ {item.status === 'done' && item.format && ( +

+ {'→ '}{ext} +

+ )} + {item.status === 'error' && item.error && ( +

+ {item.error} +

+ )} +
+ +
+ {item.status === 'pending' && ( + + + 等待中 + + )} + {item.status === 'converting' && ( + + + + + + 解密中 + + )} + {item.status === 'fetching_cover' && ( + + + + + + 获取封面 + + )} + {item.status === 'writing' && ( + + + + + + 写入元数据 + + )} + {item.status === 'done' && ( + + + + + 完成 + + )} + {item.status === 'error' && ( + + + + + 失败 + + )} +
+
+
+ ) +} diff --git a/web/src/components/FolderPicker.tsx b/web/src/components/FolderPicker.tsx new file mode 100644 index 0000000..95b05c3 --- /dev/null +++ b/web/src/components/FolderPicker.tsx @@ -0,0 +1,67 @@ +import { useState, useCallback } from 'react' + +interface Props { + label: string + description: string + onSelect: (handle: any) => void + disabled: boolean +} + +export default function FolderPicker({ label, description, onSelect, disabled }: Props) { + const [selected, setSelected] = useState(null) + const [error, setError] = useState(null) + + const pick = useCallback(async () => { + setError(null) + try { + if (typeof (window as any).showDirectoryPicker !== 'function') { + setError('浏览器不支持此功能,请使用 Chrome/Edge 并通过 localhost 或 HTTPS 访问') + return + } + const handle = await (window as any).showDirectoryPicker() + setSelected(handle.name) + onSelect(handle) + } catch (e: any) { + if (e.name !== 'AbortError') { + setError(`选择文件夹失败: ${e.message || '请通过 localhost 或 HTTPS 访问'}`) + } + } + }, [onSelect]) + + return ( +
+ + {error && ( +

{error}

+ )} +
+ ) +} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx new file mode 100644 index 0000000..31e1cfd --- /dev/null +++ b/web/src/components/Header.tsx @@ -0,0 +1,19 @@ +export default function Header() { + return ( +
+
+
+ + + +
+

+ NCM Dump +

+
+

+ 本地解密网易云 .ncm 文件,无需上传 +

+
+ ) +} diff --git a/web/src/components/StatusBar.tsx b/web/src/components/StatusBar.tsx new file mode 100644 index 0000000..e46a539 --- /dev/null +++ b/web/src/components/StatusBar.tsx @@ -0,0 +1,22 @@ +interface Props { + progress: { done: number; total: number } +} + +export default function StatusBar({ progress }: Props) { + const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0 + + return ( +
+
+ 进度 + {progress.done}/{progress.total} ({pct}%) +
+
+
+
+
+ ) +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..cab101b --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,29 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-slate-950 text-slate-200; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; + } +} + +@layer utilities { + .bg-grid { + background-image: + linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px); + background-size: 64px 64px; + } + + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + } + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent; + } + .scrollbar-thin::-webkit-scrollbar-thumb { + @apply bg-slate-700 rounded-full; + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..efa10f8 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,30 @@ +export interface ConvertResult { + data: Uint8Array + format: 'mp3' | 'flac' + title: string + artist: string + album: string + coverUrl: string +} + +export type FileStatus = 'pending' | 'converting' | 'fetching_cover' | 'writing' | 'done' | 'error' + +export interface FileItem { + name: string + status: FileStatus + error?: string + format?: string +} + +declare global { + interface Window { + ncmdumpConvert?: (data: Uint8Array) => { + data?: Uint8Array; format?: string; error?: string + title?: string; artist?: string; album?: string; coverUrl?: string + } + ncmdumpEmbed?: (audio: Uint8Array, format: string, opts: { + title?: string; artist?: string; album?: string; coverData?: Uint8Array + }) => { data?: Uint8Array; error?: string } + ncmdumpOnReady?: () => void + } +} diff --git a/web/src/wasm.ts b/web/src/wasm.ts new file mode 100644 index 0000000..5d1e101 --- /dev/null +++ b/web/src/wasm.ts @@ -0,0 +1,66 @@ +import type { ConvertResult } from './types' + +let ready = false +let initPromise: Promise | null = null + +export async function initWasm(): Promise { + if (ready) return + if (initPromise) return initPromise + + initPromise = new Promise((resolve) => { + window.ncmdumpOnReady = () => { + ready = true + resolve() + } + + const go = new (window as any).Go() + WebAssembly.instantiateStreaming(fetch('/ncmdump.wasm'), go.importObject).then( + (result) => { + go.run(result.instance) + } + ) + }) + + return initPromise +} + +export async function convertNcm(data: ArrayBuffer): Promise { + if (!ready) throw new Error('WASM 未就绪') + + const input = new Uint8Array(data) + const result = window.ncmdumpConvert!(input) + + if (result.error) throw new Error(result.error) + + return { + data: new Uint8Array(result.data!), + format: result.format as 'mp3' | 'flac', + title: result.title || '', + artist: result.artist || '', + album: result.album || '', + coverUrl: result.coverUrl || '', + } +} + +export async function embedMetadata( + audio: Uint8Array, + format: string, + meta: { title: string; artist: string; album: string }, + coverData?: Uint8Array +): Promise { + if (!ready) throw new Error('WASM 未就绪') + + const opts: any = { title: meta.title, artist: meta.artist, album: meta.album } + if (coverData) { + opts.coverData = coverData + } + + const result = window.ncmdumpEmbed!(audio, format, opts) + if (result.error) throw new Error(result.error) + + return new Uint8Array(result.data!) +} + +export function isWasmSupported(): boolean { + return typeof WebAssembly === 'object' && typeof (window as any).Go !== 'undefined' +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..f969de4 --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,35 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + darkMode: 'class', + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], + }, + animation: { + 'fade-in': 'fadeIn 0.3s ease-out', + 'slide-up': 'slideUp 0.4s ease-out', + 'pulse-ring': 'pulseRing 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0', transform: 'translateY(4px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + slideUp: { + '0%': { opacity: '0', transform: 'translateY(12px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + pulseRing: { + '0%, 100%': { boxShadow: '0 0 0 0 rgba(99, 102, 241, 0)' }, + '50%': { boxShadow: '0 0 0 8px rgba(99, 102, 241, 0.15)' }, + }, + }, + }, + }, + plugins: [], +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..75e2ff7 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo new file mode 100644 index 0000000..5fcf89b --- /dev/null +++ b/web/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/wasm.ts","./src/components/FileList.tsx","./src/components/FileRow.tsx","./src/components/FolderPicker.tsx","./src/components/Header.tsx","./src/components/StatusBar.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..d86c2cd --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, + build: { + outDir: '../cmd/server/static/dist', + emptyOutDir: true, + }, +})