Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
81d6701abc | |||
ab8fff31a7 | |||
620f030d5f | |||
![]() |
3e5570de2f | ||
![]() |
f03d578d9d | ||
![]() |
9078104529 | ||
c10bd094da | |||
c9543bc7a8 | |||
![]() |
46ae0f4b78 | ||
b722d2393e | |||
91f568e18d | |||
![]() |
6f06e25e35 | ||
a2f65f4d95 | |||
feb68498dc | |||
c2d321d21f | |||
3003b3bb3b | |||
9dcec74797 | |||
bee6f61c38 | |||
94149eabea | |||
e7d19d6892 | |||
dded74a4a9 | |||
f0ce51e87d | |||
18de4e1cea | |||
bee7f106c5 | |||
4956aaa385 | |||
42f20fbc89 |
8
.gitignore
vendored
8
.gitignore
vendored
@ -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__
|
||||
|
47
README.md
47
README.md
@ -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...
|
||||
我可能也没有太多的精力去写这个项目...
|
||||
|
40
README_cn.md
40
README_cn.md
@ -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 key),484更舒服了?
|
||||
|
||||
- 2022/8/13 啊现在啊现在, 它集成了ncmdump的功能, 可以识别网易云的加密文件格式,解密并获取歌词!真实令人激动的新功能啊!!
|
||||
|
||||
## 后续有啥功能?
|
||||
|
||||
_~~我不造,要不你来写罢(~~_
|
||||
|
||||
## 其他要说的
|
||||
|
||||
这就一简简单单普普通通的程序
|
||||
|
||||
我可能也没有太多的精力去写这么个~~破玩意~~...
|
40
README_en.md
Normal file
40
README_en.md
Normal 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
66
main.py
@ -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()
|
||||
|
@ -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
|
0
modules/functions/mainly/__init__.py
Normal file
0
modules/functions/mainly/__init__.py
Normal file
134
modules/functions/mainly/get_song.py
Normal file
134
modules/functions/mainly/get_song.py
Normal 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
|
@ -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="")
|
41
modules/functions/mainly/multi_download.py
Normal file
41
modules/functions/mainly/multi_download.py
Normal 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("按回车键返回...")
|
3
modules/functions/mainly/multi_unlock.py
Normal file
3
modules/functions/mainly/multi_unlock.py
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
def mult_unlock(...)
|
@ -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("按回车键返回...")
|
@ -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("按回车键返回...")
|
0
modules/functions/settings/__init__.py
Normal file
0
modules/functions/settings/__init__.py
Normal 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"
|
@ -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
50
modules/submenus/tools.py
Normal 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
123
modules/utils/bar.py
Normal 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)
|
@ -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
166
modules/utils/dump.py
Normal 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
11
modules/utils/initapp.py
Normal 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
11
modules/utils/length.py
Normal 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)
|
@ -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
28
modules/utils/wrappers.py
Normal 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
|
@ -1,4 +1,5 @@
|
||||
requests
|
||||
mutagen
|
||||
pycryptodomex
|
||||
progress
|
||||
progress
|
||||
colorama
|
Loading…
x
Reference in New Issue
Block a user