This commit is contained in:
Misaki
2026-06-08 18:02:54 +08:00
commit e565b80bc2
19 changed files with 1152 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
# Created by https://www.toptal.com/developers/gitignore/api/go,goland
# Edit at https://www.toptal.com/developers/gitignore?templates=go,goland
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# app sepecific
ncmdump
*.ncm
*.mp3
*.flac
# config files
config.yaml
# log files
*.log
# build files
build/*
# database files
*.sqlite3
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
### GoLand ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### GoLand Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
.idea/**/azureSettings.xml
# End of https://www.toptal.com/developers/gitignore/api/go,goland
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="GoDfaErrorMayBeNotNil" enabled="true" level="WARNING" enabled_by_default="true">
<functions>
<function importPath="ncmdump/ncmcrypt" name="NewNeteaseCloudMusic" />
</functions>
</inspection_tool>
</profile>
</component>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="-621f8711:18bafb1603a:-8000" />
<option name="version" value="8.13.2" />
</MTProjectMetadataState>
</option>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ncmdump-go.iml" filepath="$PROJECT_DIR$/.idea/ncmdump-go.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-present TaurusXin and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+114
View File
@@ -0,0 +1,114 @@
# ncmdump-go
基于 https://github.com/taurusxin/ncmdump 的 Golang 移植版
支持网易云音乐最新的 3.x 版本,但需要注意:从 3. x开始的某些网易云音乐版本不再在 ncm 文件中内置封面图片,本项目支持从网易服务器上自动下载对应歌曲的封面图并写入到最终的音乐文件中
你也可以去 https://git.taurusxin.com/taurusxin/ncmdump-gui 下载基于本项目的 gui 可视化图形应用,只需简单点击即可自动转换。
## 如何提 Issue
由于本站恶意机器人注册过多,已关闭账号注册,如果需要提 Issue 请前往 [GitHub](https://github.com/taurusxin/ncmdump),必须注明 Issue 的主题为 ncmdump-go,敬请谅解。
## 安装
你可以使用去 [releases](https://git.taurusxin.com/taurusxin/ncmdump-go/releases/latest) 下载最新版预编译好的二进制文件,或者你也可以用包管理器来安装
```shell
# Windows Scoop
scoop bucket add taurusxin https://git.taurusxin.com/taurusxin/scoop-bucket.git # 添加 scoop 源
scoop install ncmdump-go # 安装 ncmdump-go
# macOS & Linux 之后会支持
```
## 使用方法
使用 `-h``--help` 参数来打印帮助
```shell
ncmdump-go -h
```
使用 `-v``--version` 参数来打印版本信息
```shell
ncmdump-go -v
```
处理单个或多个文件
```shell
ncmdump-go 1.ncm 2.ncm...
```
使用 `-d` 参数来指定一个文件夹,对文件夹下的所有以 ncm 为扩展名的文件进行批量处理
```shell
ncmdump-go -d source_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 (
"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())
}
}
```
Executable
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
VERSION=1.7.5
# Clean up the build directory
rm -rf build
mkdir build
# Linux amd64
echo "Building for Linux amd64..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o build/ncmdump-go git.taurusxin.com/taurusxin/ncmdump-go
tar zcf build/ncmdump-go_linux_amd64_$VERSION.tar.gz -C build ncmdump-go
rm build/ncmdump-go
# Linux arm64
echo "Building for Linux arm64..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-w -s" -o build/ncmdump-go git.taurusxin.com/taurusxin/ncmdump-go
tar zcf build/ncmdump-go_linux_arm64_$VERSION.tar.gz -C build ncmdump-go
rm build/ncmdump-go
# macOS amd64
echo "Building for macOS amd64..."
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-w -s" -o build/ncmdump-go git.taurusxin.com/taurusxin/ncmdump-go
tar zcf build/ncmdump-go_darwin_amd64_$VERSION.tar.gz -C build ncmdump-go
rm build/ncmdump-go
# macOS arm64
echo "Building for macOS arm64..."
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-w -s" -o build/ncmdump-go git.taurusxin.com/taurusxin/ncmdump-go
tar zcf build/ncmdump-go_darwin_arm64_$VERSION.tar.gz -C build ncmdump-go
rm build/ncmdump-go
# Windows amd64
echo "Building for Windows amd64..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-w -s" -o build/ncmdump-go.exe git.taurusxin.com/taurusxin/ncmdump-go
zip -q -j build/ncmdump-go_windows_amd64_$VERSION.zip ./build/ncmdump-go.exe
rm build/ncmdump-go.exe
+22
View File
@@ -0,0 +1,22 @@
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/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
)
require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
golang.org/x/text v0.18.0 // indirect
)
+45
View File
@@ -0,0 +1,45 @@
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"fmt"
"git.taurusxin.com/taurusxin/ncmdump-go/ncmcrypt"
"git.taurusxin.com/taurusxin/ncmdump-go/utils"
"os"
"path/filepath"
flag "github.com/spf13/pflag"
)
func processFile(filePath string, outputDir string) error {
// skip if the extension is not .ncm
if filePath[len(filePath)-4:] != ".ncm" {
return nil
}
// process the file
currentFile, err := ncmcrypt.NewNeteaseCloudMusic(filePath)
if err != nil {
utils.ErrorPrintfln("Reading '%s' failed: %s", filePath, err.Error())
return err
}
dump, err := currentFile.Dump(outputDir)
if err != nil {
utils.ErrorPrintfln("Processing '%s' failed: %s", filePath, err.Error())
return err
}
if dump {
metadata, err := currentFile.FixMetadata(true)
if !metadata {
utils.WarningPrintfln("Fix metadata for '%s' failed: %s", filePath, err.Error())
return err
}
utils.DonePrintfln("'%s' -> '%s'", filePath, currentFile.GetDumpFilePath())
}
return nil
}
func main() {
var sourceDir string
var outputDir string
showHelp := flag.BoolP("help", "h", false, "Display help message")
showVersion := flag.BoolP("version", "v", false, "Display version information")
processRecursive := flag.BoolP("recursive", "r", false, "Process all files in the directory recursively")
flag.StringVarP(&outputDir, "output", "o", "", "Output directory for the dump files")
flag.StringVarP(&sourceDir, "dir", "d", "", "Process all files in the directory")
flag.Parse()
if len(os.Args) == 1 {
flag.Usage()
os.Exit(0)
}
if *showHelp {
flag.Usage()
os.Exit(0)
}
if *showVersion {
fmt.Println("ncmdump version 1.7.5")
os.Exit(0)
}
if !flag.Lookup("dir").Changed && sourceDir == "" && len(flag.Args()) == 0 {
flag.Usage()
os.Exit(1)
}
if flag.Lookup("recursive").Changed && !flag.Lookup("dir").Changed {
utils.ErrorPrintfln("The -r option can only be used with the -d option")
os.Exit(1)
}
outputDirSpecified := flag.Lookup("output").Changed
if outputDirSpecified {
if utils.PathExists(outputDir) {
if !utils.IsDir(outputDir) {
utils.ErrorPrintfln("Output directory '%s' is not valid.", outputDir)
os.Exit(1)
}
} else {
_ = os.MkdirAll(outputDir, os.ModePerm)
}
}
if sourceDir != "" {
if !utils.IsDir(sourceDir) {
utils.ErrorPrintfln("The source directory '%s' is not valid.", sourceDir)
os.Exit(1)
}
if *processRecursive {
_ = filepath.WalkDir(sourceDir, func(p string, d os.DirEntry, err_ error) error {
if !outputDirSpecified {
outputDir = sourceDir
}
relativePath := utils.GetRelativePath(sourceDir, p)
destinationPath := filepath.Join(outputDir, relativePath)
if utils.IsRegularFile(p) {
parentDir := filepath.Dir(destinationPath)
_ = os.MkdirAll(parentDir, os.ModePerm)
_ = processFile(p, parentDir)
}
return nil
})
} else {
// dump files in the folder
files, err := os.ReadDir(sourceDir)
if err != nil {
utils.ErrorPrintfln("Unable to read directory: '%s'", sourceDir)
os.Exit(1)
}
for _, file := range files {
if file.IsDir() {
continue
}
filePath := filepath.Join(sourceDir, file.Name())
if outputDirSpecified {
_ = processFile(filePath, outputDir)
} else {
_ = processFile(filePath, sourceDir)
}
}
}
} else {
// process files from args
for _, filePath := range flag.Args() {
// skip if the extension is not .ncm
if filePath[len(filePath)-4:] != ".ncm" {
continue
}
if outputDirSpecified {
_ = processFile(filePath, outputDir)
} else {
_ = processFile(filePath, sourceDir)
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package ncmcrypt
import (
"github.com/tidwall/gjson"
)
type NeteaseClousMusicMetadata struct {
mAlbum string
mArtist string
mFormat string
mName string
mDuration int64
mBitrate int64
}
func NewNeteaseCloudMusicMetadata(meta string) *NeteaseClousMusicMetadata {
if meta == "" {
return nil
}
metaData := &NeteaseClousMusicMetadata{
mAlbum: "",
mArtist: "",
mFormat: "",
mName: "",
mDuration: 0,
mBitrate: 0,
}
metaData.mName = gjson.Get(meta, "musicName").String()
metaData.mAlbum = gjson.Get(meta, "album").String()
artists := gjson.Get(meta, "artist").Array()
if len(artists) > 0 {
for i, artist := range artists {
if i > 0 {
metaData.mArtist += " / "
}
metaData.mArtist += artist.Array()[0].String()
}
}
metaData.mBitrate = gjson.Get(meta, "bitrate").Int()
metaData.mDuration = gjson.Get(meta, "duration").Int()
metaData.mFormat = gjson.Get(meta, "format").String()
return metaData
}
func GetAlbumPicUrl(meta string) string {
return gjson.Get(meta, "albumPic").String()
}
+392
View File
@@ -0,0 +1,392 @@
package ncmcrypt
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"git.taurusxin.com/taurusxin/ncmdump-go/utils"
"github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
)
type NcmFormat = string
const (
Mp3 NcmFormat = "mp3"
)
const (
Flac NcmFormat = "flac"
)
type NeteaseCloudMusic struct {
sCoreKey [17]byte
sModifyKey [17]byte
mPng [8]byte
mFilePath string
mDumpFilePath string
mFormat NcmFormat
mImageData []byte
mFileStream *os.File
mKeyBox [256]byte
mMetadata *NeteaseClousMusicMetadata
mAlbumPicUrl string
}
func (ncm *NeteaseCloudMusic) read(buffer *[]byte, size int) int {
if len(*buffer) < size {
*buffer = make([]byte, size)
}
res, err := ncm.mFileStream.Read(*buffer)
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) buildKeyBox(key []byte, keyLen int) {
for i := 0; i < 256; i++ {
ncm.mKeyBox[i] = byte(i)
}
var swap uint8 = 0
var c uint8 = 0
var lastByte uint8 = 0
var keyOffset uint8 = 0
for i := 0; i < 256; i++ {
swap = ncm.mKeyBox[i]
c = (swap + lastByte + key[keyOffset]) & 0xff
keyOffset++
if int(keyOffset) >= keyLen {
keyOffset = 0
}
ncm.mKeyBox[i] = ncm.mKeyBox[c]
ncm.mKeyBox[c] = swap
lastByte = c
}
}
func (ncm *NeteaseCloudMusic) mimeType() string {
if bytes.HasPrefix(ncm.mImageData, ncm.mPng[:]) {
return "image/png"
}
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
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
ncm.mDumpFilePath = utils.ReplaceExtension(ncm.mDumpFilePath, ".mp3")
} else {
ncm.mFormat = Flac
ncm.mDumpFilePath = utils.ReplaceExtension(ncm.mDumpFilePath, ".flac")
}
if targetDir != "" { // change save dir
ncm.mDumpFilePath = filepath.Join(targetDir, filepath.Base(ncm.mDumpFilePath))
}
findFormatFlag = true
output, err := os.Create(ncm.mDumpFilePath)
if err != nil {
return false, fmt.Errorf("create output file failed")
}
outputStream = output
}
outputStream.Write(buffer[:n])
}
outputStream.Close()
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) FixMetadata(fetchAlbumImageFromRemote bool) (bool, error) {
// only fetch album image from remote when it's not embedded in the ncm file
if len(ncm.mImageData) <= 0 && fetchAlbumImageFromRemote {
// get the album pic from url
resp, err := http.Get(ncm.mAlbumPicUrl)
if err != nil {
return false, err
}
if resp != nil {
if resp.StatusCode == http.StatusOK {
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return false, err
}
ncm.mImageData = bodyBytes
}
}
}
switch ncm.mFormat {
case Mp3:
audioFile, err := id3v2.Open(ncm.mDumpFilePath, id3v2.Options{Parse: true})
if err != nil {
return false, err
}
defer audioFile.Close()
audioFile.SetDefaultEncoding(id3v2.EncodingUTF8)
audioFile.SetTitle(ncm.mMetadata.mName)
audioFile.SetArtist(ncm.mMetadata.mArtist)
audioFile.SetAlbum(ncm.mMetadata.mAlbum)
if len(ncm.mImageData) > 0 {
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: ncm.mimeType(),
PictureType: id3v2.PTFrontCover,
Description: "",
Picture: ncm.mImageData,
}
audioFile.AddAttachedPicture(pic)
}
err = audioFile.Save()
if err != nil {
return false, err
}
case Flac:
audioFile, err := flac.ParseFile(ncm.mDumpFilePath)
if err != nil {
return false, err
}
if len(ncm.mImageData) > 0 {
pic, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "",
ncm.mImageData, ncm.mimeType())
if err != nil {
return false, err
}
pictureMeta := pic.Marshal()
audioFile.Meta = append(audioFile.Meta, &pictureMeta)
}
var cmts *flacvorbis.MetaDataBlockVorbisComment
var cmtIdx int
for idx, meta := range audioFile.Meta {
if meta.Type == flac.VorbisComment {
cmts, err = flacvorbis.ParseFromMetaDataBlock(*meta)
cmtIdx = idx
if err != nil {
return false, err
}
}
}
if cmts == nil && cmtIdx > 0 {
cmts = flacvorbis.New()
}
// flac 可能自带元数据 当且仅当没有该项时才向目标添加元数据
if res, _ := cmts.Get(flacvorbis.FIELD_TITLE); len(res) == 0 {
_ = cmts.Add(flacvorbis.FIELD_TITLE, ncm.mMetadata.mName)
}
if res, _ := cmts.Get(flacvorbis.FIELD_ARTIST); len(res) == 0 {
_ = cmts.Add(flacvorbis.FIELD_ARTIST, ncm.mMetadata.mArtist)
}
if res, _ := cmts.Get(flacvorbis.FIELD_ALBUM); len(res) == 0 {
_ = cmts.Add(flacvorbis.FIELD_ALBUM, ncm.mMetadata.mAlbum)
}
cmtsmeta := cmts.Marshal()
if cmtIdx > 0 {
audioFile.Meta[cmtIdx] = &cmtsmeta
} else {
audioFile.Meta = append(audioFile.Meta, &cmtsmeta)
}
err = audioFile.Save(ncm.mDumpFilePath)
if err != nil {
return false, err
}
}
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 {
return ncm.mDumpFilePath
}
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},
mFilePath: filePath,
}
if !ncm.openFile() {
return nil, fmt.Errorf("open file 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")
}
keyLen := int(binary.LittleEndian.Uint32(n))
keydata := make([]byte, keyLen)
ncm.read(&keydata, keyLen)
for i := range keydata {
keydata[i] ^= 0x64
}
mKeyData, err := utils.AesEcbDecrypt(ncm.sCoreKey[:16], keydata)
if err != nil {
return nil, 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")
}
metadataLen := int(binary.LittleEndian.Uint32(n))
if metadataLen <= 0 {
// process meta here
ncm.mMetadata = nil
} else {
// read metadata
modifyData := make([]byte, metadataLen)
ncm.read(&modifyData, metadataLen)
for i := range modifyData {
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")
}
modifyDecryptData, err := utils.AesEcbDecrypt(ncm.sModifyKey[:16], modifyOutData)
if err != nil {
panic("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")
}
// read the cover frame
coverFrameLen := make([]byte, 4)
if ncm.read(&coverFrameLen, len(coverFrameLen)) != 4 {
return nil, fmt.Errorf("read cover frame len failed")
}
if ncm.read(&n, len(n)) != 4 {
return nil, fmt.Errorf("read cover frame data len failed")
}
coverFrameLenInt := int(binary.LittleEndian.Uint32(coverFrameLen))
coverFrameDataLen := int(binary.LittleEndian.Uint32(n))
if coverFrameDataLen > 0 {
ncm.read(&ncm.mImageData, coverFrameDataLen)
}
ncm.mFileStream.Seek(int64(coverFrameLenInt)-int64(coverFrameDataLen), 1)
return ncm, nil
}
+31
View File
@@ -0,0 +1,31 @@
package utils
import (
"crypto/aes"
)
func AesEcbDecrypt(key []byte, src []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
dst := make([]byte, 0, len(src))
tmp := make([]byte, aes.BlockSize)
for i := 0; i < len(src); i += aes.BlockSize {
block.Decrypt(tmp, src[i:i+aes.BlockSize])
if i == len(src)-aes.BlockSize {
pad := int(tmp[len(tmp)-1])
if pad > aes.BlockSize {
pad = 0
}
dst = append(dst, tmp[:aes.BlockSize-pad]...)
} else {
dst = append(dst, tmp...)
}
}
return dst, nil
}
+48
View File
@@ -0,0 +1,48 @@
package utils
import (
"os"
"path/filepath"
"strings"
)
func ReplaceExtension(filepathStr, newExt string) string {
ext := filepath.Ext(filepathStr)
return strings.TrimSuffix(filepathStr, ext) + newExt
}
func PathExists(path string) bool {
_, err := os.Stat(path)
if err == nil {
return true
}
if os.IsNotExist(err) {
return false
}
return false
}
func IsDir(path string) bool {
s, err := os.Stat(path)
if err != nil {
return false
}
return s.IsDir()
}
func GetRelativePath(from, to string) string {
rel, err := filepath.Rel(from, to)
if err != nil {
return ""
}
return rel
}
func IsRegularFile(path string) bool {
s, err := os.Stat(path)
if err != nil {
return false
}
return s.Mode().IsRegular()
}
+22
View File
@@ -0,0 +1,22 @@
package utils
import (
"fmt"
"github.com/TwiN/go-color"
)
func DonePrintfln(format string, a ...interface{}) {
fmt.Printf(color.InBold(color.InGreen("[Done] "))+format+"\n", a...)
}
func InfoPrintfln(format string, a ...interface{}) {
fmt.Printf(color.InBold(color.InBlue("[Info] "))+format+"\n", a...)
}
func WarningPrintfln(format string, a ...interface{}) {
fmt.Printf(color.InBold(color.InYellow("[Warning] "))+format+"\n", a...)
}
func ErrorPrintfln(format string, a ...interface{}) {
fmt.Printf(color.InBold(color.InRed("[Error] "))+format+"\n", a...)
}