Compare commits

..

No commits in common. "main" and "nmld" have entirely different histories.
main ... nmld

35 changed files with 691 additions and 1190 deletions

14
.gitignore vendored
View File

@ -1,14 +0,0 @@
out/
settings.json
.idea/
venv/
venv_win/
test/
modules/test/
modules/__pycache__
modules/functions/__pycache__
modules/functions/mainly/__pycache__
modules/functions/settings/__pycache__
modules/submenus/__pycache__
modules/utils/__pycache__

View File

@ -1,39 +1,35 @@
# NeteaseMusicLyricDownloader NeteaseMusicLyricDownloader
**简体中文**|[English](https://github.com/1826013250/NeteaseMusicLyricDownloader/blob/main/README_en.md) ===========================
**English**|[简体中文](./README_cn.md)
一个下载网易云音乐歌词的简单工具 A simple tool to download lyrics in Netease Music
## 原理 ## How does it work?
它用了Python中的一个`requests`模块来实现抓取歌词文件的功能 It uses the module `requests` to fetch the lyric on music.163.com
网易云音乐提供给我们了一个获取歌词的API ~~然后我就做了这个程序(~~ Netease Music supply us an api that we can get the lyric of the current song, so I make this program...
## 安装 ## Installation
首先你需要一个Python环境(要求版本>=3.10,因为使用了3.10python的新特性) First, you need a python environment, and use `pip` to install these packages: `requests` `mutagen` `pycryptodome`
>Feel complex? just copy this command to your terminal: `python3 -m pip install requests mutagen pycryptodome`
然后把整个项目clone下来安装依赖项目 Second, clone the entire project and run the command `python3 main.py` in the project folder.
```commandline
python3 -m pip install -r requirements.txt
```
最后运行下面命令:
```commandline
python3 main.py
```
## 功能 ## What can it do?
Just download lyrics.
- 通过id下载特定音乐的歌词 You need to provide the id or the share link of the song, and the program will download the lyrics automatically.
- 使用分享链接下载特定音乐歌词
- 批量链接下载
- 从网易云下载的歌曲获取信息并下载
- 解锁网易云锁定的歌曲文件并下载歌词
## TODO 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.
## 其他要说的 ## Todo
这就一简简单单普普通通的程序 Add more functions like searching...
我可能也没有太多的精力去写这个项目... ## Others
Just a easy program...
I haven't much time to focus on it...

34
README_cn.md Normal file
View File

@ -0,0 +1,34 @@
# NeteaseMusicLyricDownloader
[English](https://github.com/1826013250/NeteaseMusicLyricDownloader)|**简体中文**
一个下载网易云音乐歌词的简单工具
## 这个玩意原理是啥?
它用了Python中的一个`requests`模块来实现抓取歌词文件的功能
网易云音乐提供给我们了一个获取歌词的API ~~然后我就做了这个程序(~~
## 爷要安装
首先你需要一个Python环境(要求版本>=3.10,因为使用了3.10python的新特性)然后使用pip来安装`requests` `mutagen` `pycryptodome`这几个包
>您觉得很麻烦?_~~事真多~~_ 直接复制这段命令到终端罢你得先有Python和pip `python3 -m pip install requests mutagen pycryptodome`
然后,把整个项目薅下来,在当前目录下运行`python3 main.py`就行了
## 这玩意到底能干啥?
~~nmd~~这玩意就像它的名字一样,下载歌词用的
你需要提供歌曲的id或者分享链接然后这破玩意就会自动下载歌词
啊现在它可以识别网易云音乐客户端下载的音乐了识别内部的163 key484更舒服了
- 2022/8/13 啊现在啊现在, 它集成了ncmdump的功能, 可以识别网易云的加密文件格式,解密并获取歌词!真实令人激动的新功能啊!!
## 后续有啥功能?
_~~我不造,要不你来写罢(~~_
## 其他要说的
这就一简简单单普普通通的程序
我可能也没有太多的精力去写这么个~~破玩意~~...

View File

@ -1,40 +0,0 @@
NeteaseMusicLyricDownloader
===========================
[简体中文](https://github.com/1826013250/NeteaseMusicLyricDownloader)|**English**
A simple tool to download lyrics in Netease Music
_*Caution:* The author is not native English speaker. If there are some grammar mistakes, please ignore them. Thanks!!!!_
## How does it work?
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...
## Installation
First, you need a python(>=3.10) environment
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?
Just download lyrics.
You need to provide the id or the share link of the song, and the program will download the lyrics automatically.
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`, and it can decrypt the ncm files and fetch the specific lyric.
## Todo
Add more functions like searching...
## Others
Just a easy program...
I don't have much time to focus on it...

64
main.py
View File

@ -1,63 +1,51 @@
#!/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 sys import exit
from colorama import init from modules.raw_input import rinput
from modules.information import print_info
from modules.utils.inputs import rinput from modules.multi_download import mdl
from modules.utils.prints import print_info, print_menu from modules.one_download import download_one_lyric
from modules.functions.mainly.multi_download import mdl from modules.settings import settings_menu
from modules.functions.mainly.one_download import download_one_lyric from modules.save_load_settings import load_settings
from modules.submenus.settings import settings_menu from modules.clear_screen import clear
from modules.functions.settings.save_load_settings import load_settings from modules.load_file_song import get_lyric_from_folder
from modules.utils.clear_screen import cls_stay
from modules.functions.mainly.load_file_song import get_lyric_from_folder
from modules.submenus.tools import tools_menu
class MainProcess(object): class MainProcess(object):
def __init__(self): # 项目初始化 def __init__(self): # 项目初始化
self.settings = load_settings() self.settings = load_settings()
self.version = "1.1.1" self.version = "1.0"
def mainloop(self): def mainloop(self):
"""程序主循环""" """程序主循环"""
while True: while True:
cls_stay(self, "[程序主菜单]") clear()
print_menu({ print(f"[NeteaseMusicLyricDownloader] {self.version}\n"
"0": "退出程序", "[程序主菜单]\n"
"1": "单个歌曲的歌词下载", "[0] 退出程序\n[1] 单个歌曲的歌词下载\n[2] 多个歌曲的歌词下载\n[3] 从网易云下载的歌曲中获取歌词"
"2": "多个歌曲的歌词下载", "\n[s] 进入设置\n[i] 程序信息")
"3": "从网易云下载的歌曲中获取歌词",
"t": "小工具",
"s": "进入设置",
"i": "程序信息",
})
r = rinput("请选择:") r = rinput("请选择:")
match r:
case "1": if r == "1":
download_one_lyric(self) download_one_lyric(self.settings.lyric_path)
case "2": elif r == "2":
mdl(self) mdl(self.settings.lyric_path)
case "3": elif r == "3":
get_lyric_from_folder(self) get_lyric_from_folder(self.settings.lyric_path)
case "0": elif r == "0":
exit(0) exit(0)
case "t": elif r == "i":
tools_menu(self)
case "i":
print_info(self) print_info(self)
case "s": elif r == "s":
settings_menu(self) settings_menu(self)
case _: else:
input("请输入正确的选项\n按回车键继续...") input("请输入正确的选项\n按回车键继续...")
if __name__ == "__main__": if __name__ == "__main__":
init(autoreset=True)
app = MainProcess() app = MainProcess()
try: try:
app.mainloop() app.mainloop()

12
modules/clear_screen.py Normal file
View File

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

View File

@ -1,134 +0,0 @@
"""集合 下载歌词 以及 获取歌曲信息 的功能"""
import os
from requests import post
from requests.exceptions import ConnectionError
from time import sleep
from colorama import Fore, Style
from modules.utils.bar import CompactBar, bprint
from modules.utils.dump import regular_filename
def wait_retry(kind, identify, bar=None):
bprint("api提示操作频繁等待恢复...", bar)
if kind == "information":
url = f"https://music.163.com/api/song/detail/?&ids=[{identify}]"
elif kind == "lyric":
url = f"https://music.163.com/api/song/media?id={identify}"
else:
return "unknown_kind"
while True:
try:
tmp = post(url).json()
except ConnectionError:
return "dl_err_connection"
else:
if tmp["code"] == 200:
return tmp
sleep(1)
def get_song_info_raw(types: list, identify: str, bar: CompactBar = None):
"""获取歌曲信息
``types`` 提供一个list,将会返回内部所有符合要求的信息类型\n
``identify`` 提供一个歌曲id(str),将会把歌曲的`types`信息返回"""
bprint(Fore.CYAN + "ID:%s" % identify, bar)
try:
info = post(f"https://music.163.com/api/song/detail/?&ids=[{identify}]").json()
except ConnectionError:
return "dl_err_connection"
else:
if info["code"] == 406: # 判断当操作频繁时继续获取直到可以返回值为200为止
result = wait_retry("information", identify, bar=bar)
if type(result) == dict:
info = result
elif result == "dl_err_connection":
return "dl_err_connection"
else:
raise Exception("Unknown exception...")
if not info.get("songs"): # 判断是否存在该歌曲
bprint(Fore.LIGHTBLACK_EX + "\t-> 这首歌没有找到,跳过...", bar)
return "song_nf"
else:
need = {}
for i in types: # 通过传入的变量 types 获取信息(根据返回的信息分析得到的下面这句语句)
need.setdefault(i, info["songs"][0][i])
return need
def get_song_lyric(identify: str | int | dict,
path: str,
lyric_format="%(name)s - %(artists)s",
allinfo: bool = False,
bar: CompactBar = None,
save_lyrics_time: bool = True):
"""获取歌词
``identify`` 提供一个歌曲id
``path`` 提供歌曲下载的路径
``lyric_format`` 提供歌词保存的格式
``allinfo`` 若此项为 True ,则提供的identify格式必须为存储在网易云下载文件中meta_data的格式
``bar`` 若获取歌词时下方有进度条, 则应当传入此参数"""
if allinfo:
sinfo = identify
identify = identify["id"]
bprint(Fore.CYAN + f"ID: {identify}", bar)
else:
sinfo = get_song_info_raw(["name", "artists"], identify, bar)
if sinfo == "dl_err_connection": # 处理各式各样的事件
return "dl_err_connection"
elif sinfo == "song_nf":
return "song_nf"
# 整理歌曲数据,获取歌词
artists = ""
if allinfo:
for i in sinfo["artists"]:
artists += f"{i[0]},"
else:
for i in sinfo["artists"]:
artists += f"{i['name']},"
artists = artists[:-1]
name = sinfo["name"]
if not name:
bprint(Fore.RED + "歌曲错误!这是网易云的问题,请不要找作者", bar)
return "song_err"
name = regular_filename(name)
artists = regular_filename(artists)
bprint(Fore.YELLOW + "\t-> 歌曲:" + Style.RESET_ALL + f"{name} - {artists}", bar)
filename = f"{lyric_format % {'name': name, 'artists': artists}}.lrc"
try:
info = post(f"https://music.163.com/api/song/media?id={identify}").json()
except ConnectionError:
return "dl_err_connection"
else:
if info["code"] == 406: # 此处与上方一样,防止因为请求限制而跳过下载
result = wait_retry("lyric", identify, bar=bar)
if type(result) == dict:
info = result
elif result == "dl_err_connection":
return "dl_err_connection"
else:
raise Exception("Unknown exception...")
if info.get("nolyric") or not info.get('lyric'):
bprint(Fore.LIGHTBLACK_EX + "\t--> 这首歌没有歌词,跳过...\n", bar)
return
else:
with open(os.path.join(path, filename), "w", encoding="utf-8") as f:
if not save_lyrics_time:
for lyric in info["lyric"].split("\n"):
print(lyric)
f.write("".join(lyric.split("]")[1:]))
f.write('\n')
else:
f.write(info["lyric"])
bprint(Fore.GREEN + "\t--> 歌词下载完成!文件被保存在" + Style.RESET_ALL + f"{os.path.join(path, filename)}\n", bar)
return

View File

@ -1,234 +0,0 @@
import json
import os
from base64 import b64decode
from multiprocessing import Process, Queue
from queue import Empty
from time import sleep
from sys import exit
import mutagen.mp3
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad
from mutagen import File
from colorama import Fore, Style
from modules.utils.clear_screen import cls_stay
from modules.functions.mainly.get_song import get_song_lyric
from modules.utils.inputs import cinput, rinput
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:
"""从音乐文件中的 Comment 字段获取 163 key 并解密返回歌曲信息"""
try:
file = File(path) # 使用 mutagen 获取歌曲信息
except mutagen.mp3.HeaderNotFoundError:
return "not_a_music"
if os.path.splitext(path)[-1] == ".mp3": # 当文件为 mp3 时使用 ID3 格式读取
if file.tags and 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"
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"))), 16).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 process_work(path, filename, target, lyric_format, q_err: Queue, q_info: Queue):
try:
result = load_and_decrypt_from_ncm(path, target, lyric_format)
except AssertionError:
q_err.put(f"\t- 文件 \"{filename}\" 破解失败!")
except KeyboardInterrupt:
os.remove(target)
exit(-1)
else:
if result == "no_meta_data":
q_err.put(f"\t- 文件 \"{filename}\"破译成功, 但是未发现有效的歌曲信息, 将不会下载该歌词")
q_info.put(result)
def get_lyric_from_folder(self):
cls_stay(self, "[自动获取 - 加载文件]")
path = cinput("请输入歌曲的保存文件夹(绝对路径):")
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 不是一个普通音乐文件,这可能是一个电台曲目")
elif result == "not_a_music":
fails += 1
print(f"文件 \"{i}\" 不是一个音乐文件,请检查该文件是否正常")
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 CompactArrowBar(f"正在解锁 %(index){len(str(len(ncm_files)))}d/%(max)d",
suffix="", max=len(ncm_files), color="green", width=9999) as bar:
total = len(ncm_files)
allocated = 0 # 已经分配的任务数量
while True: # 进入循环,执行 "新建进程->检测队列->检测任务完成" 的循环
sleep(0.05)
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,
self.settings.lyric_format,
q_err,
q_info)).start()
bar.print_onto_bar(Fore.CYAN + "已分配: " + Style.RESET_ALL + "%s" % ncm_files[allocated])
allocated += 1
current_process += 1
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.print_onto_bar(Fore.YELLOW +
f"\"{r['musicName']} by "
f"{''.join([x + ', ' for x in [x[0] for x in r['artist']]])[:-2]}"
"\"" + Fore.GREEN + " 已完成!")
bar.next()
except Empty:
break
if passed >= len(ncm_files):
break
if errors:
print(Fore.LIGHTRED_EX+"解锁过程中发现了以下错误:")
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 ,继续请按回车")
except KeyboardInterrupt:
return
cls_stay(self, "[自动获取 - 下载歌词]")
with CompactArrowBar(f"进度: %(index){len(str(len(musics)))}d/%(max)d",
suffix="", max=len(musics), color="yellow", width=9999) as bar:
for i in range(0, len(musics)): # 根据索引结果获取歌词
if get_song_lyric(musics[i], lyric_path, self.settings.lyric_format, True, bar) == "dl_err_connection":
bar.print_onto_bar(Fore.RED + "下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键继续任务(该任务会被跳过)...")
input()
bar.next()
if ncm_files:
if target_path != "NOT_DECRYPT":
agree = rinput(Fore.RED + "是否删除原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,41 +0,0 @@
import re
from colorama import Fore
from modules.utils.clear_screen import cls_stay
from modules.utils.inputs import rinput
from modules.functions.mainly.get_song import get_song_lyric
from modules.utils.bar import CompactArrowBar
def mdl(self):
"""多个歌词文件的下载
``path: str`` 传入歌词文件保存的路径"""
cls_stay(self, "[手动-多个下载]")
ids = []
print("输入歌曲id,用回车分开,输入s停止")
while True:
r = rinput()
if r == 's':
break
else:
try:
int(r)
except ValueError:
tmp = re.search(r"song\?id=[0-9]*", r)
if tmp:
r = tmp.group()[8:]
else:
print("不合法的形式.\n")
continue
ids.append(int(r))
print("\t#%d id:%s - 已添加!" % (len(ids), r))
cls_stay(self, "[手动-多个下载]")
with CompactArrowBar(f"进度: %(index){len(str(len(ids)))}d/%(max)d",
suffix="", max=len(ids), color="yellow", width=9999) as bar:
for i in range(0, len(ids)):
r = get_song_lyric(ids[i], self.settings.lyric_path, self.settings.lyric_format, bar=bar)
if r == "dl_err_connection":
bar.print_onto_bar(Fore.RED + "下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键继续任务(该任务会被跳过)...")
input()
bar.next()
input("按回车键返回...")

View File

@ -1,3 +0,0 @@
def mult_unlock(...)

122
modules/get_song.py Normal file
View File

@ -0,0 +1,122 @@
"""集合 下载歌词 以及 获取歌曲信息 的功能"""
import os
from json import loads
from requests import post
from requests.exceptions import ConnectionError
from time import sleep
def wait_retry():
print("api提示操作频繁等待恢复...")
while True:
try:
tmp = post(f"http://music.163.com/api/song/detail/?&ids=[1]")
except ConnectionError:
return "dl_err_connection"
else:
if loads(tmp.text)["code"] == 200:
return "continue"
sleep(1)
def get_song_info_raw(types: list, id: str):
"""获取歌曲信息
types 提供一个list,将会返回内部所有符合要求的信息类型\n
id 提供一个歌曲id(str),将会把歌曲的`types`信息返回"""
print("id:%s" % id)
try:
response = post(f"http://music.163.com/api/song/detail/?&ids=[{id}]")
except ConnectionError:
return "dl_err_connection"
else:
info = loads(response.text)
if info["code"] == 406: # 判断当操作频繁时继续获取直到可以返回值为200为止
result = wait_retry()
if result == "continue":
pass
elif result == "dl_err_connection":
return "dl_err_connection"
else:
raise Exception("Unknown exception...")
if not info.get("songs"): # 判断是否存在该歌曲
print("这首歌没有找到,跳过...")
return "song_nf"
else:
need = {}
for i in types: # 通过传入的变量 types 获取信息(根据返回的信息分析得到的下面这句语句)
need.setdefault(i, info["songs"][0][i])
return need
def get_song_lyric(id: str | int | dict, path: str, allinfo: bool = False):
"""获取歌词
``id`` 提供一个歌曲id
``path`` 提供歌曲下载的路径
``allinfo`` 若此项为 True ,则提供的id格式必须为 {"id": int | str, "name": str, "artists": [[str, ...], ...]} (dict)"""
if allinfo:
sinfo = id
id = id["id"]
else:
sinfo = get_song_info_raw(["name", "artists"], id)
if sinfo == "dl_err_connection": # 处理各式各样的事件
return "dl_err_connection"
elif sinfo == "song_nf":
return "song_nf"
# 整理歌曲数据,获取歌词
artists = ""
if allinfo:
for i in sinfo["artists"]:
artists += f"{i[0]},"
else:
for i in sinfo["artists"]:
artists += f"{i['name']},"
artists = artists[:-1]
name = sinfo["name"]
replaces = { # 处理非法字符所用的替换字典(根据网易云下载的文件分析得到)
"|": "",
":": "",
"<": "",
">": "",
"?": "",
"/": "",
"\\": "",
"*": "",
'"': ""
}
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"
try:
response = post(f"http://music.163.com/api/song/media?id={id}")
except ConnectionError:
return "dl_err_connection"
else:
info = loads(response.text)
if info["code"] == 406: # 此处与上方一样,防止因为请求限制而跳过下载
result = wait_retry()
if result == "continue":
pass
elif result == "dl_err_connection":
return "dl_err_connection"
else:
raise Exception("Unknown exception...")
tmp = loads(response.text)
if tmp.get("nolyric") or not tmp.get('lyric'):
print("这首歌没有歌词,跳过...")
return
else:
with open(os.path.join(path, filename), "w", encoding="utf-8") as f:
f.write(tmp["lyric"])
print(f"歌词下载完成!被保存在{os.path.join(path, filename)}")
return

24
modules/information.py Normal file
View File

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

37
modules/multi_download.py Normal file
View File

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

View File

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

89
modules/readmp3.py Normal file
View File

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

@ -5,38 +5,26 @@ import os
class Settings(object): # 设定一个基础的存储设置信息的 class ,并设置形参用于 json 导入设置 class Settings(object): # 设定一个基础的存储设置信息的 class ,并设置形参用于 json 导入设置
def __init__(self, l_p="./out/", l_f="%(name)s - %(artists)s", lang="en", a_s=True, s_l_t = True): def __init__(self, l_p="./out/", lang="en"):
self.lyric_path = l_p self.lyric_path = l_p
self.lyric_format = l_f
self.language = lang self.language = lang
self.auto_save = a_s
self.save_lyrics_time = s_l_t
def class2dict(aclass: Settings): # 让 json.dumps 将 class 转化为一个 dict ,用于保存 def class2dict(aclass): # 让 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,
"save_lyrics_time": aclass.save_lyrics_time
} }
def dict2class(adict): # 让 json.load 将读取到的 dict 转化为我们所需要的 class def dict2class(adict): # 让 json.load 将读取到的 dict 转化为我们所需要的 class
if len(adict) != 4: # 若检测到多余的设定将抛出异常 if len(adict) != 2: # 若检测到多余的设定将抛出异常
raise json.decoder.JSONDecodeError("Too many keys", "none", 0) raise json.decoder.JSONDecodeError("Too many keys", "none", 0)
else: else:
return Settings( return Settings(adict["lyric_path"], adict["language"])
l_p=adict["lyric_path"],
l_f=adict["lyric_format"],
lang=adict["language"],
a_s=adict["auto_save"],
s_l_t=adict["save_lyrics_time"]
)
def load_settings() -> Settings: # 加载 的函数 def load_settings(): # 加载 的函数
"""加载设置 """加载设置
调用即可无需参数 调用即可无需参数
返回: 设置 class""" 返回: 设置 class"""
@ -44,20 +32,17 @@ def load_settings() -> Settings: # 加载 的函数
with open("settings.json", 'r', encoding="utf-8") as f: with open("settings.json", 'r', encoding="utf-8") as f:
try: try:
settings = json.load(f, object_hook=dict2class) # 尝试转换 json 为 dict settings = json.load(f, object_hook=dict2class) # 尝试转换 json 为 dict
path = ""
if not os.path.exists(settings.lyric_path): # 检测输出文件夹,若文件夹不存在则在启动时创建 if not os.path.exists(settings.lyric_path): # 检测输出文件夹,若文件夹不存在则在启动时创建
os.makedirs(settings.lyric_path, exist_ok=True) os.mkdir(settings.lyric_path)
return settings return settings
except json.decoder.JSONDecodeError or KeyError: # 如果检测到文件无法读取,将会删除设置文件并重新创建 except json.decoder.JSONDecodeError: # 如果检测到文件无法读取,将会删除设置文件并重新创建
print("设置文件损坏,重新创建...") print("设置文件损坏,重新创建...")
os.remove("settings.json") os.remove("settings.json")
return load_settings() return load_settings()
else: else:
with open("settings.json", 'w', encoding="utf-8") as f: # 当 settings.json 不存在时新建一个 settings.json 并写入默认配置 with open("settings.json", 'w', encoding="utf-8") as f: # 当 settings.json 不存在时新建一个 settings.json 并写入默认配置
f.write(json.dumps(Settings(), default=class2dict)) f.write(json.dumps(Settings(), default=class2dict))
settings = Settings() return Settings()
os.makedirs(settings.lyric_path, exist_ok=True)
return settings
def save_settings(settings): # 保存 的函数 def save_settings(settings): # 保存 的函数
@ -66,4 +51,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))
return "done" input("保存完成!按回车继续...")

65
modules/settings.py Normal file
View File

@ -0,0 +1,65 @@
"""集合设置参数"""
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

@ -1,144 +0,0 @@
"""集合设置参数"""
import os
from colorama import Fore, Style
from modules.utils.clear_screen import cls_stay
from modules.utils.inputs import rinput, cinput
from modules.functions.settings.save_load_settings import save_settings
from modules.utils.prints import input_menu
def settings_menu(self):
"""设置菜单主循环"""
while True:
if self.settings.auto_save:
save_settings(self.settings)
cls_stay(self, f"[设置菜单] "
f"{Fore.LIGHTCYAN_EX}自动保存: "
f"{({True: f'{Fore.GREEN}', False: f'{Fore.RED}'}[self.settings.auto_save])}")
r = input_menu({
"0": "返回上级菜单",
"1": "歌曲保存路径",
"2": "清空输出文件夹内的内容",
"3": "歌词文件保存格式",
"4": "部分动态效果",
"s": "切换设置自动保存"
})
match r:
case "0":
return
case "1":
__set_lyric_path(self)
case "2":
__remove_output_files(self)
case "3":
__set_lyric_format(self)
case "4":
pass
case "s":
self.settings.auto_save = not self.settings.auto_save
case _:
input("输入无效!按回车键继续...")
def __remove_output_files(self):
while True:
cls_stay(self, "[设置菜单 - 删除文件]")
r = input_menu({
"0": "返回上级",
"1": "清除歌词文件",
"2": "清除歌曲文件",
"a": "清除所有文件",
}) # 选择清除的文件格式
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):
cls_stay(self, "[设置菜单 - 保存路径]")
print(f"""允许使用相对路径和绝对路径,默认为"./out/"
{Fore.RED}*不要*{Style.RESET_ALL}使用{Fore.BLUE}反斜杠{Style.RESET_ALL}来确保通用性
当前值:{Fore.GREEN}{self.settings.lyric_path}{Style.RESET_ALL}
留空回车取消当前设置
请输入新的歌词保存路径:""")
r = cinput()
if not r:
input("输入为空!\n按回车继续...")
return
if r[-1] != "/":
r += "/"
path = ""
for i in r.split("/"):
if len(i) >= 30:
input("抱歉, 目标或子目录名过长!至多30字符\n问题的目录: %s" % i)
return
for i in r.split("/"):
path += i+"/"
if not os.path.exists(path):
os.mkdir(path)
self.settings.lyric_path = r
save_settings(self.settings)
input("设置成功!\n按回车继续...")
return
def __set_lyric_format(self):
while True:
cls_stay(self, f"[设置菜单 - 文件名格式]\n{Fore.LIGHTCYAN_EX}当前格式: ", end="")
if self.settings.lyric_format == "%(name)s":
print(f"{Fore.GREEN}曲名", end="")
else:
print(Fore.GREEN + self.settings.lyric_format % {"name": "曲名", "artists": "歌手名"}, end="")
print(".xxx")
r = input_menu({
"0": "返回上级",
"1": "%(name)s - %(artists)s" % {"name": "曲名", "artists": "歌手名"},
"2": "%(artists)s - %(name)s" % {"name": "曲名", "artists": "歌手名"},
"3": "%(name)s" % {"name": "曲名", "artists": "歌手名"},
})
if r == "0":
return
elif r == "1":
self.settings.lyric_format = "%(name)s - %(artists)s"
break
elif r == "2":
self.settings.lyric_format = "%(artists)s - %(name)s"
break
elif r == "3":
self.settings.lyric_format = "%(name)s"
break
else:
input("输入无效!\n按回车继续...")
input("修改成功! \n按回车返回...")
return

View File

@ -1,50 +0,0 @@
"""小工具以及菜单"""
import os
from colorama import Fore
from modules.utils.prints import print_menu
from modules.utils.clear_screen import cls_stay
from modules.utils.inputs import cinput
from modules.functions.mainly.load_file_song import load_and_decrypt_from_ncm, process_work
def tools_menu(self):
while True:
cls_stay(self, "[小工具菜单]")
print_menu({
"0": "返回上级菜单",
"d": "解锁指定ncm文件/指定文件夹"
})
r = cinput("请选择:")
match r:
case "0":
return
case "d":
ncm_unlock(self)
input("按回车继续...")
case _:
input("请输入正确的选项\n按回车键继续...")
def ncm_unlock(self):
cls_stay(self, "[小工具 - 文件解锁]")
path = cinput("请输入绝对路径:").replace("\\","")
if not os.path.exists(path): # 判断目标存在与否
print("目标不存在!")
return
if os.path.isfile(path): # 目标为文件则执行单文件解密
r = load_and_decrypt_from_ncm(path, os.path.split(path)[-1], "original", True)
match r:
case "file_not_found":
print("文件未找到")
case "perm_error":
print("权限错误。请检查是否拥有对应权限或者文件是否被占用。")
case _:
print(f"解锁完毕!文件保存在:\n{Fore.GREEN}{r[-1]}")
return
elif os.path.isdir(path): # 目标为文件夹则执行文件夹遍历
...
else:
print("无法识别目标文件。请确认目标文件是否正确以及是否拥有对应权限。")

View File

@ -1,123 +0,0 @@
"""修改了进度条"""
from os import get_terminal_size
from progress.bar import Bar
from progress.colors import color
from modules.utils.length import get_more_length, len_abs
class CompactBar(Bar):
def print_onto_bar(self, message: str):
"""在进度条的上方打印消息,进度条保持在下方"""
# 光标移动到行首,并通过打印空格清空残留的进度条 ↓
print(f"\033[{get_terminal_size().columns}D{(get_terminal_size().columns - 1) * ' '}"
# 光标移动到该行的首部(\033[nD, n为前移个数),将需要的信息打印出来,再将光标移动到下一行 ↓
f"\033[{get_terminal_size().columns}D" + message)
self.update() # 更新进度条
def writeln(self, line, shorten=False):
"""覆写writeln配合修改过后的update"""
if self.file and self.is_tty():
width = len_abs(line)
if shorten:
self._max_width = shorten
elif width < self._max_width:
# Add padding to cover previous contents
line += ' ' * (self._max_width - width)
else:
self._max_width = width
print('\r' + line, end='', file=self.file)
self.file.flush()
def update(self):
"""
覆写原有的update方法自适应终端宽度
支持中文
"""
filled_length = int(self.width * self.progress)
empty_length = self.width - filled_length
message = self.message % self
bar = color(self.fill * filled_length, fg=self.color)
empty = self.empty_fill * empty_length
suffix = self.suffix % self
line = ''.join([message, self.bar_prefix, bar, empty, self.bar_suffix,
suffix])
# 以上为原本update代码
term_size = get_terminal_size().columns
shorten = False
if len_abs(line) > term_size: # 检测完整长度是否小于终端长度
if len_abs(line) - len_abs(''.join([bar, empty])) <= term_size: # 检测无进度条时是否小于终端长度
width = term_size - (len_abs(line) - len_abs("".join([bar, empty]))) - 1
filled_length = int(width * self.progress)
empty_length = width - filled_length
bar = color(self.fill * filled_length, fg=self.color)
empty = self.empty_fill * empty_length
line = ''.join([message, self.bar_prefix, bar, empty, self.bar_suffix,
suffix])
shorten = len_abs(line)
elif len_abs(''.join([message, suffix])) <= term_size: # 检测仅有前缀后缀时是否小于终端长度
line = ''.join([message, suffix])
shorten = len_abs(line)
else: # 全部不符合时,以仅有前缀后缀的模式,直接截断
display_length = term_size-get_more_length(''.join([message, suffix])[:term_size])-3
if display_length < 0:
display_length = 0
line = ''.join([message, suffix])[:display_length]+"..."
shorten = len_abs(line)
self.writeln(line, shorten=shorten)
class CompactArrowBar(CompactBar):
def update(self):
"""
覆写原有的update方法自适应终端宽度
支持中文
"""
filled_length = int(self.width * self.progress)
empty_length = self.width - filled_length
message = self.message % self
s = "=" * filled_length
if s:
s = s[:-1] + ">"
bar = color(s, fg=self.color)
empty = self.empty_fill * empty_length
suffix = self.suffix % self
line = ''.join([message, self.bar_prefix, bar, empty, self.bar_suffix,
suffix])
# 以上为原本update代码
term_size = get_terminal_size().columns
shorten = False
if len_abs(line) > term_size: # 检测完整长度是否小于终端长度
if len_abs(line) - len_abs(''.join([bar, empty])) <= term_size: # 检测无进度条时是否小于终端长度
width = term_size - (len_abs(line) - len_abs("".join([bar, empty]))) - 1
filled_length = int(width * self.progress)
empty_length = width - filled_length
s = "=" * filled_length
if s:
s = s[:-1] + ">"
bar = color(s, fg=self.color)
empty = self.empty_fill * empty_length
line = ''.join([message, self.bar_prefix, bar, empty, self.bar_suffix,
suffix])
shorten = len_abs(line)
elif len_abs(''.join([message, suffix])) <= term_size: # 检测仅有前缀后缀时是否小于终端长度
line = ''.join([message, suffix])
shorten = len_abs(line)
else: # 全部不符合时,以仅有前缀后缀的模式,直接截断
display_length = term_size - get_more_length(''.join([message, suffix])[:term_size]) - 3
if display_length < 0:
display_length = 0
line = ''.join([message, suffix])[:display_length] + "..."
shorten = len_abs(line)
self.writeln(line, shorten=shorten)
def bprint(content, bar: CompactBar = None):
"""添加对进度条的支持的print, 若传入bar参数, 则使用print_onto_bar参数来打印内容"""
if bar:
bar.print_onto_bar(content)
else:
print(content)

View File

@ -1,20 +0,0 @@
"""用来清空命令行的信息,自动判别系统"""
import os
from colorama import Fore
def clear():
name = os.name
if name == "nt":
os.system("cls")
elif name == "posix":
os.system("clear")
else:
os.system("clear")
def cls_stay(self, custom="", *args, **kwargs):
"""保留版本号清除屏幕"""
clear()
print(f"{Fore.YELLOW}[{Fore.GREEN}NeteaseMusicLyricDownloader{Fore.YELLOW}] {Fore.LIGHTBLACK_EX}{self.version}")
print(Fore.LIGHTMAGENTA_EX+custom, *args, **kwargs)

View File

@ -1,166 +0,0 @@
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
from modules.utils.wrappers import escape_file_not_found, escape_permission_error
def regular_filename(filename):
"""处理替换非法字符"""
replaces = { # 处理非法字符所用的替换字典(根据网易云下载的文件分析得到)
"|": "",
":": "",
"<": "",
">": "",
"?": "",
"/": "",
"\\": "",
"*": "",
'"': ""
}
for k, v in replaces.items():
filename = filename.replace(k, v)
return filename
@escape_file_not_found
@escape_permission_error
def load_and_decrypt_from_ncm(file_path, target_dir, out_format, return_output_path=False) -> dict | str | tuple:
# Original author: Nzix Repo: nondanee
"""解锁指定文件并按照规则保存在指定位置
``file_path`` 源文件路径
``target_dir`` 解锁后文件保存路径
``out_format`` 输出文件格式使用字典格式字符串若使用源文件名仅替换后缀则传入\"original\""""
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 out_format == "original":
output_path = f"{os.path.splitext(file_path)[0]}.{meta_data['format']}"
elif meta_length:
output_path = os.path.join(target_dir, regular_filename(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()
if return_output_path:
return meta_data, output_path
else:
return meta_data
else:
if return_output_path:
return "no_meta_data", output_path
else:
return "no_meta_data"

View File

@ -1,11 +0,0 @@
from os import mkdir
from os.path import exists
INIT_DIRECTORIES = [
'out'
]
def init_directories():
for dir_name in INIT_DIRECTORIES:
if not exists(dir_name):
mkdir(dir_name)

View File

@ -1,11 +0,0 @@
"""该模块提供几个自定义处理输入函数"""
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 +0,0 @@
"""一些有关计算长度的工具"""
def get_more_length(content):
"""将相对于正常长度的超出值返回"""
return (len(content.encode("utf-8")) - len(content)) // 2
def len_abs(content):
"""针对中文将一个汉字识别为2个长度而不是1个"""
return len(content) + get_more_length(content)

View File

@ -1,43 +0,0 @@
"""有关打印的函数"""
from colorama import Fore
from modules.utils.clear_screen import clear
from modules.utils.inputs import rinput
def print_info(self):
"""调用即输出,无返回值"""
clear()
print(f"""[NeteaseMusicLyricDownloader]
版本: {self.version}
本软件开源项目地址:https://github.com/1826013250/NeteaseMusicLyricDownloader
作者:David-123
联系方式:
\tQQ:1826013250
\tE-mail:1826013250@qq.com(mainly)
\t mc1826013250@gmail.com
特别感谢:
\t- nondanee - ncmdump https://github.com/nondanee/ncmdump
\t- chuyaoxin - 提供了对ncmdump以及ncm文件的详细解说 https://www.cnblogs.com/cyx-b/p/13443003.html
若程序遇到bug请提交至github上的issue""")
input("按回车键返回...")
return
def print_menu(menu: dict):
"""传入一个字典, 格式为 {"需要输入的字符": "功能描述", ...}
将会按照以下格式打印:
[字符1] 功能描述1
[字符2] 功能描述2
..."""
for k, v in menu.items():
print(f"{Fore.LIGHTBLUE_EX}[{k}] {Fore.RESET}{v}")
def input_menu(menu: dict):
"""传入一个字典, 格式为 {"需要输入的字符": "功能描述", ...}
print_menu 末尾添加'请选择: '字样并要求输入, 使用rinput获取输入"""
print_menu(menu)
return rinput("请选择: ")

View File

@ -1,28 +0,0 @@
"""一些有的没有的装饰器"""
def escape_file_not_found(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except FileNotFoundError:
return "file_not_found"
return wrapper
def escape_decrypt_unsatisfied_file(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (AssertionError, IsADirectoryError):
return "file_not_satisfied"
return wrapper
def escape_permission_error(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except PermissionError:
return "perm_error"
return wrapper

View File

@ -1,5 +0,0 @@
requests
mutagen
pycryptodomex
progress
colorama