Compare commits

..

26 Commits
1.0-1 ... main

Author SHA1 Message Date
81d6701abc Fix crash when first run the program 2024-10-04 11:01:33 +08:00
ab8fff31a7 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	modules/submenus/settings.py
2024-10-04 10:58:10 +08:00
620f030d5f add small tools 2024-10-04 10:55:48 +08:00
David-123
3e5570de2f
Merge pull request #2 from Onlyacat233/main
Add more features.

Allow users to choose whether to save the time of the lyric.
Add the initialization of the specific folders function.
2023-08-10 00:04:30 +08:00
Onlyacat233
f03d578d9d feat: 添加了设置是否保存歌词时间的功能
在本次更新中添加了设置是否保存每句歌词时间的功能,修复了设置歌词文件名保存格式无效的bug,优化了主菜单代码
2023-08-09 23:45:52 +08:00
Onlyacat233
9078104529 增加了保存歌词时不保存歌词时间的设定,增加了自动初始化文件夹的功能,修复了设置歌词文件名格式不生效的bug 2023-08-09 14:36:46 +08:00
c10bd094da Merge remote-tracking branch 'origin/main' 2023-07-20 01:07:54 +08:00
c9543bc7a8 Fix Windows Progress Bar issue
Little color additions
2023-07-20 01:06:29 +08:00
David-123
46ae0f4b78
更新 README.md 2023-06-27 14:26:20 +08:00
b722d2393e Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
2023-06-24 14:02:56 +08:00
91f568e18d Settings: Filename Format Set
and small changes to follow PEP8 code style

change default README to Chinese
2023-06-24 14:01:32 +08:00
David-123
6f06e25e35
Merge pull request #1 from the-open-source-world/main
Update README.md
2023-06-22 02:18:45 +08:00
a2f65f4d95 Update README.md 2023-06-22 02:14:46 +08:00
feb68498dc Fix Filename mistake 2023-06-22 02:14:17 +08:00
c2d321d21f Update README.md 2023-06-22 02:10:24 +08:00
3003b3bb3b Update README 2023-06-22 01:55:41 +08:00
9dcec74797 Update README 2023-06-22 01:44:06 +08:00
bee6f61c38 NCMDUMP UPDATE
Make menu more colorful!

Changed NCMDUMP code, 1000% faster!!

Settings update and small fix
2023-06-22 01:36:23 +08:00
94149eabea COLORFUL UPDATE
Make output more and more and more colorful!

Change file structure and README.md
2023-05-07 01:59:03 +08:00
e7d19d6892 Optimize README 2023-04-30 22:32:14 +08:00
dded74a4a9 Optimize Progress Bar, again...
Fix "Changing Destination Path" bug, provided by YXRain05
2023-04-30 22:14:52 +08:00
f0ce51e87d Optimize Progress Bar 2023-04-18 00:44:01 +08:00
18de4e1cea Temp Upload 2023-04-17 02:41:06 +08:00
bee7f106c5 Merge remote-tracking branch 'origin/main' 2023-04-16 17:08:31 +08:00
4956aaa385 Fix Load Error 2023-04-16 17:08:22 +08:00
42f20fbc89 Fix Load Error 2023-04-16 14:00:41 +08:00
25 changed files with 868 additions and 435 deletions

8
.gitignore vendored
View File

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

View File

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

40
README_en.md Normal file
View File

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

66
main.py
View File

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

View File

@ -1,122 +0,0 @@
"""集合 下载歌词 以及 获取歌曲信息 的功能"""
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

View File

View File

@ -0,0 +1,134 @@
"""集合 下载歌词 以及 获取歌曲信息 的功能"""
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,27 +1,33 @@
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 sys import exit
from progress.bar import Bar
import mutagen.mp3
from Cryptodome.Cipher import AES
from mutagen import File, flac
from mutagen.id3 import ID3, TPE1, APIC, COMM, TIT2, TALB
from Cryptodome.Util.Padding import unpad
from modules.utils.clear_screen import clear
from modules.functions.get_song import get_song_lyric
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 并解密返回歌曲信息"""
file = File(path) # 使用 mutagen 获取歌曲信息
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.get("COMM::XXX"):
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:
@ -39,20 +45,12 @@ def load_information_from_song(path) -> str | dict:
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")))
r = unpad(cryptor.decrypt(b64decode(bytes(ciphertext, "utf-8"))), 16).decode("utf-8")
except ValueError:
return "decrypt_failed"
@ -65,113 +63,23 @@ def load_information_from_song(path) -> str | dict:
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):
def process_work(path, filename, target, lyric_format, q_err: Queue, q_info: Queue):
try:
result = load_and_decrypt_from_ncm(path, target)
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):
clear()
path = cinput(
f"[NeteaseMusicLyricDownloader] {self.version}\n"
"[自动获取]\n"
"请输入歌曲的保存文件夹(绝对路径):")
cls_stay(self, "[自动获取 - 加载文件]")
path = cinput("请输入歌曲的保存文件夹(绝对路径):")
if not os.path.exists(path):
input("路径不存在.\n按回车返回...")
return
@ -193,6 +101,9 @@ def get_lyric_from_folder(self):
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 先加入到列表,等待解密
@ -205,7 +116,10 @@ def get_lyric_from_folder(self):
while True:
print(f"\n发现{len(ncm_files)}个ncm加密文件!")
print("请问解密后的文件保存在哪里?\n"
"[1] 保存在相同文件夹内\n[2] 保存在程序设定的下载文件夹中\n[3] 保存在自定义文件夹内\n[q] 取消解密,下载歌词时将忽略这些文件")
"[1] 保存在相同文件夹内\n"
"[2] 保存在程序设定的下载文件夹中\n"
"[3] 保存在自定义文件夹内\n"
"[q] 取消解密,下载歌词时将忽略这些文件")
select = rinput("请选择: ")
if select == 'q':
target_path = "NOT_DECRYPT"
@ -229,22 +143,23 @@ def get_lyric_from_folder(self):
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:
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)
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
bar.suffix = f"已分配: {ncm_files[allocated-1]}"
bar.update()
current_process += 1
while True: # 错误队列检测
try:
errors.append(q_err.get_nowait())
@ -260,15 +175,17 @@ def get_lyric_from_folder(self):
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.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("解密过程中发现了以下错误:")
print(Fore.LIGHTRED_EX+"解锁过程中发现了以下错误:")
for i in errors:
print(i)
@ -293,18 +210,20 @@ def get_lyric_from_folder(self):
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按回车键继续任务(该任务会被跳过)...")
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("是否删除原ncm文件? (y/n)")
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="")

View File

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

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

View File

@ -1,6 +1,6 @@
import re
from modules.utils.inputs import rinput
from modules.functions.get_song import get_song_lyric
from modules.functions.mainly.get_song import get_song_lyric
from modules.utils.clear_screen import clear
@ -23,6 +23,6 @@ def download_one_lyric(self):
input("不合法的形式.\n按回车键返回...")
return
if get_song_lyric(song_id, self.settings.lyric_path) == "dl_err_connection":
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":
input("下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键返回...")
input("按回车键返回...")

View File

@ -1,38 +0,0 @@
import re
from modules.utils.clear_screen import clear
from modules.utils.inputs import rinput
from modules.functions.get_song import get_song_lyric
def mdl(self):
"""多个歌词文件的下载
``path: str`` 传入歌词文件保存的路径"""
clear()
ids = []
print(f"[NeteaseMusicLyricDownloader] {self.version}\n"
"[手动-多个下载]\n"
"输入歌曲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("进度: %d/%d" % (i+1, len(ids)))
r = get_song_lyric(ids[i], self.settings.lyric_path)
if r == "dl_err_connection":
input("下载发生错误!可能是连接被拒绝!请检查网络后再试\n按回车键继续任务(该任务会被跳过)...")
input("按回车键返回...")

View File

View File

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

View File

@ -1,43 +1,55 @@
"""集合设置参数"""
import os
from modules.utils.clear_screen import clear
from colorama import Fore, Style
from modules.utils.clear_screen import cls_stay
from modules.utils.inputs import rinput, cinput
from modules.functions.save_load_settings import save_settings
from modules.functions.settings.save_load_settings import save_settings
from modules.utils.prints import input_menu
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("输入无效!按回车键继续...")
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:
clear()
print(f"[NeteaseMusicLyricDownloader] {self.version}\n"
"[设置菜单 - 删除文件]\n"
"[0] 返回上级\n[1] 清除歌词文件\n[2] 清除歌曲文件\n[a] 清除所有文件")
r = rinput("请选择:") # 选择清除的文件格式
cls_stay(self, "[设置菜单 - 删除文件]")
r = input_menu({
"0": "返回上级",
"1": "清除歌词文件",
"2": "清除歌曲文件",
"a": "清除所有文件",
}) # 选择清除的文件格式
if r == "0":
return
elif r == "1":
@ -74,9 +86,12 @@ def __remove_output_files(self):
def __set_lyric_path(self):
clear()
print("允许使用相对路径和绝对路径,默认为\"./out/\"\n请*不要*使用反斜杠来确保通用性\n"
"当前值:%s\n请输入新的歌词保存路径:" % self.settings.lyric_path)
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按回车继续...")
@ -84,18 +99,46 @@ def __set_lyric_path(self):
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):
pass
def __save_settings(self):
return save_settings(self.settings)
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

50
modules/submenus/tools.py Normal file
View File

@ -0,0 +1,50 @@
"""小工具以及菜单"""
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("无法识别目标文件。请确认目标文件是否正确以及是否拥有对应权限。")

123
modules/utils/bar.py Normal file
View File

@ -0,0 +1,123 @@
"""修改了进度条"""
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,5 +1,6 @@
"""包含一个函数,用来清空命令行的信息,自动判别系统"""
"""用来清空命令行的信息,自动判别系统"""
import os
from colorama import Fore
def clear():
@ -9,4 +10,11 @@ def clear():
elif name == "posix":
os.system("clear")
else:
os.system("clear")
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)

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

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

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

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

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

@ -0,0 +1,11 @@
"""一些有关计算长度的工具"""
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,5 +1,8 @@
"""该程序的自述信息,调用即输出"""
"""有关打印的函数"""
from colorama import Fore
from modules.utils.clear_screen import clear
from modules.utils.inputs import rinput
def print_info(self):
@ -16,9 +19,25 @@ def print_info(self):
特别感谢:
\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
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("请选择: ")

28
modules/utils/wrappers.py Normal file
View File

@ -0,0 +1,28 @@
"""一些有的没有的装饰器"""
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,4 +1,5 @@
requests
mutagen
pycryptodomex
progress
progress
colorama