├── .gitignore ├── Chunager.spec ├── LICENSE.txt ├── README.md ├── Source ├── config.ini ├── extra │ └── unlocker.exe ├── icon.ico ├── img │ ├── character.svg │ ├── error.svg │ ├── folder.svg │ ├── home.svg │ ├── info.svg │ ├── launch.svg │ ├── logo.jpg │ ├── manual.svg │ ├── music.svg │ ├── opt.svg │ ├── pill.svg │ ├── save.svg │ ├── setting.svg │ ├── unlock.svg │ ├── wc_spon.JPG │ └── web.svg ├── main.py ├── pages │ ├── __init__.py │ ├── about_page.py │ ├── character_page.py │ ├── home_page.py │ ├── music_page.py │ ├── opt_page.py │ ├── patcher_page.py │ ├── pfm_manual_page.py │ ├── setting_page.py │ └── unlocker_page.py ├── version.rc ├── zh-CN.qm └── zh-CN.ts └── sp_thk.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | #*.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | # Custom 163 | token.txt 164 | lyrics/ 165 | config.json 166 | venv_lin/ 167 | venv_win/ 168 | /Source/character_index.json 169 | /Source/music_index.json 170 | -------------------------------------------------------------------------------- /Chunager.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ['.\\Source\\main.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[('.\\Source\\extra', './extra/'), ('.\\Source\\img', './img/'), ('.\\Source\\pages', './pages/'), ('.\\Source\\icon.ico', '.')], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | optimize=0, 16 | ) 17 | pyz = PYZ(a.pure) 18 | 19 | exe = EXE( 20 | pyz, 21 | a.scripts, 22 | a.binaries, 23 | a.datas, 24 | [], 25 | name='Chunager', 26 | debug=False, 27 | bootloader_ignore_signals=False, 28 | strip=False, 29 | upx=True, 30 | upx_exclude=[], 31 | runtime_tmpdir=None, 32 | console=True, 33 | disable_windowed_traceback=False, 34 | argv_emulation=False, 35 | target_arch=None, 36 | codesign_identity=None, 37 | entitlements_file=None, 38 | icon=['.\\Source\\icon.ico'], 39 | version='.\\Source\\version.rc', 40 | ) 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Elliot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | 1. The original author and the authors of any other software used with this 13 | software must be credited 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CHUNAGER 2 | 3 | CHUNAGER 是一款針對 CHUNITHM HDD (SDHD 2.30.00 VERSE) 製作的管理工具,令使用者更方便地整理、維護與優化 CHUNITHM 遊戲內容。
4 | 接受功能請求, 請打開一個issues. 錯誤回報也一樣.
5 | 6 | --- 7 | 8 | ## 需求 9 | - Windows 10 | - CHUNITHM HDD 11 | 12 | --- 13 | 14 | ## 開發或編譯 15 | 1. 克隆此專案: 16 | ```bash 17 | cd anywhere 18 | git clone https://github.com/ElliotCHEN37/chunager.git 19 | cd chunager 20 | ``` 21 | 2. 安裝依賴項: 22 | ```bash 23 | pip install -r requirements.txt 24 | ``` 25 | 3. 編譯: 26 | ```bash 27 | pip install pyinstaller 28 | pyinstaller Chunager.spec 29 | ``` 30 | 31 | --- 32 | 33 | ## 授權條款 34 | 本專案以 [MIT License](https://raw.githubusercontent.com/ElliotCHEN37/chunager/refs/heads/main/LICENSE.txt) 授權。 35 | 36 | --- 37 | 38 | ## 贊助 39 | 資助超過20元即可出現在感謝名單內, 名單每月更新
40 | 41 | 42 | --- 43 | 44 | ## 免則聲明 45 | 請遵守當地法律使用。
46 | 本程式為個人開發,與任何和 Evil Leaker、SEGA、CHUNITHM 官方團隊或相關人物及事項無任何關係。 47 | 使用本程式所造成的一切後果,作者不承擔任何責任。 48 | -------------------------------------------------------------------------------- /Source/config.ini: -------------------------------------------------------------------------------- 1 | [DISPLAY] 2 | theme = AUTO 3 | translation_path = 4 | 5 | [GENERAL] 6 | version = v1.2.2 7 | segatools_path = D:/bin/segatools.ini 8 | 9 | -------------------------------------------------------------------------------- /Source/extra/unlocker.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElliotCHEN37/chunager/a49287eca518e9f77136856dc41c665e262f8a3e/Source/extra/unlocker.exe -------------------------------------------------------------------------------- /Source/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElliotCHEN37/chunager/a49287eca518e9f77136856dc41c665e262f8a3e/Source/icon.ico -------------------------------------------------------------------------------- /Source/img/character.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/folder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/info.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/launch.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElliotCHEN37/chunager/a49287eca518e9f77136856dc41c665e262f8a3e/Source/img/logo.jpg -------------------------------------------------------------------------------- /Source/img/manual.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/music.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/opt.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/pill.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/save.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/setting.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/unlock.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/img/wc_spon.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElliotCHEN37/chunager/a49287eca518e9f77136856dc41c665e262f8a3e/Source/img/wc_spon.JPG -------------------------------------------------------------------------------- /Source/img/web.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Source/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import configparser 4 | import winreg 5 | from PySide6.QtGui import QIcon, QPixmap, QPainter 6 | from PySide6.QtWidgets import QApplication 7 | from PySide6.QtSvg import QSvgRenderer 8 | from PySide6.QtCore import QByteArray, Qt, QTranslator 9 | from qfluentwidgets import FluentWindow, setTheme, Theme, NavigationItemPosition 10 | from pages.home_page import HomePage 11 | from pages.opt_page import OptPage 12 | from pages.music_page import MusicPage 13 | from pages.character_page import CharacterPage 14 | from pages.unlocker_page import UnlockerPage 15 | from pages.patcher_page import PatcherPage 16 | from pages.pfm_manual_page import PFMManualPage 17 | from pages.setting_page import SettingPage 18 | from pages.about_page import AboutPage 19 | 20 | def get_path(rel_path: str) -> str: 21 | base = getattr(sys, '_MEIPASS', os.path.abspath(".")) 22 | return os.path.join(base, rel_path) 23 | 24 | def is_dark_mode() -> bool: 25 | try: 26 | with winreg.OpenKey(winreg.HKEY_CURRENT_USER, 27 | r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as key: 28 | value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") 29 | return value == 0 30 | except Exception: 31 | return False 32 | 33 | 34 | def svg_to_icon(path: str, color: str) -> QIcon: 35 | with open(path, "r", encoding="utf-8") as f: 36 | svg_content = f.read() 37 | 38 | svg_content = svg_content.replace("fill=\"#e3e3e3\"", f"fill=\"{color}\"") 39 | svg_bytes = QByteArray(svg_content.encode("utf-8")) 40 | 41 | renderer = QSvgRenderer(svg_bytes) 42 | pixmap = QPixmap(64, 64) 43 | pixmap.fill(Qt.transparent) 44 | 45 | painter = QPainter(pixmap) 46 | renderer.render(painter) 47 | painter.end() 48 | 49 | return QIcon(pixmap) 50 | 51 | 52 | class MainWindow(FluentWindow): 53 | def __init__(self): 54 | super().__init__() 55 | self.setWindowIcon(QIcon(get_path("icon.ico"))) 56 | self.setWindowTitle("CHUNAGER") 57 | self.resize(1000, 750) 58 | 59 | self.config = self.load_config() 60 | self.apply_theme() 61 | self.setup_pages() 62 | self.setup_nav() 63 | 64 | def load_config(self): 65 | config = configparser.ConfigParser() 66 | app_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath( 67 | os.path.dirname(__file__)) 68 | config_path = os.path.join(app_dir, "config.ini") 69 | 70 | if not os.path.exists(config_path): 71 | config["GENERAL"] = {"segatools_path": ""} 72 | config["DISPLAY"] = {"theme": "AUTO"} 73 | with open(config_path, "w", encoding="utf-8") as f: 74 | config.write(f) 75 | else: 76 | config.read(config_path, encoding="utf-8") 77 | 78 | self.config_path = config_path 79 | return config 80 | 81 | def apply_theme(self): 82 | theme_setting = self.config.get("DISPLAY", "theme", fallback="AUTO").upper() 83 | use_dark = is_dark_mode() if theme_setting == "AUTO" else theme_setting == "DARK" 84 | 85 | self.theme_color = "#e3e3e3" if use_dark else "#000000" 86 | setTheme(Theme.DARK if use_dark else Theme.LIGHT) 87 | 88 | def get_icon(self, name: str) -> QIcon: 89 | return svg_to_icon(get_path(f"img/{name}.svg"), self.theme_color) 90 | 91 | def setup_pages(self): 92 | self.pages = { 93 | self.tr("首頁"): (HomePage(), self.get_icon("home"), NavigationItemPosition.TOP), 94 | self.tr("OPT"): (OptPage(), self.get_icon("opt"), NavigationItemPosition.TOP), 95 | self.tr("樂曲"): (MusicPage(), self.get_icon("music"), NavigationItemPosition.TOP), 96 | self.tr("角色"): (CharacterPage(), self.get_icon("character"), NavigationItemPosition.TOP), 97 | self.tr("解鎖"): (UnlockerPage(), self.get_icon("unlock"), NavigationItemPosition.TOP), 98 | self.tr("補丁"): (PatcherPage(), self.get_icon("pill"), NavigationItemPosition.TOP), 99 | self.tr("手冊"): (PFMManualPage(), self.get_icon("manual"), NavigationItemPosition.TOP), 100 | self.tr("設定"): (SettingPage(), self.get_icon("setting"), NavigationItemPosition.BOTTOM), 101 | self.tr("關於"): (AboutPage(), self.get_icon("info"), NavigationItemPosition.BOTTOM) 102 | } 103 | 104 | def setup_nav(self): 105 | for name, (page, icon, position) in self.pages.items(): 106 | self.addSubInterface(page, icon, name, position) 107 | self.navigationInterface.setCurrentItem("首頁") 108 | 109 | 110 | if __name__ == "__main__": 111 | app = QApplication(sys.argv) 112 | 113 | translator = QTranslator() 114 | config = configparser.ConfigParser() 115 | app_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath(os.path.dirname(__file__)) 116 | config_path = os.path.join(app_dir, "config.ini") 117 | config.read(config_path, encoding="utf-8") 118 | 119 | qm_path = config.get("DISPLAY", "translation_path", fallback="") 120 | if os.path.isfile(qm_path): 121 | if translator.load(qm_path): 122 | app.installTranslator(translator) 123 | else: 124 | print(f"can not load translation:{qm_path}") 125 | else: 126 | print("translation not exist") 127 | 128 | window = MainWindow() 129 | window.show() 130 | sys.exit(app.exec()) 131 | -------------------------------------------------------------------------------- /Source/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElliotCHEN37/chunager/a49287eca518e9f77136856dc41c665e262f8a3e/Source/pages/__init__.py -------------------------------------------------------------------------------- /Source/pages/about_page.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import sys 4 | 5 | import requests 6 | from PySide6.QtWidgets import QWidget, QVBoxLayout, QScrollArea, QTableWidgetItem 7 | from PySide6.QtCore import Qt 8 | from PySide6.QtGui import QFont 9 | from qfluentwidgets import LargeTitleLabel, StrongBodyLabel, CaptionLabel, TableWidget 10 | 11 | def get_path(relative_path: str) -> str: 12 | base_path = getattr(sys, '_MEIPASS', os.path.abspath(".")) 13 | return os.path.join(base_path, relative_path) 14 | 15 | class AboutPage(QWidget): 16 | def __init__(self): 17 | super().__init__() 18 | self.setObjectName("aboutPage") 19 | 20 | self.cfg_path = self.get_cfg_path() 21 | self.cfg = self.load_cfg() 22 | self.current_version = self.cfg.get("GENERAL", "version") 23 | 24 | scroll = QScrollArea(self) 25 | scroll.setWidgetResizable(True) 26 | 27 | inner = QWidget() 28 | layout = QVBoxLayout(inner) 29 | layout.setAlignment(Qt.AlignTop) 30 | 31 | layout.setAlignment(Qt.AlignTop) 32 | 33 | scroll.setWidget(inner) 34 | 35 | main_layout = QVBoxLayout(self) 36 | main_layout.addWidget(scroll) 37 | 38 | def styled_label(label_class, text, point_size, rich=False): 39 | label = label_class(text) 40 | font = QFont() 41 | font.setPointSize(point_size) 42 | label.setFont(font) 43 | label.setAlignment(Qt.AlignLeft) 44 | label.setWordWrap(True) 45 | if rich: 46 | label.setTextFormat(Qt.RichText) 47 | label.setTextInteractionFlags(Qt.TextBrowserInteraction) 48 | label.setOpenExternalLinks(True) 49 | return label 50 | 51 | layout.addWidget(styled_label(LargeTitleLabel, self.tr("關於 CHUNAGER"), 20)) 52 | layout.addSpacing(10) 53 | 54 | layout.addWidget(styled_label(StrongBodyLabel, self.tr("感謝名單"), 12)) 55 | 56 | self.table = TableWidget() 57 | self.table.setColumnCount(2) 58 | self.table.setRowCount(0) 59 | self.table.setHorizontalHeaderLabels([self.tr("名稱"), self.tr("贊助金額")]) 60 | self.table.setEditTriggers(TableWidget.NoEditTriggers) 61 | self.table.setSelectionMode(TableWidget.NoSelection) 62 | self.table.horizontalHeader().setStretchLastSection(True) 63 | self.table.verticalHeader().setVisible(False) 64 | layout.addWidget(self.table) 65 | 66 | self.load_thanks_list() 67 | 68 | layout.addSpacing(15) 69 | 70 | layout.addWidget(styled_label( 71 | CaptionLabel, 72 | self.tr(f'作者:Elliot
版本:{self.current_version}
GitHub:https://github.com/ElliotCHEN37/chunager'), 73 | 10, 74 | rich=True 75 | )) 76 | layout.addSpacing(10) 77 | 78 | layout.addWidget(styled_label( 79 | CaptionLabel, 80 | self.tr('
免責聲明:
本程式為個人開發,與任何和 Evil Leaker、SEGA、CHUNITHM 官方團隊或相關人物及事項無任何關係。
請遵守當地法律使用。
本程式使用 MIT 授權,詳見許可證
使用本程式所造成的一切後果,作者不承擔任何責任。'), 81 | 10, 82 | rich=True 83 | )) 84 | 85 | layout.addWidget(styled_label( 86 | CaptionLabel, 87 | self.tr(f'
贊助 20 元及以上即可出現在感謝名單中, 名單每個月更新一次
'), 88 | 10, 89 | rich=True 90 | )) 91 | 92 | def get_cfg_path(self): 93 | app_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath( 94 | os.path.dirname(sys.argv[0])) 95 | return os.path.join(app_dir, "config.ini") 96 | 97 | def load_cfg(self): 98 | cfg = configparser.ConfigParser() 99 | cfg.read(self.cfg_path, encoding="utf-8") 100 | for section in ["DISPLAY", "GENERAL"]: 101 | if not cfg.has_section(section): 102 | cfg.add_section(section) 103 | return cfg 104 | 105 | def load_thanks_list(self): 106 | url = "https://raw.githubusercontent.com/ElliotCHEN37/chunager/main/sp_thk.json" 107 | try: 108 | response = requests.get(url, timeout=5) 109 | if response.status_code == 200: 110 | data = response.json() 111 | self.table.setRowCount(len(data)) 112 | for row, person in enumerate(data): 113 | name_item = QTableWidgetItem(person.get("name", "")) 114 | donate_item = QTableWidgetItem(person.get("donate", "")) 115 | self.table.setItem(row, 0, name_item) 116 | self.table.setItem(row, 1, donate_item) 117 | except Exception as e: 118 | print("載入感謝名單失敗:", e) 119 | -------------------------------------------------------------------------------- /Source/pages/character_page.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | import os 4 | import re 5 | import shutil 6 | import sys 7 | import xml.etree.ElementTree as ET 8 | 9 | from PIL import Image 10 | from PySide6.QtCore import Qt, QThread, Signal, QTimer 11 | from PySide6.QtGui import QPixmap, QImage 12 | from PySide6.QtWidgets import QWidget, QVBoxLayout, QTableWidgetItem, QFileDialog, QHBoxLayout, QMessageBox 13 | from qfluentwidgets import LargeTitleLabel, PushButton, BodyLabel, LineEdit, TableWidget, PrimaryPushButton, ProgressBar 14 | 15 | 16 | def get_path(rel_path: str) -> str: 17 | base = getattr(sys, '_MEIPASS', os.path.abspath(".")) 18 | return os.path.join(base, rel_path) 19 | 20 | 21 | class ImageLoaderThread(QThread): 22 | image_loaded = Signal(int, QPixmap) 23 | 24 | def __init__(self, row, dds_path): 25 | super().__init__() 26 | self.row = row 27 | self.dds_path = dds_path 28 | 29 | def run(self): 30 | pixmap = self.load_dds(self.dds_path) 31 | if pixmap: 32 | self.image_loaded.emit(self.row, pixmap) 33 | 34 | def load_dds(self, dds_path): 35 | if not dds_path or not os.path.exists(dds_path): 36 | return None 37 | 38 | try: 39 | img = Image.open(dds_path).convert("RGBA") 40 | data = img.tobytes("raw", "RGBA") 41 | qimg = QImage(data, img.width, img.height, QImage.Format_RGBA8888) 42 | return QPixmap.fromImage(qimg) 43 | except Exception: 44 | base, ext = os.path.splitext(dds_path) 45 | alt_path = base + (".DDS" if ext.lower() == ".dds" else ".dds") 46 | if os.path.exists(alt_path): 47 | try: 48 | img = Image.open(alt_path).convert("RGBA") 49 | data = img.tobytes("raw", "RGBA") 50 | qimg = QImage(data, img.width, img.height, QImage.Format_RGBA8888) 51 | return QPixmap.fromImage(qimg) 52 | except Exception: 53 | pass 54 | return None 55 | 56 | 57 | class FileOperationThread(QThread): 58 | operation_completed = Signal(bool, str) 59 | 60 | def __init__(self, operation_type, **kwargs): 61 | super().__init__() 62 | self.operation_type = operation_type 63 | self.kwargs = kwargs 64 | 65 | def run(self): 66 | if self.operation_type == "extract_image": 67 | self.extract_image() 68 | elif self.operation_type == "rebuild_index": 69 | self.rebuild_index() 70 | elif self.operation_type == "reload_index": 71 | self.reload_index() 72 | 73 | def extract_image(self): 74 | try: 75 | img_path = self.kwargs['img_path'] 76 | target_dir = self.kwargs['target_dir'] 77 | 78 | if not os.path.exists(img_path): 79 | self.operation_completed.emit(False, self.tr(f"未找到角色圖像: {img_path}")) 80 | return 81 | 82 | target = os.path.join(target_dir, os.path.basename(img_path)) 83 | shutil.copy(img_path, target) 84 | self.operation_completed.emit(True, self.tr(f"已複製: {target}")) 85 | except Exception as e: 86 | self.operation_completed.emit(False, self.tr(f"複製失敗: {str(e)}")) 87 | 88 | def rebuild_index(self): 89 | try: 90 | index_path = self.kwargs['index_path'] 91 | if os.path.exists(index_path): 92 | os.remove(index_path) 93 | self.operation_completed.emit(True, self.tr("已刪除索引, 準備重建")) 94 | except Exception as e: 95 | self.operation_completed.emit(False, self.tr(f"無法刪除索引: {str(e)}")) 96 | 97 | def reload_index(self): 98 | try: 99 | index_path = self.kwargs['index_path'] 100 | if not os.path.exists(index_path): 101 | self.operation_completed.emit(False, self.tr("索引不存在, 請先建立")) 102 | return 103 | 104 | with open(index_path, 'r', encoding='utf-8') as f: 105 | index_data = json.load(f) 106 | chara_data = index_data.get("chara_data", {}) 107 | 108 | mtime = os.path.getmtime(index_path) 109 | from datetime import datetime 110 | timestamp = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S') 111 | 112 | self.operation_completed.emit(True, self.tr(f"成功重載|{json.dumps(chara_data)}|{timestamp}")) 113 | except Exception as e: 114 | self.operation_completed.emit(False, self.tr(f"索引讀取失敗: {str(e)}")) 115 | 116 | 117 | class CharaSearchThread(QThread): 118 | found = Signal(dict) 119 | progress = Signal(int) 120 | error = Signal(str, str) 121 | status_update = Signal(str) 122 | 123 | def run(self): 124 | try: 125 | self.status_update.emit(self.tr("檢查索引")) 126 | index_path = self.get_index_path() 127 | need_rescan = True 128 | chara_data = {} 129 | 130 | if os.path.exists(index_path): 131 | try: 132 | with open(index_path, 'r', encoding='utf-8') as f: 133 | index_data = json.load(f) 134 | last_opt_mtime = index_data.get("opt_last_modified", 0) 135 | chara_data = index_data.get("chara_data", {}) 136 | 137 | current_opt_mtime = self.get_opt_last_modified_time() 138 | 139 | if current_opt_mtime == last_opt_mtime: 140 | need_rescan = False 141 | self.status_update.emit(self.tr("使用現存索引")) 142 | except Exception as e: 143 | self.error.emit(self.tr("索引讀取失敗"), str(e)) 144 | return 145 | 146 | if need_rescan: 147 | self.status_update.emit(self.tr("掃描XML檔案")) 148 | xml_paths = self.find_xmls() 149 | chara_data = {} 150 | 151 | total = len(xml_paths) 152 | if total == 0: 153 | self.status_update.emit(self.tr("找不到XML檔案")) 154 | self.found.emit({}) 155 | return 156 | 157 | for idx, xml_path in enumerate(xml_paths): 158 | try: 159 | data = self.parse_xml(xml_path) 160 | chara_data[data["chara_id"]] = data 161 | progress_val = int(((idx + 1) / total) * 100) 162 | self.progress.emit(progress_val) 163 | self.status_update.emit(self.tr(f"處理中: {data['chara_name']} ({idx + 1}/{total})")) 164 | except Exception as e: 165 | print(self.tr(f"XML檔案解析失敗: {xml_path}, 錯誤: {e}")) 166 | continue 167 | 168 | self.status_update.emit(self.tr("儲存索引")) 169 | current_opt_mtime = self.get_opt_last_modified_time() 170 | try: 171 | with open(index_path, 'w', encoding='utf-8') as f: 172 | json.dump({ 173 | "opt_last_modified": current_opt_mtime, 174 | "chara_data": chara_data 175 | }, f, ensure_ascii=False, indent=2) 176 | except Exception as e: 177 | self.error.emit(self.tr("寫入索引錯誤"), str(e)) 178 | return 179 | 180 | self.status_update.emit(self.tr("完成")) 181 | self.found.emit(chara_data) 182 | except Exception as e: 183 | self.error.emit(self.tr("搜尋失敗"), str(e)) 184 | 185 | def get_cfg_path(self): 186 | base = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath( 187 | os.path.dirname(sys.argv[0])) 188 | return os.path.join(base, "config.ini") 189 | 190 | def get_index_path(self): 191 | base_dir = os.path.dirname(self.get_cfg_path()) 192 | return os.path.join(base_dir, "character_index.json") 193 | 194 | def get_opt_last_modified_time(self): 195 | cfg_path = self.get_cfg_path() 196 | cfg = configparser.ConfigParser() 197 | cfg.read(cfg_path) 198 | 199 | sega_path = cfg.get("GENERAL", "segatools_path", fallback=None) 200 | if not sega_path or not os.path.exists(sega_path): 201 | return 0 202 | 203 | sega_cfg = configparser.ConfigParser() 204 | sega_cfg.read(sega_path) 205 | 206 | opt_rel_path = sega_cfg.get("vfs", "option", fallback=None) 207 | if not opt_rel_path: 208 | return 0 209 | 210 | if os.path.isabs(opt_rel_path): 211 | opt_path = opt_rel_path 212 | else: 213 | opt_path = os.path.normpath(os.path.join(os.path.dirname(sega_path), opt_rel_path)) 214 | 215 | max_mtime = 0 216 | if os.path.isdir(opt_path): 217 | for root, dirs, files in os.walk(opt_path): 218 | for name in files: 219 | try: 220 | full_path = os.path.join(root, name) 221 | mtime = os.path.getmtime(full_path) 222 | if mtime > max_mtime: 223 | max_mtime = mtime 224 | except Exception: 225 | pass 226 | return max_mtime 227 | 228 | def find_xmls(self): 229 | result = [] 230 | cfg_path = self.get_cfg_path() 231 | cfg = configparser.ConfigParser() 232 | cfg.read(cfg_path) 233 | 234 | sega_path = cfg.get("GENERAL", "segatools_path", fallback=None) 235 | if not sega_path or not os.path.exists(sega_path): 236 | return result 237 | 238 | sega_cfg = configparser.ConfigParser() 239 | sega_cfg.read(sega_path) 240 | 241 | opt_rel_path = sega_cfg.get("vfs", "option", fallback=None) 242 | if not opt_rel_path: 243 | return result 244 | 245 | opt_path = opt_rel_path if os.path.isabs(opt_rel_path) else os.path.normpath( 246 | os.path.join(os.path.dirname(sega_path), opt_rel_path)) 247 | 248 | a000_path = os.path.normpath(os.path.join(os.path.dirname(sega_path), "..", "data", "A000", "chara")) 249 | if os.path.isdir(a000_path): 250 | result.extend(self.scan_chara_folder(a000_path)) 251 | 252 | if os.path.isdir(opt_path): 253 | for name in os.listdir(opt_path): 254 | subfolder = os.path.join(opt_path, name) 255 | chara_folder = os.path.join(subfolder, "chara") 256 | if os.path.isdir(subfolder) and name.startswith("A") and os.path.isdir(chara_folder): 257 | result.extend(self.scan_chara_folder(chara_folder)) 258 | 259 | return result 260 | 261 | def scan_chara_folder(self, root_path): 262 | found = [] 263 | if not os.path.exists(root_path): 264 | return found 265 | 266 | for folder in os.listdir(root_path): 267 | if re.match(r'^chara\d+$', folder): 268 | chara_path = os.path.join(root_path, folder) 269 | xml_path = os.path.join(chara_path, "chara.xml") 270 | if os.path.exists(xml_path): 271 | found.append(xml_path) 272 | 273 | return found 274 | 275 | def xml_text(self, root, path, default="unknown"): 276 | elem = root.find(path) 277 | return elem.text if elem is not None else default 278 | 279 | def parse_xml(self, xml_path): 280 | tree = ET.parse(xml_path) 281 | root = tree.getroot() 282 | 283 | chara_id = self.xml_text(root, ".//name/id") 284 | chara_name = self.xml_text(root, ".//name/str") 285 | works = self.xml_text(root, ".//works/str") 286 | artist = self.xml_text(root, ".//illustratorName/str") 287 | sort_name = self.xml_text(root, ".//sortName") 288 | 289 | rewards = [] 290 | for rank in root.findall(".//ranks/CharaRankData"): 291 | idx = rank.findtext("index", "0") 292 | reward = rank.find(".//rewardSkillSeed/rewardSkillSeed/str") 293 | if reward is not None and reward.text != "Invalid": 294 | rewards.append({ 295 | "rank": idx, 296 | "reward_str": reward.text 297 | }) 298 | 299 | img_default = self.xml_text(root, ".//defaultImages/str") 300 | padded_id = chara_id.zfill(6) if chara_id.isdigit() else chara_id 301 | 302 | img_suffix = img_default.replace("chara", "", 1) if img_default.startswith("chara") else img_default 303 | dds_folder = os.path.join(os.path.dirname(xml_path), "..", "..", "ddsImage", f"ddsImage{padded_id}") 304 | img_file = f"CHU_UI_Character_{img_suffix}_00.dds" 305 | img_path = os.path.join(dds_folder, img_file) 306 | 307 | return { 308 | "image_path": img_path, 309 | "chara_id": chara_id, 310 | "chara_name": chara_name, 311 | "works_name": works, 312 | "illustrator_name": artist, 313 | "sort_name": sort_name, 314 | "rank_rewards": rewards 315 | } 316 | 317 | 318 | class CharacterPage(QWidget): 319 | def __init__(self): 320 | super().__init__() 321 | self.setObjectName("characterPage") 322 | self.has_searched = False 323 | self.chara_data = {} 324 | self.image_loaders = {} 325 | self.current_file_operation = None 326 | 327 | self.init_ui() 328 | self.setup_search_thread() 329 | 330 | def init_ui(self): 331 | self.layout = QVBoxLayout(self) 332 | self.layout.setAlignment(Qt.AlignTop) 333 | 334 | self.titleLabel = LargeTitleLabel(self.tr("角色管理")) 335 | self.layout.addWidget(self.titleLabel) 336 | 337 | self.searchMsg = BodyLabel(self.tr("正在搜尋資料...")) 338 | self.searchMsg.setAlignment(Qt.AlignCenter) 339 | self.layout.addWidget(self.searchMsg) 340 | 341 | self.progress = ProgressBar(self) 342 | self.progress.setRange(0, 100) 343 | self.layout.addWidget(self.progress) 344 | 345 | self.index_status = BodyLabel(self.tr("索引狀態:尚未建立")) 346 | self.index_status.setAlignment(Qt.AlignCenter) 347 | self.layout.addWidget(self.index_status) 348 | 349 | search_layout = QHBoxLayout() 350 | self.searchBox = LineEdit(self) 351 | self.searchBox.setPlaceholderText(self.tr("搜尋角色名稱...")) 352 | search_layout.addWidget(self.searchBox) 353 | 354 | self.searchBtn = PrimaryPushButton(self.tr("搜尋")) 355 | self.searchBtn.clicked.connect(self.filter_data) 356 | search_layout.addWidget(self.searchBtn) 357 | 358 | self.resetBtn = PushButton(self.tr("重置")) 359 | self.resetBtn.clicked.connect(self.reset_filter) 360 | search_layout.addWidget(self.resetBtn) 361 | 362 | self.layout.addLayout(search_layout) 363 | 364 | btn_layout = QHBoxLayout() 365 | 366 | self.rebuild_btn = PrimaryPushButton(self.tr("重建索引")) 367 | self.rebuild_btn.clicked.connect(self.rebuild_index) 368 | btn_layout.addWidget(self.rebuild_btn) 369 | 370 | self.reload_btn = PrimaryPushButton(self.tr("重新載入")) 371 | self.reload_btn.clicked.connect(self.reload_index) 372 | btn_layout.addWidget(self.reload_btn) 373 | 374 | self.layout.addLayout(btn_layout) 375 | 376 | self.table = TableWidget(self) 377 | self.table.setColumnCount(7) 378 | self.table.setHorizontalHeaderLabels([self.tr("圖像"), self.tr("ID"), self.tr("名稱"), self.tr("出處"), self.tr("繪師"), self.tr("等級獎勵"), self.tr("提取圖像")]) 379 | self.table.horizontalHeader().setStretchLastSection(True) 380 | self.table.setSortingEnabled(True) 381 | self.layout.addWidget(self.table) 382 | 383 | def setup_search_thread(self): 384 | self.search_thread = CharaSearchThread() 385 | self.search_thread.found.connect(self.on_search_done) 386 | self.search_thread.progress.connect(self.update_progress) 387 | self.search_thread.error.connect(self.on_search_error) 388 | self.search_thread.status_update.connect(self.update_status_message) 389 | 390 | def update_progress(self, value): 391 | self.progress.setValue(value) 392 | 393 | def update_status_message(self, message): 394 | self.searchMsg.setText(message) 395 | 396 | def on_search_error(self, title, message): 397 | QMessageBox.critical(self, title, message) 398 | self.searchMsg.hide() 399 | self.progress.hide() 400 | 401 | def showEvent(self, event): 402 | if not self.has_searched: 403 | self.searchMsg.show() 404 | self.progress.show() 405 | self.progress.setValue(0) 406 | QTimer.singleShot(100, self.start_search) 407 | self.has_searched = True 408 | 409 | def start_search(self): 410 | if not self.search_thread.isRunning(): 411 | self.search_thread.start() 412 | 413 | def on_search_done(self, chara_data): 414 | self.chara_data = chara_data 415 | self.update_table(list(chara_data.values())) 416 | self.searchMsg.hide() 417 | self.progress.hide() 418 | 419 | index_path = self.search_thread.get_index_path() 420 | if os.path.exists(index_path): 421 | mtime = os.path.getmtime(index_path) 422 | from datetime import datetime 423 | timestamp = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S') 424 | self.index_status.setText(self.tr(f"索引狀態:最後更新於 {timestamp}")) 425 | else: 426 | self.index_status.setText(self.tr("索引狀態:尚未建立")) 427 | 428 | def reset_filter(self): 429 | self.searchBox.clear() 430 | self.filter_data(reset=True) 431 | 432 | def filter_data(self, reset=False): 433 | if reset: 434 | filtered = list(self.chara_data.values()) 435 | else: 436 | query = self.searchBox.text().strip().lower() 437 | 438 | def safe_match(val): 439 | return str(val).lower() if val else "" 440 | 441 | filtered = [ 442 | data for data in self.chara_data.values() 443 | if any(query in safe_match(data[key]) for key in 444 | ["chara_id", "chara_name", "works_name", "illustrator_name"]) 445 | ] 446 | 447 | self.update_table(filtered) 448 | 449 | def update_table(self, data_list): 450 | for loader in self.image_loaders.values(): 451 | if loader.isRunning(): 452 | loader.terminate() 453 | self.image_loaders.clear() 454 | 455 | self.table.clearContents() 456 | self.table.setRowCount(len(data_list)) 457 | 458 | for row, data in enumerate(data_list): 459 | self.table.setItem(row, 1, QTableWidgetItem(data["chara_id"])) 460 | self.table.setItem(row, 2, QTableWidgetItem(data["chara_name"])) 461 | self.table.setItem(row, 3, QTableWidgetItem(data["works_name"])) 462 | self.table.setItem(row, 4, QTableWidgetItem(data["illustrator_name"])) 463 | 464 | rewards = data["rank_rewards"] 465 | reward_text = ", ".join([f"Rank {r['rank']}: {r['reward_str']}" for r in rewards[:3]]) 466 | if len(rewards) > 3: 467 | reward_text += f" (+{len(rewards) - 3})" 468 | self.table.setItem(row, 5, QTableWidgetItem(reward_text)) 469 | 470 | img_label = BodyLabel(self.tr("載入中...")) 471 | self.table.setCellWidget(row, 0, img_label) 472 | self.table.setRowHeight(row, 128) 473 | self.table.setColumnWidth(0, 128) 474 | 475 | self.load_image_async(row, data["image_path"]) 476 | 477 | copy_btn = PushButton(self.tr("提取")) 478 | copy_btn.clicked.connect(lambda _, d=data: self.extract_image(d)) 479 | self.table.setCellWidget(row, 6, copy_btn) 480 | 481 | def load_image_async(self, row, dds_path): 482 | loader = ImageLoaderThread(row, dds_path) 483 | loader.image_loaded.connect(self.on_image_loaded) 484 | loader.finished.connect(lambda: self.cleanup_image_loader(row)) 485 | self.image_loaders[row] = loader 486 | 487 | QTimer.singleShot(row * 50, loader.start) 488 | 489 | def on_image_loaded(self, row, pixmap): 490 | if row < self.table.rowCount(): 491 | label = BodyLabel() 492 | label.setPixmap(pixmap.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation)) 493 | self.table.setCellWidget(row, 0, label) 494 | 495 | def cleanup_image_loader(self, row): 496 | if row in self.image_loaders: 497 | del self.image_loaders[row] 498 | 499 | def extract_image(self, data): 500 | target_dir = QFileDialog.getExistingDirectory(self, self.tr("選擇目標資料夾"), "") 501 | if not target_dir: 502 | return 503 | 504 | if self.current_file_operation and self.current_file_operation.isRunning(): 505 | QMessageBox.information(self, self.tr("提示"), self.tr("請等待當前操作完成")) 506 | return 507 | 508 | self.current_file_operation = FileOperationThread( 509 | "extract_image", 510 | img_path=data["image_path"], 511 | target_dir=target_dir 512 | ) 513 | self.current_file_operation.operation_completed.connect(self.on_file_operation_completed) 514 | self.current_file_operation.start() 515 | 516 | def rebuild_index(self): 517 | if self.search_thread.isRunning(): 518 | QMessageBox.information(self, self.tr("提示"), self.tr("正在搜索中,請稍候")) 519 | return 520 | 521 | if self.current_file_operation and self.current_file_operation.isRunning(): 522 | QMessageBox.information(self, self.tr("提示"), self.tr("請等待當前操作完成")) 523 | return 524 | 525 | index_path = self.search_thread.get_index_path() 526 | self.current_file_operation = FileOperationThread( 527 | "rebuild_index", 528 | index_path=index_path 529 | ) 530 | self.current_file_operation.operation_completed.connect(self.on_rebuild_completed) 531 | self.current_file_operation.start() 532 | 533 | def on_rebuild_completed(self, success, message): 534 | if success: 535 | self.progress.setValue(0) 536 | self.progress.show() 537 | self.searchMsg.setText(self.tr("正在重新建立索引...")) 538 | self.searchMsg.show() 539 | self.index_status.setText(self.tr("索引狀態:重新建立中...")) 540 | self.has_searched = False 541 | QTimer.singleShot(100, self.start_search) 542 | else: 543 | QMessageBox.critical(self, self.tr("刪除索引失敗"), message) 544 | 545 | def reload_index(self): 546 | if self.search_thread.isRunning(): 547 | QMessageBox.information(self, self.tr("提示"), self.tr("正在搜索中,請稍候")) 548 | return 549 | 550 | if self.current_file_operation and self.current_file_operation.isRunning(): 551 | QMessageBox.information(self, self.tr("提示"), self.tr("請等待當前操作完成")) 552 | return 553 | 554 | index_path = self.search_thread.get_index_path() 555 | self.current_file_operation = FileOperationThread( 556 | "reload_index", 557 | index_path=index_path 558 | ) 559 | self.current_file_operation.operation_completed.connect(self.on_reload_completed) 560 | self.current_file_operation.start() 561 | 562 | def on_reload_completed(self, success, message): 563 | if success: 564 | if message.startswith("reload_success"): 565 | parts = message.split("|") 566 | chara_data_str = parts[1] 567 | timestamp = parts[2] 568 | 569 | chara_data = json.loads(chara_data_str) 570 | self.chara_data = chara_data 571 | self.update_table(list(chara_data.values())) 572 | self.index_status.setText(self.tr(f"索引狀態:最後更新於 {timestamp}")) 573 | QMessageBox.information(self, self.tr("完成"), self.tr("已成功重新載入索引。")) 574 | else: 575 | QMessageBox.critical(self, self.tr("載入失敗"), message) 576 | 577 | def on_file_operation_completed(self, success, message): 578 | if success: 579 | QMessageBox.information(self, self.tr("成功"), message) 580 | else: 581 | QMessageBox.critical(self, self.tr("操作失敗"), message) 582 | 583 | def closeEvent(self, event): 584 | if self.search_thread.isRunning(): 585 | self.search_thread.terminate() 586 | self.search_thread.wait() 587 | 588 | for loader in self.image_loaders.values(): 589 | if loader.isRunning(): 590 | loader.terminate() 591 | 592 | if self.current_file_operation and self.current_file_operation.isRunning(): 593 | self.current_file_operation.terminate() 594 | self.current_file_operation.wait() 595 | 596 | event.accept() 597 | -------------------------------------------------------------------------------- /Source/pages/home_page.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from PySide6.QtWidgets import QWidget, QVBoxLayout 4 | from PySide6.QtCore import Qt 5 | from PySide6.QtGui import QFont, QPixmap 6 | from qfluentwidgets import LargeTitleLabel, TitleLabel, StrongBodyLabel, CaptionLabel 7 | 8 | def get_path(relative_path: str) -> str: 9 | base_path = getattr(sys, '_MEIPASS', os.path.abspath(".")) 10 | return os.path.join(base_path, relative_path) 11 | 12 | class HomePage(QWidget): 13 | def __init__(self): 14 | super().__init__() 15 | self.setObjectName("homePage") 16 | 17 | layout = QVBoxLayout(self) 18 | layout.setAlignment(Qt.AlignCenter) 19 | 20 | logoLabel = TitleLabel(self) 21 | pixmap = QPixmap(get_path("img/logo.jpg")) 22 | logoLabel.setPixmap(pixmap) 23 | logoLabel.setAlignment(Qt.AlignCenter) 24 | 25 | titleLabel = LargeTitleLabel(self.tr("歡迎使用 CHUNAGER")) 26 | titleFont = QFont() 27 | titleFont.setPointSize(24) 28 | titleFont.setBold(True) 29 | titleLabel.setFont(titleFont) 30 | titleLabel.setAlignment(Qt.AlignCenter) 31 | 32 | descriptionLabel = StrongBodyLabel(self.tr("CHUNAGER 是一款針對 CHUNITHM HDD (SDHD 2.30.00 VERSE)設計的管理工具\n提供歌曲管理、角色管理、OPT 管理、解鎖器、補丁管理等功能\n協助您更輕鬆地整理與優化遊戲內容")) 33 | descFont = QFont() 34 | descFont.setPointSize(14) 35 | descriptionLabel.setFont(descFont) 36 | descriptionLabel.setAlignment(Qt.AlignCenter) 37 | 38 | authorLabel = CaptionLabel(self.tr("作者:Elliot")) 39 | authorFont = QFont() 40 | authorFont.setPointSize(12) 41 | authorFont.setItalic(True) 42 | authorLabel.setFont(authorFont) 43 | authorLabel.setAlignment(Qt.AlignCenter) 44 | 45 | layout.addWidget(logoLabel) 46 | layout.addSpacing(20) 47 | layout.addWidget(titleLabel) 48 | layout.addSpacing(20) 49 | layout.addWidget(descriptionLabel) 50 | layout.addSpacing(10) 51 | layout.addWidget(authorLabel) 52 | -------------------------------------------------------------------------------- /Source/pages/music_page.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | import os 4 | import re 5 | import shutil 6 | import sys 7 | import xml.etree.ElementTree as ET 8 | from datetime import datetime 9 | 10 | from PIL import Image 11 | from PySide6.QtCore import Qt, QThread, Signal, QTimer 12 | from PySide6.QtGui import QPixmap, QImage 13 | from PySide6.QtWidgets import QWidget, QVBoxLayout, QTableWidgetItem, QFileDialog, QHBoxLayout, QMessageBox 14 | from qfluentwidgets import LargeTitleLabel, PushButton, BodyLabel, LineEdit, TableWidget, PrimaryPushButton, ProgressBar 15 | 16 | 17 | def get_path(rel_path: str) -> str: 18 | base = getattr(sys, '_MEIPASS', os.path.abspath(".")) 19 | return os.path.join(base, rel_path) 20 | 21 | 22 | class ImageLoaderThread(QThread): 23 | image_loaded = Signal(int, QPixmap) 24 | 25 | def __init__(self, row, img_path): 26 | super().__init__() 27 | self.row = row 28 | self.img_path = img_path 29 | 30 | def run(self): 31 | if not self.img_path or not os.path.exists(self.img_path): 32 | return 33 | try: 34 | img = Image.open(self.img_path).convert("RGBA") 35 | data = img.tobytes("raw", "RGBA") 36 | qimg = QImage(data, img.width, img.height, QImage.Format_RGBA8888) 37 | self.image_loaded.emit(self.row, QPixmap.fromImage(qimg)) 38 | except Exception: 39 | pass 40 | 41 | 42 | class FileOperationThread(QThread): 43 | operation_completed = Signal(bool, str) 44 | 45 | def __init__(self, operation_type, **kwargs): 46 | super().__init__() 47 | self.operation_type = operation_type 48 | self.kwargs = kwargs 49 | 50 | def run(self): 51 | try: 52 | if self.operation_type == "extract_image": 53 | self._extract_image() 54 | elif self.operation_type == "rebuild_index": 55 | self._rebuild_index() 56 | elif self.operation_type == "reload_index": 57 | self._reload_index() 58 | except Exception as e: 59 | self.operation_completed.emit(False, str(e)) 60 | 61 | def _extract_image(self): 62 | img_path = self.kwargs['img_path'] 63 | target_dir = self.kwargs['target_dir'] 64 | if not os.path.exists(img_path): 65 | self.operation_completed.emit(False, self.tr(f"封面不存在: {img_path}")) 66 | return 67 | target = os.path.join(target_dir, os.path.basename(img_path)) 68 | shutil.copy(img_path, target) 69 | self.operation_completed.emit(True, self.tr(f"已複製: {target}")) 70 | 71 | def _rebuild_index(self): 72 | index_path = self.kwargs['index_path'] 73 | if os.path.exists(index_path): 74 | os.remove(index_path) 75 | self.operation_completed.emit(True, self.tr("已刪除索引, 準備重建")) 76 | 77 | def _reload_index(self): 78 | index_path = self.kwargs['index_path'] 79 | if not os.path.exists(index_path): 80 | self.operation_completed.emit(False, self.tr("索引不存在, 請先建立")) 81 | return 82 | with open(index_path, 'r', encoding='utf-8') as f: 83 | index_data = json.load(f) 84 | music_data = index_data.get("music_data", {}) 85 | timestamp = datetime.fromtimestamp(os.path.getmtime(index_path)).strftime('%Y-%m-%d %H:%M:%S') 86 | self.operation_completed.emit(True, f"reload_success|{json.dumps(music_data)}|{timestamp}") 87 | 88 | 89 | class MusicSearchThread(QThread): 90 | found = Signal(dict) 91 | progress = Signal(int) 92 | error = Signal(str, str) 93 | status_update = Signal(str) 94 | 95 | def run(self): 96 | try: 97 | self.status_update.emit(self.tr("檢查索引")) 98 | index_path = self.get_index_path() 99 | need_rescan = True 100 | music_data = {} 101 | if os.path.exists(index_path): 102 | try: 103 | with open(index_path, 'r', encoding='utf-8') as f: 104 | index_data = json.load(f) 105 | last_opt_mtime = index_data.get("opt_last_modified", 0) 106 | music_data = index_data.get("music_data", {}) 107 | current_opt_mtime = self.get_opt_last_modified_time() 108 | if current_opt_mtime == last_opt_mtime: 109 | need_rescan = False 110 | self.status_update.emit(self.tr("使用現存索引")) 111 | except Exception as e: 112 | self.error.emit(self.tr("索引讀取失敗"), str(e)) 113 | return 114 | if need_rescan: 115 | self.status_update.emit(self.tr("掃描XML檔案中")) 116 | xml_paths = self.find_xmls() 117 | music_data = {} 118 | total = len(xml_paths) 119 | if total == 0: 120 | self.status_update.emit(self.tr("未找到XML檔案")) 121 | self.found.emit({}) 122 | return 123 | for idx, xml_path in enumerate(xml_paths): 124 | try: 125 | data = self.parse_xml(xml_path) 126 | music_data[data["music_id"]] = data 127 | progress_val = int(((idx + 1) / total) * 100) 128 | self.progress.emit(progress_val) 129 | self.status_update.emit(self.tr(f"處理中: {data['music_name']} ({idx + 1}/{total})")) 130 | except Exception as e: 131 | print(self.tr(f"XML處理失敗: {xml_path}, error: {e}")) 132 | continue 133 | self.status_update.emit(self.tr("儲存索引")) 134 | current_opt_mtime = self.get_opt_last_modified_time() 135 | try: 136 | with open(index_path, 'w', encoding='utf-8') as f: 137 | json.dump({ 138 | "opt_last_modified": current_opt_mtime, 139 | "music_data": music_data 140 | }, f, ensure_ascii=False, indent=2) 141 | except Exception as e: 142 | self.error.emit(self.tr("寫入索引失敗"), str(e)) 143 | return 144 | self.status_update.emit(self.tr("已完成")) 145 | self.found.emit(music_data) 146 | except Exception as e: 147 | self.error.emit(self.tr("搜尋失敗"), str(e)) 148 | 149 | def get_cfg_path(self): 150 | base = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath( 151 | os.path.dirname(sys.argv[0])) 152 | return os.path.join(base, "config.ini") 153 | 154 | def get_index_path(self): 155 | base_dir = os.path.dirname(self.get_cfg_path()) 156 | return os.path.join(base_dir, "music_index.json") 157 | 158 | def get_opt_last_modified_time(self): 159 | cfg_path = self.get_cfg_path() 160 | cfg = configparser.ConfigParser() 161 | cfg.read(cfg_path) 162 | sega_path = cfg.get("GENERAL", "segatools_path", fallback=None) 163 | if not sega_path or not os.path.exists(sega_path): 164 | return 0 165 | sega_cfg = configparser.ConfigParser() 166 | sega_cfg.read(sega_path) 167 | opt_rel_path = sega_cfg.get("vfs", "option", fallback=None) 168 | if not opt_rel_path: 169 | return 0 170 | if os.path.isabs(opt_rel_path): 171 | opt_path = opt_rel_path 172 | else: 173 | opt_path = os.path.normpath(os.path.join(os.path.dirname(sega_path), opt_rel_path)) 174 | max_mtime = 0 175 | if os.path.isdir(opt_path): 176 | for root, dirs, files in os.walk(opt_path): 177 | for name in files: 178 | try: 179 | full_path = os.path.join(root, name) 180 | mtime = os.path.getmtime(full_path) 181 | if mtime > max_mtime: 182 | max_mtime = mtime 183 | except Exception: 184 | pass 185 | return max_mtime 186 | 187 | def find_xmls(self): 188 | result = [] 189 | cfg_path = self.get_cfg_path() 190 | cfg = configparser.ConfigParser() 191 | cfg.read(cfg_path) 192 | sega_path = cfg.get("GENERAL", "segatools_path", fallback=None) 193 | if not sega_path or not os.path.exists(sega_path): 194 | return result 195 | sega_cfg = configparser.ConfigParser() 196 | sega_cfg.read(sega_path) 197 | opt_rel_path = sega_cfg.get("vfs", "option", fallback=None) 198 | if not opt_rel_path: 199 | return result 200 | opt_path = opt_rel_path if os.path.isabs(opt_rel_path) else os.path.normpath( 201 | os.path.join(os.path.dirname(sega_path), opt_rel_path)) 202 | a000_path = os.path.normpath(os.path.join(os.path.dirname(sega_path), "..", "data", "A000", "music")) 203 | if os.path.isdir(a000_path): 204 | result.extend(self.scan_music_folder(a000_path)) 205 | if os.path.isdir(opt_path): 206 | for name in os.listdir(opt_path): 207 | subfolder = os.path.join(opt_path, name) 208 | music_folder = os.path.join(subfolder, "music") 209 | if os.path.isdir(subfolder) and name.startswith("A") and os.path.isdir(music_folder): 210 | result.extend(self.scan_music_folder(music_folder)) 211 | return result 212 | 213 | def scan_music_folder(self, root_path): 214 | found = [] 215 | if not os.path.exists(root_path): 216 | return found 217 | for folder in os.listdir(root_path): 218 | if re.match(r'^music\d+$', folder): 219 | music_path = os.path.join(root_path, folder) 220 | xml_path = os.path.join(music_path, "music.xml") 221 | if os.path.exists(xml_path): 222 | found.append(xml_path) 223 | return found 224 | 225 | def xml_text(self, root, path, default="未知"): 226 | elem = root.find(path) 227 | return elem.text if elem is not None else default 228 | 229 | def parse_xml(self, xml_path): 230 | tree = ET.parse(xml_path) 231 | root = tree.getroot() 232 | music_id = self.xml_text(root, ".//name/id") 233 | music_name = self.xml_text(root, ".//name/str") 234 | artist = self.xml_text(root, ".//artistName/str") 235 | date_raw = self.xml_text(root, ".//releaseDate", "00000000") 236 | date = f"{date_raw[:4]}.{date_raw[4:6]}.{date_raw[6:8]}" 237 | genres = [] 238 | for genre in root.findall(".//genreNames/list/StringID"): 239 | genre_str = genre.find("str") 240 | if genre_str is not None and genre_str.text: 241 | genres.append(genre_str.text) 242 | charts = [] 243 | for chart in root.findall(".//fumens/MusicFumenData"): 244 | enable = chart.find("enable") 245 | if enable is not None and enable.text.lower() == "true": 246 | type_str = chart.findtext("./type/str", "未知") 247 | level = chart.findtext("./level", "未知") 248 | charts.append({ 249 | "type": type_str, 250 | "level": level 251 | }) 252 | jacket = self.xml_text(root, ".//jaketFile/path") 253 | jacket_path = os.path.join(os.path.dirname(xml_path), jacket) 254 | return { 255 | "jacket_path": jacket_path, 256 | "music_id": music_id, 257 | "music_name": music_name, 258 | "artist_name": artist, 259 | "genre_names": genres, 260 | "release_date": date, 261 | "fumens": charts 262 | } 263 | 264 | 265 | class MusicPage(QWidget): 266 | def __init__(self): 267 | super().__init__() 268 | self.setObjectName("musicPage") 269 | self.has_searched = False 270 | self.music_data = {} 271 | self.image_loaders = {} 272 | self.current_file_operation = None 273 | self.init_ui() 274 | self.setup_search_thread() 275 | 276 | def init_ui(self): 277 | self.layout = QVBoxLayout(self) 278 | self.layout.setAlignment(Qt.AlignTop) 279 | self.titleLabel = LargeTitleLabel(self.tr("樂曲管理")) 280 | self.layout.addWidget(self.titleLabel) 281 | self.searchMsg = BodyLabel(self.tr("正在搜尋資料...")) 282 | self.searchMsg.setAlignment(Qt.AlignCenter) 283 | self.layout.addWidget(self.searchMsg) 284 | self.progress = ProgressBar(self) 285 | self.progress.setRange(0, 100) 286 | self.layout.addWidget(self.progress) 287 | self.index_status = BodyLabel(self.tr("索引狀態:尚未建立")) 288 | self.index_status.setAlignment(Qt.AlignCenter) 289 | self.layout.addWidget(self.index_status) 290 | search_layout = QHBoxLayout() 291 | self.searchBox = LineEdit(self) 292 | self.searchBox.setPlaceholderText(self.tr("搜尋音樂名稱...")) 293 | search_layout.addWidget(self.searchBox) 294 | self.searchBtn = PrimaryPushButton(self.tr("搜尋")) 295 | self.searchBtn.clicked.connect(self.filter_data) 296 | search_layout.addWidget(self.searchBtn) 297 | self.resetBtn = PushButton(self.tr("重置")) 298 | self.resetBtn.clicked.connect(self.reset_filter) 299 | search_layout.addWidget(self.resetBtn) 300 | self.layout.addLayout(search_layout) 301 | btn_layout = QHBoxLayout() 302 | self.rebuild_btn = PrimaryPushButton(self.tr("重建索引")) 303 | self.rebuild_btn.clicked.connect(self.rebuild_index) 304 | btn_layout.addWidget(self.rebuild_btn) 305 | self.reload_btn = PrimaryPushButton(self.tr("重新載入")) 306 | self.reload_btn.clicked.connect(self.reload_index) 307 | btn_layout.addWidget(self.reload_btn) 308 | self.layout.addLayout(btn_layout) 309 | self.table = TableWidget(self) 310 | self.table.setColumnCount(8) 311 | self.table.setHorizontalHeaderLabels([self.tr("封面"), self.tr("音樂ID"), self.tr("名稱"), self.tr("曲師"), self.tr("分類"), self.tr("日期"), self.tr("可用難度"), self.tr("提取封面")]) 312 | self.table.horizontalHeader().setStretchLastSection(True) 313 | self.table.setSortingEnabled(True) 314 | self.layout.addWidget(self.table) 315 | 316 | def setup_search_thread(self): 317 | self.search_thread = MusicSearchThread() 318 | self.search_thread.found.connect(self.on_search_done) 319 | self.search_thread.progress.connect(self.update_progress) 320 | self.search_thread.error.connect(self.on_search_error) 321 | self.search_thread.status_update.connect(self.update_status_message) 322 | 323 | def update_progress(self, value): 324 | self.progress.setValue(value) 325 | 326 | def update_status_message(self, message): 327 | self.searchMsg.setText(message) 328 | 329 | def on_search_error(self, title, message): 330 | QMessageBox.critical(self, title, message) 331 | self.searchMsg.hide() 332 | self.progress.hide() 333 | 334 | def showEvent(self, event): 335 | if not self.has_searched: 336 | self.searchMsg.show() 337 | self.progress.show() 338 | self.progress.setValue(0) 339 | QTimer.singleShot(100, self.start_search) 340 | self.has_searched = True 341 | 342 | def start_search(self): 343 | if not self.search_thread.isRunning(): 344 | self.search_thread.start() 345 | 346 | def on_search_done(self, music_data): 347 | self.music_data = music_data 348 | self.update_table(list(music_data.values())) 349 | self.searchMsg.hide() 350 | self.progress.hide() 351 | index_path = self.search_thread.get_index_path() 352 | if os.path.exists(index_path): 353 | mtime = os.path.getmtime(index_path) 354 | from datetime import datetime 355 | timestamp = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S') 356 | self.index_status.setText(self.tr(f"索引狀態:最後更新於 {timestamp}")) 357 | else: 358 | self.index_status.setText(self.tr("索引狀態:尚未建立")) 359 | 360 | def reset_filter(self): 361 | self.searchBox.clear() 362 | self.filter_data(reset=True) 363 | 364 | def filter_data(self, reset=False): 365 | if reset: 366 | filtered = list(self.music_data.values()) 367 | else: 368 | query = self.searchBox.text().strip().lower() 369 | 370 | def safe_match(val): 371 | return str(val).lower() if val else "" 372 | 373 | filtered = [ 374 | data for data in self.music_data.values() 375 | if any(query in safe_match(data[key]) for key in ["music_id", "music_name", "artist_name"]) 376 | ] 377 | self.update_table(filtered) 378 | 379 | def update_table(self, data_list): 380 | for loader in self.image_loaders.values(): 381 | if loader.isRunning(): 382 | loader.terminate() 383 | self.image_loaders.clear() 384 | self.table.clearContents() 385 | self.table.setRowCount(len(data_list)) 386 | for row, data in enumerate(data_list): 387 | self.table.setItem(row, 1, QTableWidgetItem(data["music_id"])) 388 | self.table.setItem(row, 2, QTableWidgetItem(data["music_name"])) 389 | self.table.setItem(row, 3, QTableWidgetItem(data["artist_name"])) 390 | self.table.setItem(row, 4, QTableWidgetItem(", ".join(data["genre_names"]))) 391 | self.table.setItem(row, 5, QTableWidgetItem(data["release_date"])) 392 | diff_text = ", ".join([f"{d['type']}: {d['level']}" for d in data["fumens"]]) 393 | self.table.setItem(row, 6, QTableWidgetItem(diff_text)) 394 | img_label = BodyLabel(self.tr("載入中...")) 395 | img_label.setAlignment(Qt.AlignCenter) 396 | self.table.setCellWidget(row, 0, img_label) 397 | self.table.setRowHeight(row, 128) 398 | self.table.setColumnWidth(0, 128) 399 | self.load_image_async(row, data["jacket_path"]) 400 | copy_btn = PushButton(self.tr("提取")) 401 | copy_btn.clicked.connect(lambda _, d=data: self.extract_image(d)) 402 | self.table.setCellWidget(row, 7, copy_btn) 403 | 404 | def load_image_async(self, row, img_path): 405 | loader = ImageLoaderThread(row, img_path) 406 | loader.image_loaded.connect(self.on_image_loaded) 407 | loader.finished.connect(lambda: self.cleanup_image_loader(row)) 408 | self.image_loaders[row] = loader 409 | QTimer.singleShot(row * 50, loader.start) 410 | 411 | def on_image_loaded(self, row, pixmap): 412 | if row < self.table.rowCount(): 413 | label = BodyLabel() 414 | label.setPixmap(pixmap.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation)) 415 | label.setAlignment(Qt.AlignCenter) 416 | self.table.setCellWidget(row, 0, label) 417 | 418 | def cleanup_image_loader(self, row): 419 | if row in self.image_loaders: 420 | del self.image_loaders[row] 421 | 422 | def extract_image(self, data): 423 | target_dir = QFileDialog.getExistingDirectory(self, self.tr("選擇目標資料夾"), "") 424 | if not target_dir: 425 | return 426 | if self.current_file_operation and self.current_file_operation.isRunning(): 427 | QMessageBox.information(self, self.tr("提示"), self.tr("請等待當前操作完成")) 428 | return 429 | self.current_file_operation = FileOperationThread( 430 | "extract_image", 431 | img_path=data["jacket_path"], 432 | target_dir=target_dir 433 | ) 434 | self.current_file_operation.operation_completed.connect(self.on_file_operation_completed) 435 | self.current_file_operation.start() 436 | 437 | def rebuild_index(self): 438 | if self.search_thread.isRunning(): 439 | QMessageBox.information(self, self.tr("提示"), self.tr("正在搜索中,請稍候")) 440 | return 441 | if self.current_file_operation and self.current_file_operation.isRunning(): 442 | QMessageBox.information(self, self.tr("提示"), self.tr("請等待當前操作完成")) 443 | return 444 | index_path = self.search_thread.get_index_path() 445 | self.current_file_operation = FileOperationThread( 446 | "rebuild_index", 447 | index_path=index_path 448 | ) 449 | self.current_file_operation.operation_completed.connect(self.on_rebuild_completed) 450 | self.current_file_operation.start() 451 | 452 | def on_rebuild_completed(self, success, message): 453 | if success: 454 | self.progress.setValue(0) 455 | self.progress.show() 456 | self.searchMsg.setText(self.tr("正在重新建立索引...")) 457 | self.searchMsg.show() 458 | self.index_status.setText(self.tr("索引狀態:重新建立中...")) 459 | self.has_searched = False 460 | QTimer.singleShot(100, self.start_search) 461 | else: 462 | QMessageBox.critical(self, self.tr("刪除索引失敗"), message) 463 | 464 | def reload_index(self): 465 | if self.search_thread.isRunning(): 466 | QMessageBox.information(self, self.tr("提示"), self.tr("正在搜索中,請稍候")) 467 | return 468 | if self.current_file_operation and self.current_file_operation.isRunning(): 469 | QMessageBox.information(self, self.tr("提示"), self.tr("請等待當前操作完成")) 470 | return 471 | index_path = self.search_thread.get_index_path() 472 | self.current_file_operation = FileOperationThread( 473 | "reload_index", 474 | index_path=index_path 475 | ) 476 | self.current_file_operation.operation_completed.connect(self.on_reload_completed) 477 | self.current_file_operation.start() 478 | 479 | def on_reload_completed(self, success, message): 480 | if success: 481 | if message.startswith("reload_success"): 482 | parts = message.split("|") 483 | music_data_str = parts[1] 484 | timestamp = parts[2] 485 | music_data = json.loads(music_data_str) 486 | self.music_data = music_data 487 | self.update_table(list(music_data.values())) 488 | self.index_status.setText(self.tr(f"索引狀態:最後更新於 {timestamp}")) 489 | QMessageBox.information(self, self.tr("完成"), self.tr("已成功重新載入索引。")) 490 | else: 491 | QMessageBox.critical(self, self.tr("載入失敗"), message) 492 | 493 | def on_file_operation_completed(self, success, message): 494 | if success: 495 | QMessageBox.information(self, self.tr("成功"), message) 496 | else: 497 | QMessageBox.critical(self, self.tr("操作失敗"), message) 498 | 499 | def closeEvent(self, event): 500 | if self.search_thread.isRunning(): 501 | self.search_thread.terminate() 502 | self.search_thread.wait() 503 | for loader in self.image_loaders.values(): 504 | if loader.isRunning(): 505 | loader.terminate() 506 | if self.current_file_operation and self.current_file_operation.isRunning(): 507 | self.current_file_operation.terminate() 508 | self.current_file_operation.wait() 509 | event.accept() 510 | -------------------------------------------------------------------------------- /Source/pages/opt_page.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import sys 4 | import shutil 5 | from PySide6.QtGui import QFont 6 | from PySide6.QtWidgets import QWidget, QVBoxLayout, QTableWidgetItem, QMessageBox 7 | from PySide6.QtCore import Qt, QThread, Signal, QObject 8 | from qfluentwidgets import TableWidget, LargeTitleLabel, PushButton 9 | 10 | 11 | def get_path(rel_path: str) -> str: 12 | base = getattr(sys, '_MEIPASS', os.path.abspath(".")) 13 | return os.path.join(base, rel_path) 14 | 15 | 16 | class OptLoader(QObject): 17 | done = Signal(list) 18 | fail = Signal(str) 19 | 20 | def run(self): 21 | try: 22 | cfg = configparser.ConfigParser() 23 | base = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath( 24 | os.path.dirname(sys.argv[0])) 25 | cfg_path = os.path.join(base, "config.ini") 26 | cfg.read(cfg_path) 27 | 28 | st_path = cfg.get("GENERAL", "segatools_path", fallback=None) 29 | if not st_path or not os.path.exists(st_path): 30 | self.fail.emit("segatools.ini not found") 31 | return 32 | 33 | st_cfg = configparser.ConfigParser() 34 | st_cfg.read(st_path) 35 | 36 | opt_path = st_cfg.get("vfs", "option", fallback=None) 37 | if not opt_path: 38 | self.fail.emit("[vfs] option not found") 39 | return 40 | 41 | opt_dir = opt_path if os.path.isabs(opt_path) else os.path.join(os.path.dirname(st_path), opt_path) 42 | if not os.path.exists(opt_dir): 43 | self.fail.emit("option path not found") 44 | return 45 | 46 | folders = [] 47 | a000 = os.path.normpath(os.path.join(os.path.dirname(st_path), "..", "data", "A000")) 48 | if os.path.exists(a000): 49 | folders.append(a000) 50 | 51 | for f in os.listdir(opt_dir): 52 | path = os.path.join(opt_dir, f) 53 | if f.startswith('A') and os.path.isdir(path): 54 | folders.append(path) 55 | 56 | self.done.emit(folders) 57 | except Exception as e: 58 | self.fail.emit(str(e)) 59 | 60 | 61 | class OptPage(QWidget): 62 | def __init__(self): 63 | super().__init__() 64 | self.setObjectName("optPage") 65 | self.setup_ui() 66 | self.load_data() 67 | 68 | def setup_ui(self): 69 | layout = QVBoxLayout(self) 70 | layout.setAlignment(Qt.AlignTop) 71 | 72 | title = LargeTitleLabel(self.tr("OPT管理")) 73 | font = QFont() 74 | font.setPointSize(20) 75 | font.setBold(True) 76 | title.setFont(font) 77 | 78 | self.table = TableWidget(self) 79 | self.table.setColumnCount(4) 80 | self.table.setHorizontalHeaderLabels([self.tr("資料夾"), self.tr("類型"), self.tr("版本"), self.tr("操作")]) 81 | self.table.horizontalHeader().setStretchLastSection(True) 82 | 83 | layout.addWidget(title) 84 | layout.addWidget(self.table) 85 | 86 | def load_data(self): 87 | self.thread = QThread() 88 | self.loader = OptLoader() 89 | self.loader.moveToThread(self.thread) 90 | 91 | self.thread.started.connect(self.loader.run) 92 | self.loader.done.connect(self.show_data) 93 | self.loader.fail.connect(self.show_error) 94 | self.loader.done.connect(self.thread.quit) 95 | self.loader.done.connect(self.loader.deleteLater) 96 | self.thread.finished.connect(self.thread.deleteLater) 97 | 98 | self.thread.start() 99 | 100 | def show_data(self, folders): 101 | self.table.setRowCount(len(folders)) 102 | for row, path in enumerate(folders): 103 | name = os.path.basename(path) 104 | conf_path = os.path.join(path, "data.conf") 105 | 106 | self.table.setItem(row, 0, QTableWidgetItem(name)) 107 | if os.path.exists(conf_path): 108 | self.table.setItem(row, 1, QTableWidgetItem(self.tr("官方更新"))) 109 | ver = self.get_version(conf_path) 110 | self.table.setItem(row, 2, QTableWidgetItem(ver)) 111 | else: 112 | self.table.setItem(row, 1, QTableWidgetItem(self.tr("自製更新"))) 113 | self.table.setItem(row, 2, QTableWidgetItem("\\")) 114 | 115 | del_btn = PushButton("刪除", self) 116 | del_btn.clicked.connect(lambda checked, p=path, n=name: self.ask_delete(p, n)) 117 | self.table.setCellWidget(row, 3, del_btn) 118 | 119 | def show_error(self, msg): 120 | QMessageBox.critical(self, self.tr("載入錯誤"), msg) 121 | 122 | def ask_delete(self, path, name): 123 | reply = QMessageBox.warning( 124 | self, 125 | self.tr("確認刪除"), 126 | self.tr(f"你確定要刪除資料夾「{name}」嗎?此操作無法復原!"), 127 | QMessageBox.Yes | QMessageBox.No, 128 | QMessageBox.No 129 | ) 130 | if reply == QMessageBox.Yes: 131 | self.rm_folder(path) 132 | 133 | def rm_folder(self, path): 134 | if os.path.exists(path) and os.path.isdir(path): 135 | try: 136 | shutil.rmtree(path) 137 | QMessageBox.information(self, self.tr("已刪除資料夾"), path) 138 | self.load_data() 139 | except Exception as e: 140 | QMessageBox.critical(self, self.tr("刪除失敗"), e) 141 | 142 | def get_version(self, conf_path): 143 | ver_cfg = configparser.ConfigParser() 144 | try: 145 | with open(conf_path, "r", encoding="utf-8") as f: 146 | content = "[dummy]\n" + f.read() 147 | ver_cfg.read_string(content) 148 | major = ver_cfg.getint("Version", "VerMajor", fallback=0) 149 | minor = ver_cfg.getint("Version", "VerMinor", fallback=0) 150 | release = ver_cfg.getint("Version", "VerRelease", fallback=0) 151 | return f"{major}.{minor}.{release}" 152 | except Exception as e: 153 | QMessageBox.critical(self, self.tr("讀取版本失敗"), e) 154 | return self.tr("未知") 155 | -------------------------------------------------------------------------------- /Source/pages/patcher_page.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout 5 | from PySide6.QtCore import Qt 6 | from PySide6.QtGui import QFont, QIcon 7 | from qfluentwidgets import PrimaryPushButton, HeaderCardWidget, BodyLabel, LargeTitleLabel, IconWidget 8 | import webbrowser 9 | 10 | def get_path(relative_path: str) -> str: 11 | base_path = getattr(sys, '_MEIPASS', os.path.abspath(".")) 12 | return os.path.join(base_path, relative_path) 13 | 14 | class PatcherPage(QWidget): 15 | def __init__(self): 16 | super().__init__() 17 | self.setObjectName("patcherPage") 18 | 19 | layout = QVBoxLayout(self) 20 | layout.setAlignment(Qt.AlignTop) 21 | 22 | titleLabel = LargeTitleLabel(self.tr("補丁管理")) 23 | titleFont = QFont() 24 | titleFont.setPointSize(20) 25 | titleFont.setBold(True) 26 | titleLabel.setFont(titleFont) 27 | titleLabel.setAlignment(Qt.AlignLeft) 28 | 29 | openButton = PrimaryPushButton(QIcon(get_path("img/web.svg")), self.tr("打開 CHUNITHM VERSE Modder")) 30 | openButton.clicked.connect(self.open_manual) 31 | 32 | notice = HeaderCardWidget() 33 | 34 | notice.setTitle(self.tr("注意")) 35 | notice.ErrorIcon = IconWidget(QIcon(get_path("img/error.svg"))) 36 | notice.infoLabel = BodyLabel(self.tr("將打開外部連結")) 37 | 38 | notice.vBoxLayout = QVBoxLayout() 39 | notice.hBoxLayout = QHBoxLayout() 40 | 41 | notice.ErrorIcon.setFixedSize(16, 16) 42 | notice.hBoxLayout.setSpacing(10) 43 | notice.vBoxLayout.setSpacing(16) 44 | 45 | notice.hBoxLayout.setContentsMargins(0, 0, 0, 0) 46 | notice.vBoxLayout.setContentsMargins(0, 0, 0, 0) 47 | 48 | notice.hBoxLayout.addWidget(notice.ErrorIcon) 49 | notice.hBoxLayout.addWidget(notice.infoLabel) 50 | notice.vBoxLayout.addLayout(notice.hBoxLayout) 51 | notice.viewLayout.addLayout(notice.vBoxLayout) 52 | 53 | layout.addWidget(titleLabel) 54 | layout.addSpacing(10) 55 | layout.addWidget(openButton) 56 | layout.addSpacing(10) 57 | layout.addWidget(notice) 58 | 59 | def open_manual(self): 60 | webbrowser.open("https://performai.evilleaker.com/patcher/chusanvrs.html") 61 | -------------------------------------------------------------------------------- /Source/pages/pfm_manual_page.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout 4 | from PySide6.QtCore import Qt 5 | from PySide6.QtGui import QFont, QIcon 6 | from qfluentwidgets import PrimaryPushButton, HeaderCardWidget, BodyLabel, LargeTitleLabel, IconWidget 7 | import webbrowser 8 | 9 | def get_path(relative_path: str) -> str: 10 | base_path = getattr(sys, '_MEIPASS', os.path.abspath(".")) 11 | return os.path.join(base_path, relative_path) 12 | 13 | class PFMManualPage(QWidget): 14 | def __init__(self): 15 | super().__init__() 16 | self.setObjectName("pfmManualPage") 17 | 18 | layout = QVBoxLayout(self) 19 | layout.setAlignment(Qt.AlignTop) 20 | 21 | titleLabel = LargeTitleLabel(self.tr("手冊")) 22 | titleFont = QFont() 23 | titleFont.setPointSize(20) 24 | titleFont.setBold(True) 25 | titleLabel.setFont(titleFont) 26 | titleLabel.setAlignment(Qt.AlignLeft) 27 | 28 | openButton = PrimaryPushButton(QIcon(get_path("img/web.svg")), self.tr("打開 PERFORMAI MANUAL")) 29 | openButton.clicked.connect(self.open_manual) 30 | 31 | notice = HeaderCardWidget() 32 | 33 | notice.setTitle(self.tr("注意")) 34 | notice.ErrorIcon = IconWidget(QIcon(get_path("img/error.svg"))) 35 | notice.infoLabel = BodyLabel(self.tr("將打開外部連結")) 36 | 37 | notice.vBoxLayout = QVBoxLayout() 38 | notice.hBoxLayout = QHBoxLayout() 39 | 40 | notice.ErrorIcon.setFixedSize(16, 16) 41 | notice.hBoxLayout.setSpacing(10) 42 | notice.vBoxLayout.setSpacing(16) 43 | 44 | notice.hBoxLayout.setContentsMargins(0, 0, 0, 0) 45 | notice.vBoxLayout.setContentsMargins(0, 0, 0, 0) 46 | 47 | notice.hBoxLayout.addWidget(notice.ErrorIcon) 48 | notice.hBoxLayout.addWidget(notice.infoLabel) 49 | notice.vBoxLayout.addLayout(notice.hBoxLayout) 50 | notice.viewLayout.addLayout(notice.vBoxLayout) 51 | 52 | layout.addWidget(titleLabel) 53 | layout.addSpacing(10) 54 | layout.addWidget(openButton) 55 | layout.addSpacing(10) 56 | layout.addWidget(notice) 57 | 58 | def open_manual(self): 59 | webbrowser.open("https://performai.evilleaker.com/manual/") -------------------------------------------------------------------------------- /Source/pages/setting_page.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import sys 4 | import webbrowser 5 | import requests 6 | from PySide6.QtWidgets import QWidget, QVBoxLayout, QFileDialog, QHBoxLayout, QMessageBox 7 | from PySide6.QtCore import Qt 8 | from PySide6.QtGui import QFont, QIcon 9 | from qfluentwidgets import ComboBox, StrongBodyLabel, TitleLabel, LineEdit, PrimaryPushButton 10 | 11 | GITHUB_REPO_API = "https://api.github.com/repos/ElliotCHEN37/chunager/releases/latest" 12 | GITHUB_RELEASE_URL = "https://github.com/ElliotCHEN37/chunager/releases" 13 | CURRENT_VERSION = "v1.2.3" 14 | 15 | 16 | def get_path(rel_path: str) -> str: 17 | base = getattr(sys, '_MEIPASS', os.path.abspath(".")) 18 | return os.path.join(base, rel_path) 19 | 20 | 21 | class SettingPage(QWidget): 22 | def __init__(self): 23 | super().__init__() 24 | self.setObjectName("configPage") 25 | self.cfg_path = self.get_cfg_path() 26 | self.cfg = self.load_cfg() 27 | self.check_config() 28 | self.init_ui() 29 | 30 | def init_ui(self): 31 | layout = QVBoxLayout(self) 32 | layout.setAlignment(Qt.AlignTop) 33 | 34 | title = TitleLabel(self.tr("設定")) 35 | font = QFont() 36 | font.setPointSize(20) 37 | font.setBold(True) 38 | title.setFont(font) 39 | layout.addWidget(title) 40 | layout.addSpacing(10) 41 | 42 | layout.addWidget(StrongBodyLabel(self.tr("所有選項即時儲存, 重啟後生效"))) 43 | 44 | layout.addWidget(StrongBodyLabel(self.tr("檢查更新:"))) 45 | check_btn = PrimaryPushButton(self.tr(f"檢查更新 (當前版本: {CURRENT_VERSION})")) 46 | check_btn.clicked.connect(self.check_update) 47 | layout.addWidget(check_btn) 48 | 49 | layout.addSpacing(10) 50 | 51 | layout.addWidget(StrongBodyLabel(self.tr("選擇主題:"))) 52 | self.theme_box = ComboBox(self) 53 | self.theme_box.addItems(["AUTO", "DARK", "LIGHT"]) 54 | self.theme_box.setCurrentText(self.cfg.get("DISPLAY", "theme", fallback="AUTO")) 55 | self.theme_box.currentTextChanged.connect(self.update_theme) 56 | layout.addWidget(self.theme_box) 57 | layout.addSpacing(10) 58 | 59 | layout.addWidget(StrongBodyLabel(self.tr("選擇翻譯檔 (.qm):"))) 60 | qm_layout = QHBoxLayout() 61 | self.qm_path = LineEdit(self) 62 | self.qm_path.setText(self.cfg.get("DISPLAY", "translation_path", fallback="")) 63 | self.qm_path.textChanged.connect(self.update_qm_path) 64 | 65 | qm_btn = PrimaryPushButton(QIcon(get_path("img/folder.svg")), self.tr("選擇檔案")) 66 | qm_btn.clicked.connect(self.pick_qm_path) 67 | 68 | reset_qm_btn = PrimaryPushButton(self.tr("重置翻譯")) 69 | reset_qm_btn.clicked.connect(self.reset_qm_path) 70 | 71 | qm_layout.addWidget(self.qm_path) 72 | qm_layout.addWidget(qm_btn) 73 | qm_layout.addWidget(reset_qm_btn) 74 | layout.addLayout(qm_layout) 75 | 76 | layout.addWidget(StrongBodyLabel(self.tr("選擇 segatools.ini 路徑:"))) 77 | st_layout = QHBoxLayout() 78 | self.st_path = LineEdit(self) 79 | self.st_path.setText(self.cfg.get("GENERAL", "segatools_path", fallback="")) 80 | self.st_path.textChanged.connect(self.update_segatools_path) 81 | 82 | st_btn = PrimaryPushButton(QIcon(get_path("img/folder.svg")), self.tr("選擇檔案")) 83 | st_btn.clicked.connect(self.pick_st_path) 84 | 85 | st_layout.addWidget(self.st_path) 86 | st_layout.addWidget(st_btn) 87 | layout.addLayout(st_layout) 88 | 89 | def check_update(self): 90 | try: 91 | response = requests.get(GITHUB_REPO_API, timeout=5) 92 | if response.status_code == 200: 93 | data = response.json() 94 | latest = data.get("tag_name", CURRENT_VERSION) 95 | release_notes = data.get("body", self.tr("(無更新日誌)")) 96 | 97 | if latest > CURRENT_VERSION: 98 | msg = QMessageBox(self) 99 | msg.setWindowTitle(self.tr("發現新版本")) 100 | msg.setIcon(QMessageBox.Information) 101 | msg.setText(self.tr(f"發現新版本 {latest},是否前往下載?")) 102 | msg.setInformativeText(release_notes) 103 | msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) 104 | msg.setDefaultButton(QMessageBox.Yes) 105 | reply = msg.exec() 106 | 107 | if reply == QMessageBox.Yes: 108 | webbrowser.open(GITHUB_RELEASE_URL) 109 | else: 110 | QMessageBox.information(self, self.tr("已是最新"), self.tr(f"目前已是最新版本 {CURRENT_VERSION}。")) 111 | else: 112 | QMessageBox.warning(self, self.tr("更新失敗"), self.tr("無法取得更新資訊。")) 113 | except Exception as e: 114 | QMessageBox.critical(self, self.tr("錯誤"), self.tr(f"檢查更新時發生錯誤:{str(e)}")) 115 | 116 | def get_cfg_path(self): 117 | app_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath( 118 | os.path.dirname(sys.argv[0])) 119 | return os.path.join(app_dir, "config.ini") 120 | 121 | def load_cfg(self): 122 | cfg = configparser.ConfigParser() 123 | cfg.read(self.cfg_path, encoding="utf-8") 124 | return cfg 125 | 126 | def check_config(self): 127 | modified = False 128 | if not self.cfg.has_section("DISPLAY"): 129 | self.cfg.add_section("DISPLAY") 130 | self.cfg.set("DISPLAY", "theme", "AUTO") 131 | self.cfg.set("DISPLAY", "translation_path", "") 132 | modified = True 133 | 134 | if not self.cfg.has_section("GENERAL"): 135 | self.cfg.add_section("GENERAL") 136 | self.cfg.set("GENERAL", "version", CURRENT_VERSION) 137 | self.cfg.set("GENERAL", "segatools_path", "") 138 | modified = True 139 | else: 140 | current_cfg_version = self.cfg.get("GENERAL", "version", fallback="") 141 | if current_cfg_version != CURRENT_VERSION: 142 | self.cfg.set("GENERAL", "version", CURRENT_VERSION) 143 | modified = True 144 | 145 | if not self.cfg.has_option("GENERAL", "segatools_path"): 146 | self.cfg.set("GENERAL", "segatools_path", "") 147 | modified = True 148 | 149 | if not self.cfg.has_option("DISPLAY", "translation_path"): 150 | self.cfg.set("DISPLAY", "translation_path", "") 151 | modified = True 152 | 153 | if modified: 154 | self.save_cfg() 155 | 156 | def save_cfg(self): 157 | with open(self.cfg_path, "w", encoding="utf-8") as file: 158 | self.cfg.write(file) 159 | 160 | def update_theme(self, text): 161 | self.cfg.set("DISPLAY", "theme", text) 162 | self.save_cfg() 163 | 164 | def update_segatools_path(self, text): 165 | self.cfg.set("GENERAL", "segatools_path", text) 166 | self.save_cfg() 167 | 168 | def pick_st_path(self): 169 | path, _ = QFileDialog.getOpenFileName(self, self.tr("選擇 segatools.ini"), "", self.tr("SEGATOOLS配置檔 (segatools.ini)")) 170 | if path: 171 | self.st_path.setText(path) 172 | 173 | def update_qm_path(self, text): 174 | self.cfg.set("DISPLAY", "translation_path", text) 175 | self.save_cfg() 176 | 177 | def pick_qm_path(self): 178 | path, _ = QFileDialog.getOpenFileName(self, self.tr("選擇翻譯檔"), "", self.tr("QT翻譯檔案 (*.qm)")) 179 | if path: 180 | self.qm_path.setText(path) 181 | 182 | def reset_qm_path(self): 183 | self.qm_path.clear() 184 | self.cfg.set("DISPLAY", "translation_path", "") 185 | self.save_cfg() 186 | -------------------------------------------------------------------------------- /Source/pages/unlocker_page.py: -------------------------------------------------------------------------------- 1 | from qfluentwidgets import PrimaryPushButton, HeaderCardWidget, BodyLabel, LargeTitleLabel, IconWidget 2 | from PySide6.QtWidgets import QWidget, QVBoxLayout, QMessageBox, QHBoxLayout 3 | from PySide6.QtCore import Qt 4 | from PySide6.QtGui import QFont, QIcon 5 | import subprocess 6 | import os 7 | import sys 8 | 9 | def get_path(relative_path: str) -> str: 10 | base_path = getattr(sys, '_MEIPASS', os.path.abspath(".")) 11 | return os.path.join(base_path, relative_path) 12 | 13 | class UnlockerPage(QWidget): 14 | def __init__(self): 15 | super().__init__() 16 | self.setObjectName("unlockerPage") 17 | 18 | layout = QVBoxLayout(self) 19 | layout.setAlignment(Qt.AlignTop) 20 | 21 | titleLabel = LargeTitleLabel(self.tr("解鎖工具")) 22 | titleFont = QFont() 23 | titleFont.setPointSize(20) 24 | titleFont.setBold(True) 25 | titleLabel.setFont(titleFont) 26 | titleLabel.setAlignment(Qt.AlignLeft) 27 | 28 | unlockerButton = PrimaryPushButton(QIcon(get_path("img/launch.svg")), self.tr("啟動 Unlocker")) 29 | unlockerButton.clicked.connect(self.launch_unlocker) 30 | 31 | notice = HeaderCardWidget() 32 | 33 | notice.setTitle(self.tr("警告")) 34 | notice.ErrorIcon = IconWidget(QIcon(get_path("img/error.svg"))) 35 | notice.infoLabel = BodyLabel(self.tr("Unlocker 並非由本人編寫,如有疑慮請勿使用")) 36 | 37 | notice.vBoxLayout = QVBoxLayout() 38 | notice.hBoxLayout = QHBoxLayout() 39 | 40 | notice.ErrorIcon.setFixedSize(16, 16) 41 | notice.hBoxLayout.setSpacing(10) 42 | notice.vBoxLayout.setSpacing(16) 43 | 44 | notice.hBoxLayout.setContentsMargins(0, 0, 0, 0) 45 | notice.vBoxLayout.setContentsMargins(0, 0, 0, 0) 46 | 47 | notice.hBoxLayout.addWidget(notice.ErrorIcon) 48 | notice.hBoxLayout.addWidget(notice.infoLabel) 49 | notice.vBoxLayout.addLayout(notice.hBoxLayout) 50 | notice.viewLayout.addLayout(notice.vBoxLayout) 51 | 52 | layout.addWidget(titleLabel) 53 | layout.addSpacing(10) 54 | layout.addWidget(unlockerButton) 55 | layout.addSpacing(10) 56 | layout.addWidget(notice) 57 | 58 | def launch_unlocker(self): 59 | unlocker_path = os.path.abspath(get_path("extra/unlocker.exe")) 60 | if os.path.exists(unlocker_path): 61 | try: 62 | subprocess.Popen(unlocker_path, shell=True) 63 | except Exception as e: 64 | QMessageBox.critical(self, self.tr("錯誤"), self.tr(f"啟動 Unlocker 時發生錯誤:\n{str(e)}")) 65 | else: 66 | QMessageBox.warning(self, self.tr("找不到檔案"), self.tr("找不到 unlocker.exe,請確認路徑是否正確。")) 67 | -------------------------------------------------------------------------------- /Source/version.rc: -------------------------------------------------------------------------------- 1 | VSVersionInfo( 2 | ffi=FixedFileInfo( 3 | filevers=(1, 0, 0, 0), 4 | prodvers=(1, 2, 3, 0), 5 | mask=0x3f, 6 | flags=0x0, 7 | OS=0x40004, 8 | fileType=0x1, 9 | subtype=0x0, 10 | date=(0, 0) 11 | ), 12 | kids=[ 13 | StringFileInfo( 14 | [ 15 | StringTable( 16 | u'040904B0', 17 | [StringStruct(u'CompanyName', u'ElliotCHEN37'), 18 | StringStruct(u'FileDescription', u'All In One CHUNITHM HDD Manager'), 19 | StringStruct(u'FileVersion', u'1.0'), 20 | StringStruct(u'InternalName', u'chunager'), 21 | StringStruct(u'LegalCopyright', u'\xa9 ElliotCHEN37 2025 Licensed under MIT License'), 22 | StringStruct(u'OriginalFilename', u'chunager.exe'), 23 | StringStruct(u'ProductName', u'Chunager'), 24 | StringStruct(u'ProductVersion', u'1.2.3')]) 25 | ]), 26 | VarFileInfo([VarStruct(u'Translation', [1028, 1200])]) 27 | ] 28 | ) -------------------------------------------------------------------------------- /Source/zh-CN.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElliotCHEN37/chunager/a49287eca518e9f77136856dc41c665e262f8a3e/Source/zh-CN.qm -------------------------------------------------------------------------------- /Source/zh-CN.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AboutPage 6 | 7 | 8 | CHUNAGER 是一款針對 CHUNITHM HDD (SDHD 2.30.00 VERSE) 設計的管理工具 9 | 提供歌曲管理、角色管理、OPT 管理、解鎖器、補丁管理等功能 10 | 協助您更輕鬆地整理與優化遊戲內容 11 | CHUNAGER 是一款针对 CHUNITHM HDD (SDHD 2.30.00 VERSE) 设计的管理工具 12 | 提供歌曲管理、角色管理、OPT 管理、解锁器、补丁管理等功能 13 | 协助您更轻松地整理与优化游戏内容 14 | 15 | 16 | 17 | 作者:Elliot<br>版本:{self.current_version}<br>GitHub:<a href="https://github.com/ElliotCHEN37/chunager">https://github.com/ElliotCHEN37/chunager</a> 18 | 作者:Elliot<br>版本:{self.current_version><br>GitHub:<a href="https://github.com/ElliotCHEN37/chunager">https://github.com/ElliotCHEN37/chunager</a> 19 | 20 | 21 | 22 | <br>免責聲明:<br>本程式為個人開發,與任何和 Evil Leaker、SEGA、CHUNITHM 官方團隊或相關人物及事項無任何關係。<br>請遵守當地法律使用。<br>本程式使用 MIT 授權,詳見<a href="https://raw.githubusercontent.com/ElliotCHEN37/chunager/refs/heads/main/LICENSE.txt">許可證</a>。<br>使用本程式所造成的一切後果,作者不承擔任何責任。 23 | <br>免责声明:<br>本程式为个人开发,与任何和 Evil Leaker、SEGA、CHUNITHM 官方团队或相关人物及事项无任何关系。 <br>请遵守当地法律使用。 <br>本程式使用 MIT 授权,详见<a href="https://raw.githubusercontent.com/ElliotCHEN37/chunager/refs/heads/main/LICENSE.txt">许可证</a>。 <br>使用本程式所造成的一切后果,作者不承担任何责任。 24 | 25 | 26 | 27 | CharaSearchThread 28 | 29 | 30 | 檢查索引 31 | 检查索引 32 | 33 | 34 | 35 | 使用現存索引 36 | 使用现存索引 37 | 38 | 39 | 40 | 索引讀取失敗 41 | 索引读取失败 42 | 43 | 44 | 45 | 掃描XML檔案 46 | 扫描XML档案 47 | 48 | 49 | 50 | 找不到XML檔案 51 | 找不到XML档案 52 | 53 | 54 | 55 | 處理中: {data['chara_name']} ({idx + 1}/{total}) 56 | 处理中: {data['chara_name']} ({idx + 1}/{total}) 57 | 58 | 59 | 60 | XML檔案解析失敗: {xml_path}, 錯誤: {e} 61 | XML档案解析失败: {xml_path}, 错误: {e} 62 | 63 | 64 | 65 | 儲存索引 66 | 储存索引 67 | 68 | 69 | 70 | 寫入索引錯誤 71 | 写入索引错误 72 | 73 | 74 | 75 | 完成 76 | 完成 77 | 78 | 79 | 80 | 搜尋失敗 81 | 搜寻失败 82 | 83 | 84 | 85 | CharacterPage 86 | 87 | 88 | 角色管理 89 | 角色管理 90 | 91 | 92 | 93 | 正在搜尋資料... 94 | 正在搜寻资料... 95 | 96 | 97 | 98 | 99 | 索引狀態:尚未建立 100 | 索引狀態:尚未建立 101 | 102 | 103 | 104 | 搜尋角色名稱... 105 | 搜寻角色名称... 106 | 107 | 108 | 109 | 搜尋 110 | 搜索 111 | 112 | 113 | 114 | 重置 115 | 重置 116 | 117 | 118 | 119 | 重建索引 120 | 重建索引 121 | 122 | 123 | 124 | 重新載入 125 | 重新载入 126 | 127 | 128 | 129 | 圖像 130 | 图像 131 | 132 | 133 | 134 | ID 135 | ID 136 | 137 | 138 | 139 | 名稱 140 | 名称 141 | 142 | 143 | 144 | 出處 145 | 出处 146 | 147 | 148 | 149 | 繪師 150 | 绘师 151 | 152 | 153 | 154 | 等級獎勵 155 | 等级奖励 156 | 157 | 158 | 159 | 提取圖像 160 | 提取图像 161 | 162 | 163 | 164 | 165 | 索引狀態:最後更新於 {timestamp} 166 | 索引状态:最后更新于 {timestamp} 167 | 168 | 169 | 170 | 載入中... 171 | 加载中... 172 | 173 | 174 | 175 | 提取 176 | 提取 177 | 178 | 179 | 180 | 選擇目標資料夾 181 | 选择目标资料夹 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 提示 190 | 提示 191 | 192 | 193 | 194 | 195 | 196 | 請等待當前操作完成 197 | 请等待当前操作完成 198 | 199 | 200 | 201 | 202 | 正在搜索中,請稍候 203 | 正在搜索中,请稍候 204 | 205 | 206 | 207 | 正在重新建立索引... 208 | 正在重新建立索引... 209 | 210 | 211 | 212 | 索引狀態:重新建立中... 213 | 索引状态:重新建立中... 214 | 215 | 216 | 217 | 刪除索引失敗 218 | 删除索引失败 219 | 220 | 221 | 222 | 完成 223 | 完成 224 | 225 | 226 | 227 | 已成功重新載入索引。 228 | 已成功重新载入索引。 229 | 230 | 231 | 232 | 載入失敗 233 | 载入失败 234 | 235 | 236 | 237 | 成功 238 | 成功 239 | 240 | 241 | 242 | 操作失敗 243 | 操作失败 244 | 245 | 246 | 247 | FileOperationThread 248 | 249 | 250 | 未找到角色圖像: {img_path} 251 | 未找到角色图像: {img_path} 252 | 253 | 254 | 255 | 256 | 已複製: {target} 257 | 已复制: {target} 258 | 259 | 260 | 261 | 複製失敗: {str(e)} 262 | 复制失败: {str(e)} 263 | 264 | 265 | 266 | 267 | 已刪除索引, 準備重建 268 | 已删除索引, 准备重建 269 | 270 | 271 | 272 | 無法刪除索引: {str(e)} 273 | 无法删除索引: {str(e)} 274 | 275 | 276 | 277 | 278 | 索引不存在, 請先建立 279 | 索引不存在, 请先建立 280 | 281 | 282 | 283 | 成功重載|{json.dumps(chara_data)}|{timestamp} 284 | 成功重载|{json.dumps(chara_data)}|{timestamp} 285 | 286 | 287 | 288 | 索引讀取失敗: {str(e)} 289 | 索引读取失败: {str(e)} 290 | 291 | 292 | 293 | 封面不存在: {img_path} 294 | 封面不存在: {img_path} 295 | 296 | 297 | 298 | HDDPage 299 | 300 | 301 | 下載 302 | 下载 303 | 304 | 305 | 306 | 打開 Evil Leaker Data Center 307 | 打开 Evil Leaker Data Center 308 | 309 | 310 | 311 | 注意 312 | 注意 313 | 314 | 315 | 316 | 注意控制台輸出 317 | 注意控制台输出 318 | 319 | 320 | 321 | 已加密 322 | 已加密 323 | 324 | 325 | 326 | 使用Base64解密兩次即可 327 | 使用Base64解密两次即可 328 | 329 | 330 | 331 | HomePage 332 | 333 | 334 | 歡迎使用 CHUNAGER 335 | 欢迎使用 CHUNAGER 336 | 337 | 338 | 339 | CHUNAGER 是一款針對 CHUNITHM HDD (SDHD 2.30.00 VERSE)設計的管理工具 340 | 提供歌曲管理、角色管理、OPT 管理、解鎖器、補丁管理等功能 341 | 協助您更輕鬆地整理與優化遊戲內容 342 | CHUNAGER 是一款针对 CHUNITHM HDD (SDHD 2.30.00 VERSE)设计的管理工具 343 | 提供歌曲管理、角色管理、OPT 管理、解锁器、补丁管理等功能 344 | 协助您更轻松地整理与优化游戏内容 345 | 346 | 347 | 348 | 作者:Elliot 349 | 作者:Elliot 350 | 351 | 352 | 353 | MainWindow 354 | 355 | 356 | 首頁 357 | 首页 358 | 359 | 360 | 361 | OPT 362 | OPT 363 | 364 | 365 | 366 | 樂曲 367 | 乐曲 368 | 369 | 370 | 371 | 角色 372 | 角色 373 | 374 | 375 | 376 | 解鎖 377 | 解锁 378 | 379 | 380 | 381 | 補丁 382 | 补丁 383 | 384 | 385 | 386 | 下載 387 | 下载 388 | 389 | 390 | 391 | 手冊 392 | 手册 393 | 394 | 395 | 396 | 設定 397 | 设置 398 | 399 | 400 | 401 | 關於 402 | 关于 403 | 404 | 405 | 406 | MusicPage 407 | 408 | 409 | 樂曲管理 410 | 乐曲管理 411 | 412 | 413 | 414 | 正在搜尋資料... 415 | 正在搜寻资料... 416 | 417 | 418 | 419 | 420 | 索引狀態:尚未建立 421 | 索引状态:尚未建立 422 | 423 | 424 | 425 | 搜尋音樂名稱... 426 | 搜寻音乐名称... 427 | 428 | 429 | 430 | 搜尋 431 | 搜索 432 | 433 | 434 | 435 | 重置 436 | 重置 437 | 438 | 439 | 440 | 重建索引 441 | 重建索引 442 | 443 | 444 | 445 | 重新載入 446 | 重新载入 447 | 448 | 449 | 450 | 封面 451 | 封面 452 | 453 | 454 | 455 | 音樂ID 456 | ID 457 | 458 | 459 | 460 | 名稱 461 | 名称 462 | 463 | 464 | 465 | 曲師 466 | 曲师 467 | 468 | 469 | 470 | 分類 471 | 分类 472 | 473 | 474 | 475 | 日期 476 | 日期 477 | 478 | 479 | 480 | 可用難度 481 | 可用难度 482 | 483 | 484 | 485 | 提取封面 486 | 提取封面 487 | 488 | 489 | 490 | 491 | 索引狀態:最後更新於 {timestamp} 492 | 索引状态:最后更新于 {timestamp} 493 | 494 | 495 | 496 | 載入中... 497 | 加载中... 498 | 499 | 500 | 501 | 提取 502 | 提取 503 | 504 | 505 | 506 | 選擇目標資料夾 507 | 选择目标资料夹 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 提示 516 | 提示 517 | 518 | 519 | 520 | 521 | 522 | 請等待當前操作完成 523 | 请等待当前操作完成 524 | 525 | 526 | 527 | 528 | 正在搜索中,請稍候 529 | 正在搜索中,请稍候 530 | 531 | 532 | 533 | 正在重新建立索引... 534 | 正在重新建立索引... 535 | 536 | 537 | 538 | 索引狀態:重新建立中... 539 | 索引状态:重新建立中... 540 | 541 | 542 | 543 | 刪除索引失敗 544 | 删除索引失败 545 | 546 | 547 | 548 | 完成 549 | 完成 550 | 551 | 552 | 553 | 已成功重新載入索引。 554 | 已成功重新载入索引。 555 | 556 | 557 | 558 | 載入失敗 559 | 载入失败 560 | 561 | 562 | 563 | 成功 564 | 成功 565 | 566 | 567 | 568 | 操作失敗 569 | 操作失败 570 | 571 | 572 | 573 | MusicSearchThread 574 | 575 | 576 | 檢查索引 577 | 检查索引 578 | 579 | 580 | 581 | 使用現存索引 582 | 使用现存索引 583 | 584 | 585 | 586 | 索引讀取失敗 587 | 索引读取失败 588 | 589 | 590 | 591 | 掃描XML檔案中 592 | 扫描XML档案 593 | 594 | 595 | 596 | 未找到XML檔案 597 | 找不到XML档案 598 | 599 | 600 | 601 | 處理中: {data['music_name']} ({idx + 1}/{total}) 602 | 处理中: {data['music_name']} ({idx + 1}/{total}) 603 | 604 | 605 | 606 | XML處理失敗: {xml_path}, error: {e} 607 | XML档案解析失败: {xml_path}, 错误: {e} 608 | 609 | 610 | 611 | 儲存索引 612 | 储存索引 613 | 614 | 615 | 616 | 寫入索引失敗 617 | 寫入XML失敗 618 | 写入索引错误 619 | 620 | 621 | 622 | 已完成 623 | 624 | 625 | 626 | 627 | 搜尋失敗 628 | 搜寻失败 629 | 630 | 631 | 632 | OptPage 633 | 634 | 635 | OPT管理 636 | OPT管理 637 | 638 | 639 | 640 | 資料夾 641 | 资料夹 642 | 643 | 644 | 645 | 類型 646 | 类型 647 | 648 | 649 | 650 | 版本 651 | 版本 652 | 653 | 654 | 655 | 操作 656 | 操作 657 | 658 | 659 | 660 | 官方更新 661 | 官方更新 662 | 663 | 664 | 665 | 自製更新 666 | 自制更新 667 | 668 | 669 | 670 | 載入錯誤 671 | 载入错误 672 | 673 | 674 | 675 | 確認刪除 676 | 确认删除 677 | 678 | 679 | 680 | 你確定要刪除資料夾「{name}」嗎?此操作無法復原! 681 | 你确定要删除资料夹「{name}」吗?此操作无法复原! 682 | 683 | 684 | 685 | 已刪除資料夾 686 | 已删除资料夹 687 | 688 | 689 | 690 | 刪除失敗 691 | 删除失败 692 | 693 | 694 | 695 | 讀取版本失敗 696 | 读取版本失败 697 | 698 | 699 | 700 | 未知 701 | 未知 702 | 703 | 704 | 705 | PFMManualPage 706 | 707 | 708 | 手冊 709 | 手册 710 | 711 | 712 | 713 | 打開 PERFORMAI MANUAL 714 | 打开 PERFORMAI MANUAL 715 | 716 | 717 | 718 | 注意 719 | 注意 720 | 721 | 722 | 723 | 注意控制台輸出 724 | 注意控制台输出 725 | 726 | 727 | 728 | 已加密 729 | 已加密 730 | 731 | 732 | 733 | 使用Base64解密兩次即可 734 | 使用Base64解密两次即可 735 | 736 | 737 | 738 | PatcherPage 739 | 740 | 741 | 補丁管理 742 | 补丁管理 743 | 744 | 745 | 746 | 打開 CHUNITHM VERSE Modder 747 | 打开 CHUNITHM VERSE Modder 748 | 749 | 750 | 751 | 注意 752 | 注意 753 | 754 | 755 | 756 | 注意控制台輸出 757 | 注意控制台输出 758 | 759 | 760 | 761 | 已加密 762 | 已加密 763 | 764 | 765 | 766 | 使用Base64解密兩次即可 767 | 使用Base64解密两次即可 768 | 769 | 770 | 771 | SettingPage 772 | 773 | 774 | 設定 775 | 设置 776 | 777 | 778 | 779 | 所有選項即時儲存, 重啟後生效 780 | 所有选项即时储存, 重启后生效 781 | 782 | 783 | 784 | 檢查更新: 785 | 检查更新: 786 | 787 | 788 | 789 | 檢查更新 (當前版本: {CURRENT_VERSION}) 790 | 检查更新 (当前版本: {CURRENT_VERSION}) 791 | 792 | 793 | 794 | 選擇主題: 795 | 选择主题: 796 | 797 | 798 | 799 | 選擇翻譯檔 (.qm): 800 | 选择翻译档 (.qm): 801 | 802 | 803 | 804 | 805 | 選擇檔案 806 | 选择档案 807 | 808 | 809 | 810 | 重置翻譯 811 | 重置翻译 812 | 813 | 814 | 815 | 選擇 segatools.ini 路徑: 816 | 选择 segatools.ini 路径: 817 | 818 | 819 | 820 | (無更新日誌) 821 | (无更新日志) 822 | 823 | 824 | 825 | 發現新版本 826 | 发现新版本 827 | 828 | 829 | 830 | 發現新版本 {latest},是否前往下載? 831 | 发现新版本 {latest},是否前往下载? 832 | 833 | 834 | 835 | 已是最新 836 | 已是最新 837 | 838 | 839 | 840 | 目前已是最新版本 {CURRENT_VERSION}。 841 | 目前已是最新版本 {CURRENT_VERSION}。 842 | 843 | 844 | 845 | 更新失敗 846 | 更新失败 847 | 848 | 849 | 850 | 無法取得更新資訊。 851 | 无法取得更新资讯。 852 | 853 | 854 | 855 | 錯誤 856 | 错误 857 | 858 | 859 | 860 | 檢查更新時發生錯誤:{str(e)} 861 | 检查更新时发生错误:{str(e)} 862 | 863 | 864 | 865 | 選擇 segatools.ini 866 | 选择 segatools.ini 867 | 868 | 869 | 870 | SEGATOOLS配置檔 (segatools.ini) 871 | SEGATOOLS配置档 (segatools.ini) 872 | 873 | 874 | 875 | 選擇翻譯檔 876 | 选择翻译档 877 | 878 | 879 | 880 | QT翻譯檔案 (*.qm) 881 | QT翻译档案 (*.qm) 882 | 883 | 884 | 翻譯重置 885 | 翻译重置 886 | 887 | 888 | 已重置翻譯檔設定,下次啟動將恢復為預設語言。 889 | 已重置翻译档设定,下次启动将恢复为预设语言。 890 | 891 | 892 | 893 | UnlockerPage 894 | 895 | 896 | 解鎖工具 897 | 解锁工具 898 | 899 | 900 | 901 | 啟動 Unlocker 902 | 启动 Unlocker 903 | 904 | 905 | 906 | 警告 907 | 警告 908 | 909 | 910 | 911 | Unlocker 並非由本人編寫,如有疑慮請勿使用 912 | Unlocker 并非由本人编写,如有疑虑请勿使用 913 | 914 | 915 | 916 | 錯誤 917 | 错误 918 | 919 | 920 | 921 | 啟動 Unlocker 時發生錯誤: 922 | {str(e)} 923 | 启动 Unlocker 时发生错误: 924 | {str(e)} 925 | 926 | 927 | 928 | 找不到檔案 929 | 找不到档案 930 | 931 | 932 | 933 | 找不到 unlocker.exe,請確認路徑是否正確。 934 | 找不到 unlocker.exe,请确认路径是否正确。 935 | 936 | 937 | 938 | -------------------------------------------------------------------------------- /sp_thk.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Elliot", 4 | "donate": "名單更新時間, 2025/5/25/ 00:09 UTC+8" 5 | } 6 | ] --------------------------------------------------------------------------------