From e565b80bc255b85e5b362ecd3a1f57181cf11b45 Mon Sep 17 00:00:00 2001 From: Misaki Date: Mon, 8 Jun 2026 18:02:54 +0800 Subject: [PATCH] first --- .gitignore | 158 ++++++++ .idea/.gitignore | 8 + .idea/go.imports.xml | 10 + .idea/inspectionProfiles/Project_Default.xml | 10 + .idea/material_theme_project_new.xml | 13 + .idea/modules.xml | 8 + .idea/ncmdump-go.iml | 9 + .idea/vcs.xml | 6 + LICENSE | 21 + README.md | 114 ++++++ build.sh | 37 ++ go.mod | 22 ++ go.sum | 45 +++ main.go | 146 +++++++ ncmcrypt/metadata.go | 52 +++ ncmcrypt/ncmcrypt.go | 392 +++++++++++++++++++ utils/encrypt.go | 31 ++ utils/file.go | 48 +++ utils/printf.go | 22 ++ 19 files changed, 1152 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/go.imports.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/material_theme_project_new.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/ncmdump-go.iml create mode 100644 .idea/vcs.xml create mode 100644 LICENSE create mode 100644 README.md create mode 100755 build.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 ncmcrypt/metadata.go create mode 100644 ncmcrypt/ncmcrypt.go create mode 100644 utils/encrypt.go create mode 100644 utils/file.go create mode 100644 utils/printf.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a87ec03 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..07ad60f --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..4c3a64b --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..8338a00 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/ncmdump-go.iml b/.idea/ncmdump-go.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/ncmdump-go.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373ca14 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..47d06be --- /dev/null +++ b/README.md @@ -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()) + } +} +``` + diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..0db2a81 --- /dev/null +++ b/build.sh @@ -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 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e5a29e4 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3eb35a8 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..eb0fa58 --- /dev/null +++ b/main.go @@ -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) + } + } + } + +} diff --git a/ncmcrypt/metadata.go b/ncmcrypt/metadata.go new file mode 100644 index 0000000..2e13700 --- /dev/null +++ b/ncmcrypt/metadata.go @@ -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() +} diff --git a/ncmcrypt/ncmcrypt.go b/ncmcrypt/ncmcrypt.go new file mode 100644 index 0000000..7361739 --- /dev/null +++ b/ncmcrypt/ncmcrypt.go @@ -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 +} diff --git a/utils/encrypt.go b/utils/encrypt.go new file mode 100644 index 0000000..9be0498 --- /dev/null +++ b/utils/encrypt.go @@ -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 +} diff --git a/utils/file.go b/utils/file.go new file mode 100644 index 0000000..ca4cb72 --- /dev/null +++ b/utils/file.go @@ -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() +} diff --git a/utils/printf.go b/utils/printf.go new file mode 100644 index 0000000..bb4e82c --- /dev/null +++ b/utils/printf.go @@ -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...) +}