Add version decision cache and library-to-status propagation

This commit is contained in:
2026-05-12 10:35:02 +02:00
parent f22dc64041
commit 04662b96ec
8 changed files with 117 additions and 55 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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 ## [1.0.0] — 2026-04-09
### Features ### Features
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "gogupdater" name = "gogupdater"
version = "1.0.0" version = "1.0.1"
description = "" description = ""
authors = [ authors = [
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"} {name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually.""" """Auto-generated — do not edit manually."""
__version__ = "0.1.0" __version__ = "1.0.0"
+3
View File
@@ -24,6 +24,7 @@ class AppConfig:
self.english_only: bool = False self.english_only: bool = False
self.include_bonus: bool = False self.include_bonus: bool = False
self.game_settings: dict[str, GameSettings] = {} self.game_settings: dict[str, GameSettings] = {}
self.version_decisions: dict[str, bool] = {}
self._load() self._load()
def _load(self) -> None: def _load(self) -> None:
@@ -41,6 +42,7 @@ class AppConfig:
gid: GameSettings.from_dict(gs) gid: GameSettings.from_dict(gs)
for gid, gs in data.get("game_settings", {}).items() for gid, gs in data.get("game_settings", {}).items()
} }
self.version_decisions = data.get("version_decisions", {})
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
logger.warning("Failed to read config.json, using defaults") logger.warning("Failed to read config.json, using defaults")
@@ -58,6 +60,7 @@ class AppConfig:
for gid, gs in self.game_settings.items() for gid, gs in self.game_settings.items()
if not gs.is_default() if not gs.is_default()
}, },
"version_decisions": self.version_decisions,
} }
self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8") self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
+1
View File
@@ -50,6 +50,7 @@ class MainWindow(QMainWindow):
def _connect_signals(self) -> None: def _connect_signals(self) -> None:
self.auth_tab.login_state_changed.connect(self._on_login_changed) self.auth_tab.login_state_changed.connect(self._on_login_changed)
self.settings_tab.english_only_changed.connect(self._on_english_only_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: def _on_login_changed(self, logged_in: bool) -> None:
logger.info(f"Login state changed: logged_in={logged_in}") logger.info(f"Login state changed: logged_in={logged_in}")
+4
View File
@@ -37,6 +37,7 @@ class LibraryTab(QWidget):
"""Tab showing owned games with checkboxes. DLCs appear as children of their parent game.""" """Tab showing owned games with checkboxes. DLCs appear as children of their parent game."""
managed_games_changed = Signal() 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: def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -99,6 +100,7 @@ class LibraryTab(QWidget):
# games_data shared for all platforms — platform filtering happens in populate # games_data shared for all platforms — platform filtering happens in populate
games_data: list[tuple[str, str, list[tuple[str, str]], set[str]]] = [] games_data: list[tuple[str, str, list[tuple[str, str]], set[str]]] = []
# (game_id, title, [(dlc_id, dlc_title)], available_platforms) # (game_id, title, [(dlc_id, dlc_title)], available_platforms)
products_cache: dict[str, dict] = {}
for gid in owned_ids: for gid in owned_ids:
product = self.api.get_product_info(gid) product = self.api.get_product_info(gid)
@@ -106,6 +108,7 @@ class LibraryTab(QWidget):
games_data.append((str(gid), f"Game {gid}", [], set())) games_data.append((str(gid), f"Game {gid}", [], set()))
continue continue
products_cache[str(gid)] = product
title = product.get("title", f"Game {gid}") title = product.get("title", f"Game {gid}")
dlcs: list[tuple[str, str]] = [] dlcs: list[tuple[str, str]] = []
for dlc in product.get("expanded_dlcs", []): for dlc in product.get("expanded_dlcs", []):
@@ -159,6 +162,7 @@ class LibraryTab(QWidget):
self.status_label.setText( self.status_label.setText(
f"Loaded {win_count} Windows / {lin_count} Linux games. Double-click to configure per-game settings." 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: def _make_node(self, game_id: str, title: str, check: Qt.CheckState) -> QTreeWidgetItem:
node = QTreeWidgetItem() node = QTreeWidgetItem()
+94 -52
View File
@@ -33,7 +33,7 @@ from src.models import (
LanguageVersionInfo, LanguageVersionInfo,
language_folder_name, 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] = { STATUS_COLORS: dict[GameStatus, str] = {
GameStatus.UP_TO_DATE: "#2e7d32", GameStatus.UP_TO_DATE: "#2e7d32",
@@ -90,6 +90,7 @@ class StatusTab(QWidget):
self.download_btn.setEnabled(False) self.download_btn.setEnabled(False)
controls.addWidget(self.download_btn) controls.addWidget(self.download_btn)
self.prune_btn = QPushButton("Prune Old Versions") self.prune_btn = QPushButton("Prune Old Versions")
self.prune_btn.clicked.connect(self.prune_versions) self.prune_btn.clicked.connect(self.prune_versions)
controls.addWidget(self.prune_btn) controls.addWidget(self.prune_btn)
@@ -153,15 +154,32 @@ class StatusTab(QWidget):
self.check_btn.setEnabled(False) self.check_btn.setEnabled(False)
self.status_label.setText("Checking updates...") 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() self._update_platform_tab_visibility()
# TODO: Run in a thread to avoid blocking the GUI # 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: for platform in InstallerPlatform:
base_path = self._base_path_for(platform) base_path = self._base_path_for(platform)
if base_path: if base_path:
@@ -170,16 +188,19 @@ class StatusTab(QWidget):
if stale: if stale:
logger.info(f"Cleaned {stale} stale metadata entries from {base_path}") 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) 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() dlc_ids: set[str] = set()
products_cache: dict[str, dict] = {}
for game_id in managed_ids: for game_id in managed_ids:
product = self.api.get_product_info(game_id) product = products_cache.get(game_id)
if product: if product:
products_cache[game_id] = product
for dlc in product.get("expanded_dlcs", []): for dlc in product.get("expanded_dlcs", []):
dlc_ids.add(str(dlc.get("id", ""))) dlc_ids.add(str(dlc.get("id", "")))
@@ -314,6 +335,10 @@ class StatusTab(QWidget):
elif result == CompareResult.NEWER: elif result == CompareResult.NEWER:
return GameStatus.UP_TO_DATE return GameStatus.UP_TO_DATE
else: 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) question = format_comparison_question(game_name, language, current, latest)
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
@@ -321,9 +346,10 @@ class StatusTab(QWidget):
question, question,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply == QMessageBox.StandardButton.Yes: is_newer = reply == QMessageBox.StandardButton.Yes
return GameStatus.UPDATE_AVAILABLE self.config.version_decisions[cache_key] = is_newer
return GameStatus.UP_TO_DATE self.config.save()
return GameStatus.UPDATE_AVAILABLE if is_newer else GameStatus.UP_TO_DATE
def _worst_status(self, lang_infos: list[LanguageVersionInfo]) -> GameStatus: def _worst_status(self, lang_infos: list[LanguageVersionInfo]) -> GameStatus:
priority = [ priority = [
@@ -424,62 +450,54 @@ class StatusTab(QWidget):
return tree_item return tree_item
def download_updates(self) -> None: def _run_download_for_platform(self, platform: InstallerPlatform) -> tuple[int, int]:
"""Download updates for the currently visible platform.""" """Download all pending installers and bonus content for one platform.
platform = self._current_platform()
Returns (items_downloaded, bonus_files_downloaded).
"""
base_path = self._base_path_for(platform) base_path = self._base_path_for(platform)
if not base_path: if not base_path:
self.status_label.setText("No path configured for this platform.") return 0, 0
return
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED} downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
to_download = [ to_download = [item for item in self.status_items[platform] if item.status in downloadable]
item for item in self.status_items[platform]
if item.status in downloadable
]
for item in self.status_items[platform]: for item in self.status_items[platform]:
to_download.extend(dlc for dlc in item.dlcs if dlc.status in downloadable) 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 # TODO: Run in a thread to avoid blocking the GUI
completed = 0 completed = 0
metadata = MetadataStore(base_path) if to_download:
downloader = InstallerDownloader(self.api, metadata) 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: for item in to_download:
self.status_label.setText(f"Downloading: {item.name}...") self.status_label.setText(f"Downloading: {item.name}...")
folder_name = item.parent_name if item.parent_name else item.name folder_name = item.parent_name if item.parent_name else item.name
effective_langs = self.config.get_effective_languages(item.game_id) effective_langs = self.config.get_effective_languages(item.game_id)
installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs) installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs)
gs = self.config.get_game_settings(item.game_id) 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 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 single_language = is_english_only or len({i.language for i in installers}) == 1
for installer in installers: for installer in installers:
success = downloader.download_installer(installer, folder_name, single_language=single_language) success = downloader.download_installer(installer, folder_name, single_language=single_language)
if success: if success:
logger.info(f"Downloaded {installer.filename}") logger.info(f"Downloaded {installer.filename}")
else: else:
logger.error(f"Failed to download {installer.filename}") logger.error(f"Failed to download {installer.filename}")
completed += 1 completed += 1
self.progress_bar.setValue(completed) self.progress_bar.setValue(completed)
# Bonus content for this platform
bonus_count = 0 bonus_count = 0
if any(item.bonus_available > item.bonus_downloaded for item in self.status_items[platform]): 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) bonus_items_to_download = self._collect_bonus_downloads(platform, base_path)
if bonus_items_to_download: if bonus_items_to_download:
self.progress_bar.setVisible(True)
self.progress_bar.setMaximum(len(bonus_items_to_download)) self.progress_bar.setMaximum(len(bonus_items_to_download))
self.progress_bar.setValue(0) self.progress_bar.setValue(0)
for game_name, bonus in bonus_items_to_download: 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.setValue(bonus_count)
self.progress_bar.setVisible(False) 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) self.check_btn.setEnabled(True)
parts = [f"Downloaded {completed} items"] parts = [f"Downloaded {completed} items"]
if bonus_count: if bonus_count:
+5 -1
View File
@@ -18,10 +18,14 @@ _GOG_SUFFIX_RE = re.compile(r"\s*\(.*?\)\s*$")
def _normalize(version: str) -> str: def _normalize(version: str) -> str:
"""Strip GOG-specific suffixes and whitespace."""
return _GOG_SUFFIX_RE.sub("", version).strip() 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: def _parse_numeric_parts(version: str) -> list[int] | None:
"""Try to parse version as dot-separated integers. Returns None if not possible.""" """Try to parse version as dot-separated integers. Returns None if not possible."""
normalized = _normalize(version) normalized = _normalize(version)