"""Dialog showing downloaded versions of a game with management actions.""" import shutil from loguru import logger from PySide6.QtCore import Qt from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QGroupBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) from src.config import MetadataStore from src.models import DownloadedInstaller, language_folder_name COL_FILE = 0 COL_VERSION = 1 COL_LANG = 2 COL_SIZE = 3 COL_DATE = 4 def _fmt_size(size: int) -> str: for unit in ("B", "KB", "MB", "GB"): if size < 1024: return f"{size:.1f} {unit}" size //= 1024 return f"{size:.1f} GB" class GameVersionsDialog(QDialog): """Shows all downloaded versions/files for a game and lets user manage them.""" def __init__( self, game_id: str, game_title: str, metadata: MetadataStore, parent: QWidget | None = None, ) -> None: super().__init__(parent) self.game_id = game_id self.game_title = game_title self.metadata = metadata self.base_path = metadata.base_path self.setWindowTitle(f"Downloaded versions — {game_title}") self.setMinimumSize(700, 450) self._setup_ui() self._populate() def _setup_ui(self) -> None: layout = QVBoxLayout(self) # Header info self.info_label = QLabel() layout.addWidget(self.info_label) # Installers group inst_group = QGroupBox("Installer files") inst_layout = QVBoxLayout(inst_group) self.inst_tree = QTreeWidget() self.inst_tree.setColumnCount(5) self.inst_tree.setHeaderLabels(["File", "Version", "Language", "Size", "Downloaded"]) self.inst_tree.header().setStretchLastSection(False) self.inst_tree.header().resizeSection(COL_FILE, 280) self.inst_tree.header().resizeSection(COL_VERSION, 100) self.inst_tree.header().resizeSection(COL_LANG, 100) self.inst_tree.header().resizeSection(COL_SIZE, 80) self.inst_tree.header().resizeSection(COL_DATE, 160) self.inst_tree.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection) self.inst_tree.setSortingEnabled(True) inst_layout.addWidget(self.inst_tree) inst_btn_row = QHBoxLayout() self.delete_selected_btn = QPushButton("Delete selected files") self.delete_selected_btn.clicked.connect(self._delete_selected_installers) inst_btn_row.addWidget(self.delete_selected_btn) self.delete_version_btn = QPushButton("Delete entire version folder…") self.delete_version_btn.clicked.connect(self._delete_version_folder) inst_btn_row.addWidget(self.delete_version_btn) inst_btn_row.addStretch() inst_layout.addLayout(inst_btn_row) layout.addWidget(inst_group) # Bonus group bonus_group = QGroupBox("Bonus content") bonus_layout = QVBoxLayout(bonus_group) self.bonus_tree = QTreeWidget() self.bonus_tree.setColumnCount(4) self.bonus_tree.setHeaderLabels(["File", "Name", "Type", "Size"]) self.bonus_tree.header().setStretchLastSection(False) self.bonus_tree.header().resizeSection(COL_FILE, 280) self.bonus_tree.header().resizeSection(1, 180) self.bonus_tree.header().resizeSection(2, 100) self.bonus_tree.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection) bonus_layout.addWidget(self.bonus_tree) bonus_btn_row = QHBoxLayout() self.delete_bonus_btn = QPushButton("Delete selected bonus files") self.delete_bonus_btn.clicked.connect(self._delete_selected_bonuses) bonus_btn_row.addWidget(self.delete_bonus_btn) bonus_btn_row.addStretch() bonus_layout.addLayout(bonus_btn_row) layout.addWidget(bonus_group) # Close button close_btn = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) close_btn.rejected.connect(self.reject) layout.addWidget(close_btn) def _populate(self) -> None: record = self.metadata.get_game(self.game_id) if not record: self.info_label.setText("No downloaded data found for this game.") return total_size = sum(i.size for i in record.installers) + sum(b.size for b in record.bonuses) self.info_label.setText( f"{self.game_title} — {len(record.installers)} installer file(s), " f"{len(record.bonuses)} bonus file(s) — total {_fmt_size(total_size)}" ) # Group installers by version for visual clarity versions: dict[str, list[DownloadedInstaller]] = {} for inst in record.installers: versions.setdefault(inst.version, []).append(inst) self.inst_tree.clear() for version in sorted(versions): ver_node = QTreeWidgetItem() ver_node.setText(COL_FILE, f"Version {version}") ver_node.setText(COL_VERSION, version) bold = QFont() bold.setBold(True) ver_node.setFont(COL_FILE, bold) ver_node.setData(COL_FILE, Qt.ItemDataRole.UserRole, ("version", version)) ver_size = sum(i.size for i in versions[version]) ver_node.setText(COL_SIZE, _fmt_size(ver_size)) for inst in versions[version]: file_node = QTreeWidgetItem() file_node.setText(COL_FILE, inst.filename) file_node.setText(COL_VERSION, inst.version) file_node.setText(COL_LANG, language_folder_name(inst.language)) file_node.setText(COL_SIZE, _fmt_size(inst.size)) file_node.setText(COL_DATE, inst.downloaded_at[:19].replace("T", " ") if inst.downloaded_at else "") file_node.setData(COL_FILE, Qt.ItemDataRole.UserRole, ("installer", inst)) # Grey out if file no longer on disk if not self.metadata.find_installer_file(record, inst): for col in range(5): file_node.setForeground(col, self.palette().color(self.foregroundRole()).darker(150)) file_node.setToolTip(COL_FILE, "File not found on disk") ver_node.addChild(file_node) ver_node.setExpanded(True) self.inst_tree.addTopLevelItem(ver_node) # Bonus files self.bonus_tree.clear() for bonus in record.bonuses: node = QTreeWidgetItem() node.setText(0, bonus.filename) node.setText(1, bonus.name) node.setText(2, bonus.bonus_type) node.setText(3, _fmt_size(bonus.size)) node.setData(0, Qt.ItemDataRole.UserRole, bonus) if not self.metadata.find_bonus_file(record, bonus): for col in range(4): node.setForeground(col, self.palette().color(self.foregroundRole()).darker(150)) node.setToolTip(0, "File not found on disk") self.bonus_tree.addTopLevelItem(node) def _delete_selected_installers(self) -> None: selected = [ item for item in self.inst_tree.selectedItems() if item.data(COL_FILE, Qt.ItemDataRole.UserRole) and item.data(COL_FILE, Qt.ItemDataRole.UserRole)[0] == "installer" ] if not selected: QMessageBox.information(self, "Delete", "Select one or more installer files first.") return reply = QMessageBox.question( self, "Delete files", f"Delete {len(selected)} selected file(s) from disk?\nMetadata will be updated.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return record = self.metadata.get_game(self.game_id) if not record: return deleted = 0 for item in selected: inst: DownloadedInstaller = item.data(COL_FILE, Qt.ItemDataRole.UserRole)[1] path = self.metadata.find_installer_file(record, inst) if path: path.unlink() logger.info(f"Deleted {path}") deleted += 1 record.installers = [i for i in record.installers if i.filename != inst.filename or i.version != inst.version] # Update latest_version if record.installers: record.latest_version = sorted({i.version for i in record.installers})[-1] else: record.latest_version = "" self.metadata.update_game(record) self._populate() QMessageBox.information(self, "Done", f"Deleted {deleted} file(s).") def _delete_version_folder(self) -> None: """Delete an entire version directory after user selects which version.""" record = self.metadata.get_game(self.game_id) if not record: return versions = sorted({i.version for i in record.installers}) if not versions: QMessageBox.information(self, "Delete", "No versions found in metadata.") return # Pick version from selected item if any selected = self.inst_tree.currentItem() preselected_version = "" if selected: data = selected.data(COL_FILE, Qt.ItemDataRole.UserRole) if data and data[0] == "version": preselected_version = data[1] elif data and data[0] == "installer": preselected_version = data[1].version version = preselected_version or versions[-1] version_dirs = [d / version for d in self.metadata.candidate_dirs(record) if (d / version).exists()] reply = QMessageBox.question( self, "Delete version folder", f"Delete entire version folder '{version}' for '{self.game_title}'?\n\n" f"Path: {version_dirs[0] if version_dirs else '(not found on disk)'}\n\n" "This will remove all files in that folder from disk.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return for version_dir in version_dirs: shutil.rmtree(version_dir) logger.info(f"Deleted version folder {version_dir}") record.installers = [i for i in record.installers if i.version != version] if record.installers: record.latest_version = sorted({i.version for i in record.installers})[-1] else: record.latest_version = "" self.metadata.update_game(record) self._populate() def _delete_selected_bonuses(self) -> None: selected = self.bonus_tree.selectedItems() if not selected: QMessageBox.information(self, "Delete", "Select one or more bonus files first.") return reply = QMessageBox.question( self, "Delete bonus files", f"Delete {len(selected)} bonus file(s) from disk?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return record = self.metadata.get_game(self.game_id) if not record: return deleted = 0 for item in selected: bonus = item.data(0, Qt.ItemDataRole.UserRole) path = self.metadata.find_bonus_file(record, bonus) if path: path.unlink() logger.info(f"Deleted bonus {path}") deleted += 1 record.bonuses = [b for b in record.bonuses if b.filename != bonus.filename] self.metadata.update_game(record) self._populate() QMessageBox.information(self, "Done", f"Deleted {deleted} bonus file(s).")