Add new functions!!!

Change Special Thanks
ADD FUNCTION: use the source code from ncmdump and modify it to satisfy my needs
This commit is contained in:
David-123 2022-08-13 03:11:22 +08:00 committed by GitHub
parent ffdd69000c
commit 35cdec2c37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 245 additions and 9 deletions

0
modules/__init__.py Normal file
View File

View File

@ -1,4 +1,5 @@
"""集合 下载歌词 以及 获取歌曲信息 的功能"""
import os
from json import loads
from requests import post
from requests.exceptions import ConnectionError
@ -90,6 +91,7 @@ def get_song_lyric(id: str | int | dict, path: str, allinfo: bool = False):
}
for k, v in replaces.items():
name = name.replace(k, v)
artists = artists.replace(k, v)
print(f"歌曲:{name} - {artists}")
filename = f"{name} - {artists}.lrc"
@ -114,7 +116,7 @@ def get_song_lyric(id: str | int | dict, path: str, allinfo: bool = False):
print("这首歌没有歌词,跳过...")
return
else:
with open(f"{path}{filename}", "w", encoding="utf-8") as f:
with open(os.path.join(path, filename), "w", encoding="utf-8") as f:
f.write(tmp["lyric"])
print(f"歌词下载完成!被保存在{path}{filename}")
print(f"歌词下载完成!被保存在{os.path.join(path, filename)}")
return

View File

@ -5,7 +5,7 @@ from modules.clear_screen import clear
def print_info(self):
"""调用即输出,无返回值"""
clear()
print(f"""[NeteaseMusicLyricDownloader Reloaded]
print(f"""[NeteaseMusicLyricDownloader]
版本: {self.version}
本软件开源项目地址:https://github.com/1826013250/NeteaseMusicLyricDownloader
作者:David-123
@ -14,7 +14,11 @@ def print_info(self):
\tE-mail:1826013250@qq.com(mainly)
\t mc1826013250@gmail.com
Special Thanks:
\t- chenjunyu19, provided the 163key's cipher
\t- website 'MKLAB', provided the AES decryption service""")
特别感谢:
\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
若程序遇到bug请提交至github上的issue""")
input("按回车键返回...")
return

230
modules/load_file_song.py Normal file
View File

@ -0,0 +1,230 @@
import binascii
import json
import os
import struct
from base64 import b64decode
from Cryptodome.Cipher import AES
from mutagen import File
from mutagen.id3 import ID3, TPE1, APIC, COMM, TIT2, TALB
from modules.clear_screen import clear
from modules.get_song import get_song_lyric
def load_information_from_song(path):
"""从音乐文件中的 Comment 字段获取 163 key 并解密返回歌曲信息"""
file = File(path) # 使用 TinyTag 获取歌曲信息
if file.tags.get("COMM::XXX"):
if file.tags["COMM::XXX"].text[0][:7] == "163 key":
ciphertext = file.tags["COMM::XXX"].text[0][22:]
else:
return "not_support"
else:
return "not_support"
def unpad(s): # 创建清理针对于网易云的 AES-128-ECB 解密后末尾占位符的函数
if type(s[-1]) == int:
end = s[-1]
else:
end = ord(s[-1])
return s[0:-end]
# return s[0:-(s[-1] if type(s[-1]) == int else ord(s[-1]))] 更加清晰的理解 ↑
cryptor = AES.new(b"#14ljk_!\\]&0U<'(", AES.MODE_ECB) # 使用密钥创建解密器
# 下方这一行将密文 ciphertext 转换为 bytes 后进行 base64 解码, 得到加密过的 AES 密文
# 再通过上方创建的 AES 128-ECB 的解密器进行解密, 然后使用 unpad 清除末尾无用的占位符后得到结果
try:
r = unpad((cryptor.decrypt(b64decode(bytes(ciphertext, "utf-8"))).decode("utf-8")))
except ValueError:
return "decrypt_failed"
if r:
if r[:5] == "music":
return json.loads(r[6:])
else:
return "not_a_normal_music"
else:
return "decrypt_failed"
def load_and_decrypt_from_ncm(file_path, targetdir): # nondanee的源代码, 根据需求更改了某些东西
core_key = binascii.a2b_hex("687A4852416D736F356B496E62617857")
meta_key = binascii.a2b_hex("2331346C6A6B5F215C5D2630553C2728")
unpad = lambda s: s[0:-(s[-1] if type(s[-1]) == int else ord(s[-1]))]
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))[17:]
key_length = len(key_data)
key_data = bytearray(key_data)
key_box = bytearray(range(256))
c = 0
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)).decode('utf-8')[6:]
meta_data = json.loads(meta_data)
crc32 = f.read(4)
crc32 = struct.unpack('<I', bytes(crc32))[0]
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(targetdir, file_name), 'wb')
chunk = bytearray()
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()
# 对解密后的文件进行信息补全
audio = ID3(os.path.splitext(file_path)[0]+"."+meta_data["format"])
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()
return meta_data
def get_lyric_from_folder(lyric_path: str):
clear()
path = input("请输入歌曲的保存文件夹(绝对路径):").strip()
if not os.path.exists(path):
input("路径不存在.\n按回车返回...")
return
print("正在遍历目录,请稍后...")
musics = []
ncm_files = []
fails = 0
for i in os.listdir(path): # 遍历目录,查找目标文件
ext = os.path.splitext(i)[-1]
if ext in ['.mp3', '.flac']: # 对于 mp3 和 flac 文件, 使用对应读取解密方式
result = load_information_from_song(os.path.join(path, i))
if result == "not_support":
fails += 1
print(f"文件 \"{i}\" 未包含 163 key ,跳过")
elif result == "decrypt_failed":
fails += 1
print(f"文件 \"{i}\" 内 163 key 解密失败,跳过")
elif result == "not_a_normal_music":
fails += 1
print(f"文件 \"{i}\" 内 163 key 不是一个普通音乐文件,这可能是一个电台曲目")
else:
musics.append({"id": result['musicId'], "name": result["musicName"], "artists": result["artist"]})
elif ext == ".ncm": # 对于 ncm 先加入到列表,等待解密
ncm_files.append(i)
else:
pass
if ncm_files:
while True:
print(f"\n发现{len(ncm_files)}个ncm加密文件!")
print("请问解密后的文件保存在哪里?\n"
"[1] 保存在相同文件夹内\n[2] 保存在程序设定的下载文件夹中\n[3] 保存在自定义文件夹内\n[q] 取消解密,下载歌词时将忽略这些文件")
select = input("请选择: ").strip().lower()
if select == 'q':
target_path = "NOT_DECRYPT"
break
elif select == '1':
target_path = path
break
elif select == '2':
target_path = lyric_path
break
elif select == '3':
target_path = input("请输入: ").strip()
break
else:
print("输入无效!按回车继续...")
if target_path != "NOT_DECRYPT":
for i in range(0, len(ncm_files)):
print("破解进度: %d/%d ~ %s" % (i+1, len(ncm_files), ncm_files[i]))
try:
result = load_and_decrypt_from_ncm(os.path.join(path, ncm_files[i]), target_path)
except AssertionError:
print(f"\t- 文件 \"{ncm_files[i]}\" 破解失败!\n\t 可能是文件不完整或者重命名了别的文件?跳过...")
fails += 1
continue
print("\t--> %s" % os.path.splitext(ncm_files[i])[0]+"."+result["format"])
musics.append({"id": result['musicId'], "name": result["musicName"], "artists": result["artist"]})
# 汇报索引结果
print(f"\n索引完毕!共找到{fails + len(musics) + len(ncm_files)}个目标文件\n{len(musics)}个文件已载入\n{fails}个文件失败")
if ncm_files:
if target_path == "NOT_DECRYPT":
print(f"{len(ncm_files)}个文件放弃加载")
while True:
print("\n你希望如何保存这些歌曲的歌词?\n[1]保存到刚刚输入的绝对路径中\n[2]保存到程序设定的下载路径中")
r = input("请选择: ").strip().lower()
if r == "1":
break
elif r == "2":
path = lyric_path
break
else:
try:
input("无效选择, 若取消请按 ^C ,继续请按回车")
clear()
except KeyboardInterrupt:
return
clear()
for i in range(0, len(musics)): # 根据索引结果获取歌词
print("\n进度: %d/%d" % (i + 1, len(musics)))
if get_song_lyric(musics[i], path, allinfo=True) == "dl_err_connection":
input("下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键继续任务(该任务会被跳过)...")
if ncm_files:
if target_path != "NOT_DECRYPT":
agree = input("是否删除原ncm文件? (y/n)").strip().lower()
if agree == "y":
for i in range(0, len(ncm_files)):
print("删除进度: %d/%d ~ %s" % (i+1, len(ncm_files), ncm_files[i]))
os.remove(os.path.join(path, ncm_files[i]))
else:
print("取消.")
input("按回车返回...")
return

View File

@ -4,7 +4,7 @@ from modules.get_song import get_song_lyric
from modules.clear_screen import clear
def download_one_lyric(song_id, path: str):
def download_one_lyric(path: str):
"""单次下载歌词
``path: str`` 存储歌词的路径"""

View File

@ -11,7 +11,7 @@ def settings_menu(self):
"""设置菜单主循环"""
while True:
clear()
print(f"[NeteaseMusicLyricDownloader Reloaded] {self.version}\n"
print(f"[NeteaseMusicLyricDownloader] {self.version}\n"
"[设置菜单]\n"
"[0] 返回上级\n[1] 设置歌曲保存路径\n[2] 清空输出文件夹内的所有歌词\n[s] 将设置保存到文件")
r = rinput("请选择:")