├── .directory ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── config └── myrient_urls.yaml ├── core ├── __init__.py ├── app_controller.py ├── config_manager.py ├── download_manager.py ├── processing_manager.py ├── ps3_fileprocessor.py ├── queue_manager.py ├── settings.py └── state_manager.py ├── gui ├── __init__.py ├── main_window.py ├── output_window.py └── overwrite_dialog.py ├── myrientDownloaderGUI.py ├── requirements.txt └── threads ├── __init__.py ├── download_threads.py └── processing_threads.py /.directory: -------------------------------------------------------------------------------- 1 | [Dolphin] 2 | SortOrder=1 3 | SortRole=modificationtime 4 | Timestamp=2024,5,15,5,38,26.263 5 | Version=4 6 | ViewMode=1 7 | VisibleRoles=Details_text,Details_size,Details_modificationtime,Details_type,CustomizedDetails 8 | 9 | [Settings] 10 | HiddenFilesShown=true 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore json 2 | *.json 3 | 4 | # ignore ini, txt 5 | *.ini 6 | *.txt 7 | 8 | # ignore .vscode, .directory 9 | .vscode/ 10 | .directory 11 | 12 | # ignore logs 13 | log/ 14 | 15 | # ignore pyinstaller files 16 | build/ 17 | dist/ 18 | *.spec 19 | 20 | # ignore binaries 21 | ps3dec* 22 | extractps3iso* 23 | splitps3iso* 24 | 25 | # ignore game files 26 | *.zip 27 | *.iso 28 | *.iso.* 29 | *.pkg 30 | *.pkg.* 31 | *.rap 32 | 33 | # ignore game folders 34 | MyrientDownloads/ 35 | processing/ 36 | 37 | # ignore __pycache__ 38 | __pycache__/ 39 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python Debugger: Current File", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Myrient-Downloader-GUI 2 | Tool to download software from Myrient, written in Python 3 | ![image](https://github.com/hadobedo/Myrient-Downloader-GUI/assets/34556645/5d499a6b-b53e-4a09-bafe-785e01261973) 4 | 5 | Features: 6 | - Downloads software over HTTP from [the Myrient Video Game Preservationists](https://myrient.erista.me) 7 | - Options to decrypts and split downloaded software for use on consoles, storage on FAT32 devices 8 | - User-friendly setup (prompts users to download required binaries automatically) 9 | - Cross platform (macOS = ?) 10 | 11 | Usage: 12 | 1. [Download the latest release for your platform](https://github.com/hadobedo/Myrient-Downloader-GUI/releases/latest) 13 | 2. Run the exe 14 | 3. (If on Windows) On first run the software will prompt the user to automatically download [`PS3Dec`](https://github.com/Redrrx/ps3dec) & [`ps3iso-utils`](https://github.com/bucanero/ps3iso-utils) for their platform. 15 | 16 | To run the script as a .py file: 17 | 1. Clone repo & cd into folder `git clone https://github.com/hadobedo/Myrient-Downloader-GUI/ && cd Myrient-Downloader-GUI/` 18 | 2. Install the requirements (if on Arch Linux see below) `pip install -r requirements.txt` 19 | 3. Run the script `python3 ./myrientDownloaderGUI.py` 20 | 21 | Requirements on Arch Linux can be installed like so: 22 | `sudo pacman -S python-aiohttp python-beautifulsoup4 python-pyqt5 python-requests` 23 | 24 | PS3Dec is available from the AUR as [`ps3dec-git`](https://aur.archlinux.org/packages/ps3dec-git) 25 | ps3iso-utils is available from the AUR as [`ps3iso-utils-git`](https://aur.archlinux.org/packages/ps3iso-utils-git) 26 | 27 | Credits/Binaries used: 28 | - [Myrient Video Game Preservationists](https://myrient.erista.me) [[Donation Link]](https://myrient.erista.me/donate/) 29 | - [Redrrx's PS3Dec rewrite in Rust](https://github.com/Redrrx/ps3dec) 30 | - [gotbletu's `ps3-split-iso` and `ps3-split-pkg` scripts 'ported'/adapted into Python)](https://github.com/gotbletu/shownotes/blob/master/ps3_split_merge_games.md) 31 | - [bucanero's ps3iso-utils](https://github.com/bucanero/ps3iso-utils) (use their `extractps3iso`) 32 | - gpt-4 :) 33 | 34 | TODO: 35 | - add support for more software 36 | - add support for user specified software 37 | - clean up code 38 | 39 | screenshots: 40 | ![image](https://github.com/hadobedo/Myrient-Downloader-GUI/assets/34556645/4447999e-d90f-409b-aab5-e68416e54637) 41 | ![image](https://github.com/hadobedo/Myrient-Downloader-GUI/assets/34556645/3d2af247-1eeb-4821-993f-715c21e14084) 42 | -------------------------------------------------------------------------------- /config/myrient_urls.yaml: -------------------------------------------------------------------------------- 1 | # Default configuration for Myrient URLs 2 | # You can add more platforms here as needed! 3 | 4 | # EXISTING FLAGS: 5 | # - show_ps3dec: Show PS3 decryption options in GUI 6 | # - show_pkg_split: Show PS3/PSN PKG split options in GUI (*.66600, *.66601, etc.) 7 | 8 | ps3: 9 | tab_name: "PS3 (ISO)" 10 | url: "https://dl10.myrient.erista.me/files/Redump/Sony - PlayStation 3" 11 | dkeys: "https://dl10.myrient.erista.me/files/Redump/Sony - PlayStation 3 - Disc Keys TXT/" 12 | show_ps3dec: true 13 | 14 | psn: 15 | tab_name: "PS3/PSN (PKG)" 16 | url: "https://dl8.myrient.erista.me/files/No-Intro/Sony%20-%20PlayStation%203%20(PSN)%20(Content)" 17 | show_pkg_split: true 18 | 19 | ps2: 20 | tab_name: "PS2 (ISO)" 21 | url: "https://myrient.erista.me/files/Redump/Sony%20-%20PlayStation%202/" 22 | 23 | psx: 24 | tab_name: "PSX (ISO)" 25 | url: "https://myrient.erista.me/files/Redump/Sony%20-%20PlayStation/" 26 | 27 | psp: 28 | tab_name: "PSP (ISO)" 29 | url: "https://myrient.erista.me/files/Redump/Sony%20-%20PlayStation%20Portable/" 30 | 31 | gamecube: 32 | tab_name: "GameCube (RVZ)" 33 | url: "https://myrient.erista.me/files/Redump/Nintendo%20-%20GameCube%20-%20NKit%20RVZ%20[zstd-19-128k]/" 34 | 35 | wii: 36 | tab_name: "Wii (RVZ)" 37 | url: "https://myrient.erista.me/files/Redump/Nintendo%20-%20Wii%20-%20NKit%20RVZ%20[zstd-19-128k]/" 38 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- 1 | # Empty init file to make the directory a Python package 2 | -------------------------------------------------------------------------------- /core/app_controller.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from typing import List 4 | from PyQt5.QtCore import QObject, pyqtSignal, Qt 5 | from PyQt5.QtWidgets import QMessageBox 6 | from PyQt5.QtGui import QColor, QBrush 7 | 8 | from core.queue_manager import QueueManager 9 | from core.download_manager import DownloadManager 10 | from core.processing_manager import ProcessingManager 11 | from core.state_manager import StateManager 12 | 13 | 14 | class AppController(QObject): 15 | """Main application controller that coordinates all operations.""" 16 | 17 | # Signals for GUI updates 18 | progress_updated = pyqtSignal(int) 19 | speed_updated = pyqtSignal(str) 20 | eta_updated = pyqtSignal(str) 21 | size_updated = pyqtSignal(str) 22 | status_updated = pyqtSignal(str) 23 | queue_updated = pyqtSignal() 24 | operation_complete = pyqtSignal() 25 | operation_paused = pyqtSignal() 26 | error_occurred = pyqtSignal(str) 27 | 28 | def __init__(self, settings_manager, config_manager, output_window, parent=None): 29 | super().__init__(parent) 30 | self.settings_manager = settings_manager 31 | self.config_manager = config_manager 32 | self.output_window = output_window 33 | 34 | # Initialize managers 35 | self.queue_manager = QueueManager() 36 | self.download_manager = DownloadManager(settings_manager, config_manager, output_window, self) 37 | self.processing_manager = ProcessingManager(settings_manager, config_manager, output_window, self) 38 | 39 | # Application state 40 | self.is_paused = False 41 | self.current_operation = None 42 | self.current_item = None 43 | self.current_position = None 44 | self.current_file_path = None 45 | self.current_queue_item = None 46 | self.processed_items = 0 47 | self.total_items = 0 48 | 49 | # Connect manager signals 50 | self._connect_signals() 51 | 52 | def _connect_signals(self): 53 | """Connect signals from managers to controller.""" 54 | # Queue manager signals 55 | self.queue_manager.queue_updated.connect(self.queue_updated.emit) 56 | 57 | # Download manager signals 58 | self.download_manager.progress_updated.connect(self.progress_updated.emit) 59 | self.download_manager.speed_updated.connect(self.speed_updated.emit) 60 | self.download_manager.eta_updated.connect(self.eta_updated.emit) 61 | self.download_manager.size_updated.connect(self.size_updated.emit) 62 | self.download_manager.download_paused.connect(self._on_download_paused) 63 | self.download_manager.error_occurred.connect(self.error_occurred.emit) 64 | 65 | # Processing manager signals 66 | self.processing_manager.progress_updated.connect(self.progress_updated.emit) 67 | self.processing_manager.status_updated.connect(self._on_status_updated) 68 | self.processing_manager.processing_paused.connect(self._on_processing_paused) 69 | self.processing_manager.error_occurred.connect(self.error_occurred.emit) 70 | 71 | def load_queue(self): 72 | """Load the download queue.""" 73 | return self.queue_manager.load_queue() 74 | 75 | def save_queue(self, queue_list_widget): 76 | """Save the download queue.""" 77 | self.queue_manager.save_queue(queue_list_widget) 78 | 79 | def add_to_queue(self, selected_items, current_platform, platforms, queue_list_widget): 80 | """Add selected items to the download queue.""" 81 | added_count = 0 82 | platform_name = current_platform.upper() 83 | 84 | for item in selected_items: 85 | item_text = item.text() 86 | formatted_text = f"({platform_name}) {item_text}" 87 | 88 | if self.queue_manager.add_to_queue(formatted_text, queue_list_widget, platforms): 89 | added_count += 1 90 | 91 | if added_count > 0: 92 | self.save_queue(queue_list_widget) 93 | 94 | return added_count 95 | 96 | def remove_from_queue(self, selected_items, queue_list_widget): 97 | """Remove selected items from the download queue.""" 98 | items_to_remove = [] 99 | 100 | for item in selected_items: 101 | # Get the original name from the item's data 102 | original_name = item.data(Qt.UserRole) if item.data(Qt.UserRole) else item.text() 103 | 104 | # Check if this is a download in progress or paused 105 | is_current_download = (self.current_item == original_name and 106 | self.current_file_path and 107 | os.path.exists(self.current_file_path)) 108 | 109 | if is_current_download: 110 | # Handle removal of current download with confirmation 111 | if self._handle_current_download_removal(item, original_name): 112 | items_to_remove.append(item) 113 | else: 114 | items_to_remove.append(item) 115 | 116 | # Remove all marked items 117 | removed_count = self.queue_manager.remove_from_queue(items_to_remove, queue_list_widget) 118 | 119 | if removed_count > 0: 120 | self.save_queue(queue_list_widget) 121 | 122 | return removed_count 123 | 124 | def start_processing(self, queue_list_widget, settings): 125 | """Start processing the download queue.""" 126 | self.is_paused = False 127 | 128 | # Check if resuming 129 | is_resuming_session = bool(self.current_item) 130 | 131 | if not is_resuming_session: 132 | # Fresh start 133 | self.processed_items = 0 134 | self.total_items = queue_list_widget.count() 135 | 136 | # Reset overwrite choices for new download session 137 | self.download_manager.reset_overwrite_choices() 138 | self.processing_manager.reset_overwrite_choices() 139 | 140 | # Process queue until empty 141 | while queue_list_widget.count() > 0 and not self.is_paused: 142 | # Get the first item in the queue 143 | current_queue_item = queue_list_widget.item(0) 144 | item_original_name = current_queue_item.data(Qt.UserRole) if current_queue_item.data(Qt.UserRole) else current_queue_item.text() 145 | 146 | if not is_resuming_session or self.current_item != item_original_name: 147 | self.processed_items += 1 148 | 149 | # Update current item 150 | self.current_item = item_original_name 151 | self.current_position = f"{self.processed_items}/{self.total_items}" 152 | self.current_queue_item = current_queue_item 153 | 154 | # Update queue item appearance 155 | self._update_queue_item_status(current_queue_item, "DOWNLOADING", QColor(0, 128, 255)) 156 | 157 | # Process item based on its type 158 | platform_id = self.queue_manager.get_platform_from_queue_item(item_original_name) 159 | if platform_id: 160 | filename = self.queue_manager.get_filename_from_queue_item(item_original_name) 161 | self._process_item(platform_id, filename, self.current_position, settings, current_queue_item) 162 | else: 163 | self.error_occurred.emit(f"Could not determine platform for {item_original_name}") 164 | 165 | # Remove item from queue if not paused 166 | if not self.is_paused: 167 | queue_list_widget.takeItem(0) 168 | # Save queue immediately after removing completed item 169 | self.save_queue(queue_list_widget) 170 | 171 | is_resuming_session = False 172 | 173 | # Reset state if completed 174 | if not self.is_paused: 175 | self._reset_processing_state() 176 | 177 | def pause_processing(self): 178 | """Pause the current processing operation.""" 179 | self.is_paused = True 180 | self.current_operation = 'paused' 181 | 182 | # Pause appropriate manager 183 | self.download_manager.pause_download() 184 | self.processing_manager.pause_processing() 185 | 186 | self.operation_paused.emit() 187 | 188 | def resume_processing(self, queue_list_widget, settings): 189 | """Resume a previously paused processing operation.""" 190 | self.is_paused = False 191 | self.output_window.append(f"({self.current_position}) Resuming processing...\n") 192 | 193 | # Resume appropriate manager 194 | self.download_manager.resume_download() 195 | self.processing_manager.resume_processing() 196 | 197 | # Continue processing from where we left off 198 | self.start_processing(queue_list_widget, settings) 199 | 200 | def stop_processing(self): 201 | """Stop the current processing operation.""" 202 | self.is_paused = True 203 | self.download_manager.stop_download() 204 | self.processing_manager.stop_processing() 205 | 206 | def check_for_paused_download(self, queue_list_widget): 207 | """Check if there is a paused download to resume.""" 208 | pause_state = StateManager.load_pause_state() 209 | if not pause_state: 210 | return False 211 | 212 | # Clear the current queue 213 | queue_list_widget.clear() 214 | 215 | # Add the current paused item first with (PAUSED) status 216 | current_item_text = pause_state['current_item'] 217 | paused_item = self.queue_manager.add_formatted_item_to_queue(current_item_text, queue_list_widget) 218 | 219 | # Update the item to show it's paused 220 | self._update_queue_item_status(paused_item, "PAUSED", QColor(255, 215, 0), "(PAUSED)") 221 | 222 | # Add remaining items from saved queue 223 | if 'remaining_queue' in pause_state and isinstance(pause_state['remaining_queue'], list): 224 | for item_text in pause_state['remaining_queue']: 225 | if item_text != current_item_text: 226 | self.queue_manager.add_formatted_item_to_queue(item_text, queue_list_widget) 227 | 228 | # Restore state 229 | self.current_item = pause_state['current_item'] 230 | self.current_operation = pause_state['operation'] 231 | self.current_position = pause_state['queue_position'] 232 | self.current_file_path = pause_state['file_path'] 233 | self.processed_items = pause_state['processed_items'] 234 | self.total_items = pause_state['total_items'] 235 | self.is_paused = True 236 | 237 | # Save the queue to ensure it's current 238 | self.save_queue(queue_list_widget) 239 | 240 | return True 241 | 242 | def save_pause_state(self, queue_list_widget): 243 | """Save the current pause state.""" 244 | if self.is_paused: 245 | # Get remaining queue items 246 | remaining_items = [] 247 | for i in range(queue_list_widget.count()): 248 | item = queue_list_widget.item(i) 249 | if item.data(Qt.UserRole): 250 | remaining_items.append(item.data(Qt.UserRole)) 251 | else: 252 | remaining_items.append(item.text()) 253 | 254 | StateManager.save_pause_state( 255 | self.current_item, 256 | self.current_position, 257 | self.current_operation, 258 | self.current_file_path, 259 | self.processed_items, 260 | self.total_items, 261 | remaining_items 262 | ) 263 | else: 264 | StateManager.clear_pause_state() 265 | 266 | def _process_item(self, platform_id, filename, queue_position, settings, queue_item): 267 | """Process a single item.""" 268 | try: 269 | # Download the file 270 | self.current_operation = 'download' 271 | file_path = self.download_manager.download_item_by_platform(platform_id, filename, queue_position) 272 | 273 | if self.is_paused: 274 | return 275 | 276 | # Process the downloaded file 277 | self.current_operation = 'processing' 278 | self._process_downloaded_file(platform_id, file_path, filename, queue_position, settings, queue_item) 279 | 280 | except Exception as e: 281 | self.error_occurred.emit(f"Error processing {filename}: {str(e)}") 282 | 283 | def _process_downloaded_file(self, platform_id, file_path, filename, queue_position, settings, queue_item): 284 | """Process a downloaded file based on platform.""" 285 | if not os.path.exists(file_path): 286 | self.output_window.append(f"({queue_position}) File no longer exists: {file_path}") 287 | return 288 | 289 | base_name = os.path.splitext(filename)[0] 290 | 291 | # Unzip the file (status will be updated by processing manager) 292 | extracted_files = self.processing_manager.unzip_file_with_pause_support( 293 | file_path, self.settings_manager.processing_dir, queue_position, base_name 294 | ) 295 | 296 | if self.is_paused: 297 | return 298 | 299 | # Delete the zip file if it exists 300 | if os.path.exists(file_path): 301 | try: 302 | os.remove(file_path) 303 | except Exception as e: 304 | self.output_window.append(f"({queue_position}) Warning: Could not delete zip file: {e}") 305 | 306 | # Process based on platform with queue item for status updates 307 | if platform_id == 'ps3': 308 | self._update_queue_item_status(queue_item, "PROCESSING", QColor(128, 0, 128)) 309 | self.processing_manager.process_ps3_files(extracted_files, base_name, queue_position, settings, queue_item) 310 | elif platform_id == 'psn': 311 | self._update_queue_item_status(queue_item, "PROCESSING", QColor(128, 0, 128)) 312 | self.processing_manager.process_psn_files(extracted_files, base_name, queue_position, settings, queue_item) 313 | elif platform_id == 'ps2': 314 | self._update_queue_item_status(queue_item, "PROCESSING", QColor(128, 0, 128)) 315 | self.processing_manager.process_ps2_files(extracted_files, base_name, queue_position, settings, queue_item) 316 | elif platform_id == 'psx': 317 | self._update_queue_item_status(queue_item, "PROCESSING", QColor(128, 0, 128)) 318 | self.processing_manager.process_psx_files(extracted_files, base_name, queue_position, settings, queue_item) 319 | elif platform_id == 'psp': 320 | self._update_queue_item_status(queue_item, "PROCESSING", QColor(128, 0, 128)) 321 | self.processing_manager.process_psp_files(extracted_files, base_name, queue_position, settings, queue_item) 322 | else: 323 | # All other platforms (including Xbox 360 variants) use generic processing 324 | self._update_queue_item_status(queue_item, "PROCESSING", QColor(128, 0, 128)) 325 | self.processing_manager.process_generic_files(extracted_files, base_name, queue_position, platform_id, settings, queue_item) 326 | 327 | # Mark as completed 328 | self._update_queue_item_status(queue_item, "COMPLETED", QColor(0, 128, 0)) 329 | 330 | def _handle_current_download_removal(self, item, original_name): 331 | """Handle removal of currently downloading item.""" 332 | from PyQt5.QtWidgets import QMessageBox 333 | from PyQt5.QtCore import Qt 334 | 335 | try: 336 | if os.path.exists(self.current_file_path): 337 | file_size = os.path.getsize(self.current_file_path) 338 | size_str = self._format_file_size(file_size) 339 | else: 340 | size_str = "unknown size (file not found)" 341 | 342 | status = "paused" if self.is_paused else "in progress" 343 | filename = self.queue_manager.get_filename_from_queue_item(original_name) 344 | 345 | # Show confirmation dialog 346 | msg_box = QMessageBox(self.parent()) 347 | msg_box.setIcon(QMessageBox.Question) 348 | msg_box.setWindowTitle("Confirm Removal") 349 | msg_box.setText(f"'{filename}' has a {status} download ({size_str}).") 350 | msg_box.setInformativeText("Do you want to delete the incomplete download file?") 351 | 352 | delete_btn = msg_box.addButton("Delete File", QMessageBox.YesRole) 353 | keep_btn = msg_box.addButton("Keep File", QMessageBox.NoRole) 354 | cancel_btn = msg_box.addButton(QMessageBox.Cancel) 355 | 356 | msg_box.exec_() 357 | 358 | if msg_box.clickedButton() == cancel_btn: 359 | return False 360 | 361 | if msg_box.clickedButton() == delete_btn: 362 | if os.path.exists(self.current_file_path): 363 | try: 364 | os.remove(self.current_file_path) 365 | self.output_window.append(f"({self.current_position}) Deleted incomplete download: {self.current_file_path}\n") 366 | except Exception as e: 367 | self.output_window.append(f"Error deleting file: {str(e)}\n") 368 | else: 369 | self.output_window.append(f"Kept incomplete download: {self.current_file_path}") 370 | 371 | # Reset current state if this was the current operation 372 | if self.current_item == original_name: 373 | if self.is_paused: 374 | StateManager.clear_pause_state() 375 | self.is_paused = False 376 | 377 | self.stop_processing() 378 | self._reset_processing_state() 379 | 380 | return True 381 | 382 | except Exception as e: 383 | self.output_window.append(f"Error handling download removal: {str(e)}\n") 384 | return True 385 | 386 | def _update_queue_item_status(self, item, status, color, suffix=None): 387 | """Update queue item with status and formatting.""" 388 | from PyQt5.QtGui import QFont 389 | 390 | if suffix: 391 | text = f"{item.data(Qt.UserRole)} {suffix}" 392 | else: 393 | original_text = item.data(Qt.UserRole) 394 | clean_text = re.sub(r' \([A-Z]+\)$', '', original_text) 395 | text = f"{clean_text} ({status})" 396 | 397 | item.setText(text) 398 | 399 | # Apply bold font and color 400 | font = QFont() 401 | font.setBold(True) 402 | item.setFont(font) 403 | item.setForeground(QBrush(color)) 404 | 405 | def _on_download_paused(self): 406 | """Handle download paused signal.""" 407 | self.output_window.append(f"\n({self.current_position}) Download paused") 408 | self.operation_paused.emit() 409 | 410 | def _on_status_updated(self, status): 411 | """Handle status update from processing manager.""" 412 | self.status_updated.emit(status) 413 | 414 | # Update queue item with specific status colors 415 | if self.current_queue_item: 416 | if status == "UNZIPPING": 417 | self._update_queue_item_status(self.current_queue_item, "UNZIPPING", QColor(255, 165, 0)) 418 | elif status == "DECRYPTING": 419 | self._update_queue_item_status(self.current_queue_item, "DECRYPTING", QColor(255, 69, 0)) 420 | elif status == "EXTRACTING": 421 | self._update_queue_item_status(self.current_queue_item, "EXTRACTING", QColor(50, 205, 50)) 422 | elif status == "SPLITTING": 423 | self._update_queue_item_status(self.current_queue_item, "SPLITTING", QColor(255, 140, 0)) 424 | 425 | def _on_processing_paused(self): 426 | """Handle processing paused signal.""" 427 | self.output_window.append(f"({self.current_position}) Processing paused!") 428 | self.operation_paused.emit() 429 | 430 | def _reset_processing_state(self): 431 | """Reset processing state.""" 432 | self.processed_items = 0 433 | self.total_items = 0 434 | self.current_item = None 435 | self.current_position = None 436 | self.current_file_path = None 437 | self.current_queue_item = None 438 | self.current_operation = None 439 | self.is_paused = False 440 | 441 | # Clear manager states 442 | self.download_manager.clear_current_operation() 443 | self.processing_manager.clear_current_operation() 444 | 445 | # Clear pause state when processing completes normally 446 | from core.state_manager import StateManager 447 | StateManager.clear_pause_state() 448 | 449 | self.operation_complete.emit() 450 | 451 | def filter_by_regions(self, items: List[str], selected_regions: List[str]) -> List[str]: 452 | """Filter items by multiple regions.""" 453 | def get_regions(item: str) -> List[str]: 454 | # Extract all region and language information from filename 455 | regions = [] 456 | # Match all parenthetical content 457 | matches = re.finditer(r'\((.*?)\)', item) 458 | 459 | for match in matches: 460 | content = match.group(1) 461 | # Split by commas and spaces to get individual parts 462 | parts = re.split(r'[,\s]+', content) 463 | 464 | # Check each part for region or language code 465 | for part in parts: 466 | # Common region names 467 | if part in ["USA", "Europe", "Japan", "Australia", "Canada", "Korea", 468 | "Spain", "Germany", "France", "Italy"]: 469 | regions.append(part) 470 | # Language codes that might indicate region 471 | elif part == "En": 472 | if "USA" not in regions and "Europe" not in regions: 473 | regions.append("USA") 474 | elif part == "Fr": 475 | if "France" not in regions: 476 | regions.append("France") 477 | elif part == "De": 478 | if "Germany" not in regions: 479 | regions.append("Germany") 480 | elif part == "Es": 481 | if "Spain" not in regions: 482 | regions.append("Spain") 483 | elif part == "It": 484 | if "Italy" not in regions: 485 | regions.append("Italy") 486 | 487 | return regions 488 | 489 | filtered_items = [] 490 | for item in items: 491 | item_regions = get_regions(item) 492 | # If any of the selected regions match this item's regions, include it 493 | if any(region in item_regions for region in selected_regions): 494 | filtered_items.append(item) 495 | 496 | return filtered_items 497 | 498 | def _format_file_size(self, size_bytes): 499 | """Format file size for display.""" 500 | if size_bytes < 1024: 501 | return f"{size_bytes} B" 502 | elif size_bytes < 1024 * 1024: 503 | return f"{size_bytes/1024:.1f} KB" 504 | elif size_bytes < 1024 * 1024 * 1024: 505 | return f"{size_bytes/(1024*1024):.1f} MB" 506 | else: 507 | return f"{size_bytes/(1024*1024*1024):.1f} GB" -------------------------------------------------------------------------------- /core/config_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import yaml 3 | import sys 4 | import requests 5 | 6 | class ConfigManager: 7 | """Manages application configuration loaded from YAML files.""" 8 | 9 | DEFAULT_CONFIG_PATH = "config/myrient_urls.yaml" 10 | # Class variable to track if we've already printed the config loaded message 11 | _config_loaded_message_shown = False 12 | 13 | def __init__(self, config_file=None): 14 | self.config_file = config_file or self.DEFAULT_CONFIG_PATH 15 | self.config = {} 16 | self.ensure_config_exists() 17 | self.load_config() 18 | 19 | def load_config(self): 20 | """Load configuration from YAML file.""" 21 | try: 22 | with open(self.config_file, 'r') as f: 23 | self.config = yaml.safe_load(f) 24 | 25 | # Only print the message if it hasn't been shown before 26 | if not ConfigManager._config_loaded_message_shown: 27 | sys.stdout.write(f"Loaded configuration from {self.config_file}\n") 28 | 29 | # Add message about customizing the config file 30 | config_path = os.path.abspath(self.config_file) 31 | sys.stdout.write(f"You can add other URLs by editing the YAML file at {config_path}\n") 32 | sys.stdout.flush() 33 | 34 | # Mark that we've shown the message 35 | ConfigManager._config_loaded_message_shown = True 36 | 37 | except Exception as e: 38 | sys.stderr.write(f"Error loading configuration: {str(e)}\n") 39 | sys.stderr.flush() 40 | 41 | def ensure_config_exists(self): 42 | """Ensure the configuration file exists, downloading it from GitHub if needed.""" 43 | # Check in working directory 44 | config_filename = os.path.basename(self.config_file) 45 | if os.path.exists(config_filename): 46 | self.config_file = config_filename 47 | return 48 | 49 | # Check in config directory 50 | if os.path.exists(self.config_file): 51 | return 52 | 53 | # Neither location has the config, download from GitHub 54 | sys.stderr.write(f"Configuration file not found at {self.config_file}\n") 55 | sys.stderr.write(f"Downloading configuration file from GitHub...\n") 56 | sys.stderr.flush() 57 | 58 | github_url = "https://raw.githubusercontent.com/hadobedo/Myrient-Downloader-GUI/main/config/myrient_urls.yaml" 59 | 60 | try: 61 | # Create config directory if it doesn't exist 62 | os.makedirs(os.path.dirname(self.config_file), exist_ok=True) 63 | 64 | # Download the file 65 | response = requests.get(github_url) 66 | if response.status_code == 200: 67 | with open(self.config_file, 'wb') as f: 68 | f.write(response.content) 69 | sys.stderr.write(f"Successfully downloaded configuration file to {self.config_file}\n") 70 | sys.stderr.flush() 71 | return 72 | else: 73 | sys.stderr.write(f"Failed to download configuration file: HTTP {response.status_code}\n") 74 | except Exception as e: 75 | sys.stderr.write(f"Error downloading configuration file: {str(e)}\n") 76 | 77 | sys.stderr.write("Please ensure the myrient_urls.yaml file is present in the config directory.\n") 78 | sys.stderr.write("The application requires this file to function properly.\n") 79 | sys.stderr.flush() 80 | # Initialize with empty config if download failed 81 | self.config = {} 82 | 83 | def get_platform_checkbox_settings(self, platform_id): 84 | """Get checkbox visibility settings for a platform.""" 85 | if platform_id in self.config: 86 | return { 87 | 'show_ps3dec': self.config[platform_id].get('show_ps3dec', False), 88 | 'show_pkg_split': self.config[platform_id].get('show_pkg_split', False), 89 | } 90 | return { 91 | 'show_ps3dec': False, 92 | 'show_pkg_split': False, 93 | } 94 | 95 | def get_url(self, platform, url_type): 96 | """Get a specific URL from the configuration.""" 97 | try: 98 | return self.config.get(platform, {}).get(url_type) 99 | except Exception: 100 | return None 101 | 102 | def get_platforms(self): 103 | """Get all platform information including tab names and URLs.""" 104 | platforms = {} 105 | 106 | for key, data in self.config.items(): 107 | if 'url' in data and 'tab_name' in data: 108 | platforms[key] = { 109 | 'tab_name': data['tab_name'], 110 | 'url': data['url'] 111 | } 112 | # Copy additional fields 113 | if 'dkeys' in data: 114 | platforms[key]['dkeys'] = data['dkeys'] 115 | 116 | return platforms 117 | 118 | def get_platform_urls(self): 119 | """Get all platform URLs for loading software lists (legacy method).""" 120 | urls = {} 121 | 122 | platforms = self.get_platforms() 123 | for platform_id, data in platforms.items(): 124 | # Map platform IDs to the legacy URL keys 125 | if platform_id == 'ps3': 126 | urls['ps3iso'] = data['url'] 127 | elif platform_id == 'ps2': 128 | urls['ps2iso'] = data['url'] 129 | elif platform_id == 'psx': 130 | urls['psxiso'] = data['url'] 131 | elif platform_id == 'psp': 132 | urls['pspiso'] = data['url'] 133 | elif platform_id == 'psn': 134 | urls['psn'] = data['url'] 135 | else: 136 | # Add new platforms with their original IDs 137 | urls[platform_id] = data['url'] 138 | 139 | return urls 140 | -------------------------------------------------------------------------------- /core/download_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import urllib.parse 4 | from PyQt5.QtCore import QObject, pyqtSignal, QEventLoop 5 | 6 | from threads.download_threads import DownloadThread 7 | from gui.overwrite_dialog import OverwriteManager 8 | 9 | 10 | class DownloadManager(QObject): 11 | """Manages all download operations separate from GUI.""" 12 | 13 | # Signals for GUI updates 14 | progress_updated = pyqtSignal(int) 15 | speed_updated = pyqtSignal(str) 16 | eta_updated = pyqtSignal(str) 17 | size_updated = pyqtSignal(str) 18 | download_complete = pyqtSignal() 19 | download_paused = pyqtSignal() 20 | error_occurred = pyqtSignal(str) 21 | 22 | def __init__(self, settings_manager, config_manager, output_window, parent=None): 23 | super().__init__(parent) 24 | self.settings_manager = settings_manager 25 | self.config_manager = config_manager 26 | self.output_window = output_window 27 | self.download_thread = None 28 | self.current_operation = None 29 | self.current_file_path = None 30 | self.is_paused = False 31 | self.overwrite_manager = OverwriteManager() 32 | 33 | @staticmethod 34 | def check_file_exists(url, local_path): 35 | """Check if local file exists and matches the remote file size.""" 36 | if os.path.exists(local_path): 37 | local_file_size = os.path.getsize(local_path) 38 | 39 | # Get the size of the remote file 40 | response = requests.head(url) 41 | if 'content-length' in response.headers: 42 | remote_file_size = int(response.headers['content-length']) 43 | 44 | # If the local file is smaller, attempt to resume the download 45 | if local_file_size < remote_file_size: 46 | print(f"Local file is smaller than the remote file. Attempting to resume download...") 47 | return False 48 | # If the local file is the same size as the remote file, skip the download 49 | elif local_file_size == remote_file_size: 50 | # File already downloaded completely, skip 51 | return True 52 | else: 53 | print("Could not get the size of the remote file.") 54 | return False 55 | return False 56 | 57 | @staticmethod 58 | def build_download_url(base_url, filename): 59 | """Build a download URL by encoding the filename.""" 60 | encoded_filename = urllib.parse.quote(filename) 61 | # Ensure base_url does not end with a slash, as we add one. 62 | return f"{base_url.rstrip('/')}/{encoded_filename}" 63 | 64 | @staticmethod 65 | def try_alternative_domains(original_platform_url, filename_for_testing): 66 | """ 67 | Tries to find a working download domain for the given platform URL and filename. 68 | Returns a base URL (without trailing slash) for DownloadManager.build_download_url. 69 | """ 70 | parsed_original = urllib.parse.urlparse(original_platform_url) 71 | original_scheme = parsed_original.scheme 72 | original_host = parsed_original.hostname 73 | 74 | # path_from_original is already URL-encoded if original_platform_url was. 75 | # e.g., /files/No-Intro/Nintendo%20-%20Game%20Boy%20Color/ 76 | path_from_original = parsed_original.path 77 | 78 | # base_path_for_candidates will be like /files/No-Intro/Nintendo%20-%20Game%20Boy%20Color 79 | base_path_for_candidates = path_from_original.rstrip('/') 80 | 81 | # This is the base URL structure derived from the original_platform_url. 82 | # e.g., "https://myrient.erista.me/files/No-Intro/Nintendo%20-%20Game%20Boy%20Color" 83 | default_candidate_base = f"{original_scheme}://{original_host}{base_path_for_candidates}" 84 | 85 | alternative_hosts = [] 86 | for i in range(10): 87 | alternative_hosts.append(f"download{i}.mtcontent.rs") 88 | for i in range(10): 89 | alternative_hosts.append(f"cache{i}.mtcontent.rs") 90 | 91 | # 1. Test the original URL's structure first. 92 | # This handles cases like PS3 using dlX.myrient.erista.me which might be direct. 93 | if original_host: 94 | test_url_original = DownloadManager.build_download_url(default_candidate_base, filename_for_testing) 95 | try: 96 | response = requests.head(test_url_original, timeout=2, allow_redirects=True) 97 | if response.status_code == 200: 98 | return default_candidate_base 99 | except requests.exceptions.RequestException: 100 | pass # Continue to mtcontent.rs alternatives 101 | 102 | # 2. Try alternative mtcontent.rs domains. 103 | for alt_host in alternative_hosts: 104 | # candidate_dl_base will be like "https://downloadX.mtcontent.rs/files/No-Intro/Nintendo%20-%20Game%20Boy%20Color" 105 | candidate_dl_base = f"https://{alt_host}{base_path_for_candidates}" 106 | test_url_alt = DownloadManager.build_download_url(candidate_dl_base, filename_for_testing) 107 | try: 108 | response = requests.head(test_url_alt, timeout=1.5, allow_redirects=True) 109 | if response.status_code == 200: 110 | return candidate_dl_base # Return the base part, e.g., https://downloadX.mtcontent.rs/path 111 | except requests.exceptions.RequestException: 112 | continue 113 | 114 | # 3. Fallback to the original URL structure if no alternatives worked. 115 | return default_candidate_base 116 | 117 | @staticmethod 118 | def get_base_name(filename): 119 | """Get the base name of a file (without extension).""" 120 | return os.path.splitext(filename)[0] 121 | 122 | def download_item_by_platform(self, platform_id, item_text, queue_position): 123 | """Download an item based on its platform and return the downloaded file path.""" 124 | # Extract actual filename if this is a formatted queue item 125 | if '' in item_text: 126 | item_text = self._get_filename_from_queue_item(item_text) 127 | 128 | # Get URL for platform 129 | url = self.config_manager.get_url(platform_id, 'url') 130 | if not url: 131 | self.error_occurred.emit(f"ERROR: Missing URL configuration for {platform_id}") 132 | return None 133 | 134 | # Download the file 135 | # Download the file 136 | return self.download_file(item_text, queue_position, url) 137 | 138 | def download_file(self, selected_iso_filename, queue_position, base_download_url_for_platform): 139 | """ 140 | Helper function to download a file. 141 | `selected_iso_filename` is the name of the .zip file (e.g., "Game Name (Region).zip"). 142 | `base_download_url_for_platform` is the base URL for the platform (e.g., "http://example.com/ps3/"). 143 | Returns the path to the downloaded .zip file, or path to existing final file if skipping. 144 | """ 145 | self.current_operation = 'download' 146 | 147 | effective_base_url = DownloadManager.try_alternative_domains(base_download_url_for_platform, selected_iso_filename) 148 | download_url = DownloadManager.build_download_url(effective_base_url, selected_iso_filename) 149 | 150 | base_name_no_ext = DownloadManager.get_base_name(selected_iso_filename) # e.g., "Game Name (Region)" 151 | 152 | # Determine platform_id from the base_download_url_for_platform 153 | # We need to extract the platform from the URL to know which output directory to check 154 | platform_id = None 155 | for pid, config in self.config_manager.get_platforms().items(): 156 | if config['url'] in base_download_url_for_platform: 157 | platform_id = pid 158 | break 159 | 160 | if not platform_id: 161 | # Fallback: try to determine from URL patterns 162 | url_lower = base_download_url_for_platform.lower() 163 | if '/ps3/' in url_lower or 'ps3' in url_lower: 164 | platform_id = 'ps3' 165 | elif '/psn/' in url_lower or 'psn' in url_lower: 166 | platform_id = 'psn' 167 | elif '/ps2/' in url_lower: 168 | platform_id = 'ps2' 169 | elif '/psx/' in url_lower: 170 | platform_id = 'psx' 171 | elif '/psp/' in url_lower: 172 | platform_id = 'psp' 173 | elif 'xbox%20360' in url_lower or 'xbox 360' in url_lower or 'xbox360' in url_lower: 174 | # Return the specific Xbox 360 platform variant if we can determine it 175 | if 'digital' in url_lower: 176 | platform_id = 'xbox360digital' 177 | elif 'title%20update' in url_lower or 'title update' in url_lower: 178 | platform_id = 'xbox360tu' 179 | else: 180 | platform_id = 'xbox360' 181 | 182 | # Use the new directory management system to get the output directory 183 | final_output_dir = self.settings_manager.get_platform_directory(platform_id) 184 | 185 | # Define potential final filenames 186 | potential_final_filename_iso = base_name_no_ext + '.iso' 187 | potential_final_filename_pkg = base_name_no_ext + '.pkg' 188 | potential_final_dirname_extracted_ps3 = base_name_no_ext 189 | 190 | # Check for existing files and handle conflicts 191 | existing_files = [] 192 | 193 | if platform_id == 'ps3': 194 | # PS3 can result in an ISO or an extracted folder. Check both. 195 | final_iso_path = os.path.join(final_output_dir, potential_final_filename_iso) 196 | final_extracted_folder_path = os.path.join(final_output_dir, potential_final_dirname_extracted_ps3) 197 | 198 | if os.path.exists(final_iso_path): 199 | existing_files.append({ 200 | 'path': final_iso_path, 201 | 'existing_size': os.path.getsize(final_iso_path), 202 | 'new_size': 0 # Unknown until download 203 | }) 204 | if os.path.exists(final_extracted_folder_path) and os.path.isdir(final_extracted_folder_path): 205 | existing_files.append({ 206 | 'path': final_extracted_folder_path, 207 | 'existing_size': self._get_directory_size(final_extracted_folder_path), 208 | 'new_size': 0 # Unknown until processing 209 | }) 210 | 211 | elif platform_id == 'psn': 212 | # PSN games are primarily PKGs. RAPs are separate. 213 | final_pkg_path = os.path.join(final_output_dir, potential_final_filename_pkg) 214 | if os.path.exists(final_pkg_path): 215 | existing_files.append({ 216 | 'path': final_pkg_path, 217 | 'existing_size': os.path.getsize(final_pkg_path), 218 | 'new_size': 0 # Unknown until download 219 | }) 220 | else: 221 | # Generic handler for other platforms using the new directory management 222 | final_iso_path = os.path.join(final_output_dir, potential_final_filename_iso) 223 | if os.path.exists(final_iso_path): 224 | existing_files.append({ 225 | 'path': final_iso_path, 226 | 'existing_size': os.path.getsize(final_iso_path), 227 | 'new_size': 0 # Unknown until download 228 | }) 229 | 230 | # Add checks for other common extensions if necessary, e.g., .bin for PSX/PS2 231 | if platform_id in ['psx', 'ps2']: 232 | potential_final_filename_bin = base_name_no_ext + '.bin' 233 | final_bin_path = os.path.join(final_output_dir, potential_final_filename_bin) 234 | if os.path.exists(final_bin_path): 235 | existing_files.append({ 236 | 'path': final_bin_path, 237 | 'existing_size': os.path.getsize(final_bin_path), 238 | 'new_size': 0 # Unknown until download 239 | }) 240 | 241 | # Handle conflicts if any exist 242 | if existing_files: 243 | from gui.overwrite_dialog import OverwriteDialog 244 | choice, apply_to_all = self.overwrite_manager.handle_conflict( 245 | existing_files, "downloading", self.parent() 246 | ) 247 | 248 | if choice == OverwriteDialog.CANCEL: 249 | self.output_window.append(f"({queue_position}) Download cancelled due to existing files.") 250 | return None 251 | elif choice == OverwriteDialog.SKIP: 252 | self.output_window.append(f"({queue_position}) Skipping download - files already exist.") 253 | # Return the first existing file as the "downloaded" result 254 | return existing_files[0]['path'] 255 | # If OVERWRITE or RENAME, continue with download (files will be handled during processing) 256 | 257 | # If no existing final file was found, proceed to download the .zip into processing_dir 258 | zip_file_path = os.path.join(self.settings_manager.processing_dir, selected_iso_filename) 259 | 260 | # If the .zip file exists in processing_dir, compare its size to that of the remote URL 261 | if DownloadManager.check_file_exists(download_url, zip_file_path): # check_file_exists compares local and remote size 262 | self.output_window.append(f"({queue_position}) {selected_iso_filename} already exists in processing directory and matches remote size. Skipping download step.") 263 | return zip_file_path # Return path to existing zip for processing 264 | 265 | # Show download start and URL messages if not resuming 266 | if not hasattr(self, 'is_resuming') or not self.is_resuming: # 'is_resuming' seems to be a class member, ensure it's handled 267 | self.output_window.append(f"({queue_position}) Download started for {base_name_no_ext}") 268 | self.output_window.append(f"URL: {download_url}\n") 269 | 270 | self.progress_updated.emit(0) 271 | self.size_updated.emit("") 272 | 273 | self.download_thread = DownloadThread(download_url, zip_file_path) # Downloads to processing_dir 274 | self.download_thread.progress_signal.connect(self.progress_updated.emit) 275 | self.download_thread.speed_signal.connect(self.speed_updated.emit) 276 | self.download_thread.eta_signal.connect(self.eta_updated.emit) 277 | self.download_thread.size_signal.connect(self.size_updated.emit) 278 | self.download_thread.download_paused_signal.connect(self.download_paused.emit) 279 | 280 | # Create an event loop and wait for download to complete 281 | loop = QEventLoop() 282 | self.download_thread.finished.connect(loop.quit) 283 | self.download_thread.download_complete_signal.connect(loop.quit) 284 | 285 | self.download_thread.start() 286 | loop.exec_() 287 | 288 | # Clear current operation if not paused 289 | if not self.is_paused: 290 | self.current_operation = None 291 | self.current_file_path = None 292 | 293 | return zip_file_path 294 | 295 | def pause_download(self): 296 | """Pause the current download.""" 297 | self.is_paused = True 298 | if self.download_thread and self.current_operation == 'download': 299 | self.download_thread.pause() 300 | 301 | def resume_download(self): 302 | """Resume a previously paused download.""" 303 | self.is_paused = False 304 | if self.download_thread and self.current_operation == 'download': 305 | self.download_thread.resume() 306 | 307 | def stop_download(self): 308 | """Stop the current download.""" 309 | if self.download_thread: 310 | self.download_thread.stop() 311 | 312 | def _get_filename_from_queue_item(self, item_text): 313 | """Extract filename from a formatted queue item.""" 314 | import re 315 | # Handle HTML-formatted items 316 | if '<' in item_text and '>' in item_text: 317 | # Strip HTML tags first 318 | plain_text = re.sub(r'<[^>]+>', '', item_text) 319 | item_text = plain_text 320 | 321 | # Remove platform prefix pattern (PLATFORM) from the text 322 | text_without_platform = re.sub(r'^\([^)]+\)\s*', '', item_text) 323 | 324 | # Remove (DOWNLOADING) suffix if present 325 | return re.sub(r'\s*\(DOWNLOADING\)\s*$', '', text_without_platform) 326 | 327 | def clear_current_operation(self): 328 | """Clear current operation state.""" 329 | self.current_operation = None 330 | self.current_file_path = None 331 | 332 | def reset_overwrite_choices(self): 333 | """Reset overwrite manager choices for new download session.""" 334 | self.overwrite_manager.reset() 335 | 336 | def _get_directory_size(self, directory_path): 337 | """Calculate the total size of a directory and its contents.""" 338 | total_size = 0 339 | try: 340 | for dirpath, dirnames, filenames in os.walk(directory_path): 341 | for filename in filenames: 342 | file_path = os.path.join(dirpath, filename) 343 | try: 344 | total_size += os.path.getsize(file_path) 345 | except (OSError, IOError): 346 | pass # Skip files that can't be accessed 347 | except (OSError, IOError): 348 | pass # Skip directories that can't be accessed 349 | return total_size -------------------------------------------------------------------------------- /core/ps3_fileprocessor.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import subprocess 4 | import shutil 5 | import sys 6 | import tempfile 7 | from PyQt5.QtCore import QEventLoop 8 | from threads.processing_threads import CommandRunner, SplitPkgThread 9 | from gui.overwrite_dialog import OverwriteManager, OverwriteDialog 10 | class PS3FileProcessor: 11 | """Handles PS3-specific file processing operations like decrypting ISOs and splitting PKGs.""" 12 | 13 | def __init__(self, settings_manager, output_window, parent=None): 14 | """Initialize PS3FileProcessor with required dependencies.""" 15 | self.settings_manager = settings_manager 16 | self.output_window = output_window 17 | self.parent = parent 18 | self.progress_callback = None 19 | self.overwrite_manager = OverwriteManager() 20 | 21 | def set_progress_callback(self, callback): 22 | """Set a callback function for progress updates.""" 23 | self.progress_callback = callback 24 | 25 | def decrypt_iso(self, iso_path, key): 26 | """Decrypt a PS3 ISO file using ps3dec.""" 27 | if platform.system() == 'Windows': 28 | thread_count = os.cpu_count() // 2 29 | command = [ 30 | self.settings_manager.ps3dec_binary, 31 | "--iso", iso_path, 32 | "--dk", key, 33 | "--tc", str(thread_count) 34 | ] 35 | else: 36 | command = [ 37 | self.settings_manager.ps3dec_binary, 38 | 'd', 'key', key, 39 | iso_path 40 | ] 41 | 42 | # Create and start the command runner 43 | runner = CommandRunner(command) 44 | 45 | # Connect the output signal to output_window for proper formatting 46 | runner.output_signal.connect(lambda text: self.output_window.append(text)) 47 | runner.error_signal.connect(lambda text: self.output_window.append(f"ERROR: {text}")) 48 | 49 | # Connect progress if callback is available 50 | if self.progress_callback: 51 | runner.output_signal.connect(self._parse_progress_from_output) 52 | 53 | # Create an event loop to wait for the command to complete 54 | loop = QEventLoop() 55 | runner.finished_signal.connect(loop.quit) 56 | 57 | # Start the command and wait for completion 58 | runner.start() 59 | 60 | try: 61 | # Wait for the command to finish 62 | loop.exec_() 63 | 64 | # Make sure the decrypted file exists before renaming 65 | if platform.system() == 'Windows': 66 | dec_path = f"{os.path.splitext(iso_path)[0]}.iso_decrypted.iso" 67 | else: 68 | dec_path = f"{iso_path}.dec" 69 | 70 | if not os.path.exists(dec_path): 71 | self.output_window.append(f"Warning: Decryption may have failed, decrypted file not found at {dec_path}") 72 | return iso_path 73 | 74 | # Rename the original ISO file to .iso.enc 75 | enc_path = f"{iso_path}.enc" 76 | os.rename(iso_path, enc_path) 77 | 78 | # Rename the decrypted file 79 | os.rename(dec_path, iso_path) 80 | 81 | return enc_path 82 | except Exception as e: 83 | self.output_window.append(f"Error in decrypt_iso: {str(e)}") 84 | # If there's an error, return the original path so downstream code has something to work with 85 | return iso_path 86 | 87 | def split_pkg(self, pkg_path): 88 | """Split a PS3 PKG file for FAT32 filesystems.""" 89 | if os.path.getsize(pkg_path) < 4294967295: 90 | self.output_window.append(f"File {pkg_path} is smaller than 4GB. Skipping split.") 91 | return False 92 | 93 | split_pkg_thread = SplitPkgThread(pkg_path, self.overwrite_manager) 94 | split_pkg_thread.progress.connect(self._print_progress) 95 | if self.progress_callback: 96 | split_pkg_thread.progress.connect(lambda text: self._parse_split_progress(text)) 97 | split_pkg_thread.start() 98 | split_pkg_thread.wait() 99 | 100 | return True 101 | 102 | def extract_iso(self, iso_path): 103 | """ 104 | Extract contents of a PS3 ISO to a folder with the same name using extractps3iso tool. 105 | Returns a tuple of (success, extraction_path) 106 | """ 107 | # Get the base name for the folder (remove extension) 108 | base_name = os.path.splitext(os.path.basename(iso_path))[0] 109 | parent_dir = os.path.dirname(iso_path) 110 | expected_extraction_dir = os.path.join(parent_dir, base_name) 111 | 112 | # Check if extraction directory already exists and handle conflict 113 | if os.path.exists(expected_extraction_dir): 114 | try: 115 | existing_size = sum(os.path.getsize(os.path.join(dirpath, filename)) 116 | for dirpath, dirnames, filenames in os.walk(expected_extraction_dir) 117 | for filename in filenames) 118 | except OSError: 119 | existing_size = 0 120 | 121 | conflict_info = { 122 | 'path': expected_extraction_dir, 123 | 'existing_size': existing_size, 124 | 'new_size': 0 # Unknown until extraction 125 | } 126 | 127 | choice, _ = self.overwrite_manager.handle_conflict( 128 | conflict_info, "extraction", self._get_main_window() 129 | ) 130 | 131 | if choice == OverwriteDialog.CANCEL: 132 | self.output_window.append("ISO extraction cancelled due to existing directory.") 133 | return (False, expected_extraction_dir) 134 | elif choice == OverwriteDialog.SKIP: 135 | self.output_window.append(f"Skipping ISO extraction - directory already exists: {expected_extraction_dir}") 136 | return (True, expected_extraction_dir) # Return as successful since content exists 137 | elif choice == OverwriteDialog.RENAME: 138 | # Generate unique directory name 139 | expected_extraction_dir = self._generate_unique_dirname(expected_extraction_dir) 140 | self.output_window.append(f"Extracting to renamed directory: {expected_extraction_dir}") 141 | elif choice == OverwriteDialog.OVERWRITE: 142 | # Remove existing directory 143 | try: 144 | shutil.rmtree(expected_extraction_dir) 145 | self.output_window.append(f"Removed existing extraction directory: {expected_extraction_dir}") 146 | except Exception as e: 147 | self.output_window.append(f"Warning: Could not remove existing directory: {e}") 148 | 149 | try: 150 | # Check if extractps3iso is available - use settings manager first 151 | extract_tool = None 152 | if hasattr(self, 'settings_manager') and self.settings_manager.extractps3iso_binary: 153 | if os.path.isfile(self.settings_manager.extractps3iso_binary): 154 | extract_tool = self.settings_manager.extractps3iso_binary 155 | 156 | # Fallback to PATH if not found in settings 157 | if not extract_tool: 158 | extract_tool = shutil.which('extractps3iso') or shutil.which('extractps3iso.exe') 159 | 160 | if not extract_tool: 161 | self.output_window.append("extractps3iso tool not found. Please install it or configure it in settings.") 162 | return (False, expected_extraction_dir) 163 | 164 | self.output_window.append(f"Extracting PS3 ISO using extractps3iso: {iso_path}") 165 | 166 | # Run the extractps3iso command - extract to parent directory 167 | # Let extractps3iso create the folder structure naturally 168 | cmd = [extract_tool, iso_path, parent_dir] 169 | 170 | if self.progress_callback: 171 | # Start extraction with progress tracking 172 | process = subprocess.Popen( 173 | cmd, 174 | stdout=subprocess.PIPE, 175 | stderr=subprocess.PIPE, 176 | text=True, 177 | bufsize=1, 178 | universal_newlines=True 179 | ) 180 | 181 | # Monitor output for progress 182 | output_lines = [] 183 | while True: 184 | output = process.stdout.readline() 185 | if output == '' and process.poll() is not None: 186 | break 187 | if output: 188 | output_lines.append(output.strip()) 189 | self._parse_extraction_progress(output.strip()) 190 | 191 | # Get any remaining output 192 | stdout, stderr = process.communicate() 193 | if stdout: 194 | output_lines.extend(stdout.strip().split('\n')) 195 | 196 | # Combine all output 197 | process.stdout = '\n'.join(output_lines) 198 | process.stderr = stderr 199 | process.returncode = process.poll() 200 | else: 201 | process = subprocess.run( 202 | cmd, 203 | stdout=subprocess.PIPE, 204 | stderr=subprocess.PIPE, 205 | text=True, 206 | check=False 207 | ) 208 | 209 | # Check if the extraction was successful 210 | if process.returncode != 0: 211 | self.output_window.append(f"extractps3iso failed with return code {process.returncode}:") 212 | self.output_window.append(process.stderr) 213 | return (False, expected_extraction_dir) 214 | 215 | # Log the output 216 | self.output_window.append(process.stdout) 217 | 218 | # Check if extraction actually produced files in the expected directory 219 | if os.path.exists(expected_extraction_dir) and any(os.scandir(expected_extraction_dir)): 220 | self.output_window.append(f"Successfully extracted PS3 ISO to {expected_extraction_dir}") 221 | return (True, expected_extraction_dir) 222 | else: 223 | # Look for any directory that was created by extractps3iso 224 | potential_dirs = [d for d in os.listdir(parent_dir) 225 | if os.path.isdir(os.path.join(parent_dir, d)) and 226 | (d.startswith(base_name) or base_name.lower() in d.lower())] 227 | 228 | if potential_dirs: 229 | actual_dir = os.path.join(parent_dir, potential_dirs[0]) 230 | self.output_window.append(f"Found extraction at {actual_dir}") 231 | return (True, actual_dir) 232 | 233 | self.output_window.append("extractps3iso didn't extract any files.") 234 | return (False, expected_extraction_dir) 235 | 236 | except Exception as e: 237 | self.output_window.append(f"Error extracting ISO: {str(e)}") 238 | return (False, expected_extraction_dir) 239 | 240 | def _format_size(self, size_bytes): 241 | """Format file size in human-readable format.""" 242 | if size_bytes < 1024: 243 | return f"{size_bytes} bytes" 244 | elif size_bytes < 1024**2: 245 | return f"{size_bytes/1024:.2f} KB" 246 | elif size_bytes < 1024**3: 247 | return f"{size_bytes/1024**2:.2f} MB" 248 | else: 249 | return f"{size_bytes/1024**3:.2f} GB" 250 | 251 | def _safe_decode(self, data): 252 | """Safely decode binary data to string.""" 253 | if isinstance(data, str): 254 | return data 255 | elif isinstance(data, bytes): 256 | try: 257 | return data.decode('utf-8') 258 | except UnicodeDecodeError: 259 | try: 260 | return data.decode('latin1') 261 | except: 262 | # Last resort: replace invalid characters 263 | return data.decode('utf-8', errors='replace') 264 | else: 265 | return str(data) 266 | 267 | def _print_progress(self, text): 268 | """Print progress updates.""" 269 | self.output_window.append(text) 270 | 271 | def _parse_progress_from_output(self, text): 272 | """Parse progress information from ps3dec output.""" 273 | if self.progress_callback: 274 | # ps3dec typically shows progress as percentages 275 | # Look for patterns like "Progress: 50%" or similar 276 | import re 277 | 278 | # Common progress patterns in ps3dec output 279 | progress_patterns = [ 280 | r'(\d+)%', 281 | r'Progress:\s*(\d+)%', 282 | r'(\d+)/(\d+)', 283 | r'Decrypting.*?(\d+)%' 284 | ] 285 | 286 | for pattern in progress_patterns: 287 | match = re.search(pattern, text) 288 | if match: 289 | try: 290 | if len(match.groups()) == 1: 291 | # Simple percentage 292 | progress = int(match.group(1)) 293 | if 0 <= progress <= 100: 294 | self.progress_callback(progress) 295 | break 296 | elif len(match.groups()) == 2: 297 | # Fraction format like "50/100" 298 | current = int(match.group(1)) 299 | total = int(match.group(2)) 300 | if total > 0: 301 | progress = int((current / total) * 100) 302 | if 0 <= progress <= 100: 303 | self.progress_callback(progress) 304 | break 305 | except (ValueError, ZeroDivisionError): 306 | continue 307 | 308 | def _parse_split_progress(self, text): 309 | """Parse progress information from split operations.""" 310 | if self.progress_callback: 311 | import re 312 | 313 | # Look for split progress patterns like "Splitting file: part 1/5 complete" 314 | progress_patterns = [ 315 | r'part\s+(\d+)/(\d+)\s+complete', 316 | r'(\d+)/(\d+)\s+complete', 317 | r'Splitting.*?(\d+)%' 318 | ] 319 | 320 | for pattern in progress_patterns: 321 | match = re.search(pattern, text, re.IGNORECASE) 322 | if match: 323 | try: 324 | if len(match.groups()) == 2: 325 | # Fraction format like "part 1/5" 326 | current = int(match.group(1)) 327 | total = int(match.group(2)) 328 | if total > 0: 329 | progress = int((current / total) * 100) 330 | if 0 <= progress <= 100: 331 | self.progress_callback(progress) 332 | break 333 | elif len(match.groups()) == 1: 334 | # Simple percentage 335 | progress = int(match.group(1)) 336 | if 0 <= progress <= 100: 337 | self.progress_callback(progress) 338 | break 339 | except (ValueError, ZeroDivisionError): 340 | continue 341 | 342 | def _parse_extraction_progress(self, text): 343 | """Parse progress information from extractps3iso output.""" 344 | if self.progress_callback: 345 | import re 346 | 347 | # Look for extraction progress patterns 348 | # extractps3iso might show file counts or percentages 349 | progress_patterns = [ 350 | r'(\d+)%', 351 | r'(\d+)/(\d+)\s+files', 352 | r'Extracting.*?(\d+)%', 353 | r'(\d+)\s+of\s+(\d+)' 354 | ] 355 | 356 | for pattern in progress_patterns: 357 | match = re.search(pattern, text, re.IGNORECASE) 358 | if match: 359 | try: 360 | if len(match.groups()) == 1: 361 | # Simple percentage 362 | progress = int(match.group(1)) 363 | if 0 <= progress <= 100: 364 | self.progress_callback(progress) 365 | break 366 | elif len(match.groups()) == 2: 367 | # Fraction format 368 | current = int(match.group(1)) 369 | total = int(match.group(2)) 370 | if total > 0: 371 | progress = int((current / total) * 100) 372 | if 0 <= progress <= 100: 373 | self.progress_callback(progress) 374 | break 375 | except (ValueError, ZeroDivisionError): 376 | continue 377 | 378 | def _generate_unique_dirname(self, dir_path): 379 | """Generate a unique directory name by adding a suffix.""" 380 | parent_dir = os.path.dirname(dir_path) 381 | dir_name = os.path.basename(dir_path) 382 | 383 | counter = 1 384 | while True: 385 | new_dir_name = f"{dir_name} ({counter})" 386 | new_path = os.path.join(parent_dir, new_dir_name) 387 | if not os.path.exists(new_path): 388 | return new_path 389 | counter += 1 390 | 391 | def reset_overwrite_choices(self): 392 | """Reset overwrite manager choices for new processing session.""" 393 | self.overwrite_manager.reset() 394 | 395 | def _get_main_window(self): 396 | """Get the main window for dialog parenting.""" 397 | # Return the parent window that was passed during initialization 398 | return self.parent 399 | -------------------------------------------------------------------------------- /core/queue_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | import re 4 | from PyQt5.QtCore import Qt, QObject, pyqtSignal 5 | from PyQt5.QtWidgets import QListWidgetItem 6 | from PyQt5.QtGui import QFont, QBrush, QColor 7 | 8 | 9 | class QueueManager(QObject): 10 | """Manages download queue operations separate from GUI.""" 11 | 12 | queue_updated = pyqtSignal() 13 | 14 | def __init__(self): 15 | super().__init__() 16 | self.queue_items = [] 17 | self.config_dir = "config" 18 | self.queue_file = os.path.join(self.config_dir, "queue.txt") 19 | 20 | def load_queue(self): 21 | """Load the download queue from file.""" 22 | # First check if the new location exists 23 | if os.path.exists(self.queue_file): 24 | try: 25 | with open(self.queue_file, 'rb') as file: 26 | self.queue_items = pickle.load(file) 27 | except Exception as e: 28 | print(f"Error loading {self.queue_file}: {e}. Starting with an empty queue.") 29 | self.queue_items = [] 30 | else: 31 | # Check for old file in root directory 32 | old_queue_file = "queue.txt" 33 | if os.path.exists(old_queue_file): 34 | try: 35 | # Load from old location 36 | with open(old_queue_file, 'rb') as file: 37 | self.queue_items = pickle.load(file) 38 | 39 | # Ensure config directory exists 40 | os.makedirs(self.config_dir, exist_ok=True) 41 | 42 | # Save to new location 43 | with open(self.queue_file, 'wb') as file: 44 | pickle.dump(self.queue_items, file) 45 | 46 | # Remove old file after successful migration 47 | os.remove(old_queue_file) 48 | print(f"Migrated queue from root to {self.queue_file}") 49 | except Exception as e: 50 | print(f"Error migrating queue: {str(e)}") 51 | self.queue_items = [] 52 | else: 53 | self.queue_items = [] 54 | 55 | return self.queue_items 56 | 57 | def save_queue(self, queue_list_widget): 58 | """Save the current queue to file.""" 59 | # Ensure config directory exists 60 | os.makedirs(self.config_dir, exist_ok=True) 61 | 62 | # Save original names (Qt.UserRole data) 63 | queue_items = [] 64 | for i in range(queue_list_widget.count()): 65 | item = queue_list_widget.item(i) 66 | # Get original name from UserRole if it exists, otherwise use displayed text 67 | if item.data(Qt.UserRole): 68 | queue_items.append(item.data(Qt.UserRole)) 69 | else: 70 | queue_items.append(item.text()) 71 | 72 | with open(self.queue_file, 'wb') as file: 73 | pickle.dump(queue_items, file) 74 | 75 | self.queue_items = queue_items 76 | self.queue_updated.emit() 77 | 78 | def add_to_queue(self, item_text, queue_list_widget, platforms): 79 | """Add selected items to the download queue.""" 80 | # Check if item is already in queue by comparing original names 81 | already_in_queue = False 82 | for i in range(queue_list_widget.count()): 83 | queue_item = queue_list_widget.item(i) 84 | # Compare with original name stored in UserRole 85 | if queue_item.data(Qt.UserRole) == item_text: 86 | already_in_queue = True 87 | break 88 | 89 | if not already_in_queue: 90 | self.add_formatted_item_to_queue(item_text, queue_list_widget) 91 | return True 92 | return False 93 | 94 | def add_formatted_item_to_queue(self, item_text, queue_list_widget): 95 | """Add an item to the queue list with no special formatting by default.""" 96 | list_item = QListWidgetItem() 97 | 98 | # Store original text as user data 99 | list_item.setData(Qt.UserRole, item_text) 100 | 101 | # Get platform and remaining text 102 | platform_match = re.search(r'^\(([^)]+)\)', item_text) 103 | if platform_match: 104 | # Extract platform part and the rest 105 | platform = platform_match.group(0) # (PS3), (PSN), etc. 106 | remaining_text = item_text[len(platform):] 107 | 108 | # Set the full text without formatting 109 | clean_text = item_text 110 | 111 | # Check for downloading status - no special formatting initially 112 | if " (DOWNLOADING)" in remaining_text: 113 | remaining_text = remaining_text.replace(" (DOWNLOADING)", "") 114 | clean_text = platform + remaining_text 115 | 116 | # Set the display text (without HTML) 117 | list_item.setText(clean_text) 118 | else: 119 | # No platform prefix found, just use text as is 120 | list_item.setText(item_text) 121 | 122 | queue_list_widget.addItem(list_item) 123 | return list_item 124 | 125 | def remove_from_queue(self, selected_items, queue_list_widget): 126 | """Remove selected items from the download queue.""" 127 | items_to_remove = [] 128 | 129 | for item in selected_items: 130 | items_to_remove.append(item) 131 | 132 | # Remove all marked items 133 | for item in items_to_remove: 134 | queue_list_widget.takeItem(queue_list_widget.row(item)) 135 | 136 | return len(items_to_remove) 137 | 138 | def get_platform_from_queue_item(self, item_text): 139 | """Extract platform from a formatted queue item.""" 140 | # Handle HTML-formatted items 141 | if '<' in item_text and '>' in item_text: 142 | # Strip HTML tags first 143 | plain_text = re.sub(r'<[^>]+>', '', item_text) 144 | item_text = plain_text 145 | 146 | # Match pattern (PLATFORM) at the beginning of the text 147 | match = re.search(r'^\(([^)]+)\)', item_text) 148 | if match: 149 | return match.group(1).lower() 150 | return None 151 | 152 | def get_filename_from_queue_item(self, item_text): 153 | """Extract filename from a formatted queue item.""" 154 | # Handle HTML-formatted items 155 | if '<' in item_text and '>' in item_text: 156 | # Strip HTML tags first 157 | plain_text = re.sub(r'<[^>]+>', '', item_text) 158 | item_text = plain_text 159 | 160 | # Remove platform prefix pattern (PLATFORM) from the text 161 | text_without_platform = re.sub(r'^\([^)]+\)\s*', '', item_text) 162 | 163 | # Remove (DOWNLOADING) suffix if present 164 | return re.sub(r'\s*\(DOWNLOADING\)\s*$', '', text_without_platform) 165 | 166 | def update_queue_status(self, queue_list_widget, item_original_name, status, color=None): 167 | """Update the queue item text to show the current operation status.""" 168 | for i in range(queue_list_widget.count()): 169 | item = queue_list_widget.item(i) 170 | if item.data(Qt.UserRole) == item_original_name: 171 | # Extract original text without any status 172 | text = item.data(Qt.UserRole) 173 | clean_text = re.sub(r' \([A-Z]+\)$', '', text) 174 | 175 | # Add the new status 176 | new_text = f"{clean_text} ({status})" 177 | item.setText(new_text) 178 | 179 | # Apply bold font 180 | font = QFont() 181 | font.setBold(True) 182 | item.setFont(font) 183 | 184 | # Apply color if specified 185 | if color: 186 | item.setForeground(QBrush(color)) 187 | break -------------------------------------------------------------------------------- /core/state_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import time 4 | from datetime import datetime 5 | 6 | class StateManager: 7 | """Manages application state persistence for pause/resume functionality.""" 8 | 9 | CONFIG_DIR = "config" 10 | PAUSE_STATE_FILE = os.path.join(CONFIG_DIR, "pause_state.json") 11 | 12 | @staticmethod 13 | def save_pause_state(current_item, queue_position, operation, file_path, processed_items, total_items, remaining_queue): 14 | """Save the current state when paused.""" 15 | # Ensure config directory exists 16 | os.makedirs(StateManager.CONFIG_DIR, exist_ok=True) 17 | 18 | state = { 19 | "timestamp": datetime.now().isoformat(), 20 | "current_item": current_item, 21 | "queue_position": queue_position, 22 | "operation": operation, 23 | "file_path": file_path, 24 | "processed_items": processed_items, 25 | "total_items": total_items, 26 | "remaining_queue": remaining_queue # Save remaining queue items 27 | } 28 | 29 | with open(StateManager.PAUSE_STATE_FILE, 'w') as f: 30 | json.dump(state, f) 31 | 32 | print(f"Pause state saved: {current_item} - {operation}") 33 | 34 | @staticmethod 35 | def load_pause_state(): 36 | """Load the saved pause state if it exists.""" 37 | if not os.path.exists(StateManager.PAUSE_STATE_FILE): 38 | # Check for old pause state file in root directory 39 | old_file_path = "pause_state.json" 40 | if os.path.exists(old_file_path): 41 | try: 42 | # Ensure config directory exists 43 | os.makedirs(StateManager.CONFIG_DIR, exist_ok=True) 44 | 45 | # Migrate old file to new location 46 | with open(old_file_path, 'r') as old_file: 47 | state = json.load(old_file) 48 | 49 | with open(StateManager.PAUSE_STATE_FILE, 'w') as new_file: 50 | json.dump(state, new_file) 51 | 52 | # Remove old file after successful migration 53 | os.remove(old_file_path) 54 | print(f"Migrated pause state from root to {StateManager.PAUSE_STATE_FILE}") 55 | 56 | return state 57 | except Exception as e: 58 | print(f"Error migrating pause state: {str(e)}") 59 | return None 60 | return None 61 | 62 | try: 63 | with open(StateManager.PAUSE_STATE_FILE, 'r') as f: 64 | state = json.load(f) 65 | return state 66 | except Exception as e: 67 | print(f"Error loading pause state: {str(e)}") 68 | return None 69 | 70 | @staticmethod 71 | def clear_pause_state(): 72 | """Clear the saved pause state.""" 73 | if os.path.exists(StateManager.PAUSE_STATE_FILE): 74 | os.remove(StateManager.PAUSE_STATE_FILE) 75 | 76 | # Also clear old file if it exists 77 | old_file_path = "pause_state.json" 78 | if os.path.exists(old_file_path): 79 | os.remove(old_file_path) 80 | -------------------------------------------------------------------------------- /gui/__init__.py: -------------------------------------------------------------------------------- 1 | # Empty init file to make the directory a Python package 2 | -------------------------------------------------------------------------------- /gui/main_window.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import re 4 | import sys 5 | from PyQt5.QtWidgets import ( 6 | QApplication, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, 7 | QPushButton, QLineEdit, QListWidget, QLabel, QCheckBox, 8 | QFileDialog, QDialog, QGroupBox, QProgressBar, QTabWidget, QAbstractItemView, 9 | QMessageBox, QListWidgetItem, QFormLayout, QDialogButtonBox, QGridLayout, 10 | QSplitter, QFrame, QSizePolicy, QComboBox 11 | ) 12 | from PyQt5.QtCore import Qt, QSettings, QThread, pyqtSignal, QEventLoop # Removed QPropertyAnimation, QEasingCurve 13 | from PyQt5.QtGui import QFont, QBrush, QColor 14 | 15 | from gui.output_window import OutputWindow 16 | from core.settings import SettingsManager, SettingsDialog, BinaryValidationDialog 17 | from core.config_manager import ConfigManager 18 | from threads.download_threads import GetSoftwareListThread 19 | 20 | 21 | class GUIDownloader(QWidget): 22 | """The main GUI window for the Myrient Downloader application.""" 23 | 24 | def __init__(self): 25 | super().__init__() 26 | 27 | # Removed animation-related attributes 28 | # self.output_animation = None 29 | # self.is_output_visible = False 30 | 31 | # Create output window first to capture all output 32 | self.output_window = OutputWindow(self) 33 | self.output_window.set_as_stdout() # Redirect stdout early in initialization 34 | 35 | # Initialize configuration manager first 36 | self.config_manager = ConfigManager() 37 | 38 | # Pass config_manager to SettingsManager to avoid duplicate initialization 39 | self.settings_manager = SettingsManager(config_manager=self.config_manager) 40 | 41 | # Initialize AppController 42 | from core.app_controller import AppController 43 | self.app_controller = AppController( 44 | self.settings_manager, 45 | self.config_manager, 46 | self.output_window, 47 | self 48 | ) 49 | 50 | # Get platforms from configuration 51 | self.platforms = self.config_manager.get_platforms() 52 | 53 | # Load software lists 54 | self.init_software_lists() 55 | 56 | # Initialize the UI 57 | self.initUI() 58 | 59 | # Load the queue and check for paused downloads 60 | self._setup_queue() 61 | 62 | # Add signal handler for SIGINT 63 | signal.signal(signal.SIGINT, self.closeEvent) 64 | 65 | # Connect AppController signals 66 | self._connect_app_controller_signals() 67 | 68 | def _setup_queue(self): 69 | """Setup the queue and check for paused downloads.""" 70 | # Load the queue using AppController 71 | queue_items = self.app_controller.load_queue() 72 | 73 | # Add items to the queue list using AppController's queue manager 74 | for item_text in queue_items: 75 | self.app_controller.queue_manager.add_formatted_item_to_queue(item_text, self.queue_list) 76 | 77 | # If queue is empty, clear any stale pause state 78 | if self.queue_list.count() == 0: 79 | from core.state_manager import StateManager 80 | StateManager.clear_pause_state() 81 | 82 | # Check if we need to resume a paused download 83 | if self.app_controller.check_for_paused_download(self.queue_list): 84 | self.start_pause_button.setText('Resume') 85 | 86 | def _connect_app_controller_signals(self): 87 | """Connect AppController signals to GUI updates.""" 88 | self.app_controller.progress_updated.connect(self.progress_bar.setValue) 89 | self.app_controller.speed_updated.connect(self.download_speed_label.setText) 90 | self.app_controller.eta_updated.connect(self.download_eta_label.setText) 91 | self.app_controller.size_updated.connect(self.download_size_label.setText) 92 | self.app_controller.queue_updated.connect(self._on_queue_updated) 93 | self.app_controller.operation_complete.connect(self._on_operation_complete) 94 | self.app_controller.operation_paused.connect(self._on_operation_paused) 95 | self.app_controller.error_occurred.connect(self._on_error) 96 | 97 | def init_software_lists(self): 98 | """Initialize software lists and start download threads.""" 99 | # Initialize platform lists and threads 100 | self.platform_lists = {} 101 | self.platform_threads = {} 102 | 103 | # Create initial lists with loading message 104 | for platform_id in self.platforms.keys(): 105 | self.platform_lists[platform_id] = ['Loading... this will take a moment'] 106 | 107 | # Start threads to download software lists 108 | for platform_id, data in self.platforms.items(): 109 | json_filename = f"{platform_id}_filelist.json" 110 | thread = self.load_software_list( 111 | data['url'], 112 | json_filename, 113 | lambda items, pid=platform_id: self.set_platform_list(pid, items) 114 | ) 115 | self.platform_threads[platform_id] = thread 116 | thread.start() 117 | 118 | def load_software_list(self, url, json_filename, setter): 119 | """Create and return a thread to load a software list.""" 120 | thread = GetSoftwareListThread(url, json_filename) 121 | thread.signal.connect(setter) 122 | return thread 123 | 124 | def set_platform_list(self, platform_id, items): 125 | """Set the list of items for a specific platform.""" 126 | self.platform_lists[platform_id] = items 127 | 128 | # Find the correct tab index for this platform and update it 129 | for i in range(self.result_list.count()): 130 | if self.result_list.tabText(i) == self.platforms[platform_id]['tab_name']: 131 | self.result_list.widget(i).clear() 132 | self.result_list.widget(i).addItems(items) 133 | break 134 | 135 | def initUI(self): 136 | """Initialize the user interface.""" 137 | main_layout = QVBoxLayout() 138 | main_layout.setSpacing(3) # Further reduce spacing between elements 139 | main_layout.setContentsMargins(8, 8, 8, 8) # Reduce window margins 140 | 141 | # ============ TOP SECTION: Software List and Queue Side-by-Side ============ 142 | top_splitter = QSplitter(Qt.Horizontal) 143 | 144 | # Left side: Software List 145 | software_widget = QWidget() 146 | software_layout = QVBoxLayout(software_widget) 147 | software_layout.setContentsMargins(5, 5, 5, 5) 148 | 149 | # Software header and search 150 | software_header = QLabel('Software') 151 | software_header.setStyleSheet("font-weight: bold; font-size: 14px;") 152 | software_layout.addWidget(software_header) 153 | 154 | # Search and filter container 155 | search_filter_layout = QVBoxLayout() 156 | 157 | # Search bar and filters button in horizontal layout 158 | search_bar_layout = QHBoxLayout() 159 | 160 | # Search box 161 | self.search_box = QLineEdit() 162 | self.search_box.setPlaceholderText('Search...') 163 | self.search_box.textChanged.connect(self.update_results) 164 | search_bar_layout.addWidget(self.search_box) 165 | 166 | # Filters button 167 | self.filters_button = QPushButton('Filters') 168 | self.filters_button.setCheckable(True) 169 | self.filters_button.clicked.connect(self.toggle_region_filter) 170 | search_bar_layout.addWidget(self.filters_button) 171 | 172 | search_filter_layout.addLayout(search_bar_layout) 173 | 174 | # Region filter group (initially hidden) 175 | region_group = QGroupBox("Filter by Region") 176 | region_group.setStyleSheet("QGroupBox { padding-top: 15px; margin-top: 5px; }") 177 | region_group.setVisible(False) 178 | self.region_filter_group = region_group # Store reference for toggling 179 | region_layout = QGridLayout() 180 | 181 | # Create region checkboxes 182 | self.region_checkboxes = {} 183 | regions = [ 184 | "USA", "Canada", "Europe", "Japan", "Australia", 185 | "Korea", "Spain", "Germany", "France", "Italy", 186 | "World", # For multi-language/region releases 187 | "Other" # For games that don't match other regions 188 | ] 189 | 190 | row = 0 191 | col = 0 192 | for region in regions: 193 | checkbox = QCheckBox(region) 194 | checkbox.stateChanged.connect(self.update_results) 195 | region_layout.addWidget(checkbox, row, col) 196 | self.region_checkboxes[region] = checkbox 197 | 198 | col += 1 199 | if col > 2: # 3 checkboxes per row 200 | col = 0 201 | row += 1 202 | 203 | region_group.setLayout(region_layout) 204 | search_filter_layout.addWidget(region_group) 205 | 206 | software_layout.addLayout(search_filter_layout) 207 | 208 | # Create result list (software tabs) 209 | self.result_list = QTabWidget() 210 | 211 | # Create tabs based on the platforms configuration 212 | for platform_id, data in self.platforms.items(): 213 | list_widget = QListWidget() 214 | list_widget.addItems(self.platform_lists[platform_id]) 215 | list_widget.itemSelectionChanged.connect(self.update_add_to_queue_button) 216 | list_widget.setSelectionMode(QAbstractItemView.ExtendedSelection) 217 | self.result_list.addTab(list_widget, data['tab_name']) 218 | 219 | self.result_list.currentChanged.connect(self.update_add_to_queue_button) 220 | self.result_list.currentChanged.connect(self.update_checkboxes_for_platform) 221 | software_layout.addWidget(self.result_list) 222 | 223 | # Connect the itemSelectionChanged signals 224 | for i in range(self.result_list.count()): 225 | self.result_list.widget(i).itemSelectionChanged.connect(self.update_add_to_queue_button) 226 | self.result_list.widget(i).setSelectionMode(QAbstractItemView.ExtendedSelection) 227 | 228 | # Add to queue button 229 | self.add_to_queue_button = QPushButton('Add to Queue') 230 | self.add_to_queue_button.clicked.connect(self.add_to_queue) 231 | self.add_to_queue_button.setEnabled(False) 232 | software_layout.addWidget(self.add_to_queue_button) 233 | 234 | # Right side: Queue 235 | queue_widget = QWidget() 236 | queue_layout = QVBoxLayout(queue_widget) 237 | queue_layout.setContentsMargins(5, 5, 5, 5) 238 | 239 | # Queue header 240 | queue_header = QLabel('Download Queue') 241 | queue_header.setStyleSheet("font-weight: bold; font-size: 14px;") 242 | queue_layout.addWidget(queue_header) 243 | 244 | # Queue list 245 | self.queue_list = QListWidget() 246 | self.queue_list.setSelectionMode(QAbstractItemView.MultiSelection) 247 | self.queue_list.itemSelectionChanged.connect(self.update_remove_from_queue_button) 248 | self.queue_list.setWordWrap(True) 249 | self.queue_list.setMinimumHeight(150) 250 | self.queue_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) 251 | self.queue_list.setResizeMode(QListWidget.Adjust) 252 | queue_layout.addWidget(self.queue_list) 253 | 254 | # Remove from queue button 255 | self.remove_from_queue_button = QPushButton('Remove from Queue') 256 | self.remove_from_queue_button.clicked.connect(self.remove_from_queue) 257 | self.remove_from_queue_button.setEnabled(False) 258 | queue_layout.addWidget(self.remove_from_queue_button) 259 | 260 | # Add both widgets to the splitter 261 | top_splitter.addWidget(software_widget) 262 | top_splitter.addWidget(queue_widget) 263 | # top_splitter.setSizes([600, 600]) # Removed for auto-sizing 264 | 265 | main_layout.addWidget(top_splitter) 266 | 267 | # ============ MIDDLE SECTION: Settings Side-by-Side ============ 268 | settings_splitter = QSplitter(Qt.Horizontal) 269 | 270 | # Create general and platform-specific options side by side 271 | general_options_widget, platform_options_widget = self.create_options_side_by_side() 272 | 273 | settings_splitter.addWidget(general_options_widget) 274 | settings_splitter.addWidget(platform_options_widget) 275 | 276 | # Make the settings_splitter handle non-movable by the user 277 | settings_splitter_handle = settings_splitter.handle(1) 278 | if settings_splitter_handle: 279 | settings_splitter_handle.setDisabled(True) 280 | # Set initial equal sizes for the settings_splitter 281 | settings_splitter.setSizes([350, 350]) # Adjust if necessary 282 | 283 | main_layout.addWidget(settings_splitter) 284 | 285 | # ============ CONTROL BUTTONS: Settings and Start Side-by-Side ============ 286 | control_buttons_layout = QHBoxLayout() 287 | 288 | # Add stretch to center the buttons 289 | control_buttons_layout.addStretch() 290 | 291 | self.settings_button = QPushButton('Settings') 292 | self.settings_button.clicked.connect(self.open_settings) 293 | control_buttons_layout.addWidget(self.settings_button) 294 | 295 | self.start_pause_button = QPushButton('Start') 296 | self.start_pause_button.clicked.connect(self.start_or_pause_download) 297 | control_buttons_layout.addWidget(self.start_pause_button) 298 | 299 | # Add stretch to center the buttons 300 | control_buttons_layout.addStretch() 301 | 302 | main_layout.addLayout(control_buttons_layout) 303 | 304 | # ============ PROGRESS INDICATORS ============ 305 | # Create the toggle button instance here, as it's used by toggle_output_window 306 | # and create_collapsible_output_section will no longer create it. 307 | self.output_toggle_button = QPushButton('Show Logs') # Initial text 308 | self.output_toggle_button.clicked.connect(self.toggle_output_window) 309 | # self.output_window is already created and will be managed by create_collapsible_output_section 310 | 311 | progress_header = QLabel('Progress') 312 | progress_header.setStyleSheet("font-weight: bold; font-size: 14px;") 313 | main_layout.addWidget(progress_header) 314 | 315 | self.progress_bar = QProgressBar() 316 | self.progress_bar.setFormat("%p%") 317 | self.progress_bar.setAlignment(Qt.AlignCenter) 318 | main_layout.addWidget(self.progress_bar) 319 | 320 | # Download status information 321 | download_status_layout = QHBoxLayout() 322 | 323 | # Left side for speed and ETA 324 | status_left = QVBoxLayout() 325 | download_info_header = QLabel('Download Speed & ETA') 326 | status_left.addWidget(download_info_header) 327 | 328 | self.download_speed_label = QLabel() 329 | status_left.addWidget(self.download_speed_label) 330 | 331 | self.download_eta_label = QLabel() 332 | status_left.addWidget(self.download_eta_label) 333 | 334 | download_status_layout.addLayout(status_left) 335 | download_status_layout.addStretch() 336 | 337 | # Right side for file size 338 | status_right = QVBoxLayout() 339 | filesize_header = QLabel('File Size') 340 | filesize_header.setAlignment(Qt.AlignRight) 341 | status_right.addWidget(filesize_header) 342 | 343 | self.download_size_label = QLabel() 344 | self.download_size_label.setAlignment(Qt.AlignRight) 345 | status_right.addWidget(self.download_size_label) 346 | 347 | # Empty label for alignment 348 | status_right.addWidget(QLabel()) 349 | 350 | download_status_layout.addLayout(status_right) 351 | main_layout.addLayout(download_status_layout) 352 | 353 | # ============ BOTTOM SECTION: Collapsible Output Window (Window only) ============ 354 | # This method will now just return the frame with the output_window 355 | self.output_frame = self.create_collapsible_output_section() 356 | main_layout.addWidget(self.output_frame) # Add the frame containing the text area 357 | 358 | # ============ LOGS TOGGLE BUTTON (at the very bottom, right-justified) ============ 359 | logs_button_layout = QHBoxLayout() 360 | logs_button_layout.setContentsMargins(0, 0, 0, 0) # Remove margins 361 | logs_button_layout.addStretch() 362 | logs_button_layout.addWidget(self.output_toggle_button) # Add the pre-created button 363 | main_layout.addLayout(logs_button_layout) 364 | 365 | self.setLayout(main_layout) 366 | self.setWindowTitle('Myrient Downloader') 367 | 368 | # Set a reasonable width but let height be determined by content 369 | self.setMinimumWidth(1000) 370 | self.setMaximumWidth(1400) 371 | 372 | # Initialize checkbox visibility based on the current platform 373 | self.update_checkboxes_for_platform() 374 | 375 | # Let Qt calculate the optimal size based on content, then show 376 | self.adjustSize() 377 | 378 | # Ensure the window doesn't get too small 379 | current_size = self.size() 380 | if current_size.height() < 500: 381 | self.resize(current_size.width(), 500) 382 | 383 | # Initially hide the region filter 384 | region_group.setVisible(False) 385 | 386 | self.show() 387 | 388 | # Store region group reference for toggling visibility 389 | self.region_filter_group = region_group 390 | 391 | def create_collapsible_output_section(self): 392 | """Create a collapsible output window section.""" 393 | # Create the main frame 394 | output_frame = QFrame() 395 | output_frame.setFrameStyle(QFrame.StyledPanel) 396 | output_layout = QVBoxLayout(output_frame) 397 | output_layout.setContentsMargins(5, 5, 5, 5) 398 | 399 | # Header with toggle button is now created and placed in initUI 400 | 401 | # Output window (initially hidden) 402 | self.output_window.setMinimumHeight(200) 403 | self.output_window.setMaximumHeight(400) 404 | self.output_window.setVisible(False) # Content area is initially hidden 405 | output_layout.addWidget(self.output_window) 406 | output_frame.setVisible(False) # Frame itself is initially hidden 407 | 408 | return output_frame 409 | 410 | def toggle_output_window(self): 411 | """Toggle the visibility of the output window.""" 412 | if self.output_frame.isVisible(): 413 | self.output_frame.setVisible(False) 414 | self.output_toggle_button.setText('Show Logs') 415 | # Resize window to fit content without output section 416 | self.adjustSize() 417 | else: 418 | self.output_frame.setVisible(True) 419 | self.output_window.setVisible(True) # Ensure content is visible when frame is 420 | self.output_toggle_button.setText('Hide Logs') 421 | # Let the window expand to accommodate the output section 422 | self.adjustSize() 423 | 424 | # Removed animation helper methods _on_hide_animation_finished and _on_show_animation_finished 425 | 426 | def create_options_side_by_side(self): 427 | """Create general and platform-specific options side by side.""" 428 | # General options widget 429 | general_widget = QWidget() 430 | general_layout = QVBoxLayout(general_widget) 431 | general_layout.setContentsMargins(5, 5, 5, 5) 432 | 433 | general_options_group = QGroupBox("General Options") 434 | general_options_group.setStyleSheet("QGroupBox { padding-top: 15px; margin-top: 5px; font-weight: bold; }") 435 | general_options_layout = QVBoxLayout() 436 | 437 | # Create general options grid 438 | general_grid = QGridLayout() 439 | general_grid.setHorizontalSpacing(20) 440 | general_grid.setVerticalSpacing(10) 441 | 442 | # Add general options 443 | row = 0 444 | self.split_checkbox = QCheckBox('Split for FAT32 (if > 4GB)') 445 | self.split_checkbox.setChecked(self.settings_manager.split_large_files) 446 | general_grid.addWidget(self.split_checkbox, row, 0) 447 | 448 | self.keep_unsplit_dec_checkbox = QCheckBox('Keep unsplit file') 449 | self.keep_unsplit_dec_checkbox.setChecked(self.settings_manager.keep_unsplit_file) 450 | general_grid.addWidget(self.keep_unsplit_dec_checkbox, row, 1) 451 | 452 | row += 1 453 | self.organize_content_checkbox = QCheckBox('Group downloaded files per game') 454 | self.organize_content_checkbox.setChecked(self.settings_manager.organize_content_to_folders) 455 | general_grid.addWidget(self.organize_content_checkbox, row, 0, 1, 2) 456 | 457 | general_options_layout.addLayout(general_grid) 458 | general_options_group.setLayout(general_options_layout) 459 | general_layout.addWidget(general_options_group) 460 | # Removed general_layout.addStretch() to allow groupbox to potentially fill more space 461 | 462 | # Platform-specific options widget 463 | platform_widget = QWidget() 464 | platform_layout = QVBoxLayout(platform_widget) 465 | platform_layout.setContentsMargins(5, 5, 5, 5) 466 | 467 | self.platform_options_group = QGroupBox("Platform-Specific Options") 468 | self.platform_options_group.setStyleSheet("QGroupBox { padding-top: 15px; margin-top: 5px; font-weight: bold; }") 469 | platform_options_layout = QVBoxLayout() 470 | 471 | # PS3 specific options 472 | self.ps3_options_widget = QWidget() 473 | ps3_layout = QVBoxLayout(self.ps3_options_widget) 474 | ps3_layout.setContentsMargins(0, 0, 0, 0) 475 | 476 | ps3_grid = QGridLayout() 477 | ps3_grid.setHorizontalSpacing(20) 478 | ps3_grid.setVerticalSpacing(10) 479 | 480 | # PS3 options 481 | row = 0 482 | self.decrypt_checkbox = QCheckBox('Decrypt using PS3Dec') 483 | self.decrypt_checkbox.setChecked(self.settings_manager.decrypt_iso) 484 | ps3_grid.addWidget(self.decrypt_checkbox, row, 0) 485 | 486 | self.keep_enc_checkbox = QCheckBox('Keep encrypted PS3 ISO') 487 | self.keep_enc_checkbox.setChecked(self.settings_manager.keep_encrypted_iso) 488 | ps3_grid.addWidget(self.keep_enc_checkbox, row, 1) 489 | 490 | row += 1 491 | self.extract_ps3_checkbox = QCheckBox('Extract ISO using extractps3iso') 492 | self.extract_ps3_checkbox.setChecked(self.settings_manager.extract_ps3_iso) 493 | ps3_grid.addWidget(self.extract_ps3_checkbox, row, 0, 1, 2) 494 | 495 | row += 1 496 | self.keep_decrypted_iso_checkbox = QCheckBox('Keep decrypted ISO after extraction') 497 | self.keep_decrypted_iso_checkbox.setChecked(self.settings_manager.keep_decrypted_iso_after_extraction) 498 | ps3_grid.addWidget(self.keep_decrypted_iso_checkbox, row, 0, 1, 2) 499 | 500 | row += 1 501 | ps3_grid.setRowMinimumHeight(row, 5) 502 | 503 | row += 1 504 | self.keep_dkey_checkbox = QCheckBox('Keep PS3 ISO dkey file') 505 | self.keep_dkey_checkbox.setChecked(self.settings_manager.keep_dkey_file) 506 | ps3_grid.addWidget(self.keep_dkey_checkbox, row, 0, 1, 2) 507 | 508 | ps3_layout.addLayout(ps3_grid) 509 | platform_options_layout.addWidget(self.ps3_options_widget) 510 | 511 | # PSN specific options 512 | self.psn_options_widget = QWidget() 513 | psn_layout = QGridLayout(self.psn_options_widget) 514 | psn_layout.setContentsMargins(0, 0, 0, 0) 515 | 516 | self.split_pkg_checkbox = QCheckBox('Split PKG') 517 | self.split_pkg_checkbox.setChecked(self.settings_manager.split_pkg) 518 | psn_layout.addWidget(self.split_pkg_checkbox, 0, 0) 519 | 520 | platform_options_layout.addWidget(self.psn_options_widget) 521 | platform_options_layout.addStretch() 522 | 523 | self.platform_options_group.setLayout(platform_options_layout) 524 | platform_layout.addWidget(self.platform_options_group) 525 | # Removed platform_layout.addStretch() to allow groupbox to potentially fill more space 526 | 527 | # Connect signals for all checkboxes 528 | self._connect_checkbox_signals() 529 | 530 | # Set initial visibility states 531 | self.update_all_checkbox_states() 532 | self.ps3_options_widget.setVisible(False) 533 | self.psn_options_widget.setVisible(False) 534 | 535 | return general_widget, platform_widget 536 | 537 | def _connect_checkbox_signals(self): 538 | """Connect signals for all checkboxes.""" 539 | # General checkboxes 540 | self.split_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('split_large_files', state)) 541 | self.keep_unsplit_dec_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('keep_unsplit_file', state)) 542 | self.organize_content_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('organize_content_to_folders', state)) 543 | 544 | # PS3 checkboxes 545 | self.decrypt_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('decrypt_iso', state)) 546 | self.extract_ps3_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('extract_ps3_iso', state)) 547 | self.keep_enc_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('keep_encrypted_iso', state)) 548 | self.keep_dkey_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('keep_dkey_file', state)) 549 | self.keep_decrypted_iso_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('keep_decrypted_iso_after_extraction', state)) 550 | 551 | # PSN checkbox 552 | self.split_pkg_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('split_pkg', state)) 553 | 554 | def create_options_grid(self, parent_layout): 555 | """Create the options layout with sections for general and platform-specific options.""" 556 | # Create group box for general options 557 | general_options_group = QGroupBox("General Options") 558 | # Add padding to the group box title with style sheet 559 | general_options_group.setStyleSheet("QGroupBox { padding-top: 15px; margin-top: 5px; }") 560 | general_layout = QVBoxLayout() 561 | 562 | # Create a grid layout for general options 563 | general_grid = QGridLayout() 564 | general_grid.setHorizontalSpacing(20) # Space between columns 565 | general_grid.setVerticalSpacing(10) # Space between rows 566 | 567 | # Add general options - Keep related options next to each other 568 | row = 0 569 | self.split_checkbox = QCheckBox('Split for FAT32 (if > 4GB)', self) 570 | self.split_checkbox.setChecked(self.settings_manager.split_large_files) 571 | general_grid.addWidget(self.split_checkbox, row, 0) 572 | 573 | self.keep_unsplit_dec_checkbox = QCheckBox('Keep unsplit file', self) 574 | self.keep_unsplit_dec_checkbox.setChecked(self.settings_manager.keep_unsplit_file) 575 | general_grid.addWidget(self.keep_unsplit_dec_checkbox, row, 1) 576 | 577 | # Add universal content organization option 578 | row += 1 579 | self.organize_content_checkbox = QCheckBox('Group downloaded files per game', self) 580 | self.organize_content_checkbox.setChecked(self.settings_manager.organize_content_to_folders) 581 | general_grid.addWidget(self.organize_content_checkbox, row, 0, 1, 2) # Span 2 columns 582 | 583 | # Add the grid layout to the main layout 584 | general_layout.addLayout(general_grid) 585 | general_options_group.setLayout(general_layout) 586 | parent_layout.addWidget(general_options_group) 587 | 588 | # Create group box for platform-specific options 589 | self.platform_options_group = QGroupBox("Platform-Specific Options") 590 | self.platform_options_group.setStyleSheet("QGroupBox { padding-top: 15px; margin-top: 5px; }") 591 | platform_layout = QVBoxLayout() 592 | 593 | # PS3 specific options 594 | self.ps3_options_widget = QWidget() 595 | ps3_layout = QVBoxLayout(self.ps3_options_widget) 596 | ps3_layout.setContentsMargins(0, 0, 0, 0) 597 | 598 | # Create a grid layout for PS3 options with better organization and visual hierarchy 599 | ps3_grid = QGridLayout() 600 | ps3_grid.setHorizontalSpacing(20) 601 | ps3_grid.setVerticalSpacing(10) 602 | 603 | # First row - main decrypt checkbox with its direct related options 604 | row = 0 605 | self.decrypt_checkbox = QCheckBox('Decrypt using PS3Dec', self) 606 | self.decrypt_checkbox.setChecked(self.settings_manager.decrypt_iso) 607 | ps3_grid.addWidget(self.decrypt_checkbox, row, 0) 608 | 609 | self.keep_enc_checkbox = QCheckBox('Keep encrypted PS3 ISO', self) 610 | self.keep_enc_checkbox.setChecked(self.settings_manager.keep_encrypted_iso) 611 | ps3_grid.addWidget(self.keep_enc_checkbox, row, 1) 612 | 613 | # Second row - Extract ISO contents checkbox 614 | row += 1 615 | self.extract_ps3_checkbox = QCheckBox('Extract ISO using extractps3iso', self) 616 | self.extract_ps3_checkbox.setChecked(self.settings_manager.extract_ps3_iso) 617 | ps3_grid.addWidget(self.extract_ps3_checkbox, row, 0, 1, 2) # Span 2 columns 618 | 619 | # Third row - Keep decrypted ISO checkbox 620 | row += 1 621 | self.keep_decrypted_iso_checkbox = QCheckBox('Keep decrypted ISO after extraction', self) 622 | self.keep_decrypted_iso_checkbox.setChecked(self.settings_manager.keep_decrypted_iso_after_extraction) 623 | ps3_grid.addWidget(self.keep_decrypted_iso_checkbox, row, 0, 1, 2) # Span 2 columns 624 | 625 | # Separate row just for the dkey checkbox with clear separation 626 | row += 1 627 | # Add a small spacer before the dkey checkbox for visual separation 628 | ps3_grid.setRowMinimumHeight(row, 5) # 5px spacing 629 | 630 | row += 1 631 | self.keep_dkey_checkbox = QCheckBox('Keep PS3 ISO dkey file', self) 632 | self.keep_dkey_checkbox.setChecked(self.settings_manager.keep_dkey_file) 633 | # Place in first column, and make it span two columns for clarity 634 | ps3_grid.addWidget(self.keep_dkey_checkbox, row, 0, 1, 2) 635 | 636 | # Add PS3 grid to PS3 layout 637 | ps3_layout.addLayout(ps3_grid) 638 | platform_layout.addWidget(self.ps3_options_widget) 639 | 640 | # PSN specific options 641 | self.psn_options_widget = QWidget() 642 | psn_layout = QGridLayout(self.psn_options_widget) 643 | psn_layout.setContentsMargins(0, 0, 0, 0) 644 | 645 | self.split_pkg_checkbox = QCheckBox('Split PKG', self) 646 | self.split_pkg_checkbox.setChecked(self.settings_manager.split_pkg) 647 | psn_layout.addWidget(self.split_pkg_checkbox, 0, 0) 648 | 649 | platform_layout.addWidget(self.psn_options_widget) 650 | 651 | # Add stretch to push everything to the top 652 | platform_layout.addStretch() 653 | 654 | self.platform_options_group.setLayout(platform_layout) 655 | parent_layout.addWidget(self.platform_options_group) 656 | 657 | # Connect signals for all checkboxes AFTER all are created 658 | # Connect General group checkboxes 659 | self.split_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('split_large_files', state)) 660 | self.keep_unsplit_dec_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('keep_unsplit_file', state)) 661 | self.organize_content_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('organize_content_to_folders', state)) 662 | 663 | # Connect PS3 group checkboxes 664 | self.decrypt_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('decrypt_iso', state)) 665 | self.extract_ps3_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('extract_ps3_iso', state)) 666 | self.keep_enc_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('keep_encrypted_iso', state)) 667 | self.keep_dkey_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('keep_dkey_file', state)) 668 | self.keep_decrypted_iso_checkbox.stateChanged.connect(lambda state: 669 | self.handle_checkbox_change('keep_decrypted_iso_after_extraction', state)) 670 | 671 | # Connect PSN group checkbox 672 | self.split_pkg_checkbox.stateChanged.connect(lambda state: self.handle_checkbox_change('split_pkg', state)) 673 | 674 | # Set initial visibility states for all checkboxes 675 | self.update_all_checkbox_states() 676 | 677 | # Initially hide platform-specific options - they will be shown based on platform 678 | self.ps3_options_widget.setVisible(False) 679 | self.psn_options_widget.setVisible(False) 680 | 681 | def handle_checkbox_change(self, setting_name, state): 682 | """Handle checkbox state changes in a centralized way.""" 683 | try: 684 | # Convert Qt.CheckState to boolean 685 | checked = (state == Qt.Checked) 686 | 687 | # Special case: if turning on extract_ps3_iso, check if we have extractps3iso binary 688 | if setting_name == 'extract_ps3_iso' and checked: 689 | if not os.path.isfile(self.settings_manager.extractps3iso_binary): 690 | # Need to check/download extractps3iso 691 | dialog = BinaryValidationDialog("extractps3iso", self) 692 | if dialog.exec_(): 693 | # User wants to download it 694 | if not self.settings_manager.download_extractps3iso(): 695 | # Failed to download 696 | QMessageBox.warning( 697 | self, 698 | "Download Failed", 699 | "Failed to download extractps3iso. ISO extraction will not be available." 700 | ) 701 | # Uncheck the checkbox since we can't use this feature 702 | self.extract_ps3_checkbox.blockSignals(True) 703 | self.extract_ps3_checkbox.setChecked(False) 704 | self.extract_ps3_checkbox.blockSignals(False) 705 | checked = False 706 | else: 707 | # User canceled download 708 | self.extract_ps3_checkbox.blockSignals(True) 709 | self.extract_ps3_checkbox.setChecked(False) 710 | self.extract_ps3_checkbox.blockSignals(False) 711 | checked = False 712 | 713 | # Save the setting 714 | self.settings_manager.update_setting(setting_name, checked) 715 | 716 | # Update visibility and state of dependent checkboxes 717 | self.update_all_checkbox_states() 718 | except Exception as e: 719 | print(f"Error handling checkbox change for {setting_name}: {str(e)}") 720 | 721 | def update_all_checkbox_states(self): 722 | """Update visibility and state of all checkboxes based on dependencies.""" 723 | try: 724 | # Get current states 725 | decrypt_checked = self.decrypt_checkbox.isChecked() 726 | extract_checked = self.extract_ps3_checkbox.isChecked() 727 | split_checked = self.split_checkbox.isChecked() 728 | 729 | # ------ PS3 Specific Options ------ 730 | 731 | # Rule 1: Everything except the dkey checkbox depends on decrypt being checked 732 | self.keep_enc_checkbox.setVisible(decrypt_checked) 733 | self.extract_ps3_checkbox.setVisible(decrypt_checked) 734 | 735 | # Rule 2: Keep decrypted ISO checkbox depends on both decrypt AND extract 736 | self.keep_decrypted_iso_checkbox.setVisible(decrypt_checked and extract_checked) 737 | 738 | # Rule 3: If decrypt is unchecked, ensure extract is also unchecked 739 | if not decrypt_checked and extract_checked: 740 | self.extract_ps3_checkbox.blockSignals(True) 741 | self.extract_ps3_checkbox.setChecked(False) 742 | self.extract_ps3_checkbox.blockSignals(False) 743 | # Update setting directly without triggering signals 744 | self.settings_manager.update_setting('extract_ps3_iso', False) 745 | 746 | # ------ General Options ------ 747 | 748 | # Split file related options 749 | self.keep_unsplit_dec_checkbox.setVisible(split_checked) 750 | 751 | except Exception as e: 752 | print(f"Error updating checkbox states: {str(e)}") 753 | 754 | def update_checkboxes_for_platform(self): 755 | """Update which checkboxes are visible based on the active platform tab.""" 756 | try: 757 | current_tab = self.result_list.currentIndex() 758 | platform_ids = list(self.platforms.keys()) 759 | 760 | # Default visibility settings 761 | show_ps3dec = False 762 | show_pkg_split = False 763 | 764 | # Get current platform ID 765 | if 0 <= current_tab < len(platform_ids): 766 | current_platform_id = platform_ids[current_tab] 767 | 768 | # Get checkbox settings for this platform 769 | checkbox_settings = self.config_manager.get_platform_checkbox_settings(current_platform_id) 770 | show_ps3dec = checkbox_settings.get('show_ps3dec', False) 771 | show_pkg_split = checkbox_settings.get('show_pkg_split', False) 772 | 773 | # Update visibility of platform-specific options section 774 | has_platform_specific_options = show_ps3dec or show_pkg_split 775 | self.platform_options_group.setVisible(has_platform_specific_options) 776 | 777 | # Update visibility based on settings 778 | self.ps3_options_widget.setVisible(show_ps3dec) 779 | self.psn_options_widget.setVisible(show_pkg_split) 780 | 781 | # After changing platform visibility, update specific checkbox states 782 | self.update_all_checkbox_states() 783 | except Exception as e: 784 | print(f"Error updating checkboxes for platform: {str(e)}") 785 | 786 | def update_results(self): 787 | """Filter the software list based on search text and selected regions.""" 788 | search_term = self.search_box.text().lower().split() 789 | selected_regions = [region for region, checkbox in self.region_checkboxes.items() 790 | if checkbox.isChecked()] 791 | 792 | # Get the current platform based on the active tab 793 | current_tab = self.result_list.currentIndex() 794 | platform_ids = list(self.platforms.keys()) 795 | if 0 <= current_tab < len(platform_ids): 796 | current_platform = platform_ids[current_tab] 797 | list_to_search = self.platform_lists[current_platform] 798 | 799 | # Apply search filter 800 | filtered_list = [item for item in list_to_search if all(word in item.lower() for word in search_term)] 801 | 802 | def has_exact_region(item, region): 803 | """Check if item has exact region match in parentheses.""" 804 | return f"({region})" in item or f"({region.lower()})" in item.lower() 805 | 806 | def is_world_release(item): 807 | """Check if item is a World release.""" 808 | return has_exact_region(item, "World") 809 | 810 | def is_world_release(item): 811 | """Check if item is a World release.""" 812 | return "(world)" in item.lower() 813 | 814 | # Apply region filter if any regions are selected 815 | if selected_regions: 816 | if "World" in selected_regions: 817 | # Handle World releases 818 | world_matches = [item for item in filtered_list if is_world_release(item)] 819 | other_regions = [r for r in selected_regions if r not in ["World", "Other"]] 820 | 821 | if other_regions or "Other" in selected_regions: 822 | # Get items matching other selected regions exactly 823 | region_matches = [] 824 | if other_regions: 825 | for item in filtered_list: 826 | if any(has_exact_region(item, region) for region in other_regions): 827 | region_matches.append(item) 828 | 829 | if "Other" in selected_regions: 830 | # Add items that don't match any region exactly and aren't world releases 831 | no_region_matches = [ 832 | item for item in filtered_list 833 | if not is_world_release(item) and not any( 834 | has_exact_region(item, region) 835 | for region in self.region_checkboxes.keys() 836 | if region not in ["World", "Other"] 837 | ) 838 | ] 839 | filtered_list = list(set(world_matches + region_matches + no_region_matches)) 840 | else: 841 | filtered_list = list(set(world_matches + region_matches)) 842 | else: 843 | # Only World releases 844 | filtered_list = world_matches 845 | 846 | elif "Other" in selected_regions: 847 | # Handle Other (excluding World releases) 848 | standard_regions = [r for r in selected_regions if r != "Other"] 849 | if standard_regions: 850 | # Get items matching standard regions exactly 851 | standard_matches = [] 852 | for item in filtered_list: 853 | if any(has_exact_region(item, region) for region in standard_regions): 854 | standard_matches.append(item) 855 | 856 | # Get items not matching any region exactly and not world releases 857 | no_region_matches = [ 858 | item for item in filtered_list 859 | if not is_world_release(item) and not any( 860 | has_exact_region(item, region) 861 | for region in self.region_checkboxes.keys() 862 | if region not in ["World", "Other"] 863 | ) 864 | ] 865 | filtered_list = list(set(standard_matches + no_region_matches)) 866 | else: 867 | # Only "Other" is selected - show items not matching any region exactly and not world releases 868 | filtered_list = [ 869 | item for item in filtered_list 870 | if not is_world_release(item) and not any( 871 | has_exact_region(item, region) 872 | for region in self.region_checkboxes.keys() 873 | if region not in ["World", "Other"] 874 | ) 875 | ] 876 | else: 877 | # Normal region filtering - match regions exactly 878 | filtered_list = [ 879 | item for item in filtered_list 880 | if any(has_exact_region(item, region) for region in selected_regions) 881 | ] 882 | 883 | # Clear the current list widget and add the filtered items 884 | current_list_widget = self.result_list.currentWidget() 885 | current_list_widget.clear() 886 | current_list_widget.addItems(filtered_list) 887 | 888 | def update_add_to_queue_button(self): 889 | """Enable or disable the add to queue button based on selection.""" 890 | self.add_to_queue_button.setEnabled(bool(self.result_list.currentWidget().selectedItems())) 891 | 892 | def update_remove_from_queue_button(self): 893 | """Enable or disable the remove from queue button based on selection.""" 894 | self.remove_from_queue_button.setEnabled(bool(self.queue_list.selectedItems())) 895 | 896 | def add_to_queue(self): 897 | """Add selected items to the download queue.""" 898 | selected_items = self.result_list.currentWidget().selectedItems() 899 | current_tab = self.result_list.currentIndex() 900 | platform_ids = list(self.platforms.keys()) 901 | 902 | if 0 <= current_tab < len(platform_ids): 903 | current_platform = platform_ids[current_tab] 904 | added_count = self.app_controller.add_to_queue( 905 | selected_items, current_platform, self.platforms, self.queue_list 906 | ) 907 | 908 | # Removed "Added x items to queue" output to clean up logs 909 | 910 | def remove_from_queue(self): 911 | """Remove selected items from the download queue.""" 912 | selected_items = self.queue_list.selectedItems() 913 | if not selected_items: 914 | return 915 | 916 | removed_count = self.app_controller.remove_from_queue(selected_items, self.queue_list) 917 | 918 | # Update button state 919 | self.update_remove_from_queue_button() 920 | 921 | def open_settings(self): 922 | """Open the settings dialog.""" 923 | dlg = SettingsDialog(self.settings_manager, self.config_manager, self) 924 | if dlg.exec_() == QDialog.Accepted: 925 | QMessageBox.information(self, "Settings", "Settings saved.") 926 | # Optionally, update directories on disk if changed 927 | self.settings_manager.create_directories() 928 | 929 | 930 | 931 | def toggle_region_filter(self): 932 | """Toggle visibility of the region filter group.""" 933 | is_visible = self.region_filter_group.isVisible() 934 | self.region_filter_group.setVisible(not is_visible) 935 | self.filters_button.setChecked(not is_visible) 936 | 937 | def toggle_region_filter(self): 938 | """Toggle visibility of the region filter group.""" 939 | is_visible = self.region_filter_group.isVisible() 940 | self.region_filter_group.setVisible(not is_visible) 941 | self.filters_button.setChecked(not is_visible) 942 | 943 | def closeEvent(self, event): 944 | """Handle the close event.""" 945 | try: 946 | # Save pause state and queue using AppController 947 | self.app_controller.save_pause_state(self.queue_list) 948 | 949 | # Stop all threads 950 | self._stop_all_threads() 951 | 952 | # Restore stdout/stderr to their original values 953 | if hasattr(self, 'output_window'): 954 | self.output_window.restore_stdout() 955 | 956 | except Exception as e: 957 | print(f"Error during shutdown: {str(e)}") 958 | 959 | event.accept() 960 | 961 | # Schedule application quit after a short delay to allow cleanup 962 | from PyQt5.QtCore import QTimer 963 | QTimer.singleShot(100, QApplication.instance().quit) 964 | 965 | def _stop_all_threads(self): 966 | """Stop all running threads gracefully.""" 967 | # Stop AppController operations 968 | self.app_controller.stop_processing() 969 | 970 | # Stop platform threads if running 971 | if hasattr(self, 'platform_threads'): 972 | for platform_id, thread in self.platform_threads.items(): 973 | try: 974 | if thread and thread.isRunning(): 975 | thread.wait(500) # Wait up to 0.5 seconds 976 | except Exception as e: 977 | print(f"Error stopping platform thread for {platform_id}: {str(e)}") 978 | 979 | def start_or_pause_download(self): 980 | """Handle start or pause button click based on current state.""" 981 | button_text = self.start_pause_button.text() 982 | 983 | if button_text == 'Start': 984 | self.start_download() 985 | elif button_text == 'Pause': 986 | self.pause_download() 987 | elif button_text == 'Resume': 988 | self.resume_download() 989 | 990 | def start_download(self): 991 | """Start downloading the selected items.""" 992 | # Disable the GUI buttons except pause 993 | self._disable_controls_during_processing() 994 | 995 | # Change button to Pause 996 | self.start_pause_button.setText('Pause') 997 | 998 | # Get current settings 999 | settings = self._get_current_settings() 1000 | 1001 | # Start processing using AppController 1002 | self.app_controller.start_processing(self.queue_list, settings) 1003 | 1004 | def pause_download(self): 1005 | """Pause the current download or extraction process.""" 1006 | self.app_controller.pause_processing() 1007 | 1008 | # Change the button text to 'Resume' 1009 | self.start_pause_button.setText('Resume') 1010 | 1011 | # Enable settings and remove from queue buttons when paused 1012 | self._enable_controls_during_pause() 1013 | 1014 | def resume_download(self): 1015 | """Resume a previously paused download.""" 1016 | self.start_pause_button.setText('Pause') 1017 | 1018 | # Get current settings 1019 | settings = self._get_current_settings() 1020 | 1021 | # Disable controls during processing 1022 | self._disable_controls_during_processing() 1023 | 1024 | # Resume processing using AppController 1025 | self.app_controller.resume_processing(self.queue_list, settings) 1026 | 1027 | def _disable_controls_during_processing(self): 1028 | """Disable GUI controls during processing.""" 1029 | self.settings_button.setEnabled(False) 1030 | self.add_to_queue_button.setEnabled(False) 1031 | self.remove_from_queue_button.setEnabled(False) 1032 | self.decrypt_checkbox.setEnabled(False) 1033 | self.split_checkbox.setEnabled(False) 1034 | self.keep_dkey_checkbox.setEnabled(False) 1035 | self.keep_enc_checkbox.setEnabled(False) 1036 | self.keep_unsplit_dec_checkbox.setEnabled(False) 1037 | self.split_pkg_checkbox.setEnabled(False) 1038 | self.extract_ps3_checkbox.setEnabled(False) 1039 | self.keep_decrypted_iso_checkbox.setEnabled(False) 1040 | self.organize_content_checkbox.setEnabled(False) 1041 | 1042 | def _enable_controls_during_pause(self): 1043 | """Enable specific controls during pause.""" 1044 | self.settings_button.setEnabled(True) 1045 | self.remove_from_queue_button.setEnabled(True) 1046 | self.queue_list.setEnabled(True) 1047 | 1048 | # Allow selection in the queue list 1049 | self.queue_list.setSelectionMode(QAbstractItemView.MultiSelection) 1050 | 1051 | # Update the remove from queue button state based on selection 1052 | self.update_remove_from_queue_button() 1053 | 1054 | def _enable_all_buttons(self): 1055 | """Re-enable all GUI buttons.""" 1056 | self.settings_button.setEnabled(True) 1057 | self.add_to_queue_button.setEnabled(True) 1058 | self.remove_from_queue_button.setEnabled(True) 1059 | self.decrypt_checkbox.setEnabled(True) 1060 | self.split_checkbox.setEnabled(True) 1061 | self.keep_dkey_checkbox.setEnabled(True) 1062 | self.keep_enc_checkbox.setEnabled(True) 1063 | self.keep_unsplit_dec_checkbox.setEnabled(True) 1064 | self.split_pkg_checkbox.setEnabled(True) 1065 | self.extract_ps3_checkbox.setEnabled(True) 1066 | self.keep_decrypted_iso_checkbox.setEnabled(True) 1067 | self.organize_content_checkbox.setEnabled(True) 1068 | self.start_pause_button.setEnabled(True) 1069 | self.start_pause_button.setText('Start') 1070 | 1071 | def _get_current_settings(self): 1072 | """Get current settings from the GUI.""" 1073 | return { 1074 | 'decrypt_iso': self.decrypt_checkbox.isChecked(), 1075 | 'split_large_files': self.split_checkbox.isChecked(), 1076 | 'keep_dkey_file': self.keep_dkey_checkbox.isChecked(), 1077 | 'keep_encrypted_iso': self.keep_enc_checkbox.isChecked(), 1078 | 'keep_unsplit_file': self.keep_unsplit_dec_checkbox.isChecked(), 1079 | 'split_pkg': self.split_pkg_checkbox.isChecked(), 1080 | 'extract_ps3_iso': self.extract_ps3_checkbox.isChecked(), 1081 | 'keep_decrypted_iso_after_extraction': self.keep_decrypted_iso_checkbox.isChecked(), 1082 | 'organize_content_to_folders': self.organize_content_checkbox.isChecked() 1083 | } 1084 | 1085 | def _on_queue_updated(self): 1086 | """Handle queue update signal from AppController.""" 1087 | # Update button states 1088 | self.update_add_to_queue_button() 1089 | self.update_remove_from_queue_button() 1090 | 1091 | def _on_operation_complete(self): 1092 | """Handle operation complete signal from AppController.""" 1093 | # Re-enable all buttons 1094 | self._enable_all_buttons() 1095 | 1096 | # Clear download status labels 1097 | self.download_speed_label.setText("") 1098 | self.download_eta_label.setText("") 1099 | self.download_size_label.setText("") 1100 | 1101 | # Save the queue state (should be empty now) to clear the queue file 1102 | self.app_controller.save_queue(self.queue_list) 1103 | 1104 | def _on_operation_paused(self, item_name): 1105 | """Handle operation paused signal from AppController.""" 1106 | self.start_pause_button.setText('Resume') 1107 | self._enable_controls_during_pause() 1108 | 1109 | def _on_error(self, error_message): 1110 | """Handle error signal from AppController.""" 1111 | # Show output window when there's an error 1112 | if not self.output_window.isVisible(): 1113 | self.toggle_output_window() 1114 | self.output_window.append(f"ERROR: {error_message}") 1115 | 1116 | 1117 | -------------------------------------------------------------------------------- /gui/output_window.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import QTextEdit, QApplication 2 | from PyQt5.QtGui import QTextCursor 3 | from PyQt5.QtCore import pyqtSignal, QObject, QTimer 4 | import sys 5 | import re 6 | 7 | 8 | class OutputRedirector(QObject): 9 | """A QObject that can safely redirect output across threads.""" 10 | text_written = pyqtSignal(str) 11 | 12 | def __init__(self, output_window): 13 | super().__init__() 14 | self.output_window = output_window 15 | self.text_written.connect(self.output_window.append_text) 16 | self.buffer = "" 17 | 18 | def write(self, text): 19 | # Buffer the text until we get a complete line 20 | self.buffer += text 21 | 22 | # Process complete lines 23 | if '\n' in self.buffer: 24 | lines = self.buffer.split('\n') 25 | # Keep the last (potentially incomplete) line in the buffer 26 | self.buffer = lines[-1] 27 | 28 | # Send complete lines to the output window 29 | complete_lines = '\n'.join(lines[:-1]) 30 | if complete_lines: 31 | self.text_written.emit(complete_lines + '\n') 32 | 33 | # If the buffer is getting too long without a newline, flush it 34 | elif len(self.buffer) > 1000: 35 | self.text_written.emit(self.buffer) 36 | self.buffer = "" 37 | 38 | def flush(self): 39 | if self.buffer: 40 | self.text_written.emit(self.buffer) 41 | self.buffer = "" 42 | 43 | 44 | class OutputWindow(QTextEdit): 45 | """A custom QTextEdit that can be used as a stdout-like output window.""" 46 | 47 | def __init__(self, *args, **kwargs): 48 | super(OutputWindow, self).__init__(*args, **kwargs) 49 | self.setReadOnly(True) 50 | 51 | # Store the original stdout for emergency use 52 | self.original_stdout = sys.stdout 53 | self.redirector = OutputRedirector(self) 54 | 55 | # Track if cursor is at the end 56 | self.atEnd = True 57 | 58 | # Track last written text to avoid double newlines 59 | self.lastWrittenEndsWithNewline = True 60 | 61 | # Create a single shot timer for deferred scrolling 62 | self.scrollTimer = QTimer() 63 | self.scrollTimer.setSingleShot(True) 64 | self.scrollTimer.timeout.connect(self.forceScrollToBottom) 65 | 66 | # Set styling that respects the application theme 67 | self.setStyleSheet(""" 68 | QTextEdit { 69 | border: 1px solid palette(mid); 70 | border-radius: 4px; 71 | background-color: palette(base); 72 | color: palette(text); 73 | font-family: 'Consolas', 'Monaco', 'Courier New', monospace; 74 | font-size: 9pt; 75 | } 76 | """) 77 | 78 | def set_as_stdout(self): 79 | """Set this OutputWindow as the system's stdout.""" 80 | sys.stdout = self.redirector 81 | sys.stderr = self.redirector 82 | 83 | def restore_stdout(self): 84 | """Restore the original stdout.""" 85 | sys.stdout = self.original_stdout 86 | sys.stderr = self.original_stdout 87 | 88 | def append_text(self, text): 89 | """Append text to the output window (thread-safe method).""" 90 | # Clean up the text - normalize newlines and remove excessive ones 91 | text = text.replace('\r\n', '\n') 92 | text = re.sub(r'\n{3,}', '\n\n', text) 93 | 94 | # Make sure all text ends with a newline 95 | if not text.endswith('\n'): 96 | text += '\n' 97 | 98 | # If the last text we wrote ended with a newline and this one starts with one, 99 | # remove the leading newline to avoid double spacing 100 | if self.lastWrittenEndsWithNewline and text.startswith('\n'): 101 | text = text[1:] 102 | 103 | # Remember if this text ends with a newline 104 | self.lastWrittenEndsWithNewline = text.endswith('\n') 105 | 106 | # Add the text 107 | cursor = self.textCursor() 108 | cursor.movePosition(QTextCursor.End) 109 | cursor.insertText(text) 110 | self.setTextCursor(cursor) 111 | 112 | # Scroll to bottom with multiple approaches to ensure it works 113 | self.ensureCursorVisible() 114 | self.forceScrollToBottom() 115 | 116 | # Schedule another scroll to bottom after event processing 117 | self.scrollTimer.start(50) # 50ms delay 118 | 119 | # Clear any excess text if the document is getting too large 120 | document = self.document() 121 | if document.characterCount() > 100000: # Limit to ~100K characters 122 | cursor = QTextCursor(document) 123 | cursor.movePosition(QTextCursor.Start) 124 | cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 50000) # Delete oldest 50K chars 125 | cursor.removeSelectedText() 126 | 127 | QApplication.processEvents() 128 | 129 | def forceScrollToBottom(self): 130 | """Force the text edit to scroll to the bottom.""" 131 | # Move scrollbar to maximum position 132 | scrollbar = self.verticalScrollBar() 133 | scrollbar.setValue(scrollbar.maximum()) 134 | 135 | # Also make sure cursor is at the end 136 | cursor = self.textCursor() 137 | cursor.movePosition(QTextCursor.End) 138 | self.setTextCursor(cursor) 139 | self.ensureCursorVisible() 140 | -------------------------------------------------------------------------------- /gui/overwrite_dialog.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, 2 | QPushButton, QCheckBox, QFrame, QScrollArea, QWidget) 3 | from PyQt5.QtCore import Qt 4 | from PyQt5.QtGui import QFont, QIcon 5 | import os 6 | 7 | 8 | class OverwriteDialog(QDialog): 9 | """Dialog for handling file overwrite conflicts.""" 10 | 11 | # Return codes for user actions 12 | OVERWRITE = 1 13 | SKIP = 2 14 | RENAME = 3 15 | CANCEL = 4 16 | 17 | def __init__(self, conflicts, operation_type="processing", parent=None): 18 | super().__init__(parent) 19 | self.conflicts = conflicts if isinstance(conflicts, list) else [conflicts] 20 | self.operation_type = operation_type 21 | self.user_choice = self.CANCEL 22 | self.apply_to_all = False 23 | 24 | self.setWindowTitle(f"File Conflicts - {operation_type.title()}") 25 | self.setModal(True) 26 | self.setMinimumSize(500, 300) 27 | self.setMaximumSize(800, 600) 28 | 29 | self.setup_ui() 30 | 31 | def setup_ui(self): 32 | """Set up the dialog UI.""" 33 | layout = QVBoxLayout() 34 | 35 | # Title 36 | title_label = QLabel(f"File conflicts detected during {self.operation_type}") 37 | title_font = QFont() 38 | title_font.setBold(True) 39 | title_font.setPointSize(12) 40 | title_label.setFont(title_font) 41 | layout.addWidget(title_label) 42 | 43 | # Separator 44 | separator = QFrame() 45 | separator.setFrameShape(QFrame.HLine) 46 | separator.setFrameShadow(QFrame.Sunken) 47 | layout.addWidget(separator) 48 | 49 | # Conflicts area 50 | if len(self.conflicts) > 1: 51 | # Multiple conflicts - show in scrollable area 52 | scroll_area = QScrollArea() 53 | scroll_widget = QWidget() 54 | scroll_layout = QVBoxLayout(scroll_widget) 55 | 56 | count_label = QLabel(f"Found {len(self.conflicts)} file conflicts:") 57 | count_font = QFont() 58 | count_font.setBold(True) 59 | count_label.setFont(count_font) 60 | scroll_layout.addWidget(count_label) 61 | 62 | for i, conflict in enumerate(self.conflicts[:10]): # Show first 10 63 | conflict_widget = self._create_conflict_widget(conflict, i + 1) 64 | scroll_layout.addWidget(conflict_widget) 65 | 66 | if len(self.conflicts) > 10: 67 | more_label = QLabel(f"... and {len(self.conflicts) - 10} more files") 68 | more_font = QFont() 69 | more_font.setItalic(True) 70 | more_label.setFont(more_font) 71 | scroll_layout.addWidget(more_label) 72 | 73 | scroll_area.setWidget(scroll_widget) 74 | scroll_area.setMaximumHeight(200) 75 | layout.addWidget(scroll_area) 76 | else: 77 | # Single conflict 78 | conflict_widget = self._create_conflict_widget(self.conflicts[0]) 79 | layout.addWidget(conflict_widget) 80 | 81 | # Options section 82 | options_label = QLabel("What would you like to do?") 83 | options_font = QFont() 84 | options_font.setBold(True) 85 | options_label.setFont(options_font) 86 | layout.addWidget(options_label) 87 | 88 | # Button layout 89 | button_layout = QHBoxLayout() 90 | 91 | # Overwrite button 92 | self.overwrite_btn = QPushButton("Overwrite") 93 | self.overwrite_btn.setToolTip("Replace existing files with new ones") 94 | self.overwrite_btn.clicked.connect(lambda: self._set_choice(self.OVERWRITE)) 95 | button_layout.addWidget(self.overwrite_btn) 96 | 97 | # Skip button 98 | self.skip_btn = QPushButton("Skip") 99 | self.skip_btn.setToolTip("Keep existing files, skip processing new ones") 100 | self.skip_btn.clicked.connect(lambda: self._set_choice(self.SKIP)) 101 | button_layout.addWidget(self.skip_btn) 102 | 103 | # Rename button (for some operations) 104 | if self.operation_type in ["processing", "extraction"]: 105 | self.rename_btn = QPushButton("Rename") 106 | self.rename_btn.setToolTip("Create new files with different names") 107 | self.rename_btn.clicked.connect(lambda: self._set_choice(self.RENAME)) 108 | button_layout.addWidget(self.rename_btn) 109 | 110 | # Cancel button 111 | self.cancel_btn = QPushButton("Cancel") 112 | self.cancel_btn.setToolTip("Cancel the operation") 113 | self.cancel_btn.clicked.connect(lambda: self._set_choice(self.CANCEL)) 114 | button_layout.addWidget(self.cancel_btn) 115 | 116 | layout.addLayout(button_layout) 117 | 118 | # Apply to all checkbox (for multiple conflicts) 119 | if len(self.conflicts) > 1: 120 | self.apply_all_cb = QCheckBox("Apply this choice to all conflicts") 121 | self.apply_all_cb.setChecked(True) 122 | layout.addWidget(self.apply_all_cb) 123 | 124 | self.setLayout(layout) 125 | 126 | # Set default button based on operation type 127 | if self.operation_type == "downloading": 128 | self.skip_btn.setDefault(True) 129 | else: 130 | self.overwrite_btn.setDefault(True) 131 | 132 | def _create_conflict_widget(self, conflict_info, index=None): 133 | """Create a widget showing conflict information.""" 134 | widget = QWidget() 135 | layout = QVBoxLayout(widget) 136 | layout.setContentsMargins(10, 5, 10, 5) 137 | 138 | if index: 139 | index_label = QLabel(f"Conflict {index}:") 140 | index_font = QFont() 141 | index_font.setBold(True) 142 | index_label.setFont(index_font) 143 | layout.addWidget(index_label) 144 | 145 | # File path 146 | if isinstance(conflict_info, dict): 147 | file_path = conflict_info.get('path', 'Unknown file') 148 | existing_size = conflict_info.get('existing_size', 0) 149 | new_size = conflict_info.get('new_size', 0) 150 | else: 151 | file_path = str(conflict_info) 152 | existing_size = 0 153 | new_size = 0 154 | 155 | path_label = QLabel(f"File: {file_path}") 156 | path_label.setWordWrap(True) 157 | layout.addWidget(path_label) 158 | 159 | # Size information if available 160 | if existing_size > 0 or new_size > 0: 161 | size_info = QLabel(f"Existing: {self._format_size(existing_size)} | New: {self._format_size(new_size)}") 162 | size_font = QFont() 163 | size_font.setPointSize(8) 164 | size_info.setFont(size_font) 165 | layout.addWidget(size_info) 166 | 167 | # Simple border styling that respects system theme 168 | widget.setStyleSheet("QWidget { border: 1px solid palette(mid); border-radius: 4px; margin: 2px; }") 169 | 170 | return widget 171 | 172 | def _set_choice(self, choice): 173 | """Set the user's choice and close dialog.""" 174 | self.user_choice = choice 175 | if hasattr(self, 'apply_all_cb'): 176 | self.apply_to_all = self.apply_all_cb.isChecked() 177 | else: 178 | self.apply_to_all = False 179 | self.accept() 180 | 181 | def _format_size(self, size_bytes): 182 | """Format file size in human-readable format.""" 183 | if size_bytes == 0: 184 | return "Unknown" 185 | elif size_bytes < 1024: 186 | return f"{size_bytes} B" 187 | elif size_bytes < 1024 * 1024: 188 | return f"{size_bytes/1024:.1f} KB" 189 | elif size_bytes < 1024 * 1024 * 1024: 190 | return f"{size_bytes/(1024*1024):.1f} MB" 191 | else: 192 | return f"{size_bytes/(1024*1024*1024):.1f} GB" 193 | 194 | @staticmethod 195 | def ask_overwrite(conflicts, operation_type="processing", parent=None): 196 | """Show overwrite dialog and return user choice.""" 197 | dialog = OverwriteDialog(conflicts, operation_type, parent) 198 | 199 | if dialog.exec_() == QDialog.Accepted: 200 | return dialog.user_choice, dialog.apply_to_all 201 | else: 202 | return OverwriteDialog.CANCEL, False 203 | 204 | 205 | class OverwriteManager: 206 | """Manages overwrite decisions and applies user choices.""" 207 | 208 | def __init__(self): 209 | self.global_choice = None 210 | self.apply_to_all = False 211 | 212 | def reset(self): 213 | """Reset global choices.""" 214 | self.global_choice = None 215 | self.apply_to_all = False 216 | 217 | def handle_conflict(self, conflict_info, operation_type="processing", parent=None): 218 | """ 219 | Handle a file conflict, either using global choice or asking user. 220 | 221 | Args: 222 | conflict_info: Dictionary with conflict details or file path string 223 | operation_type: Type of operation (downloading, extraction, processing) 224 | parent: Parent widget for dialog 225 | 226 | Returns: 227 | Tuple of (action, apply_to_all) where action is one of: 228 | OverwriteDialog.OVERWRITE, SKIP, RENAME, or CANCEL 229 | """ 230 | # If we have a global choice that applies to all, use it 231 | if self.global_choice is not None and self.apply_to_all: 232 | return self.global_choice, True 233 | 234 | # Ask the user 235 | choice, apply_to_all = OverwriteDialog.ask_overwrite( 236 | conflict_info, operation_type, parent 237 | ) 238 | 239 | # Store global choice if apply to all is selected 240 | if apply_to_all: 241 | self.global_choice = choice 242 | self.apply_to_all = True 243 | 244 | return choice, apply_to_all 245 | 246 | def should_overwrite(self, file_path, operation_type="processing", parent=None): 247 | """ 248 | Simple helper to check if a file should be overwritten. 249 | 250 | Returns: 251 | True if should overwrite, False if should skip, None if cancelled 252 | """ 253 | if not os.path.exists(file_path): 254 | return True # No conflict 255 | 256 | # Get file size for conflict info 257 | try: 258 | existing_size = os.path.getsize(file_path) 259 | except OSError: 260 | existing_size = 0 261 | 262 | conflict_info = { 263 | 'path': file_path, 264 | 'existing_size': existing_size, 265 | 'new_size': 0 # Unknown for simple checks 266 | } 267 | 268 | choice, _ = self.handle_conflict(conflict_info, operation_type, parent) 269 | 270 | if choice == OverwriteDialog.OVERWRITE: 271 | return True 272 | elif choice == OverwriteDialog.SKIP: 273 | return False 274 | else: # CANCEL or RENAME 275 | return None # Indicates cancellation -------------------------------------------------------------------------------- /myrientDownloaderGUI.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | import platform 5 | 6 | # Import Qt core module first and set attributes before any other Qt imports 7 | from PyQt5.QtCore import Qt, QCoreApplication, QSettings 8 | 9 | # Set Qt attributes before creating QApplication or importing other Qt modules 10 | QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) 11 | QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) 12 | 13 | # Now import other Qt modules and application modules 14 | from PyQt5.QtWidgets import QApplication, QMessageBox, QDialog, QStyleFactory 15 | from PyQt5.QtGui import QFontDatabase, QPalette, QColor 16 | 17 | from gui.main_window import GUIDownloader 18 | from core.settings import SettingsManager 19 | 20 | # Global settings for theme 21 | app_settings = None 22 | 23 | # Capture early output 24 | class OutputBuffer: 25 | def __init__(self): 26 | self.buffer = [] 27 | self.orig_stdout = sys.stdout 28 | self.orig_stderr = sys.stderr 29 | 30 | def write(self, text): 31 | self.buffer.append(text) 32 | # Still write to original stdout for early debugging 33 | self.orig_stdout.write(text) 34 | 35 | def flush(self): 36 | pass 37 | 38 | def transfer_to_output(self, output_window): 39 | for text in self.buffer: 40 | output_window.append_text(text) 41 | self.buffer = [] 42 | 43 | # Add a method to properly handle stderr 44 | def stderr_write(self, text): 45 | """Write to the buffer and original stderr.""" 46 | self.buffer.append(text) 47 | self.orig_stderr.write(text) 48 | 49 | def detect_system_dark_mode(): 50 | """Simple detection of system dark mode preference.""" 51 | try: 52 | if platform.system() == 'Windows': 53 | import winreg 54 | with winreg.OpenKey(winreg.HKEY_CURRENT_USER, 55 | r'Software\Microsoft\Windows\CurrentVersion\Themes\Personalize') as key: 56 | value, _ = winreg.QueryValueEx(key, 'AppsUseLightTheme') 57 | return value == 0 # 0 = dark, 1 = light 58 | elif platform.system() == 'Darwin': 59 | import subprocess 60 | result = subprocess.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], 61 | capture_output=True, text=True, timeout=5) 62 | return result.stdout.strip().lower() == 'dark' 63 | elif platform.system() == 'Linux': 64 | # Simple check for common dark themes 65 | gtk_theme = os.environ.get('GTK_THEME', '').lower() 66 | return 'dark' in gtk_theme 67 | except Exception: 68 | pass 69 | return False 70 | 71 | def apply_theme(app): 72 | """Apply theme using Qt's built-in system with user preference.""" 73 | global app_settings 74 | app_settings = QSettings('./config/myrientDownloaderGUI.ini', QSettings.IniFormat) 75 | 76 | # Get user preference: 'auto', 'light', 'dark', 'system' - default to 'dark' 77 | theme_preference = app_settings.value('appearance/theme', 'dark') 78 | 79 | if theme_preference == 'dark': 80 | use_dark = True 81 | elif theme_preference == 'light': 82 | use_dark = False 83 | elif theme_preference == 'system': 84 | # Use Qt's default system theme 85 | app.setStyle(QStyleFactory.create("Fusion")) 86 | app.setPalette(app.style().standardPalette()) 87 | app.setStyleSheet("") 88 | return False 89 | else: # 'auto' - follow system 90 | use_dark = detect_system_dark_mode() 91 | 92 | if use_dark: 93 | # Apply simple dark theme using Qt's palette system 94 | app.setStyle(QStyleFactory.create("Fusion")) 95 | palette = QPalette() 96 | 97 | # Use colors with good contrast ratios 98 | palette.setColor(QPalette.Window, QColor(45, 45, 45)) 99 | palette.setColor(QPalette.WindowText, QColor(255, 255, 255)) 100 | palette.setColor(QPalette.Base, QColor(35, 35, 35)) 101 | palette.setColor(QPalette.AlternateBase, QColor(60, 60, 60)) 102 | palette.setColor(QPalette.ToolTipBase, QColor(35, 35, 35)) 103 | palette.setColor(QPalette.ToolTipText, QColor(255, 255, 255)) 104 | palette.setColor(QPalette.Text, QColor(255, 255, 255)) 105 | palette.setColor(QPalette.Button, QColor(60, 60, 60)) 106 | palette.setColor(QPalette.ButtonText, QColor(255, 255, 255)) 107 | palette.setColor(QPalette.BrightText, QColor(255, 0, 0)) 108 | palette.setColor(QPalette.Link, QColor(42, 130, 218)) 109 | palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) 110 | palette.setColor(QPalette.HighlightedText, QColor(0, 0, 0)) 111 | 112 | app.setPalette(palette) 113 | 114 | # Minimal stylesheet for better readability 115 | app.setStyleSheet(""" 116 | QToolTip { 117 | color: #ffffff; 118 | background-color: #353535; 119 | border: 1px solid #767676; 120 | } 121 | """) 122 | else: 123 | # Use Qt's default light theme 124 | app.setStyle(QStyleFactory.create("Fusion")) 125 | app.setPalette(app.style().standardPalette()) 126 | app.setStyleSheet("") 127 | 128 | return use_dark 129 | 130 | def show_styled_message_box(icon, title, text, parent=None): 131 | """Show a message box with basic styling.""" 132 | msg_box = QMessageBox(parent) 133 | msg_box.setIcon(icon) 134 | msg_box.setWindowTitle(title) 135 | msg_box.setText(text) 136 | return msg_box.exec_() 137 | 138 | def is_dark_mode(): 139 | """Check if dark mode is currently active (for backward compatibility).""" 140 | global app_settings 141 | if app_settings: 142 | theme_preference = app_settings.value('appearance/theme', 'dark') 143 | if theme_preference == 'dark': 144 | return True 145 | elif theme_preference == 'light': 146 | return False 147 | elif theme_preference == 'auto': 148 | return detect_system_dark_mode() 149 | return True # Default to dark mode 150 | 151 | def style_dialog_for_theme(dialog): 152 | """Apply minimal theme styling to dialogs.""" 153 | # Qt handles this automatically with the palette 154 | return dialog 155 | 156 | def validate_startup_prerequisites(): 157 | """Validate startup prerequisites using the new settings-based system.""" 158 | try: 159 | from core.settings import SettingsManager 160 | settings_manager = SettingsManager() 161 | return settings_manager.validate_startup_prerequisites() 162 | except Exception as e: 163 | print(f"Error during startup prerequisite validation: {str(e)}") 164 | return True # Continue on validation errors 165 | 166 | 167 | def main(): 168 | """Main entry point for the application.""" 169 | 170 | # Create output buffer to capture early messages 171 | buffer = OutputBuffer() 172 | sys.stdout = buffer 173 | sys.stderr = buffer 174 | 175 | # Detect Wayland environment and apply necessary fixes 176 | if os.environ.get("XDG_SESSION_TYPE") == "wayland": 177 | print("Wayland session detected, applying compatibility settings") 178 | # Force Qt to use x11 platform plugin for better compatibility 179 | os.environ["QT_QPA_PLATFORM"] = "xcb" 180 | # Disable high DPI scaling which can cause rendering issues in dialogs 181 | os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "0" 182 | 183 | # Initialize application 184 | app = QApplication(sys.argv) 185 | 186 | # Apply theme using simplified system 187 | is_dark = apply_theme(app) 188 | 189 | # Validate startup prerequisites on Windows 190 | validate_startup_prerequisites() # We always continue now, regardless of the return value 191 | 192 | # Create main window 193 | ex = GUIDownloader() 194 | 195 | # Transfer captured output to the application's output window 196 | buffer.transfer_to_output(ex.output_window) 197 | 198 | sys.exit(app.exec_()) 199 | 200 | if __name__ == '__main__': 201 | main() 202 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.7.4.post0 2 | asyncio>=3.4.3 3 | beautifulsoup4>=4.9.3 4 | PyQt5>=5.15.2 5 | requests>=2.25.1 6 | PyYAML>=6.0 7 | pycdlib>=1.10.0 -------------------------------------------------------------------------------- /threads/__init__.py: -------------------------------------------------------------------------------- 1 | # Empty init file to make the directory a Python package 2 | -------------------------------------------------------------------------------- /threads/download_threads.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import random 4 | import asyncio 5 | import aiohttp 6 | import requests 7 | import json 8 | import urllib.parse 9 | import collections # Add this import for deque 10 | from urllib.parse import unquote 11 | from bs4 import BeautifulSoup 12 | from PyQt5.QtCore import QThread, pyqtSignal, QEventLoop 13 | 14 | 15 | class GetSoftwareListThread(QThread): 16 | signal = pyqtSignal('PyQt_PyObject') 17 | 18 | def __init__(self, url, json_file): 19 | QThread.__init__(self) 20 | self.url = url 21 | # Ensure json file is in config directory 22 | self.json_file = os.path.join("config", json_file) 23 | 24 | def run(self): 25 | iso_list = [] 26 | 27 | # Ensure config directory exists 28 | os.makedirs(os.path.dirname(self.json_file), exist_ok=True) 29 | 30 | # Check for file in new location 31 | if os.path.exists(self.json_file): 32 | try: 33 | with open(self.json_file, 'r') as file: 34 | iso_list = json.load(file) 35 | except Exception as e: 36 | print(f"Error loading {self.json_file}: {str(e)}") 37 | else: 38 | # Check for old file in root directory 39 | old_file_path = os.path.basename(self.json_file) 40 | if os.path.exists(old_file_path): 41 | try: 42 | with open(old_file_path, 'r') as file: 43 | iso_list = json.load(file) 44 | 45 | # Save to new location 46 | with open(self.json_file, 'w') as file: 47 | json.dump(iso_list, file) 48 | 49 | # Remove old file after successful migration 50 | os.remove(old_file_path) 51 | print(f"Migrated file list from root to {self.json_file}") 52 | except Exception as e: 53 | print(f"Error migrating file list: {str(e)}") 54 | 55 | # Only fetch new list if empty or file doesn't exist 56 | if not iso_list: 57 | try: 58 | response = requests.get(self.url) 59 | soup = BeautifulSoup(response.text, 'html.parser') 60 | iso_list = [unquote(link.get('href')) for link in soup.find_all('a') if link.get('href').endswith('.zip')] 61 | 62 | # Save the file 63 | with open(self.json_file, 'w') as file: 64 | json.dump(iso_list, file) 65 | except Exception as e: 66 | print(f"Error fetching software list from {self.url}: {str(e)}") 67 | iso_list = ["Error loading list. Please check your connection."] 68 | 69 | self.signal.emit(iso_list) 70 | 71 | 72 | class DownloadThread(QThread): 73 | progress_signal = pyqtSignal(int) 74 | download_complete_signal = pyqtSignal() 75 | download_paused_signal = pyqtSignal() 76 | speed_signal = pyqtSignal(str) 77 | eta_signal = pyqtSignal(str) 78 | size_signal = pyqtSignal(str) # Add size signal 79 | 80 | def __init__(self, url, filename, retries=50): 81 | QThread.__init__(self) 82 | self.url = url 83 | self.filename = filename 84 | self.retries = retries 85 | self.existing_file_size = 0 86 | self.start_time = None 87 | self.current_session_downloaded = 0 88 | self.running = True 89 | self.paused = False 90 | self.pause_event = asyncio.Event() 91 | self.pause_event.set() # Not paused initially 92 | 93 | # For smooth speed calculation 94 | self.speed_window_size = 20 # Reduced window size to be more responsive 95 | self.download_chunks = collections.deque(maxlen=self.speed_window_size) 96 | self.last_update_time = 0 97 | self.last_chunk_time = 0 98 | self.last_emitted_speed = 0 99 | self.last_emitted_eta = 0 100 | 101 | # Dynamic chunking parameters 102 | self.initial_chunk_size = 262144 # Start with 256KB chunks 103 | self.min_chunk_size = 65536 # 64KB minimum 104 | self.max_chunk_size = 4194304 # 4MB maximum 105 | self.current_chunk_size = self.initial_chunk_size 106 | self.chunk_adjust_threshold = 5 # Number of chunks before adjustment 107 | self.chunk_counter = 0 108 | self.last_adjust_time = 0 109 | self.adjust_interval = 2.0 # Seconds between adjustments 110 | 111 | # Connection parameters 112 | self.tcp_nodelay = True # Disable Nagle's algorithm for better responsiveness 113 | self.read_timeout = 30.0 # Read timeout in seconds 114 | 115 | async def download(self): 116 | headers = { 117 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 118 | 'Accept': '*/*', 119 | 'Accept-Encoding': 'gzip, deflate, br', 120 | 'Connection': 'keep-alive' 121 | } 122 | 123 | for i in range(self.retries): 124 | try: 125 | if os.path.exists(self.filename): 126 | self.existing_file_size = os.path.getsize(self.filename) 127 | headers['Range'] = f'bytes={self.existing_file_size}-' 128 | 129 | # Configure client session with optimized parameters 130 | connector = aiohttp.TCPConnector( 131 | force_close=False, # Keep connections alive 132 | ssl=False, # Disable SSL verification for speed 133 | ttl_dns_cache=300, # Cache DNS for 5 minutes 134 | limit=0, # No connection limit 135 | enable_cleanup_closed=True # Clean up closed connections 136 | ) 137 | 138 | timeout = aiohttp.ClientTimeout( 139 | total=None, # No total timeout 140 | connect=20.0, # 20s connection timeout 141 | sock_read=self.read_timeout # Configurable read timeout 142 | ) 143 | 144 | async with aiohttp.ClientSession( 145 | connector=connector, 146 | timeout=timeout, 147 | headers=headers 148 | ) as session: 149 | async with session.get(self.url) as response: 150 | if response.status not in (200, 206): 151 | raise aiohttp.ClientPayloadError() 152 | 153 | # Get total file size 154 | if 'content-range' in response.headers: 155 | total_size = int(response.headers['content-range'].split('/')[-1]) 156 | else: 157 | total_size = int(response.headers.get('content-length', 0)) + self.existing_file_size 158 | 159 | with open(self.filename, 'ab') as file: 160 | self.start_time = time.time() 161 | self.last_update_time = time.time() 162 | self.download_chunks.clear() # Clear any old chunks 163 | self.last_adjust_time = time.time() 164 | self.chunk_counter = 0 165 | 166 | while True: 167 | # Check if we should stop 168 | if not self.running: 169 | print("Download thread stopped") 170 | return 171 | 172 | # Check if we should pause 173 | if self.paused: 174 | self.download_paused_signal.emit() 175 | await self.pause_event.wait() 176 | # Reset timing information after pause 177 | self.start_time = time.time() 178 | self.last_update_time = time.time() 179 | self.download_chunks.clear() # Clear old chunks after pause 180 | self.current_session_downloaded = 0 181 | self.last_adjust_time = time.time() 182 | 183 | # Read data with dynamic chunk size 184 | chunk = await response.content.read(self.current_chunk_size) 185 | if not chunk: 186 | break 187 | 188 | # Write the chunk and update counters 189 | file.write(chunk) 190 | chunk_size = len(chunk) 191 | self.existing_file_size += chunk_size 192 | self.current_session_downloaded += chunk_size 193 | 194 | # Record this chunk for speed calculation 195 | current_time = time.time() 196 | self.download_chunks.append((current_time, chunk_size)) 197 | self.last_chunk_time = current_time 198 | 199 | # Update progress 200 | progress = int((self.existing_file_size / total_size) * 100) if total_size > 0 else 0 201 | self.progress_signal.emit(progress) 202 | 203 | # Emit file size information 204 | size_str = f"{self.format_size(self.existing_file_size)}/{self.format_size(total_size)}" 205 | self.size_signal.emit(size_str) 206 | 207 | # Dynamically adjust chunk size based on download speed 208 | self.chunk_counter += 1 209 | if self.chunk_counter >= self.chunk_adjust_threshold and (current_time - self.last_adjust_time) >= self.adjust_interval: 210 | self.adjust_chunk_size() 211 | self.chunk_counter = 0 212 | self.last_adjust_time = current_time 213 | 214 | # Calculate and emit speed and ETA more frequently (0.1s) for "live" feeling 215 | if current_time - self.last_update_time >= 0.1: # Update UI every 100ms 216 | speed = self.calculate_speed() 217 | remaining_bytes = total_size - self.existing_file_size 218 | eta = self.calculate_eta(speed, remaining_bytes) 219 | 220 | speed_str = self.format_speed(speed) 221 | eta_str = self.format_eta(eta) 222 | 223 | # Be more responsive for speed updates (reduce throttling) 224 | speed_changed_enough = abs(speed - self.last_emitted_speed) > self.last_emitted_speed * 0.02 225 | eta_changed_enough = abs(eta - self.last_emitted_eta) > 1.0 226 | 227 | # Always update speed, but throttle ETA updates 228 | self.speed_signal.emit(speed_str) 229 | if eta_changed_enough: 230 | self.eta_signal.emit(eta_str) 231 | self.last_emitted_eta = eta 232 | 233 | self.last_emitted_speed = speed 234 | self.last_update_time = current_time 235 | 236 | # If the download was successful, break the loop 237 | break 238 | except aiohttp.ClientPayloadError: 239 | print(f"Download interrupted. Retrying ({i+1}/{self.retries})...") 240 | await asyncio.sleep(2 ** i + random.random()) # Exponential backoff 241 | if i == self.retries - 1: # If this was the last retry 242 | raise # Re-raise the exception 243 | except asyncio.TimeoutError: 244 | print(f"Download timed out. Retrying ({i+1}/{self.retries})...") 245 | # Reduce chunk size upon timeout to improve stability 246 | self.current_chunk_size = max(self.current_chunk_size // 2, self.min_chunk_size) 247 | await asyncio.sleep(2 ** i + random.random()) # Exponential backoff 248 | if i == self.retries - 1: # If this was the last retry 249 | raise # Re-raise the exception 250 | except Exception as e: 251 | print(f"Download error: {str(e)}. Retrying ({i+1}/{self.retries})...") 252 | # Reduce chunk size on general errors too 253 | self.current_chunk_size = max(self.current_chunk_size // 2, self.min_chunk_size) 254 | await asyncio.sleep(2 ** i + random.random()) # Exponential backoff 255 | if i == self.retries - 1: 256 | raise 257 | 258 | def adjust_chunk_size(self): 259 | """Dynamically adjust chunk size based on download speed""" 260 | if not self.download_chunks: 261 | return 262 | 263 | # Calculate recent download speed 264 | speed = self.calculate_speed() 265 | 266 | # Don't adjust if speed is too low (likely unstable connection) 267 | if speed < 50000: # Less than 50 KB/s 268 | return 269 | 270 | # Calculate optimal chunk size - approximately the amount downloaded in 1 second 271 | optimal_chunk = min(max(int(speed), self.min_chunk_size), self.max_chunk_size) 272 | 273 | # Gradually adjust chunk size (don't change too drastically) 274 | if optimal_chunk > self.current_chunk_size: 275 | # Increase by 25% if optimal is higher 276 | self.current_chunk_size = min(int(self.current_chunk_size * 1.25), optimal_chunk) 277 | elif optimal_chunk < self.current_chunk_size * 0.75: 278 | # Decrease if optimal is significantly lower (25% lower) 279 | self.current_chunk_size = max(int(self.current_chunk_size * 0.75), optimal_chunk) 280 | 281 | # Ensure chunk size stays within bounds 282 | self.current_chunk_size = min(max(self.current_chunk_size, self.min_chunk_size), self.max_chunk_size) 283 | 284 | def calculate_speed(self): 285 | """Calculate current download speed in bytes per second using sliding window.""" 286 | if not self.download_chunks: 287 | return 0 288 | 289 | # Get the oldest and newest chunk timestamps 290 | oldest_time = self.download_chunks[0][0] 291 | newest_time = self.last_chunk_time 292 | time_diff = newest_time - oldest_time 293 | 294 | # Calculate total downloaded in the window 295 | total_downloaded = sum(size for _, size in self.download_chunks) 296 | 297 | # Calculate speed, protecting against zero time_diff 298 | if time_diff > 0.001: # At least 1 millisecond 299 | return total_downloaded / time_diff 300 | elif total_downloaded > 0: # If we have downloads but time diff is tiny 301 | return total_downloaded # Return bytes as bytes/s (avoids division by zero) 302 | else: 303 | return 0 304 | 305 | def calculate_eta(self, speed, remaining_bytes): 306 | """Calculate estimated time of arrival (ETA) in seconds.""" 307 | if speed > 0: 308 | return remaining_bytes / speed 309 | return float('inf') # infinity if speed is 0 310 | 311 | def format_speed(self, speed): 312 | """Format speed with appropriate units.""" 313 | if speed < 0: 314 | speed = 0 315 | 316 | if speed == 0: 317 | return "0 B/s" 318 | elif speed < 1024: 319 | return f"{speed:.0f} B/s" 320 | elif speed < 1024**2: 321 | return f"{speed/1024:.1f} KB/s" 322 | elif speed < 1024**3: 323 | return f"{speed/(1024**2):.2f} MB/s" 324 | else: 325 | return f"{speed/(1024**3):.2f} GB/s" 326 | 327 | def format_eta(self, eta): 328 | """Format ETA in a human-readable format.""" 329 | if eta == float('inf'): 330 | return "Calculating..." 331 | 332 | if eta < 0: 333 | eta = 0 334 | 335 | if eta < 1: 336 | return "Less than a second" 337 | elif eta < 60: 338 | return f"{eta:.0f} seconds remaining" 339 | elif eta < 3600: 340 | minutes, seconds = divmod(int(eta), 60) 341 | return f"{minutes} minutes {seconds} seconds remaining" 342 | else: 343 | hours, remainder = divmod(int(eta), 3600) 344 | minutes, seconds = divmod(remainder, 60) 345 | if hours == 1: 346 | return f"1 hour {minutes} minutes remaining" 347 | else: 348 | return f"{hours} hours {minutes} minutes remaining" 349 | 350 | def format_file_size(self, size_bytes): 351 | """Format file size in a human-readable format.""" 352 | if size_bytes < 1024: 353 | return f"{size_bytes} B" 354 | elif size_bytes < 1024 * 1024: 355 | return f"{size_bytes/1024:.1f} KB" 356 | elif size_bytes < 1024 * 1024 * 1024: 357 | return f"{size_bytes/(1024*1024):.1f} MB" 358 | else: 359 | return f"{size_bytes/(1024*1024*1024):.2f} GB" 360 | 361 | def format_size(self, size_in_bytes): 362 | """Format file size with appropriate units.""" 363 | if size_in_bytes < 1024: 364 | return f"{size_in_bytes} B" 365 | elif size_in_bytes < 1024 * 1024: 366 | return f"{size_in_bytes/1024:.1f} KB" 367 | elif size_in_bytes < 1024 * 1024 * 1024: 368 | return f"{size_in_bytes/(1024*1024):.1f} MB" 369 | else: 370 | return f"{size_in_bytes/(1024*1024*1024):.1f} GB" 371 | 372 | def run(self): 373 | asyncio.run(self.download()) 374 | # Only emit completion if not paused 375 | if not self.paused: 376 | self.download_complete_signal.emit() 377 | 378 | def stop(self): 379 | self.running = False 380 | 381 | def pause(self): 382 | if not self.paused: 383 | self.paused = True 384 | self.pause_event.clear() 385 | 386 | def resume(self): 387 | if self.paused: 388 | self.paused = False 389 | self.pause_event.set() 390 | -------------------------------------------------------------------------------- /threads/processing_threads.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import zipfile 4 | import platform 5 | import threading 6 | import time 7 | from pathlib import Path 8 | from PyQt5.QtCore import QThread, pyqtSignal, QMutex, QWaitCondition 9 | from PyQt5.QtWidgets import QApplication 10 | 11 | 12 | class SplitPkgThread(QThread): 13 | progress = pyqtSignal(str) 14 | status = pyqtSignal(bool) 15 | 16 | def __init__(self, file_path, overwrite_manager=None): 17 | QThread.__init__(self) 18 | self.file_path = file_path 19 | self.overwrite_manager = overwrite_manager 20 | def run(self): 21 | file_size = os.path.getsize(self.file_path) 22 | if file_size < 4294967295: 23 | self.status.emit(False) 24 | return 25 | else: 26 | chunk_size = 4294967295 27 | num_parts = -(-file_size // chunk_size) 28 | 29 | # Check for existing split files if overwrite manager is available 30 | if self.overwrite_manager: 31 | existing_parts = [] 32 | for i in range(num_parts): 33 | split_file_path = os.path.join(os.path.dirname(self.file_path), f"{Path(self.file_path).stem}.pkg.666{str(i).zfill(2)}") 34 | if os.path.exists(split_file_path): 35 | existing_parts.append({ 36 | 'path': split_file_path, 37 | 'existing_size': os.path.getsize(split_file_path), 38 | 'new_size': min(chunk_size, file_size - (i * chunk_size)) 39 | }) 40 | 41 | if existing_parts: 42 | from gui.overwrite_dialog import OverwriteDialog 43 | choice, _ = self.overwrite_manager.handle_conflict( 44 | existing_parts, "processing", None 45 | ) 46 | 47 | if choice == OverwriteDialog.CANCEL: 48 | self.status.emit(False) 49 | return 50 | elif choice == OverwriteDialog.SKIP: 51 | self.status.emit(True) # Consider existing files as successful 52 | return 53 | # If OVERWRITE or RENAME, continue with splitting 54 | 55 | with open(self.file_path, 'rb') as f: 56 | i = 0 57 | while True: 58 | chunk = f.read(chunk_size) 59 | if not chunk: 60 | break 61 | split_file_path = os.path.join(os.path.dirname(self.file_path), f"{Path(self.file_path).stem}.pkg.666{str(i).zfill(2)}") 62 | with open(split_file_path, 'wb') as chunk_file: 63 | chunk_file.write(chunk) 64 | progress_text = f"Splitting {self.file_path}: part {i+1}/{num_parts} complete" 65 | # print(progress_text) # Removed: This will be handled by the connected signal 66 | self.progress.emit(progress_text) 67 | i += 1 68 | os.remove(self.file_path) 69 | self.status.emit(True) 70 | 71 | 72 | class SplitIsoThread(QThread): 73 | progress = pyqtSignal(str) 74 | status = pyqtSignal(bool) 75 | 76 | def __init__(self, file_path): 77 | QThread.__init__(self) 78 | self.file_path = file_path 79 | 80 | def run(self): 81 | file_size = os.path.getsize(self.file_path) 82 | if file_size < 4294967295: 83 | self.status.emit(False) 84 | return 85 | else: 86 | chunk_size = 4294967295 87 | num_parts = -(-file_size // chunk_size) 88 | with open(self.file_path, 'rb') as f: 89 | i = 0 90 | while True: 91 | chunk = f.read(chunk_size) 92 | if not chunk: 93 | break 94 | with open(f"{os.path.splitext(self.file_path)[0]}.iso.{str(i)}", 'wb') as chunk_file: 95 | chunk_file.write(chunk) 96 | progress_text = f"Splitting {self.file_path}: part {i+1}/{num_parts} complete" 97 | # print(progress_text) # Removed: This will be handled by the connected signal 98 | self.progress.emit(progress_text) 99 | i += 1 100 | self.status.emit(True) 101 | 102 | 103 | class CommandRunner(QThread): 104 | output_signal = pyqtSignal(str) 105 | finished_signal = pyqtSignal() 106 | error_signal = pyqtSignal(str) 107 | 108 | def __init__(self, command): 109 | super().__init__() 110 | self.command = command 111 | self.process = None 112 | self.is_complete = False 113 | self.mutex = QMutex() 114 | self.wait_condition = QWaitCondition() 115 | 116 | def run(self): 117 | self.is_complete = False 118 | try: 119 | self.process = subprocess.Popen( 120 | self.command, 121 | stdout=subprocess.PIPE, 122 | stderr=subprocess.STDOUT, 123 | stdin=subprocess.PIPE, 124 | bufsize=1, 125 | universal_newlines=True 126 | ) 127 | 128 | # If on Windows, send a newline character to ps3dec's standard input 129 | if platform.system() == 'Windows': 130 | self.process.stdin.write('\n') 131 | self.process.stdin.flush() 132 | 133 | # Start thread to read output 134 | read_thread = threading.Thread(target=self._reader_thread) 135 | read_thread.daemon = True 136 | read_thread.start() 137 | 138 | # Wait for process to complete 139 | self.process.wait() 140 | 141 | # Wait a bit for the read thread to complete 142 | read_thread.join(timeout=2.0) 143 | 144 | if self.process.returncode != 0: 145 | error_msg = f"Error: Command failed with return code {self.process.returncode}" 146 | # Error message will be emitted via signal 147 | self.output_signal.emit(error_msg) 148 | self.error_signal.emit(error_msg) 149 | except Exception as e: 150 | error_msg = f"Exception in command execution: {str(e)}" 151 | # Error message will be emitted via signal 152 | self.error_signal.emit(error_msg) 153 | finally: 154 | # Notify that we're done 155 | self.mutex.lock() 156 | self.is_complete = True 157 | self.wait_condition.wakeAll() 158 | self.mutex.unlock() 159 | 160 | # Always emit finished signal, even in case of error 161 | self.finished_signal.emit() 162 | 163 | def _reader_thread(self): 164 | """Thread function to read process output""" 165 | for line in iter(self.process.stdout.readline, ''): 166 | line_text = line.rstrip('\n') 167 | # Only use one output path - send through signal 168 | # The signal will be connected to a method that handles the output 169 | self.output_signal.emit(line_text) 170 | QApplication.processEvents() 171 | 172 | def wait_for_completion(self, timeout=None): 173 | """Wait for the command to complete with optional timeout in seconds""" 174 | self.mutex.lock() 175 | success = True 176 | if not self.is_complete: 177 | success = self.wait_condition.wait(self.mutex, int(timeout * 1000) if timeout else -1) 178 | self.mutex.unlock() 179 | return success 180 | 181 | 182 | class UnzipRunner(QThread): 183 | progress_signal = pyqtSignal(int) 184 | unzip_paused_signal = pyqtSignal() 185 | 186 | def __init__(self, zip_path, output_path, overwrite_manager=None): 187 | super().__init__() 188 | self.zip_path = zip_path 189 | self.output_path = output_path 190 | self.extracted_files = [] 191 | self.running = True 192 | self.paused = False 193 | self.partial_files = [] # Track files being extracted 194 | self.overwrite_manager = overwrite_manager 195 | 196 | def _should_preserve_folder_structure(self, zip_ref): 197 | """ 198 | Determine if folder structure should be preserved based on zip contents. 199 | Returns (should_preserve, common_root_folder) 200 | """ 201 | zip_base_name = os.path.splitext(os.path.basename(self.zip_path))[0] 202 | file_list = zip_ref.infolist() 203 | 204 | if not file_list: 205 | return False, None 206 | 207 | # Get all top-level entries (files and directories) 208 | top_level_entries = set() 209 | for info in file_list: 210 | # Normalize path separators 211 | normalized_path = info.filename.replace('\\', '/') 212 | # Skip empty entries 213 | if not normalized_path or normalized_path == '/': 214 | continue 215 | # Get the first path component 216 | first_component = normalized_path.split('/')[0] 217 | if first_component: 218 | top_level_entries.add(first_component) 219 | 220 | # If there's exactly one top-level directory, check if it differs from zip name 221 | if len(top_level_entries) == 1: 222 | top_level_dir = list(top_level_entries)[0] 223 | 224 | # Check if this top-level entry is actually a directory 225 | # (look for files that are inside this directory) 226 | is_directory = any( 227 | info.filename.replace('\\', '/').startswith(top_level_dir + '/') 228 | for info in file_list 229 | if info.filename.replace('\\', '/') != top_level_dir 230 | ) 231 | 232 | if is_directory and top_level_dir != zip_base_name: 233 | # Removed "Preserving folder structure" print to clean up logs 234 | return True, top_level_dir 235 | 236 | # Removed "Flattening structure" print to clean up logs 237 | return False, None 238 | 239 | def _get_extraction_path(self, info, preserve_structure, common_root): 240 | """ 241 | Get the extraction path for a file based on preservation settings. 242 | """ 243 | if preserve_structure: 244 | # Preserve the full directory structure 245 | normalized_path = info.filename.replace('\\', '/') 246 | return os.path.join(self.output_path, normalized_path) 247 | else: 248 | # Flatten the structure (original behavior) 249 | return os.path.join(self.output_path, os.path.basename(info.filename)) 250 | 251 | def run(self): 252 | if not self.zip_path.lower().endswith('.zip'): 253 | print(f"File {self.zip_path} is not a .zip file. Skipping unzip.") 254 | return 255 | 256 | # Check if the zip file exists before trying to open it 257 | if not os.path.exists(self.zip_path): 258 | print(f"Error: Zip file does not exist: {self.zip_path}") 259 | return 260 | 261 | try: 262 | with zipfile.ZipFile(self.zip_path, 'r') as zip_ref: 263 | total_size = sum([info.file_size for info in zip_ref.infolist()]) 264 | extracted_size = 0 265 | 266 | # Determine extraction strategy 267 | preserve_structure, common_root = self._should_preserve_folder_structure(zip_ref) 268 | 269 | # Get list of files in the zip 270 | file_list = zip_ref.infolist() 271 | file_count = len([info for info in file_list if not (info.filename.endswith('/') or info.filename.endswith('\\'))]) 272 | processed_files = 0 273 | 274 | for info in file_list: 275 | # Skip directory entries (they don't contain actual file data) 276 | if info.filename.endswith('/') or info.filename.endswith('\\'): 277 | continue 278 | 279 | # Check if we are paused 280 | if self.paused: 281 | # Unzip paused, cleanup will be handled 282 | self.cleanup_partial_files() 283 | self.unzip_paused_signal.emit() 284 | return 285 | 286 | # Check if we are stopped 287 | if not self.running: 288 | # Unzip stopped 289 | self.cleanup_partial_files() # Clean up when stopped too 290 | return 291 | file_out_path = self._get_extraction_path(info, preserve_structure, common_root) 292 | 293 | # Create directories if needed for structure preservation 294 | os.makedirs(os.path.dirname(file_out_path), exist_ok=True) 295 | 296 | # Check for existing file and handle conflicts 297 | if os.path.exists(file_out_path): 298 | existing_size = os.path.getsize(file_out_path) 299 | 300 | # Skip if file already exists and is complete (for resuming) 301 | if existing_size == info.file_size: 302 | # File already exists and is complete, skipping 303 | self.extracted_files.append(file_out_path) 304 | extracted_size += info.file_size 305 | processed_files += 1 306 | 307 | # Update progress for skipped files too 308 | size_progress = (extracted_size / total_size) * 100 if total_size > 0 else 0 309 | file_progress = (processed_files / file_count) * 100 if file_count > 0 else 0 310 | progress_percent = max(size_progress, file_progress) 311 | self.progress_signal.emit(int(min(progress_percent, 100))) 312 | continue 313 | 314 | # Handle file conflict with overwrite manager if available 315 | if self.overwrite_manager: 316 | from gui.overwrite_dialog import OverwriteDialog 317 | 318 | conflict_info = { 319 | 'path': file_out_path, 320 | 'existing_size': existing_size, 321 | 'new_size': info.file_size 322 | } 323 | 324 | choice, _ = self.overwrite_manager.handle_conflict( 325 | conflict_info, "extraction", None # No parent widget in thread 326 | ) 327 | 328 | if choice == OverwriteDialog.CANCEL: 329 | # Stop extraction 330 | self.running = False 331 | return 332 | elif choice == OverwriteDialog.SKIP: 333 | # Skip this file, keep existing 334 | self.extracted_files.append(file_out_path) 335 | extracted_size += info.file_size # Count as processed 336 | processed_files += 1 337 | 338 | # Update progress for skipped files 339 | size_progress = (extracted_size / total_size) * 100 if total_size > 0 else 0 340 | file_progress = (processed_files / file_count) * 100 if file_count > 0 else 0 341 | progress_percent = max(size_progress, file_progress) 342 | self.progress_signal.emit(int(min(progress_percent, 100))) 343 | continue 344 | elif choice == OverwriteDialog.RENAME: 345 | # Generate unique filename 346 | file_out_path = self._generate_unique_filename(file_out_path) 347 | # If OVERWRITE, continue with normal extraction (will overwrite) 348 | 349 | # Add to partial files since we're going to extract it 350 | self.partial_files.append(file_out_path) 351 | 352 | try: 353 | with zip_ref.open(info) as source, open(file_out_path, 'wb') as target: 354 | # Use a buffer size of 8MB for faster copying 355 | buffer_size = 8 * 1024 * 1024 # 8MB chunks 356 | bytes_written = 0 357 | file_size = info.file_size 358 | 359 | while self.running and not self.paused: # Check flags in loop 360 | chunk = source.read(buffer_size) 361 | if not chunk: 362 | break 363 | target.write(chunk) 364 | bytes_written += len(chunk) 365 | 366 | # For large files, emit progress updates during extraction 367 | if file_size > 50 * 1024 * 1024: # Files larger than 50MB 368 | file_progress_within = (bytes_written / file_size) if file_size > 0 else 0 369 | overall_file_progress = (processed_files + file_progress_within) / file_count if file_count > 0 else 0 370 | overall_size_progress = (extracted_size + bytes_written) / total_size if total_size > 0 else 0 371 | 372 | progress_percent = max(overall_file_progress * 100, overall_size_progress * 100) 373 | self.progress_signal.emit(int(min(progress_percent, 100))) 374 | 375 | # Periodically check if we should stop 376 | QApplication.processEvents() 377 | except Exception as e: 378 | print(f"Error extracting {info.filename}: {e}") 379 | # Mark the file as incomplete 380 | if file_out_path in self.partial_files: 381 | self.partial_files.remove(file_out_path) 382 | continue 383 | 384 | if self.running and not self.paused: # Only count if not stopped/paused 385 | self.extracted_files.append(file_out_path) 386 | extracted_size += info.file_size 387 | processed_files += 1 388 | 389 | # Emit progress based on both size and file count for more responsive updates 390 | size_progress = (extracted_size / total_size) * 100 if total_size > 0 else 0 391 | file_progress = (processed_files / file_count) * 100 if file_count > 0 else 0 392 | 393 | # Use the maximum of the two progress calculations for better responsiveness 394 | progress_percent = max(size_progress, file_progress) 395 | self.progress_signal.emit(int(min(progress_percent, 100))) 396 | except FileNotFoundError as e: 397 | print(f"Error during unzip operation: {str(e)}") 398 | # No need to clean up since the file doesn't exist 399 | except Exception as e: 400 | print(f"Error during unzip operation: {str(e)}") 401 | self.cleanup_partial_files() 402 | 403 | def cleanup_partial_files(self): 404 | """Remove partially extracted files and empty directories""" 405 | for file_path in self.partial_files: 406 | try: 407 | if os.path.exists(file_path): 408 | os.remove(file_path) 409 | # Removed partial file during cleanup 410 | 411 | # Try to remove empty parent directories 412 | parent_dir = os.path.dirname(file_path) 413 | while parent_dir and parent_dir != self.output_path: 414 | try: 415 | if os.path.exists(parent_dir) and not os.listdir(parent_dir): 416 | os.rmdir(parent_dir) 417 | # Removed empty directory during cleanup 418 | parent_dir = os.path.dirname(parent_dir) 419 | else: 420 | break 421 | except OSError: 422 | break 423 | except Exception as e: 424 | print(f"Error removing partial file {file_path}: {e}") 425 | 426 | self.partial_files = [] # Clear the list after cleanup 427 | 428 | def stop(self): 429 | """Stop the extraction process""" 430 | self.running = False 431 | # Don't call wait() here - let the caller handle waiting 432 | 433 | def pause(self): 434 | """Pause the extraction process""" 435 | self.paused = True 436 | 437 | def resume(self): 438 | """Resume the extraction process""" 439 | self.paused = False 440 | 441 | def _generate_unique_filename(self, file_path): 442 | """Generate a unique filename by adding a suffix.""" 443 | directory = os.path.dirname(file_path) 444 | filename = os.path.basename(file_path) 445 | name, ext = os.path.splitext(filename) 446 | 447 | counter = 1 448 | while True: 449 | new_filename = f"{name} ({counter}){ext}" 450 | new_path = os.path.join(directory, new_filename) 451 | if not os.path.exists(new_path): 452 | return new_path 453 | counter += 1 454 | --------------------------------------------------------------------------------