diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfc2545 --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# +temp \ No newline at end of file diff --git a/README.md b/README.md index 7055cb3..26b1ddc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ -# ncmdump -将.ncm格式音频文件转换为flac格式,提供windows客户端和WEB两种使用方式。(Convert .ncm format audio files to flac format, providing two usage options: a Windows client and a web interface.) +# ncm -> flac converter + +将.ncm格式音频文件转换为flac格式,提供windows客户端和WEB两种使用方式。 + +## 1 环境 + +基本环境: +```bash +pip install mutagen +pip install pycryptodome +``` + +GUI额外环境: +```bash +pip install PyQt6 +pip install pyinstaller +``` + +WEB额外环境: +```bash +pip install streamlit +``` + +全安装: +```bash +pip install -r requirements.txt +``` + +## 2 使用 + +### 2.1 GUI + +运行: +```bash +python gui.py +``` + +编译: +```bash +pyinstaller --onefile --add-data="file:file" -wF -i file/favicon-32x32.png -n "NCM转换器" .\gui.py +``` + +效果: +![s1](./file/s1.gif) + + +### 2.2 WEB + +运行: +```bash +streamlit run web.py --server.port 1111 +``` + +效果: +![s2](./file/s2.gif) diff --git a/cplusplus/libncmdump.dll b/cplusplus/libncmdump.dll new file mode 100644 index 0000000..0ad1b05 Binary files /dev/null and b/cplusplus/libncmdump.dll differ diff --git a/cplusplus/ncmdumpdll.py b/cplusplus/ncmdumpdll.py new file mode 100644 index 0000000..c8c8775 --- /dev/null +++ b/cplusplus/ncmdumpdll.py @@ -0,0 +1,51 @@ +import ctypes +import os + +class NcmdumpDll: + def __init__(self, file_name): + dll_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "libncmdump.dll")) + self.dll = ctypes.CDLL(dll_path) + + # Define function prototypes + self.dll.CreateNeteaseCrypt.argtypes = [ctypes.c_char_p] + self.dll.CreateNeteaseCrypt.restype = ctypes.c_void_p + + self.dll.Dump.argtypes = [ctypes.c_void_p] + self.dll.Dump.restype = ctypes.c_int + + self.dll.FixMetadata.argtypes = [ctypes.c_void_p] + self.dll.FixMetadata.restype = None + + self.dll.DestroyNeteaseCrypt.argtypes = [ctypes.c_void_p] + self.dll.DestroyNeteaseCrypt.restype = None + + # Convert file name to bytes + file_bytes = file_name.encode('utf-8') + + # Allocate memory and copy file name bytes + input_ptr = ctypes.create_string_buffer(file_bytes) + + # Create NeteaseCrypt instance + self.netease_crypt = self.dll.CreateNeteaseCrypt(input_ptr) + + def dump(self): + return self.dll.Dump(self.netease_crypt) + + def fix_metadata(self): + self.dll.FixMetadata(self.netease_crypt) + + def destroy(self): + self.dll.DestroyNeteaseCrypt(self.netease_crypt) + + def process_file(self): + self.dump() + self.fix_metadata() + self.destroy() + +if __name__ == "__main__": + # 文件名 + file_path = "YOASOBI.ncm" + # 创建 NeteaseCrypt 类的实例 + netease_crypt = NcmdumpDll(file_path) + # 启动转换过程 + netease_crypt.process_file() diff --git a/file/bk.png b/file/bk.png new file mode 100644 index 0000000..8e9a6d9 Binary files /dev/null and b/file/bk.png differ diff --git a/file/favicon-32x32.png b/file/favicon-32x32.png new file mode 100644 index 0000000..d236703 Binary files /dev/null and b/file/favicon-32x32.png differ diff --git a/file/s1.gif b/file/s1.gif new file mode 100644 index 0000000..7e43ee9 Binary files /dev/null and b/file/s1.gif differ diff --git a/file/s2.gif b/file/s2.gif new file mode 100644 index 0000000..50b4f22 Binary files /dev/null and b/file/s2.gif differ diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..221ac54 --- /dev/null +++ b/gui.py @@ -0,0 +1,69 @@ +import sys +from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QDropEvent,QPixmap, QPainter, QFont, QColor,QFontMetrics,QIcon,QDragEnterEvent +import os +from ncmdump import dump + +class DragDropWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self.init_ui() + + def init_ui(self): + self.setWindowTitle("NCM转换器") + self.setGeometry(100, 100, 573, 573) + + self.setWindowIcon(QIcon(self.get_resource_path("file/favicon-32x32.png"))) + # 加载图片 + self.original_pixmap = QPixmap(self.get_resource_path("file/bk.png")) + + self.label = QLabel(self) + self.label.setPixmap(self.original_pixmap) + self.label.setGeometry(0, 0, 573, 573) + self.update_text('将ncm文件拖拽到此处') # 初始文本内容 + def update_text(self, text): + pixmap = self.original_pixmap.copy() # 复制原始的 QPixmap 对象 + painter = QPainter(pixmap) + painter.setPen(QColor('black')) # 设置文本颜色 + font = QFont('SimHei', 20) # 设置字体和大小 + painter.setFont(font) + + font_metrics = QFontMetrics(font) + text_width = font_metrics.horizontalAdvance(text) + text_height = font_metrics.height() + + x = (pixmap.width() - text_width) // 2 + y = (pixmap.height() - text_height) // 2 + font_metrics.ascent() + + painter.drawText(x, y, text) # 在图片中居中绘制文本 + painter.end() + self.label.setPixmap(pixmap) # 更新 QLabel 中的图片 + + def get_resource_path(self,relative_path): + if hasattr(sys, '_MEIPASS'): + return os.path.join(sys._MEIPASS, relative_path) + return os.path.join(os.path.abspath("."), relative_path) + + + def dragEnterEvent(self, event: QDragEnterEvent): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event: QDropEvent): + for url in event.mimeData().urls(): + file_path = url.toLocalFile() + if file_path.endswith(".ncm"): + try: + dump(file_path) + self.update_text(f"处理完成:{file_path}") + except Exception as e: + self.update_text(f"处理文件时出错:{str(e)}") + + +if __name__ == "__main__": + app = QApplication(sys.argv) + widget = DragDropWidget() + widget.show() + sys.exit(app.exec()) diff --git a/ncmdump/__init__.py b/ncmdump/__init__.py new file mode 100644 index 0000000..71dcdf4 --- /dev/null +++ b/ncmdump/__init__.py @@ -0,0 +1 @@ +from .core import dump \ No newline at end of file diff --git a/ncmdump/app.py b/ncmdump/app.py new file mode 100644 index 0000000..a8fea5e --- /dev/null +++ b/ncmdump/app.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +""" +Created on Fri Sep 28 13:32:51 2018 + +@author: Nzix +""" + +import argparse, os, sys, traceback, re +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +if __package__ is None: + from core import dump +else: + from .core import dump + +parser = argparse.ArgumentParser( + prog = 'ncmdump', add_help = False +) +parser.add_argument( + '-h', action = 'help', + help = 'show this help message and exit' +) +parser.add_argument( + 'input', metavar = 'input', nargs = '*', default = ['.'], + help = 'ncm file or folder path' +) +parser.add_argument( + '-f', metavar = 'format', dest = 'format', default = '', + help = 'customize naming format' +) +parser.add_argument( + '-o', metavar = 'output', dest = 'output', + help = 'customize saving folder' +) +parser.add_argument( + '-d', dest = 'delete', action = 'store_true', + help = 'delete source after conversion' +) +group = parser.add_mutually_exclusive_group() +group.add_argument( + '-c', dest = 'cover', action = 'store_true', + help = 'overwrite file with the same name' +) +group.add_argument( + '-r', dest = 'rename', action = 'store_true', + help = 'auto rename if file name conflicts' +) +args = parser.parse_args() + +def validate_name(name): + pattern = {u'\\': u'\', u'/': u'/', u':': u':', u'*': u'*', u'?': u'?', u'"': u'"', u'<': u'<', u'>': u'>', u'|': u'|'} + for character in pattern: + name = name.replace(character, pattern[character]) + return name + +def validate_collision(path): + index = 1 + origin = path + while os.path.exists(path): + path = '({})'.format(index).join(os.path.splitext(origin)) + index += 1 + return path + +def name_format(path, meta): + information = { + 'artist': ','.join([artist[0] for artist in meta.get('artist')]) if 'artist' in meta else None, + 'title': meta.get('musicName'), + 'album': meta.get('album') + } + + def substitute(matched): + key = matched.group(1) + if key in information: + return information[key] + else: + return key + + name = re.sub(r'%(.+?)%', substitute, args.format) + name = os.path.splitext(os.path.split(path)[1])[0] if not name else name + name = validate_name(name) + name += '.' + meta['format'] + folder = args.output if args.output else os.path.dirname(path) + save = os.path.join(folder, name) + if args.rename: save = validate_collision(save) + return save + +def traverse(path): + path = os.path.abspath(path) + if not os.path.exists(path): + return [] + elif os.path.isdir(path): + return sum([traverse(os.path.join(path, name)) for name in os.listdir(path)], []) + else: + return [path] if os.path.splitext(path)[-1] == '.ncm' else [] + +def main(): + if args.output: + args.output = os.path.abspath(args.output) + if not os.path.exists(args.output): + print('output does not exist') + exit() + if not os.path.isdir(args.output): + print('output is not a folder') + exit() + + input_files = sum([traverse(path) for path in args.input], []) + files = sorted(set(input_files), key = input_files.index) + + if sys.version[0] == '2': + files = [path.decode(sys.stdin.encoding) for path in files] + + if not files: + print('empty input') + exit() + + for path in files: + try: + save = dump(path, name_format, not args.cover) + if save: print(os.path.split(save)[-1]) + if args.delete: os.remove(path) + except KeyboardInterrupt: + exit() + except: + print(traceback.format_exc()) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/ncmdump/core.py b/ncmdump/core.py new file mode 100644 index 0000000..4313405 --- /dev/null +++ b/ncmdump/core.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +""" +Created on Sun Jul 15 01:05:58 2018 + +@author: Nzix +""" + +import binascii, struct +import base64, json +import os + +from Crypto.Cipher import AES +from Crypto.Util.Padding import unpad +from Crypto.Util.strxor import strxor +from mutagen import mp3, flac, id3 + +def dump(input_path, output_path = None, skip = True): + + output_path = (lambda path, meta: os.path.splitext(path)[0] + '.' + meta['format']) if not output_path else output_path + output_path_generator = (lambda path, meta: output_path) if not callable(output_path) else output_path + + core_key = binascii.a2b_hex('687A4852416D736F356B496E62617857') + meta_key = binascii.a2b_hex('2331346C6A6B5F215C5D2630553C2728') + + f = open(input_path, 'rb') + + # magic header + header = f.read(8) + assert header == binascii.a2b_hex('4354454e4644414d') + + f.seek(2, 1) + + # key data + key_length = f.read(4) + key_length = struct.unpack(' 1024 ** 2 * 16 else 'mp3'} + + f.seek(5, 1) + + # album cover + image_space = f.read(4) + image_space = struct.unpack('