diff --git a/LDDC.py b/LDDC.py index e2b5e09..b4e2132 100644 --- a/LDDC.py +++ b/LDDC.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: Copyright (c) 2024 沉默の金 -__version__ = "v0.2.1" +__version__ = "v0.2.2" import logging import os import sys @@ -26,6 +26,7 @@ str2log_level, ) from view.about import AboutWidget +from view.encrypted_lyrics import EncryptedLyricsWidget from view.search import SearchWidget from view.setting import SettingWidget from worker import CheckUpdate @@ -55,13 +56,15 @@ def __init__(self) -> None: self.setWindowTitle("LDDC") self.resize(1050, 600) self.setWindowIcon(QIcon(":/LDDC/img/icon/logo.png")) - self.set_sidebar_width(70) + self.set_sidebar_width(80) self.search_widget = SearchWidget(self, data, data_mutex, threadpool) self.settings_widget = SettingWidget(data, logger) self.about_widget = AboutWidget(__version__) + self.encrypted_lyrics_widget = EncryptedLyricsWidget() self.add_widget("搜索", self.search_widget) + self.add_widget("打开\n加密歌词", self.encrypted_lyrics_widget) self.add_widget("关于", self.about_widget, SBPosition.BOTTOM) self.add_widget("设置", self.settings_widget, SBPosition.BOTTOM) self.connect_signals() diff --git a/README.md b/README.md index 12172dc..20c3abc 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ ### 歌词解密 [![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=WXRIW&repo=Lyricify-Lyrics-Helper)](https://github.com/WXRIW/Lyricify-Lyrics-Helper) +[![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=jixunmoe&repo=qmc-decode)](https://github.com/jixunmoe/qmc-decode) +[![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=parakeet-rs&repo=libparakeet)](https://github.com/parakeet-rs/libparakeet) ### QQ音乐api diff --git a/api.py b/api.py index 720301c..229ffc1 100644 --- a/api.py +++ b/api.py @@ -70,6 +70,7 @@ def get_qrc(songid: str) -> str | requests.Response: } try: response = requests.get('https://c.y.qq.com/qqmusic/fcgi-bin/lyric_download.fcg', + headers=QMD_headers, params=params, timeout=10) response.raise_for_status() diff --git a/decryptor/__init__.py b/decryptor/__init__.py new file mode 100644 index 0000000..d2e56e1 --- /dev/null +++ b/decryptor/__init__.py @@ -0,0 +1,53 @@ +import logging +from enum import Enum +from zlib import decompress + +from decryptor.qmc1 import qmc1_decrypt +from decryptor.tripledes import DECRYPT, tripledes_crypt, tripledes_key_setup + + +class QrcType(Enum): + LOCAL = 0 + CLOUD = 1 + + +def qrc_decrypt(encrypted_qrc: str | bytearray | bytes, qrc_type: int) -> tuple[str | None, str | None]: + if encrypted_qrc is None or encrypted_qrc.strip() == "": + logging.error("没有可解密的数据") + return None, None + try: + key = bytearray(b"!@#)(*$%123ZXC!@!@#)(NHL") + + if isinstance(encrypted_qrc, str): + encrypted_text_byte = bytearray.fromhex(encrypted_qrc) # 将文本解析为字节数组 + elif isinstance(encrypted_qrc, bytearray): + encrypted_text_byte = encrypted_qrc + elif isinstance(encrypted_qrc, bytes): + encrypted_text_byte = bytearray(encrypted_qrc) + else: + logging.error("无效的加密数据类型") + return None, "无效的加密数据类型" + + if qrc_type == QrcType.LOCAL: + qmc1_decrypt(encrypted_text_byte) + encrypted_text_byte = encrypted_text_byte[11:] + + data = bytearray(len(encrypted_text_byte)) + schedule = [[[0] * 6 for _ in range(16)] for _ in range(3)] + tripledes_key_setup(key, schedule, DECRYPT) + + # 以 8 字节为单位迭代 encrypted_text_byte + for i in range(0, len(encrypted_text_byte), 8): + temp = bytearray(8) + + tripledes_crypt(encrypted_text_byte[i:], temp, schedule) + + # 将结果复制到数据数组 + for j in range(8): + data[i + j] = temp[j] + + decrypted_qrc = decompress(data).decode("utf-8") + except Exception as e: + logging.exception("解密失败") + return None, str(e) + return decrypted_qrc, None diff --git a/decryptor/qmc1.py b/decryptor/qmc1.py new file mode 100644 index 0000000..0cea09d --- /dev/null +++ b/decryptor/qmc1.py @@ -0,0 +1,36 @@ +# 参考原C代码: +# https://github.com/jixunmoe/qmc-decode/blob/master/src/qmc_crypto.c + +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright (c) 2024 沉默の金 + +PRIVKey = [ + 0xc3, 0x4a, 0xd6, 0xca, 0x90, 0x67, 0xf7, 0x52, + 0xd8, 0xa1, 0x66, 0x62, 0x9f, 0x5b, 0x09, 0x00, + + 0xc3, 0x5e, 0x95, 0x23, 0x9f, 0x13, 0x11, 0x7e, + 0xd8, 0x92, 0x3f, 0xbc, 0x90, 0xbb, 0x74, 0x0e, + + 0xc3, 0x47, 0x74, 0x3d, 0x90, 0xaa, 0x3f, 0x51, + 0xd8, 0xf4, 0x11, 0x84, 0x9f, 0xde, 0x95, 0x1d, + + 0xc3, 0xc6, 0x09, 0xd5, 0x9f, 0xfa, 0x66, 0xf9, + 0xd8, 0xf0, 0xf7, 0xa0, 0x90, 0xa1, 0xd6, 0xf3, + + 0xc3, 0xf3, 0xd6, 0xa1, 0x90, 0xa0, 0xf7, 0xf0, + 0xd8, 0xf9, 0x66, 0xfa, 0x9f, 0xd5, 0x09, 0xc6, + + 0xc3, 0x1d, 0x95, 0xde, 0x9f, 0x84, 0x11, 0xf4, + 0xd8, 0x51, 0x3f, 0xaa, 0x90, 0x3d, 0x74, 0x47, + + 0xc3, 0x0e, 0x74, 0xbb, 0x90, 0xbc, 0x3f, 0x92, + 0xd8, 0x7e, 0x11, 0x13, 0x9f, 0x23, 0x95, 0x5e, + + 0xc3, 0x00, 0x09, 0x5b, 0x9f, 0x62, 0x66, 0xa1, + 0xd8, 0x52, 0xf7, 0x67, 0x90, 0xca, 0xd6, 0x4a, +] + + +def qmc1_decrypt(data: bytearray) -> None: + for i in range(len(data)): + data[i] ^= PRIVKey[(i % 0x7FFF) & 0x7F] if i > 0x7FFF else PRIVKey[i & 0x7F] # noqa: PLR2004 diff --git a/decryptor.py b/decryptor/tripledes.py similarity index 92% rename from decryptor.py rename to decryptor/tripledes.py index a273b4c..7b8d405 100644 --- a/decryptor.py +++ b/decryptor/tripledes.py @@ -4,8 +4,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: Copyright (c) 2024 沉默の金 -import logging -from zlib import decompress ENCRYPT = 1 DECRYPT = 0 @@ -258,32 +256,3 @@ def tripledes_crypt(input_data: bytearray, output: bytearray, key: list) -> None crypt(input_data, output, key[0]) crypt(output, output, key[1]) crypt(output, output, key[2]) - - -def qrc_decrypt(encrypted_qrc: str) -> tuple[str | None, str | None]: - if encrypted_qrc is None or encrypted_qrc.strip() == "": - logging.error("没有可解密的数据") - return None, None - try: - key = bytearray(b"!@#)(*$%123ZXC!@!@#)(NHL") - encrypted_text_byte = bytearray.fromhex(encrypted_qrc) # 将文本解析为字节数组 - - data = bytearray(len(encrypted_text_byte)) - schedule = [[[0] * 6 for _ in range(16)] for _ in range(3)] - tripledes_key_setup(key, schedule, DECRYPT) - - # 以 8 字节为单位迭代 encrypted_text_byte - for i in range(0, len(encrypted_text_byte), 8): - temp = bytearray(8) - - tripledes_crypt(encrypted_text_byte[i:], temp, schedule) - - # 将结果复制到数据数组 - for j in range(8): - data[i + j] = temp[j] - - decrypted_qrc = decompress(data).decode("utf-8") - except Exception as e: - logging.exception("解密失败") - return None, str(e) - return decrypted_qrc, None diff --git a/lyrics.py b/lyrics.py index bf8aa9a..59a6afe 100644 --- a/lyrics.py +++ b/lyrics.py @@ -6,7 +6,7 @@ from bs4 import BeautifulSoup from api import get_qrc, qm_get_lyric -from decryptor import qrc_decrypt +from decryptor import QrcType, qrc_decrypt class LyricType: @@ -56,6 +56,7 @@ def time2ms(m: int, s: int, ms: int) -> int: def qrc2lrc(qrc: str) -> str: """将明文qrc转换为lrc""" + qrc = re.findall(r'', qrc, re.DOTALL)[0] qrc_lines = qrc.split('\n') lrc_lines = [] wrods_split_pattern = re.compile(r'(?:\[\d+,\d+\])?((?:(?!\(\d+,\d+\)).)+)\((\d+),(\d+)\)') # 逐字匹配 @@ -155,12 +156,12 @@ def download_and_decrypt(self) -> tuple[str | None, int | None]: if encrypted_lyric.startswith(c): return f"没有获取到可解密的歌词(encrypted_lyric starts with {c})", LyricProcessingError.NOT_FOUND - lyric, error = qrc_decrypt(encrypted_lyric) + lyric, error = qrc_decrypt(encrypted_lyric, QrcType.CLOUD) if lyric is not None: type_ = judge_lyric_type(lyric) if type_ == LyricType.QRC: - lyric = qrc2lrc(re.findall(r'', lyric, re.DOTALL)[0]) + lyric = qrc2lrc(lyric) self[key] = lyric self.orig_type = "qrc" elif error is not None: diff --git a/ui/encrypted_lyrics.ui b/ui/encrypted_lyrics.ui new file mode 100644 index 0000000..616c1cc --- /dev/null +++ b/ui/encrypted_lyrics.ui @@ -0,0 +1,61 @@ + + + encrypted_lyrics + + + + 0 + 0 + 802 + 413 + + + + Form + + + + + + + + + + + + 0 + 0 + + + + 打开加密qrc歌词 + + + + + + + 转换为lrc + + + + + + + + 0 + 0 + + + + 保存歌词 + + + + + + + + + + diff --git a/ui/encrypted_lyrics_ui.py b/ui/encrypted_lyrics_ui.py new file mode 100644 index 0000000..050d3f7 --- /dev/null +++ b/ui/encrypted_lyrics_ui.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'encrypted_lyrics.ui' +## +## Created by: Qt User Interface Compiler version 6.6.1 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import ( + QCoreApplication, + QMetaObject, +) +from PySide6.QtWidgets import ( + QHBoxLayout, + QPlainTextEdit, + QPushButton, + QSizePolicy, + QVBoxLayout, +) + + +class Ui_encrypted_lyrics(object): + def setupUi(self, encrypted_lyrics): + if not encrypted_lyrics.objectName(): + encrypted_lyrics.setObjectName(u"encrypted_lyrics") + encrypted_lyrics.resize(802, 413) + self.verticalLayout = QVBoxLayout(encrypted_lyrics) + self.verticalLayout.setObjectName(u"verticalLayout") + self.plainTextEdit = QPlainTextEdit(encrypted_lyrics) + self.plainTextEdit.setObjectName(u"plainTextEdit") + + self.verticalLayout.addWidget(self.plainTextEdit) + + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName(u"horizontalLayout") + self.open_pushButton = QPushButton(encrypted_lyrics) + self.open_pushButton.setObjectName(u"open_pushButton") + sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.open_pushButton.sizePolicy().hasHeightForWidth()) + self.open_pushButton.setSizePolicy(sizePolicy) + + self.horizontalLayout.addWidget(self.open_pushButton) + + self.convert_pushButton = QPushButton(encrypted_lyrics) + self.convert_pushButton.setObjectName(u"convert_pushButton") + + self.horizontalLayout.addWidget(self.convert_pushButton) + + self.save_pushButton = QPushButton(encrypted_lyrics) + self.save_pushButton.setObjectName(u"save_pushButton") + sizePolicy.setHeightForWidth(self.save_pushButton.sizePolicy().hasHeightForWidth()) + self.save_pushButton.setSizePolicy(sizePolicy) + + self.horizontalLayout.addWidget(self.save_pushButton) + + + self.verticalLayout.addLayout(self.horizontalLayout) + + + self.retranslateUi(encrypted_lyrics) + + QMetaObject.connectSlotsByName(encrypted_lyrics) + # setupUi + + def retranslateUi(self, encrypted_lyrics): + encrypted_lyrics.setWindowTitle(QCoreApplication.translate("encrypted_lyrics", u"Form", None)) + self.open_pushButton.setText(QCoreApplication.translate("encrypted_lyrics", u"\u6253\u5f00\u52a0\u5bc6qrc\u6b4c\u8bcd", None)) + self.convert_pushButton.setText(QCoreApplication.translate("encrypted_lyrics", u"\u8f6c\u6362\u4e3alrc", None)) + self.save_pushButton.setText(QCoreApplication.translate("encrypted_lyrics", u"\u4fdd\u5b58\u6b4c\u8bcd", None)) + # retranslateUi + diff --git a/view/encrypted_lyrics.py b/view/encrypted_lyrics.py new file mode 100644 index 0000000..e3e1924 --- /dev/null +++ b/view/encrypted_lyrics.py @@ -0,0 +1,71 @@ +import os + +from PySide6.QtWidgets import QFileDialog, QMessageBox, QWidget + +from decryptor import QrcType, qrc_decrypt +from lyrics import get_clear_lyric, qrc2lrc +from ui.encrypted_lyrics_ui import Ui_encrypted_lyrics + + +class EncryptedLyricsWidget(QWidget, Ui_encrypted_lyrics): + def __init__(self) -> None: + super().__init__() + self.setupUi(self) + self.connect_signals() + self.is_lrc = False + + def connect_signals(self) -> None: + self.open_pushButton.clicked.connect(self.open_file) + self.convert_pushButton.clicked.connect(self.convert) + self.save_pushButton.clicked.connect(self.save) + + def open_file(self) -> None: + file_path = QFileDialog.getOpenFileName(self, "选取加密歌词", "", "QRC Files(*.qrc)")[0] + if file_path == "": + return + if not os.path.exists(file_path): + QMessageBox.warning(self, "警告", "文件不存在!") + return + try: + with open(file_path, "rb") as f: + data = f.read() + except Exception as e: + QMessageBox.warning(self, "警告", f"读取文件失败:{e}") + return + magic_header = b'\x98%\xb0\xac\xe3\x02\x83h\xe8\xfc\x6c' + if data[:len(magic_header)] != magic_header: + QMessageBox.warning(self, "警告", "文件格式不正确!") + return + lyrics, error = qrc_decrypt(data, QrcType.LOCAL) + if lyrics is None: + msg = "解密失败" if error is None else f"解密失败:{error}" + QMessageBox.critical(self, "错误", msg) + return + self.plainTextEdit.setPlainText(lyrics) + self.is_lrc = False + + def convert(self) -> None: + if self.is_lrc: + QMessageBox.information(self, "提示", "当前歌词已经是lrc格式了!") + return + lyrics = self.plainTextEdit.toPlainText() + if lyrics.strip() == "": + QMessageBox.warning(self, "警告", "歌词内容不能为空!") + return + try: + lrc = get_clear_lyric(qrc2lrc(lyrics)) + except Exception as e: + QMessageBox.critical(self, "错误", f"转换失败:{e}") + return + self.plainTextEdit.setPlainText(lrc) + self.is_lrc = True + + def save(self) -> None: + file_path, _ = QFileDialog.getSaveFileName(self, "保存文件", "", "LRC文件 (*.lrc)") + if file_path == "": + return + try: + with open(file_path, "w", encoding="utf-8") as f: + f.write(self.plainTextEdit.toPlainText()) + except Exception as e: + QMessageBox.critical(self, "错误", f"保存失败:{e}")