14 Commits
nmld ... 1.0-1

Author SHA1 Message Date
f84d8ccf05 Optimize README.md 2023-04-16 11:03:27 +08:00
6fe6a1c759 Optimize Progress bar 2023-04-16 10:56:09 +08:00
e371ae1052 Change file structure 2023-04-04 20:01:17 +08:00
43cf038ec0 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	modules/functions/load_file_song.py
#	modules/utils/inputs.py
2023-04-04 20:00:49 +08:00
71e967808b Change file structure 2023-04-04 19:58:02 +08:00
dcd275e3b1 character spelling fix 2023-04-04 19:25:03 +08:00
afd3e84a8e OPTIMIZED OPTION!!!!!!!!!!!!
SUPPORT MULTI-PROCESSING DECRYPTION
2023-04-04 00:56:35 +08:00
2f4db4e11c Optimized README file 2023-04-04 00:41:47 +08:00
4c5bd467cb Upload requirements.txt
Allow user to install packages in a more easy way
2023-04-04 00:41:36 +08:00
David-123
07318a2ef8 Re-Upload
Nothing
2023-04-03 22:29:25 +08:00
David-123
474efd1321 Update README_cn.md 2022-08-15 22:05:18 +08:00
David-123
27a3eda9fe Update README.md 2022-08-15 22:05:06 +08:00
David-123
1db3555ea8 Delete readmp3.py 2022-08-13 11:20:00 +08:00
David-123
5f025cdfbf Update README.md 2022-08-13 03:27:15 +08:00
21 changed files with 557 additions and 491 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
out/
settings.json
.idea/
venv/
venv_win/
test/

View File

@@ -10,10 +10,13 @@ It uses the module `requests` to fetch the lyric on music.163.com
Netease Music supply us an api that we can get the lyric of the current song, so I make this program... Netease Music supply us an api that we can get the lyric of the current song, so I make this program...
## Installation ## Installation
First, you need a python environment, and use `pip` to install these packages: `requests` `mutagen` `pycryptodome` First, you need a python(>=3.10) environment
>Feel complex? just copy this command to your terminal: `python3 -m pip install requests mutagen pycryptodome`
Second, clone the entire project and run the command `python3 main.py` in the project folder. Second, clone the entire project and install packages with the command below:
```commandline
python3 -m pip install -r requirements.txt
```
Last, run the command `python3 main.py` in the project folder.
## What can it do? ## What can it do?
Just download lyrics. Just download lyrics.
@@ -22,7 +25,7 @@ You need to provide the id or the share link of the song, and the program will d
Now it can recognize 163 key in music files that download from Netease Cloudmusic client. Now it can recognize 163 key in music files that download from Netease Cloudmusic client.
- 2022/8/13 Now it has the function from `ncmdump`, it can decrypt the ncm files and fetch the specific lyric. - 2022/8/13 Now it has the function from `ncmdump`, and it can decrypt the ncm files and fetch the specific lyric.
## Todo ## Todo

View File

@@ -9,10 +9,16 @@
网易云音乐提供给我们了一个获取歌词的API ~~然后我就做了这个程序(~~ 网易云音乐提供给我们了一个获取歌词的API ~~然后我就做了这个程序(~~
## 爷要安装 ## 爷要安装
首先你需要一个Python环境(要求版本>=3.10,因为使用了3.10python的新特性)然后使用pip来安装`requests` `mutagen` `pycryptodome`这几个包 首先你需要一个Python环境(要求版本>=3.10,因为使用了3.10python的新特性)
>您觉得很麻烦?_~~事真多~~_ 直接复制这段命令到终端罢你得先有Python和pip `python3 -m pip install requests mutagen pycryptodome`
然后,把整个项目薅下来,在当前目录下运行`python3 main.py`就行了 然后,把整个项目薅下来,安装依赖项目:
```commandline
python3 -m pip install -r requirements.txt
```
最后运行下面命令:
```commandline
python3 main.py
```
## 这玩意到底能干啥? ## 这玩意到底能干啥?
~~nmd~~这玩意就像它的名字一样,下载歌词用的 ~~nmd~~这玩意就像它的名字一样,下载歌词用的

24
main.py
View File

@@ -1,17 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# ↑ For Linux & MacOS to run this program directly if the user currently installed python and third-party packages. # ↑ For Linux & macOS to run this program directly if the user currently installed python and third-party packages.
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# author: David-123 # author: David-123
from modules.raw_input import rinput from modules.utils.inputs import rinput
from modules.information import print_info from modules.utils.information import print_info
from modules.multi_download import mdl from modules.functions.multi_download import mdl
from modules.one_download import download_one_lyric from modules.functions.one_download import download_one_lyric
from modules.settings import settings_menu from modules.submenus.settings import settings_menu
from modules.save_load_settings import load_settings from modules.functions.save_load_settings import load_settings
from modules.clear_screen import clear from modules.utils.clear_screen import clear
from modules.load_file_song import get_lyric_from_folder from modules.functions.load_file_song import get_lyric_from_folder
class MainProcess(object): class MainProcess(object):
@@ -30,11 +30,11 @@ class MainProcess(object):
r = rinput("请选择:") r = rinput("请选择:")
if r == "1": if r == "1":
download_one_lyric(self.settings.lyric_path) download_one_lyric(self)
elif r == "2": elif r == "2":
mdl(self.settings.lyric_path) mdl(self)
elif r == "3": elif r == "3":
get_lyric_from_folder(self.settings.lyric_path) get_lyric_from_folder(self)
elif r == "0": elif r == "0":
exit(0) exit(0)
elif r == "i": elif r == "i":

View File

View File

@@ -0,0 +1,315 @@
import binascii
import json
import os
import struct
from base64 import b64decode
from multiprocessing import Process, Queue
from queue import Empty
from time import sleep
from progress.bar import Bar
from Cryptodome.Cipher import AES
from mutagen import File, flac
from mutagen.id3 import ID3, TPE1, APIC, COMM, TIT2, TALB
from modules.utils.clear_screen import clear
from modules.functions.get_song import get_song_lyric
from modules.utils.inputs import cinput, rinput
def load_information_from_song(path) -> str | dict:
"""从音乐文件中的 Comment 字段获取 163 key 并解密返回歌曲信息"""
file = File(path) # 使用 mutagen 获取歌曲信息
if os.path.splitext(path)[-1] == ".mp3": # 当文件为 mp3 时使用 ID3 格式读取
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"
elif os.path.splitext(path)[-1] == ".flac": # 当文件为 flac 时使用 FLAC 格式读取
if file.tags.get("DESCRIPTION"):
if file.tags["DESCRIPTION"][0][:7] == "163 key":
ciphertext = file.tags["DESCRIPTION"][0][22:]
else:
return "not_support"
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, target_dir) -> dict: # nondanee的源代码, 根据需求更改了某些东西
core_key = binascii.a2b_hex("687A4852416D736F356B496E62617857")
meta_key = binascii.a2b_hex("2331346C6A6B5F215C5D2630553C2728")
def unpad(s):
return 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))
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]
..., 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:
result = load_and_decrypt_from_ncm(path, target)
except AssertionError:
q_err.put(f"\t- 文件 \"{filename}\" 破解失败!")
else:
q_info.put(result)
def get_lyric_from_folder(self):
clear()
path = cinput(
f"[NeteaseMusicLyricDownloader] {self.version}\n"
"[自动获取]\n"
"请输入歌曲的保存文件夹(绝对路径):")
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
target_path = ""
if ncm_files:
while True:
print(f"\n发现{len(ncm_files)}个ncm加密文件!")
print("请问解密后的文件保存在哪里?\n"
"[1] 保存在相同文件夹内\n[2] 保存在程序设定的下载文件夹中\n[3] 保存在自定义文件夹内\n[q] 取消解密,下载歌词时将忽略这些文件")
select = rinput("请选择: ")
if select == 'q':
target_path = "NOT_DECRYPT"
break
elif select == '1':
target_path = path
break
elif select == '2':
target_path = self.settings.lyric_path
break
elif select == '3':
target_path = cinput("请输入: ")
break
else:
print("输入无效!按回车继续...")
if target_path != "NOT_DECRYPT": # 开始进行逐个文件解密
errors = [] # 初始化变量
q_err = Queue() # 错误信息队列
q_info = Queue() # 返回信息队列
max_process = 20 # 最大进程数
current_process = 0 # 当前正在活动的进程数
passed = 0 # 总共结束的进程数
with Bar(f"正在破解 %(index){len(str(len(ncm_files)))}d/%(max)d",
suffix="", max=len(ncm_files), color="blue") as bar:
total = len(ncm_files)
allocated = 0 # 已经分配的任务数量
while True: # 进入循环,执行 新建进程->检测队列->检测任务完成 的循环
sleep(0)
if current_process <= max_process and allocated < total: # 分配进程
Process(target=process_work,
args=(os.path.join(path, ncm_files[allocated]),
ncm_files[allocated],
target_path,
q_err,
q_info)).start()
allocated += 1
bar.suffix = f"已分配: {ncm_files[allocated-1]}"
bar.update()
while True: # 错误队列检测
try:
errors.append(q_err.get_nowait())
passed += 1 # 总任务完成数
current_process -= 1 # 检测到进程完毕将进程-1
bar.next() # 推动进度条
fails += 1 # 错误数量+1
except Empty:
break
while True: # 信息队列检测
try:
r = q_info.get_nowait()
musics.append({"id": r['musicId'], "name": r["musicName"], "artists": r["artist"]})
passed += 1
current_process -= 1
bar.suffix = f"已完成: {r['musicName']} - "\
f"{''.join([x + ', ' for x in [x[0] for x in r['artist']]])[:-2]}"
bar.next()
except Empty:
break
if passed >= len(ncm_files):
break
if errors:
print("解密过程中发现了以下错误:")
for i in errors:
print(i)
# 汇报索引结果
ncm_files_num = 0
if ncm_files:
if target_path == "NOT_DECRYPT":
ncm_files_num = len(ncm_files)
print(f"\n索引完毕!共找到{fails + len(musics) + ncm_files_num}个目标文件\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 = rinput("请选择: ")
if r == "1":
lyric_path = path
break
elif r == "2":
lyric_path = self.settings.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], lyric_path, allinfo=True) == "dl_err_connection":
input("下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键继续任务(该任务会被跳过)...")
if ncm_files:
if target_path != "NOT_DECRYPT":
agree = rinput("是否删除原ncm文件? (y/n)")
if agree == "y":
for i in range(0, len(ncm_files)):
print("删除进度: %d/%d\n -> %s\033[F" % (i + 1, len(ncm_files), ncm_files[i]), end="")
os.remove(os.path.join(path, ncm_files[i]))
else:
print("取消.", end="")
input("\n\033[K按回车返回...")
return

View File

@@ -1,37 +1,38 @@
import re import re
from modules.clear_screen import clear from modules.utils.clear_screen import clear
from modules.raw_input import rinput from modules.utils.inputs import rinput
from modules.get_song import get_song_lyric from modules.functions.get_song import get_song_lyric
def mdl(path: str): def mdl(self):
"""多个歌词文件的下载 """多个歌词文件的下载
``path: str`` 传入歌词文件保存的路径""" ``path: str`` 传入歌词文件保存的路径"""
clear() clear()
ids = [] ids = []
print("输入歌曲id,用回车分开,输入s停止") print(f"[NeteaseMusicLyricDownloader] {self.version}\n"
while True: "[手动-多个下载]\n"
r = rinput() "输入歌曲id,用回车分开,输入s停止")
if r == 's': while True:
break r = rinput()
else: if r == 's':
try: break
int(r) else:
try:
except ValueError: int(r)
except ValueError:
tmp = re.search(r"song\?id=[0-9]*", r) tmp = re.search(r"song\?id=[0-9]*", r)
if tmp: if tmp:
r = tmp.group()[8:] r = tmp.group()[8:]
else: else:
print("不合法的形式.\n") print("不合法的形式.\n")
continue continue
ids.append(r) ids.append(r)
print("\t#%d id:%s - 已添加!" % (len(ids), r)) print("\t#%d id:%s - 已添加!" % (len(ids), r))
clear() clear()
for i in range(0, len(ids)): for i in range(0, len(ids)):
print("\n进度: %d/%d" % (i+1, len(ids))) print("进度: %d/%d" % (i+1, len(ids)))
if get_song_lyric(ids[i], path) == "dl_err_connection": r = get_song_lyric(ids[i], self.settings.lyric_path)
input("下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键继续任务(该任务会被跳过)...") if r == "dl_err_connection":
input("按回车键返回...") input("下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键继续任务(该任务会被跳过)...")
input("按回车键返回...")

View File

@@ -1,15 +1,18 @@
import re import re
from modules.raw_input import rinput from modules.utils.inputs import rinput
from modules.get_song import get_song_lyric from modules.functions.get_song import get_song_lyric
from modules.clear_screen import clear from modules.utils.clear_screen import clear
def download_one_lyric(path: str): def download_one_lyric(self):
"""单次下载歌词 """单次下载歌词
``path: str`` 存储歌词的路径""" ``path: str`` 存储歌词的路径"""
clear() clear()
song_id = rinput("请输入歌曲id:") song_id = rinput(
f"[NeteaseMusicLyricDownloader] {self.version}\n"
"[手动-单个下载]\n"
"请输入歌曲id:")
try: try:
int(song_id) int(song_id)
except ValueError: except ValueError:
@@ -20,6 +23,6 @@ def download_one_lyric(path: str):
input("不合法的形式.\n按回车键返回...") input("不合法的形式.\n按回车键返回...")
return return
if get_song_lyric(song_id, path) == "dl_err_connection": if get_song_lyric(song_id, self.settings.lyric_path) == "dl_err_connection":
input("下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键返回...") input("下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键返回...")
input("按回车键返回...") input("按回车键返回...")

View File

@@ -1,230 +0,0 @@
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

@@ -1,89 +0,0 @@
import json
import os
import re
from tinytag import TinyTag
from requests import post
from modules.get_song import get_song_lyric
from modules.clear_screen import clear
def load_information_from_mp3(path):
"""从音乐文件中的 Comment 字段获取 163 key 并解密返回歌曲信息"""
file = TinyTag.get(path) # 使用 TinyTag 获取歌曲信息
if file.comment:
if file.comment[:7] == "163 key":
ciphertext = file.comment[22:]
else:
return "not_support"
else:
return "not_support"
data = {
"data": ciphertext,
"type": "aes_decrypt",
"encode": "base64",
"key": "#14ljk_!\\]&0U<'(", # 感谢大佬 chenjunyu19 在 MorFans Dev 上提供的密文密钥
"digit": 128,
"mode": "ECB",
"pad": "Pkcs5Padding"
}
try:
r = post("https://www.mklab.cn/utils/handle", data=data).text[17:-2].replace("\\\"", "\"") # 十分感谢 MKLAB 网站提供的解密接口!!
except ConnectionError:
return "dl_err_connection"
if r:
return json.loads(r)
else:
return "decrypt_failed"
def get_lyric_from_folder(lyric_path: str):
clear()
path = input("请输入歌曲的保存文件夹(绝对路径):").strip().replace("\\", "/")
if not os.path.exists(path):
input("路径不存在,请检查输入...")
return
if path[-1] != "/":
path += "/"
print("正在遍历目录,请稍后...")
musics = []
fails = 0
for i in os.listdir(path): # 遍历目录,查找目标文件
match = re.match(r".*\.((mp3)|(flac))$", i)
if match:
result = load_information_from_mp3(path+match.group())
if result == "not_support":
fails += 1
print(f"文件 \"{i}\" 未包含 163 key ,跳过")
elif result == "decrypt_failed":
fails += 1
print(f"文件 \"{i}\" 内 163 key 解密失败,跳过")
elif result == "dl_err_connection":
input("获取解密结果失败!请检查网络连接是否正常,若确信自身没有问题请向作者反馈是否解密接口出现问题!")
return
else:
musics.append({"id": result['musicId'], "name": result["musicName"], "artists": result["artist"]})
# 汇报索引结果
print(f"\n索引完毕!共找到{fails+len(musics)}个目标文件\n{len(musics)}个文件已载入\n{fails}个文件失败\n")
while True:
r = input("你希望如何保存这些歌曲的歌词?\n[1]保存到刚刚输入的绝对路径中\n[2]保存到程序设定的下载路径中\n请选择: ").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按回车键继续任务(该任务会被跳过)...")
input("按回车键返回...")
return

View File

@@ -1,65 +0,0 @@
"""集合设置参数"""
import os
import re
from modules.clear_screen import clear
from modules.raw_input import rinput, cinput
from modules.save_load_settings import save_settings
def settings_menu(self):
"""设置菜单主循环"""
while True:
clear()
print(f"[NeteaseMusicLyricDownloader] {self.version}\n"
"[设置菜单]\n"
"[0] 返回上级\n[1] 设置歌曲保存路径\n[2] 清空输出文件夹内的所有歌词\n[s] 将设置保存到文件")
r = rinput("请选择:")
if r == "0":
return
elif r == "1":
__set_lyric_path(self)
elif r == "2":
__remove_lyric_files(self.settings.lyric_path)
elif r == "s":
__save_settings(self)
else:
input("输入无效!按回车键继续...")
def __remove_lyric_files(path):
clear()
files = []
for i in os.listdir(path):
if re.match(r".*(\.lrc)$", i):
files.append(i)
if len(files) != 0:
for i in range(0, len(files)):
print("正在删除(%d/%d): %s" % (i+1, len(files), files[i]))
os.remove(path+files[i])
input("删除完毕!\n按回车继续...")
else:
input("文件夹内没有要删除的东西\n按回车继续...")
def __set_lyric_path(self):
clear()
print("允许使用相对路径和绝对路径,默认为\"./out/\"\n请*不要*使用反斜杠来确保通用性\n"
"当前值:%s\n请输入新的歌词保存路径:" % self.settings.lyric_path)
r = cinput()
if not r:
input("输入为空!\n按回车继续...")
return
if r[-1] != "/":
r += "/"
path = ""
for i in r.split("/"):
path += i+"/"
if not os.path.exists(path):
os.mkdir(path)
self.settings.lyric_path = r
input("设置成功!\n按回车继续...")
def __save_settings(self):
return save_settings(self.settings)

View File

View File

@@ -0,0 +1,101 @@
"""集合设置参数"""
import os
from modules.utils.clear_screen import clear
from modules.utils.inputs import rinput, cinput
from modules.functions.save_load_settings import save_settings
def settings_menu(self):
"""设置菜单主循环"""
while True:
clear()
print(f"[NeteaseMusicLyricDownloader] {self.version}\n"
"[设置菜单]\n"
"[0] 返回上级\n[1] 歌曲保存路径\n[2] 清空输出文件夹内的内容\n[3] 歌词文件保存格式\n[4] 部分动态效果\n"
"[s] 将设置保存到文件")
r = rinput("请选择:")
if r == "0":
return
elif r == "1":
__set_lyric_path(self)
elif r == "2":
__remove_output_files(self)
elif r == "3":
pass
elif r == "4":
pass
elif r == "s":
__save_settings(self)
else:
input("输入无效!按回车键继续...")
def __remove_output_files(self):
while True:
clear()
print(f"[NeteaseMusicLyricDownloader] {self.version}\n"
"[设置菜单 - 删除文件]\n"
"[0] 返回上级\n[1] 清除歌词文件\n[2] 清除歌曲文件\n[a] 清除所有文件")
r = rinput("请选择:") # 选择清除的文件格式
if r == "0":
return
elif r == "1":
dellist = [".lrc"]
break
elif r == "2":
dellist = [".mp3", ".flac"]
break
elif r == "a":
dellist = ["ALL"]
break
else:
input("输入无效!\n按回车键继续...")
files = []
for i in os.listdir(self.settings.lyric_path): # 列出所有文件
if dellist[0] == "ALL":
files = os.listdir(self.settings.lyric_path)
break
elif os.path.splitext(i)[-1] in dellist: # 匹配文件
files.append(i) # 将匹配到的文件加入到列表, 等待删除
if len(files) != 0:
if len(files) > 30:
special_text = "\033[F"
else:
special_text = "\n"
for i in range(0, len(files)):
print("删除进度: %d/%d\n -> %s%s" % (i+1, len(files), files[i], special_text), end="") # 删除进度提示
os.remove(self.settings.lyric_path+files[i])
input("\n\033[K删除完毕!\n按回车继续...")
return
else:
input("文件夹内没有要删除的东西\n按回车继续...")
return
def __set_lyric_path(self):
clear()
print("允许使用相对路径和绝对路径,默认为\"./out/\"\n请*不要*使用反斜杠来确保通用性\n"
"当前值:%s\n请输入新的歌词保存路径:" % self.settings.lyric_path)
r = cinput()
if not r:
input("输入为空!\n按回车继续...")
return
if r[-1] != "/":
r += "/"
path = ""
for i in r.split("/"):
path += i+"/"
if not os.path.exists(path):
os.mkdir(path)
self.settings.lyric_path = r
input("设置成功!\n按回车继续...")
return
def __set_lyric_format(self):
pass
def __save_settings(self):
return save_settings(self.settings)

View File

View File

@@ -1,12 +1,12 @@
"""包含一个函数,用来清空命令行的信息,自动判别系统""" """包含一个函数,用来清空命令行的信息,自动判别系统"""
import os import os
def clear(): def clear():
name = os.name name = os.name
if name == "nt": if name == "nt":
os.system("cls") os.system("cls")
elif name == "posix": elif name == "posix":
os.system("clear") os.system("clear")
else: else:
os.system("clear") os.system("clear")

View File

@@ -1,24 +1,24 @@
"""该程序的自述信息,调用即输出""" """该程序的自述信息,调用即输出"""
from modules.clear_screen import clear from modules.utils.clear_screen import clear
def print_info(self): def print_info(self):
"""调用即输出,无返回值""" """调用即输出,无返回值"""
clear() clear()
print(f"""[NeteaseMusicLyricDownloader] print(f"""[NeteaseMusicLyricDownloader]
版本: {self.version} 版本: {self.version}
本软件开源项目地址:https://github.com/1826013250/NeteaseMusicLyricDownloader 本软件开源项目地址:https://github.com/1826013250/NeteaseMusicLyricDownloader
作者:David-123 作者:David-123
联系方式: 联系方式:
\tQQ:1826013250 \tQQ:1826013250
\tE-mail:1826013250@qq.com(mainly) \tE-mail:1826013250@qq.com(mainly)
\t mc1826013250@gmail.com \t mc1826013250@gmail.com
特别感谢: 特别感谢:
\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- 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

11
modules/utils/inputs.py Normal file
View File

@@ -0,0 +1,11 @@
"""该模块提供几个自定义处理输入函数"""
def rinput(string: str = ''):
"""当调用该函数时同input()一样但是返回一个去除首尾空格并全部小写的str"""
return input(string).strip().lower()
def cinput(string: str = ''):
"""当调用该函数时同input()一样但是返回一个去除首尾空格的str"""
return input(string).strip()

View File

@@ -1,11 +1,11 @@
"""该模块提供几个自定义处理输入函数""" """该模块提供几个自定义处理输入函数"""
def rinput(string: str = ''): def rinput(string: str = ''):
"""当调用该函数时同input()一样但是返回一个去除首位空格并全部小写的str""" """当调用该函数时同input()一样但是返回一个去除首位空格并全部小写的str"""
return input(string).strip().lower() return input(string).strip().lower()
def cinput(string: str = ''): def cinput(string: str = ''):
"""当调用该函数时同input()一样但是返回一个去除首尾空格的str""" """当调用该函数时同input()一样但是返回一个去除首尾空格的str"""
return input(string).strip() return input(string).strip()

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
requests
mutagen
pycryptodomex
progress