完成Web ASM
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
cmd/server/static/dist/
|
||||
build/
|
||||
.env
|
||||
*.log
|
||||
@@ -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
|
||||
|
||||
|
||||
+30
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Executable
+22
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
ncmdump:
|
||||
build: .
|
||||
container_name: ncmdump-web
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+130
-94
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NCM Dump</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<script src="/wasm_exec.js"></script>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+200
@@ -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<string | null>(null)
|
||||
const [inputFiles, setInputFiles] = useState<Map<string, { file: File; name: string }>>(new Map())
|
||||
const [outputHandle, setOutputHandle] = useState<any>(null)
|
||||
const [fileItems, setFileItems] = useState<FileItem[]>([])
|
||||
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<string, { file: File; name: string }>()
|
||||
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<FileItem>) =>
|
||||
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 (
|
||||
<div className="min-h-screen bg-grid">
|
||||
<div className="mx-auto max-w-2xl px-4 py-8 sm:py-12">
|
||||
<Header />
|
||||
|
||||
{wasmError && (
|
||||
<div className="mt-4 animate-fade-in rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{wasmError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!wasmError && !wasmReady && (
|
||||
<div className="mt-4 animate-fade-in rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 text-center text-sm text-slate-400">
|
||||
<svg className="mx-auto mb-2 h-5 w-5 animate-spin text-indigo-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
正在加载 WASM 解密模块...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wasmReady && (
|
||||
<>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 animate-slide-up">
|
||||
<FolderPicker
|
||||
label="输入文件夹"
|
||||
description="选择包含 .ncm 文件的文件夹"
|
||||
onSelect={handleInputFolder}
|
||||
disabled={converting}
|
||||
/>
|
||||
<FolderPicker
|
||||
label="输出文件夹"
|
||||
description="选择转换后文件的保存位置"
|
||||
onSelect={handleOutputFolder}
|
||||
disabled={converting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inputFiles.size > 0 && (
|
||||
<div className="mt-3 animate-fade-in flex items-center gap-3">
|
||||
<StatusBar progress={progress} />
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
disabled={!canConvert}
|
||||
className="shrink-0 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white
|
||||
hover:bg-indigo-500 active:bg-indigo-700 transition-colors
|
||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
开始转换
|
||||
</button>
|
||||
{converting && (
|
||||
<button
|
||||
onClick={handleAbort}
|
||||
className="shrink-0 rounded-lg bg-slate-700 px-4 py-2.5 text-sm font-medium text-slate-300
|
||||
hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileItems.length > 0 && (
|
||||
<FileList items={fileItems} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mt-6 animate-fade-in">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-300">文件列表</h3>
|
||||
<span className="rounded-full bg-slate-800 px-2 py-0.5 text-xs text-slate-400">
|
||||
{done + error}/{total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, idx) => (
|
||||
<FileRow key={idx} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 transition-colors hover:border-slate-700">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-200">
|
||||
{item.name}
|
||||
</p>
|
||||
{item.status === 'done' && item.format && (
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||
{'→ '}{ext}
|
||||
</p>
|
||||
)}
|
||||
{item.status === 'error' && item.error && (
|
||||
<p className="mt-0.5 truncate text-xs text-red-400">
|
||||
{item.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{item.status === 'pending' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-slate-600" />
|
||||
等待中
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'converting' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-indigo-400">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
解密中
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'fetching_cover' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-cyan-400">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
获取封面
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'writing' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-amber-400">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
写入元数据
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'done' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
完成
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'error' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-red-400">
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
失败
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div>
|
||||
<button
|
||||
onClick={pick}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
flex flex-col items-center gap-2 rounded-2xl border-2 border-dashed p-6 text-center w-full
|
||||
transition-all duration-200
|
||||
${selected
|
||||
? 'border-indigo-500/50 bg-indigo-500/10'
|
||||
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600 hover:bg-slate-900/80'
|
||||
}
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
{selected ? (
|
||||
<svg className="h-7 w-7 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-7 w-7 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||
</svg>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">{label}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{selected ? selected : description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-amber-400 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="mb-8 text-center animate-fade-in">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-indigo-500/20 ring-1 ring-indigo-500/30">
|
||||
<svg className="h-5 w-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">
|
||||
NCM Dump
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
本地解密网易云 .ncm 文件,无需上传
|
||||
</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 mb-1">
|
||||
<span>进度</span>
|
||||
<span>{progress.done}/{progress.total} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ConvertResult } from './types'
|
||||
|
||||
let ready = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
export async function initWasm(): Promise<void> {
|
||||
if (ready) return
|
||||
if (initPromise) return initPromise
|
||||
|
||||
initPromise = new Promise<void>((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<ConvertResult> {
|
||||
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<Uint8Array> {
|
||||
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'
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user