├── .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 | ]
--------------------------------------------------------------------------------