From 04662b96ec113d49390c01e5b5cdc4fd66025af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Tue, 12 May 2026 10:35:02 +0200 Subject: [PATCH] Add version decision cache and library-to-status propagation --- CHANGELOG.md | 8 +++ pyproject.toml | 2 +- src/_version.py | 2 +- src/config.py | 3 + src/ui/main_window.py | 1 + src/ui/tab_library.py | 4 ++ src/ui/tab_status.py | 146 ++++++++++++++++++++++++++--------------- src/version_compare.py | 6 +- 8 files changed, 117 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7707c41..ae5aa6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.0.1] — 2026-05-12 + +### Improvements + +- Version decision cache — ambiguous version comparisons are stored in `config.json`; the dialog is shown only once per version pair, answer is reused on subsequent checks +- Library → Status propagation — after "Refresh Library", the Status tab is automatically populated for both platforms without additional API calls; product data fetched during library refresh is reused directly +- Download logic refactored into `_run_download_for_platform()` shared helper + ## [1.0.0] — 2026-04-09 ### Features diff --git a/pyproject.toml b/pyproject.toml index b9cfc5c..d4814e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gogupdater" -version = "1.0.0" +version = "1.0.1" description = "" authors = [ {name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"} diff --git a/src/_version.py b/src/_version.py index 941897d..e922c7e 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "0.1.0" +__version__ = "1.0.0" diff --git a/src/config.py b/src/config.py index f30c187..1b41cb2 100644 --- a/src/config.py +++ b/src/config.py @@ -24,6 +24,7 @@ class AppConfig: self.english_only: bool = False self.include_bonus: bool = False self.game_settings: dict[str, GameSettings] = {} + self.version_decisions: dict[str, bool] = {} self._load() def _load(self) -> None: @@ -41,6 +42,7 @@ class AppConfig: gid: GameSettings.from_dict(gs) for gid, gs in data.get("game_settings", {}).items() } + self.version_decisions = data.get("version_decisions", {}) except (json.JSONDecodeError, OSError): logger.warning("Failed to read config.json, using defaults") @@ -58,6 +60,7 @@ class AppConfig: for gid, gs in self.game_settings.items() if not gs.is_default() }, + "version_decisions": self.version_decisions, } self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/src/ui/main_window.py b/src/ui/main_window.py index a3b01ef..90bf640 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -50,6 +50,7 @@ class MainWindow(QMainWindow): def _connect_signals(self) -> None: self.auth_tab.login_state_changed.connect(self._on_login_changed) self.settings_tab.english_only_changed.connect(self._on_english_only_changed) + self.library_tab.library_refreshed.connect(self.status_tab.check_updates_from_cache) def _on_login_changed(self, logged_in: bool) -> None: logger.info(f"Login state changed: logged_in={logged_in}") diff --git a/src/ui/tab_library.py b/src/ui/tab_library.py index 477be1b..73f789e 100644 --- a/src/ui/tab_library.py +++ b/src/ui/tab_library.py @@ -37,6 +37,7 @@ class LibraryTab(QWidget): """Tab showing owned games with checkboxes. DLCs appear as children of their parent game.""" managed_games_changed = Signal() + library_refreshed = Signal(object, object) # products_cache: dict[str, dict], owned_set: set[str] def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -99,6 +100,7 @@ class LibraryTab(QWidget): # games_data shared for all platforms — platform filtering happens in populate games_data: list[tuple[str, str, list[tuple[str, str]], set[str]]] = [] # (game_id, title, [(dlc_id, dlc_title)], available_platforms) + products_cache: dict[str, dict] = {} for gid in owned_ids: product = self.api.get_product_info(gid) @@ -106,6 +108,7 @@ class LibraryTab(QWidget): games_data.append((str(gid), f"Game {gid}", [], set())) continue + products_cache[str(gid)] = product title = product.get("title", f"Game {gid}") dlcs: list[tuple[str, str]] = [] for dlc in product.get("expanded_dlcs", []): @@ -159,6 +162,7 @@ class LibraryTab(QWidget): self.status_label.setText( f"Loaded {win_count} Windows / {lin_count} Linux games. Double-click to configure per-game settings." ) + self.library_refreshed.emit(products_cache, owned_set) def _make_node(self, game_id: str, title: str, check: Qt.CheckState) -> QTreeWidgetItem: node = QTreeWidgetItem() diff --git a/src/ui/tab_status.py b/src/ui/tab_status.py index a41b45a..d3a71b8 100644 --- a/src/ui/tab_status.py +++ b/src/ui/tab_status.py @@ -33,7 +33,7 @@ from src.models import ( LanguageVersionInfo, language_folder_name, ) -from src.version_compare import CompareResult, compare_versions, format_comparison_question +from src.version_compare import CompareResult, compare_versions, format_comparison_question, normalize_version STATUS_COLORS: dict[GameStatus, str] = { GameStatus.UP_TO_DATE: "#2e7d32", @@ -90,6 +90,7 @@ class StatusTab(QWidget): self.download_btn.setEnabled(False) controls.addWidget(self.download_btn) + self.prune_btn = QPushButton("Prune Old Versions") self.prune_btn.clicked.connect(self.prune_versions) controls.addWidget(self.prune_btn) @@ -153,15 +154,32 @@ class StatusTab(QWidget): self.check_btn.setEnabled(False) self.status_label.setText("Checking updates...") - for items in self.status_items.values(): - items.clear() - self.tree_windows.clear() - self.tree_linux.clear() self._update_platform_tab_visibility() # TODO: Run in a thread to avoid blocking the GUI - # Verify metadata integrity — remove entries for files deleted outside the app + self._verify_metadata() + + owned_set = self.api.get_owned_ids_set() + products_cache: dict[str, dict] = {} + for game_id in managed_ids: + product = self.api.get_product_info(game_id) + if product: + products_cache[game_id] = product + + self._run_check(products_cache, owned_set) + + def check_updates_from_cache(self, products_cache: dict[str, dict], owned_set: set[str]) -> None: + """Populate status using products already fetched by Library refresh (no API calls).""" + if not self.config.managed_game_ids: + return + self.check_btn.setEnabled(False) + self.status_label.setText("Checking updates...") + self._update_platform_tab_visibility() + self._verify_metadata() + self._run_check(products_cache, owned_set) + + def _verify_metadata(self) -> None: for platform in InstallerPlatform: base_path = self._base_path_for(platform) if base_path: @@ -170,16 +188,19 @@ class StatusTab(QWidget): if stale: logger.info(f"Cleaned {stale} stale metadata entries from {base_path}") - owned_set = self.api.get_owned_ids_set() + def _run_check(self, products_cache: dict[str, dict], owned_set: set[str]) -> None: + managed_ids = self.config.managed_game_ids managed_set = set(managed_ids) - # First pass: collect all DLC IDs and cache products + for items in self.status_items.values(): + items.clear() + self.tree_windows.clear() + self.tree_linux.clear() + dlc_ids: set[str] = set() - products_cache: dict[str, dict] = {} for game_id in managed_ids: - product = self.api.get_product_info(game_id) + product = products_cache.get(game_id) if product: - products_cache[game_id] = product for dlc in product.get("expanded_dlcs", []): dlc_ids.add(str(dlc.get("id", ""))) @@ -314,6 +335,10 @@ class StatusTab(QWidget): elif result == CompareResult.NEWER: return GameStatus.UP_TO_DATE else: + cache_key = f"{normalize_version(current)}|||{normalize_version(latest)}" + if cache_key in self.config.version_decisions: + logger.debug(f"Version decision cache hit: '{current}' vs '{latest}'") + return GameStatus.UPDATE_AVAILABLE if self.config.version_decisions[cache_key] else GameStatus.UP_TO_DATE question = format_comparison_question(game_name, language, current, latest) reply = QMessageBox.question( self, @@ -321,9 +346,10 @@ class StatusTab(QWidget): question, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) - if reply == QMessageBox.StandardButton.Yes: - return GameStatus.UPDATE_AVAILABLE - return GameStatus.UP_TO_DATE + is_newer = reply == QMessageBox.StandardButton.Yes + self.config.version_decisions[cache_key] = is_newer + self.config.save() + return GameStatus.UPDATE_AVAILABLE if is_newer else GameStatus.UP_TO_DATE def _worst_status(self, lang_infos: list[LanguageVersionInfo]) -> GameStatus: priority = [ @@ -424,62 +450,54 @@ class StatusTab(QWidget): return tree_item - def download_updates(self) -> None: - """Download updates for the currently visible platform.""" - platform = self._current_platform() + def _run_download_for_platform(self, platform: InstallerPlatform) -> tuple[int, int]: + """Download all pending installers and bonus content for one platform. + + Returns (items_downloaded, bonus_files_downloaded). + """ base_path = self._base_path_for(platform) if not base_path: - self.status_label.setText("No path configured for this platform.") - return + return 0, 0 downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED} - to_download = [ - item for item in self.status_items[platform] - if item.status in downloadable - ] + to_download = [item for item in self.status_items[platform] if item.status in downloadable] for item in self.status_items[platform]: to_download.extend(dlc for dlc in item.dlcs if dlc.status in downloadable) - if not to_download: - self.status_label.setText("Nothing to download.") - return - - self.download_btn.setEnabled(False) - self.check_btn.setEnabled(False) - self.progress_bar.setVisible(True) - self.progress_bar.setMaximum(len(to_download)) - self.progress_bar.setValue(0) - # TODO: Run in a thread to avoid blocking the GUI completed = 0 - metadata = MetadataStore(base_path) - downloader = InstallerDownloader(self.api, metadata) + if to_download: + metadata = MetadataStore(base_path) + downloader = InstallerDownloader(self.api, metadata) + self.progress_bar.setVisible(True) + self.progress_bar.setMaximum(len(to_download)) + self.progress_bar.setValue(0) - for item in to_download: - self.status_label.setText(f"Downloading: {item.name}...") - folder_name = item.parent_name if item.parent_name else item.name - effective_langs = self.config.get_effective_languages(item.game_id) - installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs) + for item in to_download: + self.status_label.setText(f"Downloading: {item.name}...") + folder_name = item.parent_name if item.parent_name else item.name + effective_langs = self.config.get_effective_languages(item.game_id) + installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs) - gs = self.config.get_game_settings(item.game_id) - is_english_only = gs.english_only if gs.english_only is not None else self.config.english_only - single_language = is_english_only or len({i.language for i in installers}) == 1 + gs = self.config.get_game_settings(item.game_id) + is_english_only = gs.english_only if gs.english_only is not None else self.config.english_only + single_language = is_english_only or len({i.language for i in installers}) == 1 - for installer in installers: - success = downloader.download_installer(installer, folder_name, single_language=single_language) - if success: - logger.info(f"Downloaded {installer.filename}") - else: - logger.error(f"Failed to download {installer.filename}") + for installer in installers: + success = downloader.download_installer(installer, folder_name, single_language=single_language) + if success: + logger.info(f"Downloaded {installer.filename}") + else: + logger.error(f"Failed to download {installer.filename}") - completed += 1 - self.progress_bar.setValue(completed) + completed += 1 + self.progress_bar.setValue(completed) - # Bonus content for this platform bonus_count = 0 if any(item.bonus_available > item.bonus_downloaded for item in self.status_items[platform]): bonus_items_to_download = self._collect_bonus_downloads(platform, base_path) if bonus_items_to_download: + self.progress_bar.setVisible(True) self.progress_bar.setMaximum(len(bonus_items_to_download)) self.progress_bar.setValue(0) for game_name, bonus in bonus_items_to_download: @@ -491,6 +509,30 @@ class StatusTab(QWidget): self.progress_bar.setValue(bonus_count) self.progress_bar.setVisible(False) + return completed, bonus_count + + def download_updates(self) -> None: + """Download updates for the currently visible platform.""" + platform = self._current_platform() + if not self._base_path_for(platform): + self.status_label.setText("No path configured for this platform.") + return + + downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED} + has_items = any( + item.status in downloadable or any(dlc.status in downloadable for dlc in item.dlcs) + for item in self.status_items[platform] + ) + has_bonus = any(item.bonus_available > item.bonus_downloaded for item in self.status_items[platform]) + if not has_items and not has_bonus: + self.status_label.setText("Nothing to download.") + return + + self.download_btn.setEnabled(False) + self.check_btn.setEnabled(False) + + completed, bonus_count = self._run_download_for_platform(platform) + self.check_btn.setEnabled(True) parts = [f"Downloaded {completed} items"] if bonus_count: diff --git a/src/version_compare.py b/src/version_compare.py index ec8a7c5..1dbdbeb 100644 --- a/src/version_compare.py +++ b/src/version_compare.py @@ -18,10 +18,14 @@ _GOG_SUFFIX_RE = re.compile(r"\s*\(.*?\)\s*$") def _normalize(version: str) -> str: - """Strip GOG-specific suffixes and whitespace.""" return _GOG_SUFFIX_RE.sub("", version).strip() +def normalize_version(version: str) -> str: + """Strip GOG-specific suffixes and whitespace for use as a cache key.""" + return _normalize(version) + + def _parse_numeric_parts(version: str) -> list[int] | None: """Try to parse version as dot-separated integers. Returns None if not possible.""" normalized = _normalize(version)