commit e8314a95bf43e5f72d8ce585a4f3620ea8ab24d7 Author: TaurusXin Date: Sat Sep 14 22:39:50 2024 +0800 init repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ab041b --- /dev/null +++ b/.gitignore @@ -0,0 +1,152 @@ +# 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 + +# 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/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/go.mod b/go.mod new file mode 100644 index 0000000..72dad94 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module ncmdump + +go 1.23.0 + +require github.com/tidwall/gjson v1.17.3 + +require ( + github.com/bogem/id3v2/v2 v2.1.4 // indirect + github.com/frolovo22/tag v0.0.2 // indirect + github.com/go-flac/flacpicture v0.3.0 // indirect + github.com/go-flac/go-flac v1.0.0 // indirect + github.com/go-flac/go-flac/v2 v2.0.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + 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..89fbfd2 --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +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/frolovo22/tag v0.0.2 h1:gFv5P5nqE7purEipbKT7X/OjP286nx5gA30mjt/4SgA= +github.com/frolovo22/tag v0.0.2/go.mod h1:Bt1H06v6RQFTrplGixhtUXVzHA/RpmhGEVxC7wqWGIw= +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/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/go-flac/go-flac/v2 v2.0.1 h1:1zilNkbmmpK9DLsz2NbjLHG8avOmthYqUfVc9YKB/Ps= +github.com/go-flac/go-flac/v2 v2.0.1/go.mod h1:hvgeR2hElLbwk0Q1/vMazIDmIc2LAFSd9Bx/Fk6ViKo= +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..ed573e0 --- /dev/null +++ b/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + flag "github.com/spf13/pflag" + "ncmdump/ncmcrypt" + "os" + "path/filepath" +) + +func processFile(filePath string) error { + currentFile, err := ncmcrypt.NewNeteaseCloudMusic(filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "[Error] Reading '%s' failed: '%s'\n", filePath, err.Error()) + return err + } + dump, err := currentFile.Dump() + if err != nil { + fmt.Fprintf(os.Stderr, "[Error] Processing '%s' failed: '%s'\n", filePath, err.Error()) + return err + } + if dump { + metadata, _ := currentFile.FixMetadata() + if !metadata { + fmt.Fprintf(os.Stderr, "[Warning] Fix metadata for '%s' failed: '%s'\n", filePath, err.Error()) + return err + } + fmt.Printf("[Done] '%s' -> '%s'\n", filePath, currentFile.GetDumpFilePath()) + } + return nil +} + +func main() { + var folderPath string + showHelp := flag.BoolP("help", "h", false, "Display help message") + showVersion := flag.BoolP("version", "v", false, "Display version information") + flag.StringVarP(&folderPath, "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.5.0") + os.Exit(0) + } + + if folderPath != "" { + // check if the folder exists + info, err := os.Stat(folderPath) + if err != nil { + fmt.Fprintf(os.Stderr, "[Error] Unable to access directory '%s'", folderPath) + os.Exit(1) + } + + if !info.IsDir() { + fmt.Fprintf(os.Stderr, "[Error] '%s' is not a directory", folderPath) + os.Exit(1) + } + + // dump files in the folder + files, err := os.ReadDir(folderPath) + if err != nil { + fmt.Fprintf(os.Stderr, "[Error] Unable to read directory '%s'", folderPath) + os.Exit(1) + } + + for _, file := range files { + if file.IsDir() { + continue + } + + filePath := filepath.Join(folderPath, file.Name()) + // skip if the extension is not .ncm + if filePath[len(filePath)-4:] != ".ncm" { + continue + } + err = processFile(filePath) + if err != nil { + continue + } + } + } else { + // dump file from args + for _, filePath := range flag.Args() { + // skip if the extension is not .ncm + if filePath[len(filePath)-4:] != ".ncm" { + continue + } + err := processFile(filePath) + if err != nil { + continue + } + } + } + +} diff --git a/ncmcrypt/metadata.go b/ncmcrypt/metadata.go new file mode 100644 index 0000000..8324d5d --- /dev/null +++ b/ncmcrypt/metadata.go @@ -0,0 +1,48 @@ +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 +} diff --git a/ncmcrypt/ncmcrypt.go b/ncmcrypt/ncmcrypt.go new file mode 100644 index 0000000..dc8603d --- /dev/null +++ b/ncmcrypt/ncmcrypt.go @@ -0,0 +1,323 @@ +package ncmcrypt + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "fmt" + "github.com/bogem/id3v2/v2" + "github.com/go-flac/flacpicture" + "github.com/go-flac/go-flac" + "ncmdump/utils" + "os" +) + +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 +} + +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" +} + +func (ncm *NeteaseCloudMusic) Dump() (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") + } + findFormatFlag = true + + output, err := os.Create(ncm.mDumpFilePath) + if err != nil { + return false, fmt.Errorf("create output file failed") + } + outputStream = output + } + + outputStream.Write(buffer) + } + + outputStream.Close() + return true, nil +} + +func (ncm *NeteaseCloudMusic) FixMetadata() (bool, error) { + if ncm.mFormat == Mp3 { + audioFile, err := id3v2.Open(ncm.mDumpFilePath, id3v2.Options{Parse: true}) + if err != nil { + return false, err + } + defer audioFile.Close() + 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 + } + } else if ncm.mFormat == 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) + } + generalMeta := &flac.MetaDataBlock{ + Type: flac.VorbisComment, + Data: []byte(fmt.Sprintf("title=%s\nartist=%s\nalbum=%s", ncm.mMetadata.mName, ncm.mMetadata.mArtist, ncm.mMetadata.mAlbum)), + } + audioFile.Meta = append(audioFile.Meta, generalMeta) + err = audioFile.Save(ncm.mDumpFilePath) + if err != nil { + return false, err + } + } + return true, nil +} + +func (ncm *NeteaseCloudMusic) GetDumpFilePath() string { + return ncm.mDumpFilePath +} + +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:]) + + 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) + } else { + fmt.Printf("[Warning] Missing album: '%s'\n", filePath) + } + + 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..91000bb --- /dev/null +++ b/utils/file.go @@ -0,0 +1,11 @@ +package utils + +import ( + "path/filepath" + "strings" +) + +func ReplaceExtension(filepathStr, newExt string) string { + ext := filepath.Ext(filepathStr) + return strings.TrimSuffix(filepathStr, ext) + newExt +}