NCMDUMP UPDATE

Make menu more colorful!

Changed NCMDUMP code, 1000% faster!!

Settings update and small fix
This commit is contained in:
1826013250 2023-06-22 01:36:23 +08:00
parent 94149eabea
commit bee6f61c38
7 changed files with 193 additions and 112 deletions

12
main.py
View File

@ -8,7 +8,7 @@ from sys import exit
from colorama import init from colorama import init
from modules.utils.inputs import rinput from modules.utils.inputs import rinput
from modules.utils.information import print_info from modules.utils.prints import print_info, print_menu
from modules.functions.mainly.multi_download import mdl from modules.functions.mainly.multi_download import mdl
from modules.functions.mainly.one_download import download_one_lyric from modules.functions.mainly.one_download import download_one_lyric
from modules.submenus.settings import settings_menu from modules.submenus.settings import settings_menu
@ -26,8 +26,14 @@ class MainProcess(object):
"""程序主循环""" """程序主循环"""
while True: while True:
cls_stay(self, "[程序主菜单]") cls_stay(self, "[程序主菜单]")
print("[0] 退出程序\n[1] 单个歌曲的歌词下载\n[2] 多个歌曲的歌词下载\n[3] 从网易云下载的歌曲中获取歌词" print_menu({
"\n[s] 进入设置\n[i] 程序信息") "0": "退出程序",
"1": "单个歌曲的歌词下载",
"2": "多个歌曲的歌词下载",
"3": "从网易云下载的歌曲中获取歌词",
"s": "进入设置",
"i": "程序信息",
})
r = rinput("请选择:") r = rinput("请选择:")
if r == "1": if r == "1":

View File

@ -1,23 +1,23 @@
import binascii
import json import json
import os import os
import struct
from base64 import b64decode from base64 import b64decode
from multiprocessing import Process, Queue from multiprocessing import Process, Queue
from queue import Empty from queue import Empty
from time import sleep from time import sleep
from sys import exit
import mutagen.mp3 import mutagen.mp3
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad from Cryptodome.Util.Padding import unpad
from mutagen import File, flac
from mutagen.id3 import ID3, TPE1, APIC, COMM, TIT2, TALB from mutagen import File
from colorama import Fore, Style from colorama import Fore, Style
from modules.utils.clear_screen import cls_stay from modules.utils.clear_screen import cls_stay
from modules.functions.mainly.get_song import get_song_lyric from modules.functions.mainly.get_song import get_song_lyric
from modules.utils.inputs import cinput, rinput from modules.utils.inputs import cinput, rinput
from modules.utils.bar import CompactBar, CompactArrowBar from modules.utils.bar import CompactBar, CompactArrowBar
from modules.utils.dump import load_and_decrypt_from_ncm
def load_information_from_song(path) -> str | dict: def load_information_from_song(path) -> str | dict:
@ -27,7 +27,7 @@ def load_information_from_song(path) -> str | dict:
except mutagen.mp3.HeaderNotFoundError: except mutagen.mp3.HeaderNotFoundError:
return "not_a_music" return "not_a_music"
if os.path.splitext(path)[-1] == ".mp3": # 当文件为 mp3 时使用 ID3 格式读取 if os.path.splitext(path)[-1] == ".mp3": # 当文件为 mp3 时使用 ID3 格式读取
if file.tags.get("COMM::XXX"): if file.tags and file.tags.get("COMM::XXX"):
if file.tags["COMM::XXX"].text[0][:7] == "163 key": if file.tags["COMM::XXX"].text[0][:7] == "163 key":
ciphertext = file.tags["COMM::XXX"].text[0][22:] ciphertext = file.tags["COMM::XXX"].text[0][22:]
else: else:
@ -63,101 +63,17 @@ def load_information_from_song(path) -> str | dict:
return "decrypt_failed" return "decrypt_failed"
def load_and_decrypt_from_ncm(file_path, target_dir) -> dict: # nondanee的源代码, 根据需求更改了某些东西 def process_work(path, filename, target, lyric_format, q_err: Queue, q_info: Queue):
core_key = binascii.a2b_hex("687A4852416D736F356B496E62617857")
meta_key = binascii.a2b_hex("2331346C6A6B5F215C5D2630553C2728")
f = open(file_path, 'rb')
header = f.read(8)
assert binascii.b2a_hex(header) == b'4354454e4644414d'
f.seek(2, 1)
key_length = f.read(4)
key_length = struct.unpack('<I', bytes(key_length))[0]
key_data = f.read(key_length)
key_data_array = bytearray(key_data)
for i in range(0, len(key_data_array)):
key_data_array[i] ^= 0x64
key_data = bytes(key_data_array)
cryptor = AES.new(core_key, AES.MODE_ECB)
key_data = unpad(cryptor.decrypt(key_data), 16)[17:]
key_length = len(key_data)
key_data = bytearray(key_data)
key_box = bytearray(range(256))
last_byte = 0
key_offset = 0
for i in range(256):
swap = key_box[i]
c = (swap + last_byte + key_data[key_offset]) & 0xff
key_offset += 1
if key_offset >= key_length:
key_offset = 0
key_box[i] = key_box[c]
key_box[c] = swap
last_byte = c
meta_length = f.read(4)
meta_length = struct.unpack('<I', bytes(meta_length))[0]
meta_data = f.read(meta_length)
meta_data_array = bytearray(meta_data)
for i in range(0, len(meta_data_array)):
meta_data_array[i] ^= 0x63
meta_data = bytes(meta_data_array)
comment = meta_data
meta_data = b64decode(meta_data[22:])
cryptor = AES.new(meta_key, AES.MODE_ECB)
meta_data = unpad(cryptor.decrypt(meta_data), 16).decode('utf-8')[6:]
meta_data = json.loads(meta_data)
crc32 = f.read(4)
crc32 = struct.unpack('<I', bytes(crc32))[0]
..., crc32
f.seek(5, 1)
image_size = f.read(4)
image_size = struct.unpack('<I', bytes(image_size))[0]
image_data = f.read(image_size)
file_name = f.name.split("/")[-1].split(".ncm")[0] + '.' + meta_data['format']
m = open(os.path.join(target_dir, file_name), 'wb')
while True:
chunk = bytearray(f.read(0x8000))
chunk_length = len(chunk)
if not chunk:
break
for i in range(1, chunk_length + 1):
j = i & 0xff
chunk[i - 1] ^= key_box[(key_box[j] + key_box[(key_box[j] + j) & 0xff]) & 0xff]
m.write(chunk)
m.close()
f.close()
# 对解密后的文件进行信息补全
if meta_data["format"] == "mp3": # 针对 mp3 使用 ID3 进行信息补全
audio = ID3(os.path.join(target_dir, os.path.splitext(file_path.split("/")[-1])[0] + ".mp3"))
artists = []
for i in meta_data["artist"]:
artists.append(i[0])
audio["TPE1"] = TPE1(encoding=3, text=artists) # 插入歌手
audio["APIC"] = APIC(encoding=3, mime='image/jpg', type=3, desc='', data=image_data) # 插入封面
audio["COMM::XXX"] = COMM(encoding=3, lang='XXX', desc='', text=[comment.decode("utf-8")]) # 插入 163 key 注释
audio["TIT2"] = TIT2(encoding=3, text=[meta_data["musicName"]]) # 插入歌曲名
audio["TALB"] = TALB(encoding=3, text=[meta_data["album"]]) # 插入专辑名
audio.save()
elif meta_data["format"] == "flac": # 针对 flac 使用 FLAC 进行信息补全
audio = flac.FLAC(os.path.join(target_dir, os.path.splitext(file_path.split("/")[-1])[0] + ".flac"))
artists = []
for i in meta_data["artist"]:
artists.append(i[0])
audio["artist"] = artists[:] # 插入歌手
audio["title"] = [meta_data["musicName"]] # 插入歌曲名
audio["album"] = [meta_data["album"]] # 插入专辑名
audio["description"] = comment.decode("utf-8") # 插入 163 key 注释
audio.save()
return meta_data
def process_work(path, filename, target, q_err: Queue, q_info: Queue):
try: try:
result = load_and_decrypt_from_ncm(path, target) result = load_and_decrypt_from_ncm(path, target, lyric_format)
except AssertionError: except AssertionError:
q_err.put(f"\t- 文件 \"{filename}\" 破解失败!") q_err.put(f"\t- 文件 \"{filename}\" 破解失败!")
except KeyboardInterrupt:
os.remove(target)
exit(-1)
else: else:
if result == "no_meta_data":
q_err.put(f"\t- 文件 \"{filename}\"破译成功, 但是未发现有效的歌曲信息, 将不会下载该歌词")
q_info.put(result) q_info.put(result)
@ -238,6 +154,7 @@ def get_lyric_from_folder(self):
args=(os.path.join(path, ncm_files[allocated]), args=(os.path.join(path, ncm_files[allocated]),
ncm_files[allocated], ncm_files[allocated],
target_path, target_path,
self.settings.lyric_format,
q_err, q_err,
q_info)).start() q_info)).start()
bar.print_onto_bar(Fore.CYAN + "已分配: " + Style.RESET_ALL + "%s" % ncm_files[allocated]) bar.print_onto_bar(Fore.CYAN + "已分配: " + Style.RESET_ALL + "%s" % ncm_files[allocated])

View File

@ -5,24 +5,32 @@ import os
class Settings(object): # 设定一个基础的存储设置信息的 class ,并设置形参用于 json 导入设置 class Settings(object): # 设定一个基础的存储设置信息的 class ,并设置形参用于 json 导入设置
def __init__(self, l_p="./out/", l_f="", lang="en"): def __init__(self, l_p="./out/", l_f="%(name)s - %(artists)s", lang="en", a_s=True):
self.lyric_path = l_p self.lyric_path = l_p
self.lyric_format = l_f self.lyric_format = l_f
self.language = lang self.language = lang
self.auto_save = a_s
def class2dict(aclass): # 让 json.dumps 将 class 转化为一个 dict ,用于保存 def class2dict(aclass: Settings): # 让 json.dumps 将 class 转化为一个 dict ,用于保存
return { return {
"lyric_path": aclass.lyric_path, "lyric_path": aclass.lyric_path,
"lyric_format": aclass.lyric_format,
"language": aclass.language, "language": aclass.language,
"auto_save": aclass.auto_save
} }
def dict2class(adict): # 让 json.load 将读取到的 dict 转化为我们所需要的 class def dict2class(adict): # 让 json.load 将读取到的 dict 转化为我们所需要的 class
if len(adict) != 2: # 若检测到多余的设定将抛出异常 if len(adict) != 4: # 若检测到多余的设定将抛出异常
raise json.decoder.JSONDecodeError("Too many keys", "none", 0) raise json.decoder.JSONDecodeError("Too many keys", "none", 0)
else: else:
return Settings(adict["lyric_path"], adict["language"]) return Settings(
l_p=adict["lyric_path"],
l_f=adict["lyric_format"],
lang=adict["language"],
a_s=adict["auto_save"]
)
def load_settings() -> Settings: # 加载 的函数 def load_settings() -> Settings: # 加载 的函数
@ -36,7 +44,7 @@ def load_settings() -> Settings: # 加载 的函数
if not os.path.exists(settings.lyric_path): # 检测输出文件夹,若文件夹不存在则在启动时创建 if not os.path.exists(settings.lyric_path): # 检测输出文件夹,若文件夹不存在则在启动时创建
os.mkdir(settings.lyric_path) os.mkdir(settings.lyric_path)
return settings return settings
except json.decoder.JSONDecodeError: # 如果检测到文件无法读取,将会删除设置文件并重新创建 except json.decoder.JSONDecodeError or KeyError: # 如果检测到文件无法读取,将会删除设置文件并重新创建
print("设置文件损坏,重新创建...") print("设置文件损坏,重新创建...")
os.remove("settings.json") os.remove("settings.json")
return load_settings() return load_settings()
@ -52,4 +60,4 @@ def save_settings(settings): # 保存 的函数
返回 done 即为完成理论上不存在报错所以暂时没有做其他处理""" 返回 done 即为完成理论上不存在报错所以暂时没有做其他处理"""
with open("settings.json", 'w', encoding="utf-8") as f: with open("settings.json", 'w', encoding="utf-8") as f:
f.write(json.dumps(settings, default=class2dict)) f.write(json.dumps(settings, default=class2dict))
input("保存完成!按回车继续...") return "done"

View File

@ -1,17 +1,29 @@
"""集合设置参数""" """集合设置参数"""
import os import os
from colorama import Fore
from modules.utils.clear_screen import cls_stay from modules.utils.clear_screen import cls_stay
from modules.utils.inputs import rinput, cinput from modules.utils.inputs import rinput, cinput
from modules.functions.settings.save_load_settings import save_settings from modules.functions.settings.save_load_settings import save_settings
from modules.utils.prints import print_menu
def settings_menu(self): def settings_menu(self):
"""设置菜单主循环""" """设置菜单主循环"""
while True: while True:
cls_stay(self, "[设置菜单]") if self.settings.auto_save:
print("[0] 返回上级\n[1] 歌曲保存路径\n[2] 清空输出文件夹内的内容\n[3] 歌词文件保存格式\n[4] 部分动态效果\n" save_settings(self.settings)
"[s] 将设置保存到文件") cls_stay(self, f"[设置菜单] "
f"{Fore.LIGHTCYAN_EX}自动保存: "
f"{({True: f'{Fore.GREEN}', False: f'{Fore.RED}'}[self.settings.auto_save])}")
print_menu({
"0": "返回上级菜单",
"1": "歌曲保存路径",
"2": "清空输出文件夹内的内容",
"3": "歌词文件保存格式",
"4": "部分动态效果",
"s": "切换设置自动保存"
})
r = rinput("请选择:") r = rinput("请选择:")
if r == "0": if r == "0":
return return
@ -24,7 +36,7 @@ def settings_menu(self):
elif r == "4": elif r == "4":
pass pass
elif r == "s": elif r == "s":
__save_settings(self) self.settings.auto_save = not self.settings.auto_save
else: else:
input("输入无效!按回车键继续...") input("输入无效!按回车键继续...")

View File

@ -1,5 +1,6 @@
"""用来清空命令行的信息,自动判别系统""" """用来清空命令行的信息,自动判别系统"""
import os import os
from colorama import Fore
def clear(): def clear():
@ -15,5 +16,5 @@ def clear():
def cls_stay(self, custom=""): def cls_stay(self, custom=""):
"""保留版本号清除屏幕""" """保留版本号清除屏幕"""
clear() clear()
print(f"[NeteaseMusicLyricDownloader] {self.version}") print(f"{Fore.YELLOW}[{Fore.GREEN}NeteaseMusicLyricDownloader{Fore.YELLOW}] {Fore.LIGHTBLACK_EX}{self.version}")
print(custom) print(Fore.LIGHTMAGENTA_EX+custom)

131
modules/utils/dump.py Normal file
View File

@ -0,0 +1,131 @@
import binascii
import json
import os
import struct
import base64
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad
from Cryptodome.Util.strxor import strxor as xor
from mutagen import mp3, flac, id3
def load_and_decrypt_from_ncm(file_path, target_dir, out_format) -> dict | str: # author: Nzix Repo: nondanee
core_key = binascii.a2b_hex('687A4852416D736F356B496E62617857')
meta_key = binascii.a2b_hex('2331346C6A6B5F215C5D2630553C2728')
f = open(file_path, 'rb')
# magic header
header = f.read(8)
assert binascii.b2a_hex(header) == b'4354454e4644414d'
f.seek(2, 1)
# key data
key_length = f.read(4)
key_length = struct.unpack('<I', bytes(key_length))[0]
key_data = bytearray(f.read(key_length))
key_data = bytes(bytearray([byte ^ 0x64 for byte in key_data]))
cryptor = AES.new(core_key, AES.MODE_ECB)
key_data = unpad(cryptor.decrypt(key_data), 16)[17:]
key_length = len(key_data)
# S-box (standard RC4 Key-scheduling algorithm)
key = bytearray(key_data)
S = bytearray(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key[i % key_length]) & 0xFF
S[i], S[j] = S[j], S[i]
# meta data
meta_length = f.read(4)
meta_length = struct.unpack('<I', bytes(meta_length))[0]
if meta_length:
meta_data = bytearray(f.read(meta_length))
meta_data = bytes(bytearray([byte ^ 0x63 for byte in meta_data]))
identifier = meta_data.decode('utf-8')
meta_data = base64.b64decode(meta_data[22:])
cryptor = AES.new(meta_key, AES.MODE_ECB)
meta_data = unpad(cryptor.decrypt(meta_data), 16).decode('utf-8')
meta_data = json.loads(meta_data[6:])
else:
meta_data = {'format': 'flac' if os.fstat(f.fileno()).st_size > 1024 ** 2 * 16 else 'mp3'}
f.seek(5, 1)
# album cover
image_space = f.read(4)
image_space = struct.unpack('<I', bytes(image_space))[0]
image_size = f.read(4)
image_size = struct.unpack('<I', bytes(image_size))[0]
image_data = f.read(image_size) if image_size else None
f.seek(image_space - image_size, 1)
# media data
if meta_length:
output_path = os.path.join(target_dir, out_format % {"name": meta_data["musicName"], "artists": "".join(
[x[0]+"," for x in meta_data["artist"]]
)[:-1]} + "." + meta_data["format"])
else:
output_path = os.path.join(target_dir, "Unnamed." + meta_data["format"])
data = f.read()
f.close()
# stream cipher (modified RC4 Pseudo-random generation algorithm)
stream = [S[(S[i] + S[(i + S[i]) & 0xFF]) & 0xFF] for i in range(256)]
stream = bytes(bytearray(stream * (len(data) // 256 + 1))[1:1 + len(data)])
data = xor(data, stream)
m = open(output_path, 'wb')
m.write(data)
m.close()
# media tag
if meta_length:
def embed(item, content, t):
item.encoding = 0
item.type = t
item.mime = 'image/png' if content[0:4] == binascii.a2b_hex('89504E47') else 'image/jpeg'
item.data = content
if image_data:
if meta_data['format'] == 'flac':
audio = flac.FLAC(output_path)
image = flac.Picture()
embed(image, image_data, 3)
audio.clear_pictures()
audio.add_picture(image)
audio.save()
elif meta_data['format'] == 'mp3':
audio = mp3.MP3(output_path)
image = id3.APIC()
embed(image, image_data, 6)
audio.tags.add(image)
audio.save()
if meta_length:
if meta_data['format'] == 'flac':
audio = flac.FLAC(output_path)
audio['description'] = identifier
else:
audio = mp3.EasyMP3(output_path)
audio['title'] = 'placeholder'
audio.tags.RegisterTextKey('comment', 'COMM')
audio['comment'] = identifier
audio['title'] = meta_data['musicName']
audio['album'] = meta_data['album']
audio['artist'] = '/'.join([artist[0] for artist in meta_data['artist']])
audio.save()
return meta_data
else:
return "no_meta_data"

View File

@ -1,5 +1,6 @@
"""该程序的自述信息,调用即输出""" """该程序的自述信息,调用即输出"""
from modules.utils.clear_screen import clear from modules.utils.clear_screen import clear
from colorama import Fore
def print_info(self): def print_info(self):
@ -16,9 +17,14 @@ def print_info(self):
特别感谢: 特别感谢:
\t- nondanee - ncmdump https://github.com/nondanee/ncmdump \t- nondanee - ncmdump https://github.com/nondanee/ncmdump
\t- QCloudHao - 保存了完整的ncmdump源代码 https://github.com/QCloudHao/ncmdump
\t- chuyaoxin - 提供了对ncmdump以及ncm文件的详细解说 https://www.cnblogs.com/cyx-b/p/13443003.html \t- chuyaoxin - 提供了对ncmdump以及ncm文件的详细解说 https://www.cnblogs.com/cyx-b/p/13443003.html
若程序遇到bug请提交至github上的issue""") 若程序遇到bug请提交至github上的issue""")
input("按回车键返回...") input("按回车键返回...")
return return
def print_menu(menu: dict):
"""传入一个字典, 格式为 {"需要输入的字符": "功能描述", ...}"""
for k, v in menu.items():
print(f"{Fore.LIGHTBLUE_EX}[{k}] {Fore.LIGHTWHITE_EX}{v}")