NCM 文件批量转换 (保留专辑和封面信息)

这段时间打算用网易云音乐囤点资源, 但是下载之后才发现内容已经全部加密, 变成了 ncm 后缀的文件. 搜索一番, 找到了一些现成的转换工具和源码. 但是功能都十分有限, 而且转换出来之后的文件都没有专辑和封面信息强迫症大怒. 看了看有关的分析和源码之后, 决定自己重新整理一下转换功能, 并且把专辑和封面信息也加进去, 做个完善的命令行工具自用, 顺便打包成 Python 库, 上传到 PyPI 上.

本文包括以下内容, 首先整理一下网上已有的 ncm 格式转换代码, 然后增加补充专辑和封面信息的功能, 并实现批量转换和界面友好, 最后打包成二进制文件和 Python 库, 并上传到 PyPI 上.

文末附有项目的 Github 地址, 以及使用 PyInstaller 打包的二进制文件下载地址和 PyPI 项目地址.

快速上手

安装:

pip install ncmdump-py

命令行使用:

1
python -m ncmdump [-h] [--in-folder IN_FOLDER] [--out-folder OUT_FOLDER] [--dump-metadata] [--dump-cover] [files ...]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
usage: ncmdump [-h] [--in-folder IN_FOLDER] [--out-folder OUT_FOLDER] [--dump-metadata] [--dump-cover] [files ...]

Dump ncm files with progress bar and logging info, only process files with suffix '.ncm'

positional arguments:
files Files to dump, can follow multiple files.

optional arguments:
-h, --help show this help message and exit
--in-folder IN_FOLDER
Input folder of files to dump.
--out-folder OUT_FOLDER
Output folder of files dumped.
--dump-metadata Whether dump metadata.
--dump-cover Whether dump album cover.

导入代码使用:

1
2
3
4
5
6
7
8
9
10
from ncmdump import NeteaseCloudMusicFile

ncmfile = NeteaseCloudMusicFile("filename.ncm")
ncmfile.decrypt()

ncmfile.dump_music("filename.mp3") # auto detect correct suffix

# Maybe you need metadata or cover image
# ncmfile.dump_metadata("filename.json")
# ncmfile.dump_cover("filename.jpeg")

NCM 格式分析

Github 上搜 ncmdump, 有很多现成的项目, 通过源码和自己尝试后总结一下 ncm 格式如下表:

字段 长度 含义
MAGIC_HEADER 8 字节 ncm 文件的文件头标识, 内容为 b"CTENFDAM"
gap1 2 字节 不确定, 据观察所有的文件都相同, 为 b"\x01\x61", 猜测也是文件头一部分
rc4_key_enc_size 4 字节 int 整数
rc4_key_enc rc4_key_enc_size 加密后的 RC4 算法密钥
metadata_enc_size 4 字节 int 整数
metadata_enc metadata_enc_size 加密后的 metadata, 包含音乐的专辑和封面信息
crc32 4 字节 不确定, 据网上流传为 CRC32 校验码, 但是没找到是算哪个值的
gap2 5 字节 不确定, 据观察都为 b"\x01" 开头, b"\x00" 结尾
cover_data_size 4 字节 int 整数
cover_data cover_data_size 专辑封面图片二进制数据
music_data_enc 任意长度 加密后的音乐二进制数据, 处于 ncm 文件的最末尾

格式很清晰, 虽然有一些未知项, 但是不影响核心内容. 其中 rc4_key_enc, metadata_enc, music_data_enc 是加密过的, 只要全部解密就能还原出原本完整的音乐文件.

NCM 文件解密

这部分内容也是总结已有的源码.

ncm 文件中使用了两种密码算法:

  • 一种是经过魔改的 RC4 算法, 后文称它为 NCMRC4
  • 另一种是标准的 AES 算法, 分组长度 128 比特, 工作模式 ECB, 填充算法为 PKCS7, 后文称它为 NCMAES

ncm 文件的加密方式如下:

  • music_data_enc = NCMRC4.encrypt(music_data, rc4_key)
  • metadata_enc = NCMAES.encrypt(metadata_enc, AES_KEY_METADATA)
  • rc4_key_enc = NCMAES.encrypt(rc4_key, AES_KEY_RC4_KEY)

较大的 music_data 用魔改后的流密码加密, 节省时间; 较短的 metadatarc4_key 用对称密码加密, 提高安全性.

这里 AES_KEY_RC4_KEYAES_KEY_METADATA 是两个关键的对称密码密钥, 前人已经通过逆向等手段挖出来了, 这里就不放出来了, 可以去看看别人的分析或者看本文的源码, 里面都有.

除了使用加密手段, 还有一些简单的混淆操作, 以及去除解密后内容多余的头部字段等, 这里也不详细写了, 源码里面写的很清楚, 可以直接看源码.

添加专辑和封面信息

来到本文的重点部分, 前面对 ncm 格式和加密方式的分析都是已有的, 我们主要想扩展功能, 自动添加专辑和封面信息到解密后的文件里, 这里主要用到 mutagen 这个库, 支持向 mp3flac 文件内加入专辑等信息.

解密后的 metadata 是一份 json 数据, 格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"format": "flac",
"musicId": 431259256,
"musicName": "カタオモイ",
"artist": [["Aimer", 16152]],
"album": "daydream",
"albumId": 34826361,
"albumPicDocId": 109951165052089697,
"albumPic": "http://p1.music.126.net/2QRYxUqXfW0zQpm2_DVYRA==/109951165052089697.jpg",
"mvId": 0,
"flag": 4,
"bitrate": 876923,
"duration": 207866,
"alias": [],
"transNames": ["单相思"]
}

v1.1.0 中的改动

metadata 可能存在多种类型, 上面是最简单的 music 类型, 新增了 dj 类型, 格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"programId": 2506516081,
"programName": "03 踏遍万水千山",
"mainMusic": {
"musicId": 1957438579,
"musicName": "03 踏遍万水千山",
"artist": [],
"album": "[DJ节目]北方文艺出版社的DJ节目 第8期",
"albumId": 0,
"albumPicDocId": 109951167551086981,
"albumPic": "https://p1.music.126.net/M48NPuT591tIqqUdQyKZlg==/109951167551086981.jpg",
"mvId": 0,
"flag": 0,
"bitrate": 320000,
"duration": 1222948,
"alias": [],
"transNames": []
},
"djId": 7891086863,
"djName": "北方文艺出版社",
"djAvatarUrl": "http://p1.music.126.net/DQr2q_S23tYY8vU_C-kAYw==/109951167535553901.jpg",
"createTime": 1655691020376,
"brand": "林徽因传:倾我所能去坚强",
"serial": 3,
"programDesc": "这是一本有温度、有态度的传记,记录了真正意义上的民国女神——林徽因,从容坚强、传奇丰沛的一生。",
"programFeeType": 15,
"programBuyed": true,
"radioId": 977264730,
"radioName": "林徽因传:倾我所能去坚强",
"radioCategory": "文学出版",
"radioCategoryId": 3148096,
"radioDesc": "这是一本有温度、有态度的传记,记录了真正意义上的民国女神——林徽因,从容坚强、传奇丰沛的一生。",
"radioFeeType": 1,
"radioFeeScope": 0,
"radioBuyed": true,
"radioPrice": 30,
"radioPurchaseCount": 0
}

我们需要把 musicName, artist, album 这三个字段的内容保留, 同时把 ncm 中附带的封面图片也保留.

我们导入一下要用到的库:

1
2
from mutagen import flac, id3, mp3
from PIL import Image

对于 mp3, 如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _addinfo_mp3(self, path: Union[str, PathLike]) -> None:
"""Add info for mp3 format."""

audio = mp3.MP3(path)

audio["TIT2"] = id3.TIT2(text=self.name, encoding=id3.Encoding.UTF8) # title
audio["TALB"] = id3.TALB(text=self.album, encoding=id3.Encoding.UTF8) # album
audio["TPE1"] = id3.TPE1(text="/".join(self.artists), encoding=id3.Encoding.UTF8) # artists
audio["TPE2"] = id3.TPE2(text="/".join(self.artists), encoding=id3.Encoding.UTF8) # album artists

if self._cover_data_size > 0:
audio["APIC"] = id3.APIC(type=id3.PictureType.COVER_FRONT, mime=self.cover_mime, data=self._cover_data) # cover

audio.save()

这里 cover_mime 就是图像的内容类型, 例如 image/jpeg 这种, 可以通过 mimetypes 库获取.

而对于 flac, 如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def _addinfo_flac(self, path: Union[str, PathLike]) -> None:
"""Add info for flac format."""

audio = flac.FLAC(path)

# add music info
audio["title"] = self.name
audio["artist"] = self.artists
audio["album"] = self.album
audio["albumartist"] = "/".join(self.artists)

# add cover
if self._cover_data_size > 0:
cover = flac.Picture()
cover.type = id3.PictureType.COVER_FRONT
cover.data = self._cover_data

with BytesIO(self._cover_data) as data:
with Image.open(data) as f:
cover.mime = self.cover_mime
cover.width = f.width
cover.height = f.height
cover.depth = len(f.getbands()) * 8

audio.add_picture(cover)

audio.save()

添加专辑封面图片的时候稍微复杂一点, 需要使用 Pillow 库读取一下图像的基本信息, 然后填进去.

值得注意的是, ncm 文件里可能没有存放专辑封面图片, 这种时候可以借助 metadata 里的 albumPic 字段, 它代表专辑封面图片的一个 url, 我们可以用 urllib 尝试联网获取内容, 代码片段如下:

1
2
3
4
5
6
7
8
9
# if no cover data, try get cover data by url in metadata
if self._cover_data_size <= 0:
try:
with request.urlopen(self._metadata.get("albumPic", "")) as res:
if res.status < 400:
self._cover_data = res.read()
self._cover_data_size = len(self._cover_data)
except:
pass

值得注意的是, 代码里需要判断, 如果最终就是没有办法获得图片数据, 那么就放弃添加图片信息, 避免报错.

命令行工具

到这里, 我们的包目录结构长这样:

1
2
3
4
5
ncmdump
├── core.py
├── crypto.py
├── __init__.py
└── __main__.py

共有四份文件, 为了能够在命令行里运行这个包, 我们需要在 __main__.py 里添加一些内容.

这里我们使用 ArgumentParser 解析命令行输入, rich 库显示进度条和日志输出, 完整代码如下.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from argparse import ArgumentParser
from pathlib import Path

from rich.progress import Progress, SpinnerColumn, TimeElapsedColumn

from ncmdump import NeteaseCloudMusicFile

if __name__ == "__main__":
parser = ArgumentParser("ncmdump", description="Dump ncm files with progress bar and logging info, only process files with suffix '.ncm'")
parser.add_argument("files", nargs="*", help="Files to dump, can follow multiple files.")
parser.add_argument("--in-folder", help="Input folder of files to dump.")
parser.add_argument("--out-folder", help="Output folder of files dumped.", default=".")

parser.add_argument("--dump-metadata", help="Whether dump metadata.", action="store_true")
parser.add_argument("--dump-cover", help="Whether dump album cover.", action="store_true")

args = parser.parse_args()

out_folder = Path(args.out_folder)
out_folder.mkdir(parents=True, exist_ok=True)

dump_metadata = args.dump_metadata
dump_cover = args.dump_cover

files = args.files
if args.in_folder:
files.extend(Path(args.in_folder).iterdir())
files = list(filter(lambda p: p.suffix == ".ncm", map(Path, files)))

if not files:
parser.print_help()
else:
with Progress(SpinnerColumn(), *Progress.get_default_columns(), TimeElapsedColumn()) as progress:
task = progress.add_task("[#d75f00]Dumping files", total=len(files))

for ncm_path in files:
output_path = out_folder.joinpath(ncm_path.stem)

try:
ncmfile = NeteaseCloudMusicFile(ncm_path).decrypt()
music_path = ncmfile.dump_music(output_path)

if dump_metadata:
ncmfile.dump_metadata(output_path)
if dump_cover:
ncmfile.dump_cover(output_path)

except Exception as e:
progress.log(f"[red]ERROR[/red]: {ncm_path} -> {e}")

else:
if not ncmfile.metadata:
progress.log(f"[yellow]WARNING[/yellow]: {ncm_path} -> {music_path}, no metadata found")
if not ncmfile.cover_data:
progress.log(f"[yellow]WARNING[/yellow]: {ncm_path} -> {music_path}, no cover data found")

finally:
progress.advance(task)

该命令行可以接收若干数量的 .ncm 文件作为输入, 可以用 --in-folder 指定一整个文件夹, 会和输入的单个文件合并一起处理, 比如有下面几种使用示例.

1
2
REM 指定某些文件
python -m ncmdump file1.ncm file2.ncm
1
2
REM 指定一整个文件夹, 同时指定输出目录
python -m ncmdump --in-folder ncmfiles --out-folder musicfolder
1
2
REM 同时指定
python -m ncmdump file1.ncm file2.ncm --in-folder ncmfiles --out-folder musicfolder

输出文件的文件名和原本的 .ncm 文件名相同, 但是后缀会自动替换成对应格式的后缀 (.mp3 或者 .flac).

打包到 PyPI

最后就是打包上传造福大众的时间, 这也是我第一次在 PyPI 上上传自己的包, 之后会单独写一篇文章说说中途遇到的一些问题.

上传完成之后, 就可以通过命令 pip install ncmdump-py 安装使用了~

PS: 虽然库名字叫 ncmdump-py, 但是导入的时候还是 import ncmdump, 因为 ncmdump 这个项目名字在 PyPI 上已经被前人注册了, 只好加点东西区分一下.

相关资源

Github 地址: https://github.com/ww-rm/ncmdump-py/

PyPI 项目页: https://pypi.org/project/ncmdump-py/

PyInstaller 打包的命令行工具: ncmdump v1.1.0.zip